Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
ZeroDays CVE-2026-59937
High CVE-2026-59937 CVSS 7.5 pypdf Python

pypdf ReDoS via Malformed XRef Streams

CVE-2026-59937: pypdf before 6.14.0 allows denial of service via crafted PDFs with malformed cross-reference streams causing unbounded recovery loops.

Offensive360 Research Team
Affects: < 6.14.0
Source Code View Patch

Overview

CVE-2026-59937 is an algorithmic complexity vulnerability in pypdf, the widely used pure-Python PDF processing library. An attacker can craft a PDF file containing repeated, deliberately malformed cross-reference (XRef) stream entries that force pypdf’s recovery logic into an effectively unbounded loop. Because pypdf is designed to be fault-tolerant with broken PDFs — a feature that is legitimately useful for handling real-world documents — the library will faithfully attempt to reconstruct a damaged XRef table for as long as the malformed data keeps feeding it recovery candidates. The result is excessive CPU consumption and wall-clock time that can render the hosting process completely unresponsive, satisfying the criteria for a remotely triggerable denial-of-service condition.

The vulnerability was disclosed via the pypdf GitHub security advisory tracker and addressed in version 6.14.0 released in 2026. The CVSS 3.1 base score of 7.5 (HIGH) reflects the network-accessible attack vector, low attack complexity, no required authentication, and the availability impact against any service that processes attacker-supplied PDF files. Projects in document management, data extraction pipelines, CI/CD artifact inspection, and web-based PDF preview are all squarely in scope.

pypdf is one of the most downloaded Python packages on PyPI, appearing as a direct or transitive dependency in thousands of applications. The broad install base, combined with the trivial nature of supplying a PDF to many web endpoints, makes this a high-priority patching target for any organization running Python services that ingest PDF content.

Technical Analysis

PDF files use a cross-reference table or cross-reference stream to map object numbers to byte offsets inside the file. When a PDF reader encounters structural inconsistencies — truncated streams, mismatched object counts, corrupted startxref pointers — it falls back to a recovery mode that scans the raw byte content looking for object header patterns (N G obj) to rebuild the XRef table from scratch.

pypdf implements this recovery path in its XRef parsing subsystem. Prior to 6.14.0, the recovery loop did not maintain adequate deduplication or iteration-count guards when processing XRef stream entries. Consider the simplified pattern that existed in the vulnerable code:

# VULNERABLE — simplified representation of the pre-6.14.0 recovery path
def _recover_xref_table(self, stream):
    xref = {}
    while True:
        token = self._read_next_token(stream)
        if token is None:
            break
        try:
            # Attempt to parse an XRef entry from the token
            obj_num, generation, offset = self._parse_xref_entry(token)
            # No deduplication guard: the same malformed entry can be
            # processed indefinitely if the stream loops or self-references
            xref[obj_num] = (generation, offset)
        except PdfStreamError:
            # Recovery: rewind slightly and retry — attacker controls
            # how many times this retry path executes
            stream.seek(max(stream.tell() - 20, 0))
            continue
    return xref

The critical weakness is twofold. First, exceptions thrown during XRef entry parsing trigger a seek-and-retry rather than advancing past the offending bytes and counting failures. Second, there is no ceiling on the number of retry attempts, nor any deduplication check that would detect that the parser is revisiting bytes it has already processed. A malicious PDF author can exploit this by constructing an XRef stream whose byte layout causes _parse_xref_entry to repeatedly raise PdfStreamError at the same stream position, causing the seek-back to reset the read cursor to the same problematic offset on every iteration. The loop becomes infinite — or, more precisely, it runs until an OS-level timeout or process kill signal terminates it.

The attack requires only that the victim application open the crafted PDF with pypdf.PdfReader. No password, no encryption, no special permissions — the vulnerable code path is exercised during the initial parse phase before any page content is accessed.

# Attacker-side: delivering the payload is trivial
import pypdf

# Any application doing this with attacker-supplied data is vulnerable
reader = pypdf.PdfReader("malicious.pdf")  # hangs indefinitely on < 6.14.0

The root cause is a failure to apply the classic algorithmic safeguard for recovery loops: track visited positions and bound the number of recovery attempts. This is a form of CWE-400 (Uncontrolled Resource Consumption) arising specifically from unbounded iteration — closely related to but distinct from regex-based ReDoS, because the pathological behavior lives in the PDF parsing state machine rather than in a regular expression engine.

Impact

From a practical standpoint, any Python service that passes untrusted PDF bytes to pypdf.PdfReader() is vulnerable to a single-request denial-of-service. A one-request-per-thread architecture (Flask with Gunicorn workers, Django with synchronous WSGI, FastAPI in sync mode) will have the affected worker hung indefinitely, degrading capacity proportionally to the number of malicious requests in flight. Async frameworks that run pypdf in a thread pool (the typical pattern, since pypdf is synchronous I/O) will exhaust the pool.

