CVE-2025-69224

Absolute Zero Security: Smuggling Requests into aiohttp with the Kelvin Sign

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 5, 2026·7 min read

Executive Summary (TL;DR)

aiohttp's pure-Python parser incorrectly normalizes certain Unicode characters (like the Kelvin sign) into ASCII during HTTP header processing. This allows 'chunKed' to become 'chunked' on the backend, while proxies see it as garbage. The resulting desynchronization enables HTTP Request Smuggling.

A high-impact HTTP Request Smuggling vulnerability in aiohttp's pure-Python parser allows attackers to bypass security controls using Unicode case-folding anomalies (specifically the Kelvin sign 'K').

The Hook: When Physics Meets HTTP

It is 2025, and we are still smuggling HTTP requests. You would think by now we’d have figured out how to parse a text-based protocol without accidentally lighting the server on fire, but here we are. This time, the culprit isn't a complex memory corruption bug or a sophisticated logic error in a C library. It's a fundamental quirk of how Python handles Unicode, specifically when it tries to be helpful.

The target is aiohttp, the go-to asynchronous HTTP client/server framework for Python. If you are building high-concurrency web apps in Python, you are probably using this. Under the hood, aiohttp usually relies on a high-speed C extension to parse HTTP. It’s fast, it’s strict, and it’s generally safe. But, if you are running on PyPy, or if you were paranoid enough to disable C extensions (via AIOHTTP_NO_EXTENSIONS=1), or if the build just failed silently, you fall back to the pure-Python parser.

And that is where the demons live. That pure-Python parser tries to normalize headers to handle case-insensitivity. But unlike C, which treats bytes as bytes, Python treats strings as rich, semantic Unicode objects. This leads to a scenario where a mathematical symbol for temperature can act as a skeleton key for your backend infrastructure.

The Flaw: The Unicode Trojan Horse

The vulnerability (CVE-2025-69224) is a classic HTTP Request Smuggling issue, specifically a CL.TE (Content-Length vs. Transfer-Encoding) desynchronization. The root cause is a feature in Python's str.lower() method. In the English language, lowercasing is simple: 'A' becomes 'a'. But in the vast world of Unicode, things get weird. Some characters, when lowercased, transform into entirely different ASCII characters.

The star of today's show is the Kelvin Sign (, U+212A). To the naked eye, and to most dumb C-based proxies (like Nginx, HAProxy, or AWS ALB), this looks like a piece of garbage or just a random extended character. It certainly doesn't look like the letter 'k'.

However, watch what happens in Python:

>>> 'chunKed'.lower()
'chunked'

The aiohttp parser was taking header values and immediately running .lower() on them to check for standard values like chunked, gzip, or websocket. It didn't check if the string was ASCII first. This creates a beautiful schism in reality:

  1. The Proxy (Frontend): Sees Transfer-Encoding: chunKed. It says, "I don't know what 'chunKed' is. I'm going to ignore this header and use the Content-Length to decide where this request ends."
  2. The Backend (aiohttp): Sees Transfer-Encoding: chunKed. It runs .lower(), gets chunked. It says, "Ah, standard chunked encoding! I will ignore Content-Length and process the chunks."

Boom. Desynchronization. The proxy thinks the request is one length; the backend thinks it's another. The bytes remaining in the buffer become the start of the next request.

The Code: Diffing the Disaster

Let's look at the smoking gun in aiohttp/http_parser.py. The developers were checking for chunked transfer encoding by splitting the header and normalizing it. This is where the logic failed.

The Vulnerable Code:

# Vulnerable implementation
def _is_chunked_te(self, te: str) -> bool:
    te = te.rsplit(",", maxsplit=1)[-1].strip(" \t")
    # The fatal flaw: .lower() converts Kelvin sign to 'k'
    if te.lower() == "chunked":
        return True
    return False

If you send chunKed, the if statement evaluates to True. The parser switches to chunked mode, but the upstream proxy (which likely follows RFC 7230 more strictly regarding ASCII) has no idea this is happening.

The Fix (Commit 32677f2):

The fix is delightfully simple: stop trusting Unicode. The patch forces an .isascii() check before attempting to normalize the string. If the header contains fancy Unicode symbols, it's rejected immediately as a bad message or treated as a non-match.

# Patched implementation
def _is_chunked_te(self, te: str) -> bool:
    te = te.rsplit(",", maxsplit=1)[-1].strip(" \t")
    # The shield: Ensure it's ASCII first
    if te.isascii() and te.lower() == "chunked":
        return True
    raise BadHttpMessage("Request has invalid `Transfer-Encoding`")

This same pattern was applied to the Upgrade header check (where websocKet could smuggle a WebSocket upgrade) and Content-Encoding checks.

The Exploit: Smuggling with Science

To exploit this, we need to construct a payload that fools the proxy into using Content-Length but convinces aiohttp to use Transfer-Encoding. We use the Kelvin sign (\u212a) in the TE header.

Here is a conceptual breakdown of the attack request. Note that 0 indicates the end of a chunk in chunked encoding.

The Attack Payload:

POST / HTTP/1.1
Host: vulnerable-target.com
Content-Length: 6
Transfer-Encoding: chunKed
 
0
 
GET /admin/delete_user?id=1 HTTP/1.1
Host: vulnerable-target.com
Foo: bar

What the Proxy Sees (CL Interpretation): The Proxy sees Content-Length: 6. It reads the headers, then reads the body: 0\r\n\r\n. That is exactly 6 bytes (depending on newline handling). It thinks the request is done. It forwards this to the backend.

What aiohttp Sees (TE Interpretation): aiohttp receives the headers. It normalizes chunKed to chunked. It ignores Content-Length. It sees the 0 chunk immediately, which signifies the end of the request body.

The Smuggled Request: The remaining bytes (GET /admin...) are now sitting in the TCP buffer of the aiohttp server. When the next legitimate user sends a request, aiohttp will read these bytes first, thinking they are the start of that user's request. That user effectively executes your GET /admin request with their cookies/session. This is Mass Assignment via socket poisoning.

The Impact: Why You Should Care

Request smuggling is the "silent killer" of web architecture. It doesn't crash the server; it poisons the data stream.

  1. Cache Poisoning: An attacker can smuggle a request that fetches a malicious JavaScript file. If the cache stores this response under the key of a legitimate request (e.g., jquery.js), every user on the site gets served the malware.
  2. Auth Bypass: As shown in the exploit section, you can force other users to execute actions on your behalf. If the smuggled request hits an endpoint that relies on the victim's session cookie (which comes in strictly after the smuggled prefix), you own their account.
  3. WAF Bypass: Most WAFs sit at the proxy level. If the WAF parses the request one way (benign) and the backend parses it another (malicious), the payload slips right through invisible curtains.

For aiohttp, this is particularly nasty because the pure-Python parser is often the default in Docker containers built on minimal Alpine images or environments where compiling C extensions failed silently.

The Fix: IsASCII or Die

The mitigation here is straightforward but critical. You have two main paths:

1. Patch Immediately Upgrade aiohttp to version 3.13.3 or later. The patch is robust; it explicitly forbids non-ASCII characters in these critical headers. If you try to pull the Kelvin trick on the new version, aiohttp will throw a BadHttpMessage exception and close the connection.

2. Enforce C Extensions The C-based parser in aiohttp (built on llhttp) typically does not suffer from these Python-specific string normalization issues. Ensure your production environment has the necessary build tools (gcc, python-dev, etc.) to compile the extensions. Check your logs or application startup to ensure it hasn't fallen back to the pure-Python parser.

[!WARNING] Do not set AIOHTTP_NO_EXTENSIONS=1 in production unless you have a very specific reason and have patched the library.

Fix Analysis (1)

Technical Appendix

CVSS Score
6.3/ 10
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N

Affected Systems

aiohttp < 3.13.3 (Pure Python parser mode)Applications using AIOHTTP_NO_EXTENSIONS=1PyPy environments running aiohttp

Affected Versions Detail

Product
Affected Versions
Fixed Version
aiohttp
aio-libs
< 3.13.33.13.3
AttributeDetail
CWECWE-444 (HTTP Request Smuggling)
CVSS v4.06.3 (Medium)
Attack VectorNetwork (Protocol Manipulation)
ImpactSecurity Bypass / Cache Poisoning
Root CauseUnicode Normalization (Kelvin Sign)
Affected Componentaiohttp pure-Python parser
CWE-444
HTTP Request Smuggling

Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')

Vulnerability Timeline

Fix committed to aiohttp repository
2025-01-03
GHSA-69f9-5gxw-wvc2 Published
2025-01-05
aiohttp v3.13.3 Released
2025-01-05

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.