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.5–3.13.5, yarl 1.13.1–1.23.0 (tested across the range allowed by pyproject.toml)
- Linux 6.19, x86_64
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.ClientSessionbuilds requests throughyarl.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 inmainare affected.Reproduction
A 30-line script with no external dependencies, run against Nettacker's pinned
aiohttp(3.13.5) /yarl(1.23.0):Output:
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.pylines around 308-311 and the_normalize_path_segmentshelper around line 138. The behavior is opt-out, not opt-in: the constructor takesencoded=Trueas an escape hatch that preserves the original byte sequence.aiohttp.ClientSession._build_urlcallsURL(str_or_url)withoutencoded=True. Result: every URL passed as a string is normalized.Confirmation against a simulated TeamCity-style filter (skip auth iff
requestURIstarts with/res/,/update/, or/.well-known/acme-challenge/):curl --path-as-is(raw bytes preserved)200— bypass worksaiohttp.ClientSession().get(url)401— bypass killed by clientaiohttp.ClientSession().get(yarl.URL(url, encoded=True))200— bypass worksAffected modules in
mainConfirmed by replaying each module's payload through aiohttp's URL constructor:
apache_cve_2021_41773.yamlcgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd/etc/passwdcitrix_cve_2019_19781.yamlvpn/../vpns/cfg/smb.conf/vpns/cfg/smb.confgrafana_cve_2021_43798.yamlpublic/plugins/{id}/../../../../etc/passwd/etc/passwdvite_cve_2025_31125.yaml@fs/../../../../etc/passwd?import/etc/passwd?importaiohttp_cve_2024_23334.yamlstatic/%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, andaviatrix_cve_2021_40870.yamlwere 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 withyarl.URL(url, encoded=True)before passing it tosession.<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.yamlready 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
mainat HEAD (cloned 2026-04-26)aiohttp 3.9.5–3.13.5,yarl 1.13.1–1.23.0(tested across the range allowed bypyproject.toml)