Skip to content

HTTP modules with .. in the URL path are silently broken (aiohttp/yarl normalize before sending) #1532

@juandiego-bmu

Description

@juandiego-bmu

HTTP modules with .. in the URL path are silently broken (aiohttp/yarl normalize before sending)

TL;DR

Modules whose payloads put dot-segments (.., %2e%2e, .%2e, %2e.) inside the URL path never test what they claim to test. aiohttp.ClientSession builds requests through yarl.URL(str), which decodes percent-encoded dots and collapses dot-segments before the bytes hit the wire. The target server receives the already-flattened path, so the bypass condition the module is checking for never triggers. Five modules currently in main are affected.

Reproduction

A 30-line script with no external dependencies, run against Nettacker's pinned aiohttp (3.13.5) / yarl (1.23.0):

import asyncio, socket, threading, aiohttp, time

received = []
def echo():
    s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("127.0.0.1", 19000)); s.listen(8)
    while True:
        c,_ = s.accept(); d = b""
        while b"\r\n\r\n" not in d:
            ch = c.recv(4096)
            if not ch: break
            d += ch
        received.append(d.split(b"\r\n", 1)[0].decode("latin-1", "replace"))
        c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); c.close()

threading.Thread(target=echo, daemon=True).start(); time.sleep(0.3)

async def go():
    async with aiohttp.ClientSession() as s:
        for url in [
            "http://127.0.0.1:19000/res/%2e%2e/admin/diagnostic.jsp",
            "http://127.0.0.1:19000/cgi-bin/.%2e/%2e%2e/etc/passwd",
            "http://127.0.0.1:19000/vpn/../vpns/cfg/smb.conf",
        ]:
            received.clear()
            async with s.get(url, allow_redirects=False) as r: pass
            print(f"input  : {url}\nonwire : {received[0]}\n")

asyncio.run(go())

Output:

input  : http://127.0.0.1:19000/res/%2e%2e/admin/diagnostic.jsp
onwire : GET /admin/diagnostic.jsp HTTP/1.1

input  : http://127.0.0.1:19000/cgi-bin/.%2e/%2e%2e/etc/passwd
onwire : GET /etc/passwd HTTP/1.1

input  : http://127.0.0.1:19000/vpn/../vpns/cfg/smb.conf
onwire : GET /vpns/cfg/smb.conf HTTP/1.1

The /res/, /cgi-bin/, and /vpn/ prefixes are gone before the request leaves the client. Whatever auth-filter or path-confusion logic the CVE relies on never sees them.

Why this happens

yarl.URL(str) runs the path through _PATH_REQUOTER (decodes %2e.) and then _normalize_path (drops . and .. segments). Source: yarl/_url.py lines around 308-311 and the _normalize_path_segments helper around line 138. The behavior is opt-out, not opt-in: the constructor takes encoded=True as an escape hatch that preserves the original byte sequence.

aiohttp.ClientSession._build_url calls URL(str_or_url) without encoded=True. Result: every URL passed as a string is normalized.

Confirmation against a simulated TeamCity-style filter (skip auth iff requestURI starts with /res/, /update/, or /.well-known/acme-challenge/):

Client Status
curl --path-as-is (raw bytes preserved) 200 — bypass works
aiohttp.ClientSession().get(url) 401 — bypass killed by client
aiohttp.ClientSession().get(yarl.URL(url, encoded=True)) 200 — bypass works

Affected modules in main

Confirmed by replaying each module's payload through aiohttp's URL constructor:

Module Declared payload (path fragment) What aiohttp puts on the wire
apache_cve_2021_41773.yaml cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd /etc/passwd
citrix_cve_2019_19781.yaml vpn/../vpns/cfg/smb.conf /vpns/cfg/smb.conf
grafana_cve_2021_43798.yaml public/plugins/{id}/../../../../etc/passwd /etc/passwd
vite_cve_2025_31125.yaml @fs/../../../../etc/passwd?import /etc/passwd?import
aiohttp_cve_2024_23334.yaml static/%2e%2e%2f%2e%2e%2fetc/passwd /static/..%2F..%2Fetc/passwd (partially preserved; depends on target server's URL parser)

Modules whose traversal lives in the query string or POST body are unaffected. wp_plugin_cve_2021_39316.yaml, adobe_coldfusion_cve_2023_26360.yaml, and aviatrix_cve_2021_40870.yaml were checked and pass through cleanly.

Why this isn't caught in CI

Module tests run against mock fixtures rather than vulnerable targets, so a request that arrives at the mock with a flattened path still matches the expected response (the mock doesn't enforce the auth filter). The bug only shows up when the module is run against a real instance of the vulnerable software, which CI doesn't do.

Proposed fix

Add an opt-in YAML flag (url_raw: true) that, at request-build time, wraps the final URL with yarl.URL(url, encoded=True) before passing it to session.<method>. Default behavior unchanged, so no existing module is impacted unless its YAML opts in.

Concretely the change is two-line in nettacker/core/lib/http.py::send_request, plus documenting the flag in the module-writing guide.

I have a working patch and a regression update for apache_cve_2021_41773.yaml ready to send as a PR. I will follow up with a separate PR for a new TeamCity CVE-2024-27199 module that exercises the same code path.

Environment

  • Nettacker main at HEAD (cloned 2026-04-26)
  • Python 3.13, aiohttp 3.9.53.13.5, yarl 1.13.11.23.0 (tested across the range allowed by pyproject.toml)
  • Linux 6.19, x86_64

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions