Oct 31, 2026·9 min read·3 visits
Versions of the Python pypdf library prior to 6.21.4 contain an infinite loop vulnerability (CWE-835) in the _read_standard_xref_table method. Processing a malformed PDF with an incomplete cross-reference table that lacks a trailer causes the parser to repeatedly seek backward to the same byte position, hanging the executing thread and causing a denial of service.
CVE-2026-119203 is a denial-of-service vulnerability affecting the pypdf library in versions prior to 6.21.4. The flaw occurs within the standard cross-reference (xref) table parsing routine when processing truncated or malformed PDF streams that terminate before a valid trailer dictionary is encountered. Because the parser attempts to rewind the stream pointer by a fixed offset whenever the trailer token is absent without verifying forward progress, hitting the end-of-file (EOF) or non-advancing tokens causes the parser to re-read the same byte offset indefinitely. An unauthenticated attacker can exploit this flaw by supplying a crafted PDF document to an application using pypdf, resulting in continuous CPU consumption and thread exhaustion.
pypdf is a widely deployed, pure-Python library used for reading, manipulating, and writing PDF documents across web applications, automated document processing pipelines, and backend services. Because PDF parsing requires interpreting complex binary and text structures—including object streams, indirect references, and cross-reference tables—the parser represents a primary attack surface whenever applications accept untrusted documents from external users.
In versions of pypdf prior to 6.21.4, a logic flaw exists in the PdfReader._read_standard_xref_table method inside pypdf/_reader.py. This method is responsible for parsing traditional PDF cross-reference (xref) tables, which map indirect object numbers to their exact byte offsets within the file stream. According to the PDF specification, a standard cross-reference table consists of one or more subsections followed immediately by the trailer keyword and its associated dictionary.
The vulnerability, tracked as CVE-2026-119203 (and GitHub Security Advisory GHSA-5849-834g-hvw5), is classified under CWE-835 (Loop with Unreachable Exit Condition). When pypdf processes an incomplete or specifically crafted PDF whose cross-reference table reaches the end of the file (EOF) or contains trailing garbage without a valid trailer keyword, the parser fails to advance the stream position. It enters an infinite loop that repeatedly seeks backward and re-evaluates the same byte offset, pinning the executing Python thread at 100% CPU utilization.
To locate objects within a PDF document, pypdf first reads the startxref offset near the end of the file and seeks to the beginning of the cross-reference section. If the xref keyword is present, PdfReader invokes _read_standard_xref_table(self, stream: StreamType) to iterate through the cross-reference subsections. Each subsection begins with a header specifying the starting object number and the count of contiguous entries, followed by 20-byte lines defining each object's byte offset, generation number, and status (n for in-use, f for free).
Because a standard cross-reference table can be split into multiple contiguous subsections before terminating at the trailer keyword, _read_standard_xref_table wraps the subsection parsing logic inside an unbounded while True: loop. At the end of each subsection iteration, the parser checks whether the stream has reached the trailer keyword by seeking back one byte (stream.seek(-1, 1)) and reading a 7-byte chunk (trailer_tag = stream.read(7)).
The root cause of CVE-2026-119203 is an unchecked assumption regarding stream progression when trailer_tag != b"trailer". The parser assumed that if the next 7 bytes did not match b"trailer", another cross-reference subsection must follow, and it executed stream.seek(-7, 1) to rewind the stream so the next loop iteration could read the subsequent subsection header.
However, if the input stream is truncated or malformed—such as declaring a subsection with zero entries (1 0) followed by fewer than 7 bytes before EOF (e.g., b"aaa")—stream.read(7) reads only the remaining bytes (3 bytes in the case of b"aaa") before hitting EOF. The subsequent relative rewind stream.seek(-7, 1) moves the file pointer 7 bytes backward from EOF, landing inside or prior to the already parsed tokens. In the next iteration of the while True loop, the parser processes the exact same tokens and reaches the exact same stream offset after stream.seek(-7, 1), creating an invariant state where the loop exit condition (trailer_tag == b"trailer") can never be reached.
The vulnerability was remediated in commit 06593f993789a4b573dd955730c1a7051128bb2a (Pull Request #4289). The modification targets pypdf/_reader.py inside the _read_standard_xref_table method.
Prior to version 6.21.4, the loop termination check at the bottom of _read_standard_xref_table performed a blind relative seek whenever the 7-byte read did not match b"trailer":
# Vulnerable implementation in pypdf < 6.21.4 (pypdf/_reader.py)
def _read_standard_xref_table(self, stream: StreamType) -> None:
stream.seek(-1, 1)
first_time = True # check if the first time looking at the xref table
recovery_cache: Optional[dict[int, tuple[int, int]]] = None
while True:
num = cast(int, read_object(stream, self))
# ... [subsection entry parsing logic] ...
stream.seek(-1, 1)
trailer_tag = stream.read(7)
if trailer_tag != b"trailer":
# more xrefs!
# FLAW: Relative seek backward without verifying forward progress
# or checking how many bytes stream.read(7) actually consumed.
stream.seek(-7, 1)
else:
breakThe patch introduces a state tracking variable, previous_position: Optional[int] = None, initialized before the while True loop. At the end of each iteration, after rewinding the stream via stream.seek(-7, 1), the parser queries the current stream offset using stream.tell() and compares it against previous_position:
File: pypdf/_reader.py
@@ -1072,6 +1072,7 @@ def _read_standard_xref_table(self, stream: StreamType) -> None:
stream.seek(-1, 1)
first_time = True # check if the first time looking at the xref table
recovery_cache: Optional[dict[int, tuple[int, int]]] = None
+ previous_position: Optional[int] = None
while True:
num = cast(int, read_object(stream, self))
if first_time and num != 0:
@@ -1186,12 +1187,20 @@ def _read_standard_xref_table(self, stream: StreamType) -> None:
stream.seek(-1, 1)
stream.seek(-1, 1)
trailer_tag = stream.read(7)
- if trailer_tag != b"trailer":
- # more xrefs!
- stream.seek(-7, 1)
- else:
+ if trailer_tag == b"trailer":
break
+ # More xrefs should move the parser to another section.
+ stream.seek(-7, 1)
+
+ # Detect a repeated read at the same position.
+ rewound_position = stream.tell()
+ if rewound_position == previous_position:
+ raise PdfReadError(
+ f"Xref parsing stuck at position {rewound_position}: {trailer_tag!r}"
+ )
+ previous_position = rewound_position
+
def _read_xref_tables_and_trailers(
self, stream: StreamType, startxref: Optional[int], xref_issue_nr: int
) -> None:> [!NOTE]
> Assessment of Fix Completeness: Tracking previous_position directly detects single-step stationary stalls where rewound_position == previous_position across consecutive iterations, raising a PdfReadError. Because valid cross-reference subsections must advance the stream pointer forward as entries are consumed, comparing rewound_position against previous_position stops stationary EOF loops. A still more defensive invariant would enforce monotonic forward progress (rewound_position <= previous_position), ensuring that even multi-step oscillating seeks or backward jumps across corrupted tokens cannot cycle between two distinct offsets.
Exploiting CVE-2026-119203 requires no authentication or complex environmental conditions. The attacker only needs to deliver a crafted PDF file to a target application or service that invokes pypdf.PdfReader on the input stream. Common attack vectors include web application file upload endpoints (such as resume parsers, invoice processors, or document management platforms), email attachment scanners, and automated document indexing pipelines.
To construct a minimal trigger payload, the input stream only needs to satisfy the basic entry criteria of PdfReader: a valid %PDF- header, a startxref pointer directing the reader to the byte offset of an xref keyword, an xref subsection header specifying 0 object entries (e.g., 1 0), and trailing non-trailer bytes at the end of the file.
The official regression test included in commit 06593f993789a4b573dd955730c1a7051128bb2a provides a complete, 40-byte proof-of-concept payload:
from io import BytesIO
import pytest
from pypdf import PdfReader
from pypdf.errors import PdfReadError
def test_read_standard_xref_table__end_of_file__trailer():
# 40-byte crafted PDF stream where the standard xref table
# declares 0 entries ('1 0') and terminates with 'aaa' at EOF:
data = b"%PDF-1.4\nstartxref\n28\n%%EOF\nxref\n1 0\naaa"
# On pypdf < 6.21.4, calling PdfReader(BytesIO(data)) never returns.
# On pypdf >= 6.21.4, PdfReadError is raised on the second iteration:
with pytest.raises(PdfReadError, match=r"^Xref parsing stuck at position 33: b'aaa'$"):
PdfReader(BytesIO(data))When PdfReader(BytesIO(data)) is executed on pypdf 6.21.3 or earlier:
startxref at offset 28, which points to xref\n1 0\naaa._read_standard_xref_table, read_object reads the subsection start ID (1) and entry count (0).0, the inner entry loop completes immediately.trailer_tag), obtaining b"aaa" before hitting EOF at byte 40.b"aaa" != b"trailer", stream.seek(-7, 1) rewinds 7 bytes from offset 40 to offset 33 (1 0\naaa).while True loop repeats from offset 33, reading 1 and 0 again and rewinding to offset 33 indefinitely.The primary impact of CVE-2026-119203 is a high-severity Denial of Service (DoS) against the application availability, reflected in its CVSS v4.0 score of 7.1 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N). The vulnerability does not allow memory corruption, arbitrary code execution, or unauthorized data disclosure (VC:N/VI:N), as the operations are strictly confined to stream offset manipulation within the Python interpreter.
In synchronous Python web frameworks (such as Flask, Django, or FastAPI running synchronous worker pools via Gunicorn or uWSGI), each incoming HTTP request is handled by a dedicated worker process or thread. Because the infinite loop in _read_standard_xref_table is entirely CPU-bound (performing in-memory stream reads and seeks when backed by BytesIO or buffered file streams), a single malicious upload permanently occupies one worker thread and saturates a CPU core due to Python's Global Interpreter Lock (GIL) within that process.
By submitting multiple concurrent requests containing the 40-byte payload, an unauthenticated attacker can exhaust all available worker processes in the application pool. Once all workers are trapped in the while True loop, the service becomes completely unresponsive to legitimate users until the application processes are forcibly terminated and restarted by an external supervisor.
The definitive remediation for CVE-2026-119203 is to upgrade pypdf to version 6.21.4 or later. In version 6.21.4, encountering an non-advancing stream offset during standard cross-reference table parsing immediately raises a pypdf.errors.PdfReadError exception, which calling applications can catch and handle cleanly.
Administrators and developers should update their dependencies using pip or their respective package manager:
pip install --upgrade "pypdf>=6.21.4"When catching exceptions around PdfReader initialization in application code, ensure that PdfReadError (or its base class PyPdfError) is caught so that malformed uploads return a standard HTTP 400 Bad Request response rather than an unhandled 500 Internal Server Error:
from io import BytesIO
from pypdf import PdfReader
from pypdf.errors import PyPdfError
def parse_uploaded_pdf(raw_bytes: bytes) -> int:
try:
reader = PdfReader(BytesIO(raw_bytes))
return len(reader.pages)
except PyPdfError as exc:
# Log and reject malformed or truncated PDF files safely
raise ValueError("Invalid PDF document supplied") from excAs a defense-in-depth measure for applications that must process untrusted PDF files, document parsing should be offloaded from web request threads into isolated background worker processes (such as Celery, RQ, or Python multiprocessing) configured with strict wall-clock and CPU time limits (for example, Gunicorn --timeout or Celery time_limit). Enforcing execution timeouts ensures that any future parser stalls or resource-exhaustion bugs in third-party file format libraries are terminated before exhausting host resources.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
pypdf py-pdf | < 6.21.4 | 6.21.4 |
| Attribute | Detail |
|---|---|
| Vulnerability ID | CVE-2026-119203 / GHSA-5849-834g-hvw5 |
| Weakness Class | CWE-835: Loop with Unreachable Exit Condition ('Infinite Loop') |
| CVSS v4.0 Score | 7.1 (High) - CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N |
| Affected Component | pypdf._reader.PdfReader._read_standard_xref_table (< 6.21.4) |
| Patched Version | pypdf 6.21.4 |
| Primary Impact | Denial of Service via CPU and worker thread exhaustion |
| EPSS Score | 0.30% (23.18th Percentile) |
| CISA KEV Status | Not Listed |
The program contains an iteration or loop statement whose exit condition cannot be satisfied, causing the loop to repeat indefinitely.
CVE-2026-119204 (also tracked as GHSA-cggf-277g-3q54) is a Denial of Service (DoS) vulnerability in pypdf prior to version 6.21.4. The vulnerability stems from uncontrolled resource consumption (CWE-400) during the flattening of a PDF document's page tree. By supplying a compact PDF containing a hierarchical amplification structure of shared `/Pages` and `/Page` nodes with dense dictionary entries or inherited resources, an unauthenticated attacker can cause pypdf to copy millions of dictionary entries into individual `PageObject` instances without enforcing a document-wide entry budget. This triggers severe heap memory exhaustion and process termination via Out-Of-Memory (OOM) kills.
Prior to version 2.18.0, the Kubeflow Pipelines API server contains a missing authorization and confused deputy vulnerability in its run log streaming endpoint. The HTTP handler for `GET /apis/v1alpha1/runs/{run_id}/nodes/{node_id}/log` fails to propagate incoming request headers to gRPC metadata and omits caller authorization checks against the requested run. Furthermore, the backend accepts the caller-supplied `node_id` parameter directly as a Kubernetes pod name without verifying that the pod belongs to the specified pipeline run. An attacker with network access to the API server who knows a valid run identifier can read live or archived execution logs from any pod containing a `main` container within the run's Kubernetes namespace.
A high-severity Denial of Service (DoS) vulnerability exists in Kubeflow Pipelines prior to version 2.18.0 due to improper handling of highly compressed archive files (CWE-409). The API server decompresses uploaded ZIP and gzip-compressed TAR archives directly into memory without bounding the expanded byte stream or archive traversal metadata. Because decompression occurs before namespace-level authorization in multipart upload handlers, an unauthenticated or unauthorized attacker with network access to the API server can exhaust container heap memory and trigger Out-Of-Memory (OOM) terminations.
A credential exposure vulnerability exists in the @sigstore/oci library prior to version 0.7.2. The RegistryClient implementation stores sensitive authentication headers, including Bearer tokens, Basic authentication credentials, and custom Docker configuration headers, as client-wide defaults on its shared HTTP client instance. When uploading binary blobs via the OCI distribution specification, the client extracts the target upload URI from the server-supplied Location header and dispatches an independent HTTP PUT request. If a compromised or untrusted registry returns a cross-origin URI in the Location header, the client transmits the stored authentication credentials directly to the third-party host.
An OS command injection and argument injection vulnerability exists in the Nx build system and its official Maven (@nx/maven) and Gradle (@nx/gradle) plugins. When constructing the project dependency graph, the plugins invoke underlying Java build wrappers via subshells while interpolating unsanitized plugin options directly from nx.json into the command arguments. An attacker who supplies a crafted nx.json file via a repository or pull request can execute arbitrary shell commands with the privileges of the user or CI/CD runner invoking any Nx command that computes the project graph.
AdminNeo versions 4.6.2 through 5.4.1 contain an improper input validation vulnerability (CWE-20) in the version verification endpoint (?script=version). An unauthenticated remote attacker can submit a crafted HTTP POST request containing an array parameter for 'version', which is serialized into a shared server-side temporary cache file without type checking or authentication. On subsequent requests, the application deserializes this cached array and passes it to cookie() and rawurlencode(), triggering an uncaught fatal TypeError in PHP 8.x environments and causing a persistent 24-hour Denial of Service (DoS) for all users.