The CVSS vector AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H accurately captures the situation: network-reachable, trivial to exploit, no account required, full availability impact on the affected component. There is no confidentiality or integrity risk — this is a pure availability attack. However, secondary impacts are worth considering: a hung PDF parser can hold file handles, consume memory for partially constructed object graphs, and in poorly isolated deployments, starve shared resources affecting unrelated request paths.

Document processing pipelines, email attachment scanners, legal discovery platforms, and any SaaS product offering PDF manipulation are the most likely targets. Organizations using pypdf inside Lambda functions or container-per-request architectures have natural blast radius containment, but still face cost amplification attacks.

How to Fix It

The authoritative fix is upgrading to pypdf 6.14.0 or later. The patch introduces position-tracking and a retry-count ceiling inside the XRef recovery loop so that the parser advances past malformed regions rather than cycling on them.

Package manager upgrade commands:

# pip
pip install "pypdf>=6.14.0"

# pip with constraints file — update your constraints.txt entry:
# pypdf>=6.14.0

# Poetry
poetry add "pypdf>=6.14.0"
poetry update pypdf

# pipenv
pipenv install "pypdf>=6.14.0"

# uv
uv add "pypdf>=6.14.0"

The corrected logic, reflecting the approach taken in the patch commit, bounds recovery iterations and tracks the stream positions already visited:

# FIXED — representative of the post-6.14.0 recovery approach
MAX_RECOVERY_ATTEMPTS = 256  # hard ceiling on retry iterations

def _recover_xref_table(self, stream):
    xref = {}
    visited_positions = set()
    recovery_attempts = 0

    while recovery_attempts < MAX_RECOVERY_ATTEMPTS:
        pos = stream.tell()
        if pos in visited_positions:
            # We have already tried this byte offset — stop to avoid a loop
            break
        visited_positions.add(pos)

        token = self._read_next_token(stream)
        if token is None:
            break
        try:
            obj_num, generation, offset = self._parse_xref_entry(token)
            xref[obj_num] = (generation, offset)
        except PdfStreamError:
            recovery_attempts += 1
            # Advance past the bad bytes rather than rewinding into them
            stream.seek(pos + 1)
            continue
    return xref

If an immediate upgrade is not possible, a short-term mitigation is to enforce a process-level timeout around any pypdf call that handles untrusted input using concurrent.futures or signal.alarm, and to enforce a maximum file-size limit before passing data to the parser.

Our Take

This vulnerability is a textbook example of a class of bugs that emerges specifically at the intersection of “be lenient with malformed input” and “don’t bound your recovery logic.” PDF, by specification and by decades of real-world implementation divergence, practically demands liberal parsing. Library authors who want their tools to work on real-world PDFs have to handle broken XRef tables — but every lenient recovery path is a potential complexity attack surface.

The pattern appears repeatedly across document parsing libraries in every language. The lesson for developers is simple but easy to skip under deadline pressure: every loop that exists to handle malformed input must have an explicit exit condition that is independent of the malformed input providing valid data. Visiting-set deduplication and hard retry ceilings are cheap to implement and should be the default posture.

For enterprises, this reinforces the case for treating any document ingestion endpoint as an untrusted-input boundary with the same rigor applied to SQL queries or OS command arguments. PDF parsers are complex, their attack surface is large, and DoS via parser exhaustion has a long history.

Detection with SAST

This vulnerability class maps to CWE-400: Uncontrolled Resource Consumption and CWE-834: Excessive Iteration. SAST detection focuses on identifying loops whose termination conditions can be influenced by externally supplied data without a compensating bound.

Offensive360’s SAST engine flags the following patterns in Python codebases:

  • while True or while <condition> loops that perform stream.seek() inside an exception handler without incrementing a counter checked against a maximum.
  • Retry or recovery loops operating on file-like objects where the loop variable or break condition is derived from parsing the same external input that caused the exception.
  • Absence of a visited-position set or equivalent deduplication structure when iterating over byte-stream offsets during error recovery.
  • Calls to PDF, Office document, or archive parsing APIs inside request handlers without a surrounding timeout or resource-limit context manager.

The relevant CWE taxonomy for rule categorization is CWE-400 (primary) with CWE-834 (secondary). In a DAST context, this class is detectable by fuzzing document parsers with adversarially structured files that contain self-referential or cyclically malformed structural metadata and measuring response latency as an oracle.

References

#denial-of-service #pdf-parsing #algorithmic-complexity #python

Detect this vulnerability class in your codebase

Offensive360 SAST scans your source code for CVE-2026-59937-class vulnerabilities and thousands of other patterns — across 60+ languages.