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

BabelDOC's vendored pdfminer unpickles CMap files from a PDF-controlled name; hex-encoded absolute path in /Encoding redirects pickle.loads to attacker file, giving code execution.

- Payout: Research
- Severity: high
- Program: BabelDOC
- Found by: EQSTLab
- Disclosed: 2026-07-10
- CVE: CVE-2026-54071
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-m8gf-v64p-gfmg
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-m8gf-v64p-gfmg
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:python technique:chained technique:source-review vuln:deserialization vuln:path-traversal vuln:rce

**Target:** PDF parser CMap loader (babeldoc/pdfminer/cmapdb.py _load_data)
**Root cause:** _load_data() strips only NUL bytes from the PDF-controlled CMap name, then joins it into a trusted cmap directory and unconditionally pickle.loads() the file; os.path.join() discards the directory when the name is an absolute path.
**Steps:**
1. Craft a PDF Type0 font whose /Encoding name hex-encodes an absolute path: `/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious` (decodes to `/tmp/babeldoc-cmap-poc/malicious`).
2. Place a gzipped pickle at the matching path `/tmp/babeldoc-cmap-poc/malicious.pickle.gz` whose __reduce__ executes a command.
3. Victim processes the PDF via `extract_text()` / babeldoc CLI; pdfminer hex-decodes #2F to '/', _normalize_cmap_name strips one leading '/', literal_name becomes the absolute path.
4. `os.path.join('/usr/share/pdfminer/', '/tmp/.../malicious.pickle.gz')` returns the absolute path, bypassing trusted dirs.
5. `gzip.open()` + `pickle.loads()` executes the payload; a benign TypeError from `type(name, (), <int>)` follows after execution.
**How found:** Reporter traced the PDF /Encoding name through the new-parser font pipeline into CMapDB._load_data and noticed the only sanitization was NUL stripping, then realized PDF name hex-encoding (#2F) plus os.path.join's absolute-path override defeats the trusted directory.
**Exploitation:** Attacker stages a malicious .pickle.gz in a predictable world-writable location (e.g. /tmp) and delivers a crafted PDF; when the victim's BabelDOC pipeline parses it, the pickle executes with the process's privileges, yielding full code execution.
**Escalation:** Maintainer review found a second independently exploitable path via the PostScript `usecmap` operator in an embedded CMap stream using `..` relative traversal; sink-level fix also covers CIDSystemInfo.Ordering call sites.
**Insight:** When a PDF name feeds a filesystem path, remember PDF name hex escapes (#xx) decode to arbitrary bytes including '/', and Python's os.path.join silently drops the base dir on absolute segments — check both together at any name-to-path sink.

## Proof of concept (copied exactly from the original report)

**Malicious pickle payload class with __reduce__**

```
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}")
```

