Absolute Zero Security: Smuggling Requests into aiohttp with the Kelvin Sign
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 (K, 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:
- 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 theContent-Lengthto decide where this request ends." - The Backend (aiohttp): Sees
Transfer-Encoding: chunKed. It runs.lower(), getschunked. It says, "Ah, standard chunked encoding! I will ignoreContent-Lengthand 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 FalseIf 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: barWhat 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.
- 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. - 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.
- 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=1in production unless you have a very specific reason and have patched the library.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
aiohttp aio-libs | < 3.13.3 | 3.13.3 |
| Attribute | Detail |
|---|---|
| CWE | CWE-444 (HTTP Request Smuggling) |
| CVSS v4.0 | 6.3 (Medium) |
| Attack Vector | Network (Protocol Manipulation) |
| Impact | Security Bypass / Cache Poisoning |
| Root Cause | Unicode Normalization (Kelvin Sign) |
| Affected Component | aiohttp pure-Python parser |
MITRE ATT&CK Mapping
Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.