CVE-2026-21441

The Invisible Avalanche: urllib3 Decompression Bomb

Amit Schendel
Amit Schendel
Senior Security Researcher

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):
        pass

The 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):
        pass

The 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:

  1. Attacker Server: Responds to GET / with a 302 Found.
  2. Headers: Location: /nowhere and Content-Encoding: gzip.
  3. 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 Score
8.9/ 10
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:H
EPSS Probability
0.01%
Top 98% most exploited

Affected Systems

Python applications using urllib3 directlyPython applications using the `requests` library (depending on version coupling)Web scrapers and crawlersWebhook handlersCI/CD pipelines fetching external resources

Affected Versions Detail

Product
Affected Versions
Fixed Version
urllib3
urllib3
>= 1.22, < 2.6.32.6.3
AttributeDetail
CWE IDCWE-409
Attack VectorNetwork
CVSS 4.08.9 (High)
ImpactDenial of Service (Resource Exhaustion)
Affected ComponentHTTPResponse.drain_conn
Prerequisitespreload_content=False AND redirects enabled
CWE-409
Data Amplification

Improper Handling of Highly Compressed Data (Data Amplification)

Vulnerability Timeline

Vulnerability Disclosed
2026-01-07
Patch Released (v2.6.3)
2026-01-07
CVE Assigned
2026-01-08

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.