Skip to content

Latest commit

 

History

History
129 lines (99 loc) · 6.25 KB

File metadata and controls

129 lines (99 loc) · 6.25 KB

pypdf: Incomplete Fix of CVE-2026-27025 — /ToUnicode CMap bfrange Per-Entry Byte-Width Is Still Unbounded

Overview

Field Value
Package pypdf
Version <= 6.14.2 (latest at time of analysis)
Repository https://github.com/py-pdf/pypdf
Weekly Downloads ~20M (PyPI)
Vulnerability Type CWE-400: Uncontrolled Resource Consumption / CWE-789: Memory Allocation with Excessive Size Value
CVSS Score 6.5 (Medium) — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Impact Memory exhaustion / OOM during extract_text() on an attacker-supplied PDF
Prior art Incomplete fix of CVE-2026-27025 / GHSA-wgvp-vg3v-2xq3 (fixed in 6.7.1, PR #3646)
Candidate IDI-CAND-0047-pypdf/IDI-2026-0017

Summary

This is a security regression / incomplete fix of CVE-2026-27025 (GHSA-wgvp-vg3v-2xq3), which was addressed in pypdf 6.7.1 by PR #3646 "Limit size of /ToUnicode entries". That patch introduced MAPPING_DICTIONARY_SIZE_LIMIT = 100_000, which caps the number of /ToUnicode CMap entries but does not cap the byte width of an individual entry. Because the hex token length inside a beginbfrange … endbfrange block determines the key width stored in map_dict, an attacker can keep the entry count under the 100,000 limit while inflating each entry's byte width with leading-zero padding. The product (100,000 entries × unbounded per-entry width) drives memory growth that the count cap does not constrain. The condition is reached through the ordinary extract_text() path — the same function, same threat model, and same advisory text ("unusually large values … large memory consumption during text extraction") as CVE-2026-27025.

We report this as a follow-on robustness/regression fix rather than a novel discovery, and note that the original advisory's wording is broad enough that the CNA may choose to fold this into the existing advisory rather than assign a new CVE.

Root Cause

pypdf/_cmap.py, parse_bfrange():

# _check_mapping_size() enforces the entry COUNT cap from #3646 …
def _check_mapping_size(map_dict, ...):
    if len(map_dict) > MAPPING_DICTIONARY_SIZE_LIMIT:   # 100_000 — count only
        raise ...

# … but the per-entry key WIDTH is taken straight from the token length:
nbi = max(len(lst[0]), len(lst[1]))     # token byte length, no upper bound
map_dict[-1] = ceil(nbi / 2)            # key width derived from attacker token length

#3646 added the count limit (MAPPING_DICTIONARY_SIZE_LIMIT) but left the per-entry byte width (nbi, derived directly from the hex token length) unbounded. Leading-zero padding in the bfrange hex tokens lets the attacker grow nbi arbitrarily while each padded token still counts as a single entry, so the count cap never trips.

Why the OOM is not caught: process_cm_line only catches (ValueError, IndexError), and the extract_text() call site in _page.py only catches (AttributeError, TypeError). A MemoryError — or an OS OOM-killer terminating the process for a large cumulative allocation — propagates and crashes the consumer.

Reachability

Confirmed by code trace, default extract_text() path:

PdfReader.pages[i].extract_text()
  → _page.py  from_font_resource()
  → _font.py  get_encoding()
  → _cmap.py  _parse_to_unicode()  → parse_bfrange()

A /ToUnicode CMap is a standard component of Type0/CID fonts, so it is always processed on the normal text-extraction path. No special configuration, object injection, network, or elevated privilege is required — the single precondition is "call extract_text() on an untrusted PDF."

Attack Scenario

  1. A service extracts text from user-supplied PDFs (uploads, mail attachments, crawled documents) using pypdf — pypdf's headline feature, routinely applied to untrusted input.
  2. The attacker crafts a small PDF embedding a Type0 font whose /ToUnicode stream contains a beginbfrange … endbfrange block with leading-zero-padded hex tokens.
  3. On extract_text(), parse_bfrange allocates ~100,000 entries each of attacker-controlled byte width, exhausting memory and crashing the worker / process.

Impact

  • Availability: High — memory exhaustion / OOM crashes the processing worker or host.
  • Confidentiality / Integrity: None — pure resource-consumption DoS; no code execution or data disclosure.

Measured amplification: a 2,011-byte input produced a 121 MB peak (×60,163). Scaling the padded token toward the existing 75 MB stream ceiling drives the peak into the tens of GB, reaching OOM on typical worker memory limits.

Proof of Concept

poc.py builds a minimal single-page PDF with a Type0 font and a malicious /ToUnicode CMap, then drives the full extract_text() path (not a unit-level call to parse_bfrange) and reports peak memory via tracemalloc/resource.

pip install pypdf==6.14.2
python3 poc.py

Expected: a small input PDF yields a multi-x peak-memory amplification during reader.pages[0].extract_text(); raising the padding width pushes the process to OOM.

Suggested Fix

Decision is the maintainer's; only direction is offered. Bound the per-entry key byte width in parse_bfrange (cap nbi / map_dict[-1]), or extend _check_mapping_size to account for cumulative bytes rather than entry count alone, so the #3646 count cap is complemented by a width/total-size cap. This closes the residual gap left by the CVE-2026-27025 fix.

References

  • CVE-2026-27025 / GHSA-wgvp-vg3v-2xq3 — pypdf /ToUnicode large-value memory consumption during text extraction, fixed in 6.7.1 (PR #3646 "Limit size of /ToUnicode entries"). This report is the residual incomplete fix of that advisory.
  • CVE-2023-36464 (IndexError), CVE-2023-36810 (infinite loop) — prior pypdf DoS precedents (different root cause; cited for class acceptance of CWE-400 in PDF parsers).
  • CWE-400: Uncontrolled Resource Consumption
  • CWE-789: Memory Allocation with Excessive Size Value

Timeline

  • 2026-06-29: Regression identified and reproduced on pypdf 6.14.2 (latest) via the full extract_text() path; confirmed as the residual byte-width gap left by PR #3646.