The Invisible Avalanche: urllib3 Decompression Bomb
Jan 8, 2026·5 min read
Executive Summary (TL;DR)
When using urllib3's streaming API, the library automatically follows redirects. During this process, it attempts to 'drain' and clean up the connection of the redirect response. Due to a default argument oversight, this cleanup process decompressed the response body. An attacker can serve a small, highly compressed payload (a zip bomb) inside a 302 Redirect, causing the client to exhaust memory and crash while attempting to process the internal redirect.
A resource exhaustion vulnerability in the ubiquitous urllib3 Python library allows attackers to crash applications via malicious HTTP redirects containing compressed 'bombs'.
The Hook: The Invisible Chokehold
urllib3 is the unspoken titan of the Python ecosystem. It powers requests, the AWS CLI, and likely the script managing your Kubernetes cluster. One of its "power user" features is the streaming API—invoked by setting preload_content=False. You use this when you want to download a massive file without loading it all into RAM. You are telling the library: "Hand me the socket, I'll deal with the bytes."
Ideally, this is the safest way to handle untrusted data. But CVE-2026-21441 turns this logic on its head. In versions prior to 2.6.3, asking for a stream while following a redirect is like opening a door to check who knocked, only to have an ocean flood your house.
The vulnerability is particularly insidious because it triggers during an internal housekeeping process. You, the developer, never even see the malicious response object. Your code is waiting for the final destination, but the library is busy suffocating on the journey there.
The Flaw: The "Helpful" Janitor
To understand the bug, you have to understand HTTP keep-alive. When urllib3 receives an HTTP Redirect (like a 301 or 302) and automatic redirection is on (the default), it wants to reuse the underlying TCP connection for the next request. However, the current socket might still have data sitting in the receive buffer—specifically, the body of the 302 response itself (which servers often send, even if browsers ignore it).
To clean the socket, urllib3 calls an internal method named drain_conn(). Think of this as flushing the pipes before the next usage. The problem? The library didn't just flush the pipes; it inspected the sewage.
The drain_conn() method blindly called the standard read() function to empty the buffer. In urllib3, read() defaults to respecting Content-Encoding headers. So, if a malicious server sends a redirect with a Content-Encoding: gzip header and a body full of compressed zeros, urllib3 silently sits there, decompressing gigabytes of data into your RAM, all while "cleaning up" to get ready for a request you'll never get to make.
The Code: One Argument to Rule Them All
The vulnerability lived in urllib3/response.py. It is a textbook case of dangerous default arguments. The developer intended to discard the data, but by calling read() without arguments, they implicitly opted into decompression.
Here is the vulnerable code in the drain_conn method (pre-2.6.3):
def drain_conn(self) -> None:
try:
# FATAL FLAW: read() defaults to decode_content=True
self.read()
except (HTTPError, OSError, BaseSSLError, HTTPException):
passThe fix was absurdly simple: explicitly tell the reader not to decode the content unless the user had already started decoding it (which, in this auto-drain scenario, they haven't).
Here is the patch from commit 8864ac40:
def drain_conn(self) -> None:
"""
Unread data in the HTTPResponse connection blocks the connection from being released back to the pool.
"""
try:
self.read(
# FIX: Do not spend resources decoding the content unless
# decoding has already been initiated.
decode_content=self._has_decoded_content,
)
except (HTTPError, OSError, BaseSSLError, HTTPException):
passThe Exploit: The 42KB Apocalypse
Let's break things. To exploit this, we don't need fancy memory corruption or ROP chains. We just need a Python script and a malicious Flask server.
The Setup:
- Attacker Server: Responds to
GET /with a302 Found. - Headers:
Location: /nowhereandContent-Encoding: gzip. - Payload: A "zip bomb". A string of zeros, repeated and compressed. A 42KB payload of nested gzip streams can expand to 4.5 Petabytes (though the process will crash long before that).
The Trigger:
import urllib3
http = urllib3.PoolManager()
# "I'll just stream this safely," says the victim.
# preload_content=False is the key requirement.
try:
r = http.request('GET', 'http://evil-server.com/', preload_content=False)
except MemoryError:
print("Boom. System down.")When PoolManager sees the 302, it pauses to drain the response body so it can follow the Location header. The Gzip decoder kicks in. It allocates memory for the first chunk. Then the second. The expansion ratio is exponential. The Python process swells until the OOM (Out of Memory) killer steps in and puts it out of its misery.
The Impact: Silent but Deadly
Why is this a High Severity (8.9) issue? Because it bypasses the standard defenses developers put in place.
If I write code to fetch a URL, I might protect myself by checking Content-Length or reading in chunks. But this vulnerability triggers before control is returned to my code. I never get the chance to say "Stop!" because urllib3 is trying to be helpful behind the scenes.
This is a dream vector for Denial of Service against:
- Webhooks: Services that fetch user-provided URLs.
- Link Preview Bots: Slack/Discord bots fetching metadata.
- Proxy Services: Any tool that forwards HTTP requests.
Since urllib3 is a dependency of requests, which is a dependency of everything, the blast radius is massive.
The Fix: Stop the Bleeding
The remediation is straightforward, but urgency is required due to the popularity of the library.
Primary Fix:
Upgrade to urllib3 version 2.6.3 or later immediately.
Workaround: If you are stuck in a legacy environment (we've all been there) and cannot upgrade, you must disable automatic redirects for any request involving untrusted input. You must handle the redirection logic manually.
# Safe(r) usage on vulnerable versions
response = http.request(
"GET",
"http://untrusted.com",
preload_content=False,
redirect=False # <--- The safety switch
)
# You now have the 302 response object.
# Do NOT call response.read() without checking headers!Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
urllib3 urllib3 | >= 1.22, < 2.6.3 | 2.6.3 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-409 |
| Attack Vector | Network |
| CVSS 4.0 | 8.9 (High) |
| Impact | Denial of Service (Resource Exhaustion) |
| Affected Component | HTTPResponse.drain_conn |
| Prerequisites | preload_content=False AND redirects enabled |
MITRE ATT&CK Mapping
Improper Handling of Highly Compressed Data (Data Amplification)
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.