Feb 28, 2026·7 min read·36 visits
AIOHTTP's pure-Python parser is vulnerable to Request Smuggling (CL.TE) because it normalizes Unicode characters to ASCII in HTTP headers. Attackers can bypass proxies by sending headers like 'Transfer-Encoding: chunKed', which aiohttp interprets as 'chunked'. This affects versions prior to 3.13.3 where C extensions are disabled.
A critical HTTP Request Smuggling vulnerability exists in aiohttp's pure-Python HTTP parser due to improper handling of Unicode characters during header processing. Specifically, the parser performs case normalization on header values before validating their content, allowing non-ASCII characters (such as the Kelvin sign 'K') to transform into ASCII keywords (like 'k'). This behavior creates a parsing discrepancy between strict upstream proxies and the aiohttp backend, enabling attackers to smuggle requests, bypass security controls, and poison caches.
CVE-2025-69224 identifies a significant logic flaw in the pure-Python implementation of the aiohttp HTTP parser. The vulnerability manifests as HTTP Request Smuggling (CWE-444), a class of attacks where the frontend server (load balancer or reverse proxy) and the backend application server disagree on the boundaries of an HTTP request. This specific issue arises because aiohttp attempts to normalize HTTP headers using Python's standard string manipulation methods without first enforcing strict ASCII compliance, as required by RFC 9112.
The core of the vulnerability lies in the handling of Transfer-Encoding and Range headers. When aiohttp is deployed without its C extensions (common in PyPy environments or when compilation fails), it falls back to a Python-based parser. This parser accepts non-ASCII Unicode characters in header values. When these values are processed, Python's Unicode normalization rules allow certain symbols to 'collapse' into ASCII characters. For example, the Unicode Kelvin sign (U+212A) transforms into the ASCII letter 'k' when lowercased.
In a typical production environment, aiohttp sits behind a reverse proxy like Nginx or HAProxy. These proxies are generally written in C and strictly adhere to HTTP standards, rejecting or treating non-ASCII headers as opaque strings. This discrepancy—where the proxy sees a malformed header but aiohttp sees a valid directive—allows an attacker to desynchronize the request stream. This can lead to unauthorized access, cache poisoning, or the bypassing of firewall rules.
The root cause is the misuse of Python's str.lower() method and re module on untrusted HTTP input without prior ASCII validation. The HTTP/1.1 specification requires header field values to be visible ASCII characters. However, the vulnerable aiohttp code applied .lower() to header values to perform case-insensitive comparisons against keywords like chunked, gzip, or upgrade.
In Python, str.lower() is Unicode-aware. It handles characters that have case mappings defined in the Unicode standard. The most critical gadget for this exploit is the Kelvin Sign (K, U+212A). When .lower() is called on this character, Python returns the ASCII character k (U+006B). An attacker can construct the header Transfer-Encoding: chunKed. A standard proxy sees chunKed, does not recognize the keyword chunked, and defaults to using the Content-Length for framing. The aiohttp parser converts this to chunked, enabling Chunked Transfer Encoding.
A secondary root cause exists in the processing of the Range header. The parser used the regular expression \d to identify byte ranges. In Python 3, \d matches any Unicode decimal digit, not just [0-9]. This includes characters like Devanagari digits (e.g., '५'). Consequently, aiohttp would interpret these characters as valid numbers for range requests, while upstream systems would likely reject them or interpret them differently, leading to further desynchronization or logic errors.
The vulnerability exists in aiohttp/http_parser.py, specifically within the HttpRequestParser class. The fix involves ensuring that sensitive header values contain only ASCII characters before any normalization or logic processing occurs.
Vulnerable Code (Simplified):
# Inside the pure-Python parser
def _is_chunked_te(self, te: str) -> bool:
# The parser splits the header and immediately lowercases it
# Vulnerability: .lower() transforms 'K' to 'k'
val = te.rsplit(",", maxsplit=1)[-1].strip()
return val.lower() == "chunked"Patched Code (Commit 32677f2):
The patch introduces a strict isascii() check. If the header value contains non-ASCII characters, it is rejected before comparison. Additionally, regex compilations were updated to use the re.ASCII flag.
# Corrected implementation
def _is_chunked_te(self, te: str) -> bool:
val = te.rsplit(",", maxsplit=1)[-1].strip()
# FIX: Explicitly check for ASCII compliance first
if val.isascii() and val.lower() == "chunked":
return True
# If non-ASCII or not chunked, raise error or return False
raise BadHttpMessage("Request has invalid `Transfer-Encoding`")Similarly, for the Range header, the regex was updated:
# Before: Matches Unicode digits
RANGE_PATTERN = re.compile(r"bytes=(\d+)-(\d+)")
# After: Matches only ASCII [0-9]
RANGE_PATTERN = re.compile(r"bytes=(\d+)-(\d+)", re.ASCII)To exploit this vulnerability, an attacker requires a target environment where aiohttp is running with the pure-Python parser (e.g., AIOHTTP_NO_EXTENSIONS=1) behind a reverse proxy that uses Content-Length (CL) preference when Transfer-Encoding is invalid.
Transfer-Encoding: chunKed. They calculate the Content-Length to include the entire body, plus the smuggled prefix.Transfer-Encoding: chunKed. Since chunKed != chunked, Nginx ignores the TE header and uses the Content-Length. It forwards the full payload as a single request body.aiohttp receives the request. It parses Transfer-Encoding: chunKed, normalizes it to chunked, and enables chunked decoding. It reads the first chunk (defined by the attacker) and stops when it hits a 0 chunk terminator.aiohttp treats this leftover data as the beginning of the next request.> [!WARNING]
> This allows the attacker to prefix the next legitimate user's request with arbitrary headers or content. This can be used to steal cookies, redirect users to malicious sites, or bypass authentication checks that rely on frontend headers (e.g., X-Forwarded-For).
The impact of CVE-2025-69224 is classified as Medium (CVSS 6.5) but can be critical depending on the deployment architecture. Request smuggling attacks break the integrity of the HTTP request stream, effectively bypassing the security model enforced by frontend proxies.
Specific Consequences:
It is important to note that the vulnerability is limited to the pure-Python parser. Installations using the default C extensions (llhttp) are not vulnerable, which significantly mitigates the widespread risk.
The primary remediation is to upgrade aiohttp to version 3.13.3 or later. This version patches the pure-Python parser to enforce strict ASCII validation on Transfer-Encoding, Content-Encoding, and other critical headers.
Immediate Workarounds:
If an immediate upgrade is not feasible, ensure that aiohttp is utilizing its C extensions. The vulnerability is specific to the fallback Python implementation. Verify the environment:
AIOHTTP_NO_EXTENSIONS is not set to 1.aiohttp so that the C extensions were built correctly.import aiohttp
print(aiohttp.http_parser.HttpRequestParser)
# Should NOT be a pure python class if extensions are loadedWAF Rules:
Deploy WAF rules to block incoming requests containing non-ASCII characters in standard HTTP headers. Specifically, block Transfer-Encoding headers that match the regex [^\x20-\x7E].
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
aiohttp aio-libs | <= 3.13.2 | 3.13.3 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-444 (Request Smuggling) |
| Attack Vector | Network |
| CVSS v3.1 | 6.5 (Medium) |
| EPSS Score | 0.04% |
| Exploit Status | Proof-of-Concept Available |
| Affected Component | Pure-Python HTTP Parser |
Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')