ATS authproxy prefix duplicate header plants identity
Apache Traffic Server 10.2.0 authproxy --forward-header-prefix overlays identity from a 2xx auth helper and overwrites only the first client field. Extra copies survive. Origin last-wins treats the client value as identity. Labbed. Plugin on. No CVE yet.
- Name
- ATS authproxy prefix duplicate header plants identity
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Apache Traffic Server (ASF)
- Affected
- Apache Traffic Server 10.2.0 (b1df7334) authproxy with --forward-header-prefix. Client who already gets a 2xx from the helper. Stock plugin.config is empty. Not default.
- Published
- 13 Oct 2026
- Updated
- 14 Oct 2026
- Tags
- 0-day, trafficserver, authproxy, privilege-leftover, cwe-444, cwe-863
HttpSetMimeHeader overwrites the first line. HTTP has never been a first-line protocol.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/ats-authproxy-prefix (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, auth.py, origin.py. Authorized lab only. It talks to loopback.
This is Apache Traffic Server 10.2.0 (b1df7334b39a878971c376da849e8da4984918b6). No CVE yet. CWE-444 / CWE-863. 8.1 High (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H). Remote privilege leftover. Plugin on. PR:N once authproxy is loaded with --forward-header-prefix.
--forward-header-prefix is the documented way to mark identity headers as coming from the authorization service. After a 2xx, StateAuthorized copies matching fields from the auth response onto the original client request. It never strips client copies of those names first. HttpSetMimeHeader finds the first field, clears its values, inserts the auth value, and appends. Duplicate client fields of the same name (TSMimeHdrFieldNextDup) are not destroyed. An origin that last-wins X-Auth-User treats the leftover client line as the user.
The client still has to pass auth. A 403 helper never reaches origin. A single planted header is overwritten. Two lines is the leftover.
Why Traffic Server
ATS is the other large ASF reverse proxy. People put it in front of an origin that is not supposed to see the internet, then load a plugin so the origin can trust a header. That is a security product whether plugin.config ships empty or not.
I only wanted Critical or High. Remote preferred. Local in-scope. Medium stays on the floor unless it chains. Stock reverse-proxy 10.2.0 (empty plugin.config, remap_required: 1, at least one map) has no unpublished RCE on this pin. The July 2026 wave already closed hop-by-hop / @ strip, H2/H3 Transfer-Encoding, SNI/Host full-length compare, HPACK encode-before-confirm, origin-session empty-buffer pool, ESI include validator, CONNECT dest deny, regex_map full-host anchor.
A leftover on this hunt needed a named guard that did not land, or a plugin path the changelog mentioned next to a crash fix and then forgot to strip.
authproxy's changelog on 10.2.0 is the empty-header assert, request-body framing strip, and StateAuthorized handle release. None of those strip client prefix headers.
Pinning 10.2.0
Latest Stable on the project site the day I cloned was 10.2.0, 17 Aug 2026. Tag object d89fdd257ea3e6cd0e70bcae9d36f3b7355af279. Peel landed on b1df7334b39a878971c376da849e8da4984918b6. CMake project(ats VERSION 10.2.0). I did not update it when a later commit appeared. Master moves. This pin is the thing operators pull.
Lab image ats-10.2.0-lab:local is a source build of that peel on loopback. Not a distro package. Not a plugin ABI guess.
The skip list is half the hunt
SECURITY.md trusts administrative users and configuration files. Stock plugin.config is empty. Experimental plugins are unstable unless the hole is serious. I still hunted them.
Already public on this pin, so not this post:
- CVE-2026-33267 hop-by-hop /
@headers not stripped - CVE-2026-58150 HTTP/2 Transfer-Encoding smuggling
- CVE-2026-58157 server-session reuse
- CVE-2026-22068 unanchored regex ACL/SNI/plugin
- CVE-2026-58162 certifier unsafe SNI
- CVE-2026-58181 uri_signing / url_sig stack
- CVE-2026-65100 HPACK encode-before-confirm
- CVE-2026-65325 / CVE-2026-102795 SNI to Host
url_sig C= without a delimiter denies. uri_signing verify is cjose_jws_verify against configured JWKs. access_control hash names are SHA-256/SHA-512 only. certifier SNI is allow-listed before any store path is built. Those are closed.
Twenty-four surfaces, then this overlay
I split the tree: HTTP/1.1 parser, HTTP/2, MIME/HdrHeap, TLS/SNI, remap/ACL, cache, DNS/HostDB, PROXY protocol, JSON-RPC, origin session reuse, ESI, header_rewrite, Lua, authproxy/certifier/url_sig, regex_remap, xdebug/stats, experimental plugins, Cripts, SSRF/redirects, UDS/http+unix, Range/slice/compress, parent/NextHop, logging, HTTP/3.
NONE at this bar: http1-parser, http2, mime-headers, tls-sni, cache, dns-hostdb, proxy-protocol, session-reuse, plugin-header-rewrite, plugin-debug-stats, uds-unix-remap, range-slice-compress, parent-nighthop, logging, http3-quic.
Default reverse-proxy leftovers exist (mixed-case method vs ip_allow.yaml, JSON-RPC UDS). Plugin leftovers exist (regex_remap userinfo, ESI unix recv-port). This post is the authproxy one.
origin_server_auth already walks NextDup and destroys extras. access_control setHeader does the same. authproxy HttpRemoveMimeHeader already walks every instance - for Transfer-Encoding / Trailer / Expect on the auth sub-request. It is sitting in the same utils file. It is not used on the client request for the configured prefix.
That is the leftover. Not a new smuggling CVE. A duplicate-header plant next to a 2xx.
How the overlay actually works
--auth-transform=redirect (the lab) copies the client request to an auth helper. 2xx means proceed. StateAuthorized then:
if (!options->forward_header_prefix.empty()) {
// Copy headers with configured prefix in the authentication response
// to the original request
...
if (key && val && ContainsPrefix(..., options->forward_header_prefix)) {
HttpSetMimeHeader(request_bufp, request_hdr, key, val);
}
}HttpSetMimeHeader (plugins/authproxy/utils.cc:87-102):
mloc = TSMimeHdrFieldFind(mbuf, mhdr, name.data(), name.size());
if (mloc == TS_NULL_MLOC) {
TSMimeHdrFieldCreateNamed(...);
} else {
TSMimeHdrFieldValuesClear(mbuf, mhdr, mloc);
}
TSMimeHdrFieldValueStringInsert(...);
TSMimeHdrFieldAppend(...);TSMimeHdrFieldFind returns the first field. ValuesClear clears that field. The second X-Auth-User on the client request is a different MLoc. It is still there when the request goes to origin.
The helper in the lab always writes X-Auth-User: realuser on 2xx. Origin last-wins. CONTROL none: client sent nothing, origin sees only realuser, body REALUSER-ONLY. CONTROL single: client sent one witness line, first field overwritten, body still REALUSER-ONLY. POC two lines: realuser then ATS-AUTHPROXY-PREFIX-WITNESS. Origin last-wins the witness.
CONTROL 403: helper denies. Origin hits unchanged. Witness not in the body.
--auth-transform=head or range can make the helper a HEAD/Range against the same origin. If that origin 200s HEAD for unauthenticated clients, the plant is PR:N without a separate auth service. Combining those options is operator choice. The lab used redirect plus a dedicated helper so the 2xx/403 split is obvious.
Wrong turns, in order
Treating no-prefix as the leftover. Without --forward-header-prefix the overlay block is skipped. Client headers still go to origin as ordinary HTTP. Origin must not trust them. The option exists because operators do trust them.
Claiming this is unauth when the helper 403s. It is not. 403 never reaches origin. The client has to pass auth. The leftover is identity after a 2xx, not an auth skip.
Calling it RCE. It is a header plant. What the origin then grants is the origin's problem. Lab origin last-wins a string. A real origin last-wins a user.
Re-filing hop-by-hop. CVE-2026-33267 already strips Connection-listed names and @ headers before plugin hooks. X-Auth-User is not hop-by-hop.
Empty auth-response crash. Closed. TSMimeHdrFieldGet(..., 0) returning null skips the loop. That was the changelog item. This leftover is the identity overlay next to that loop.
A reverse shell. Theatre. The oracle is dup-last-wins=yes plus REALUSER-ONLY on the controls.
The lab
Loopback 127.0.0.1:18760. Origin 18761. Auth helper 18762. Compose ats-authproxy-prefix. Image ats-10.2.0-lab:local. Remap loads authproxy.so with --forward-header-prefix=X-Auth-. Helper 2xx writes X-Auth-User: realuser. Origin prints every X-Auth-User and last-wins.
Last line:
SUCCESS ats-authproxy-prefix pin=10.2.0 control-none=REALUSER-ONLY control-single=REALUSER-ONLY control-403=yes dup-last-wins=yes ATS-AUTHPROXY-PREFIX-WITNESSWhat an attacker can do
Reach an ATS remap that loads authproxy.so with --forward-header-prefix=X-Auth-. Send two X-Auth-User lines on a request the helper accepts as 2xx.
| Method | Sink | Privilege |
|---|---|---|
Duplicate X-Auth-User after a 2xx auth helper |
StateAuthorized overlay then HttpSetMimeHeader (authproxy.cc:660-690, utils.cc:87-102) |
Integrity (lab-proven): origin last-wins. Lab: X-Auth-User-0: realuser plus X-Auth-User-1: ATS-AUTHPROXY-PREFIX-WITNESS. CONTROL none/single stay REALUSER-ONLY. |
| Same plant | origin ACL / user lookup keyed on that header | Recon (inferred): whatever that planted identity can read at origin. Not a heap dump. Not ATS config. |
| Auth helper 403 with the same duplicates | StateUnauthorized; origin not reenabled |
Closed (lab-proven): origin hits unchanged. Client 403. Witness not in the body. |
Lab-proven vs inferred: duplicate last-wins, single overwrite, none, and 403-not-reached are lab. Origin privilege after plant is inferred from last-wins identity.
Requires: authproxy.so with --forward-header-prefix. Client must pass auth (2xx). Origin must treat those prefix headers as identity (the reason the option exists). Not loaded in default plugin.config.
You get the origin's idea of identity. You do not skip the helper. You do not crash traffic_server. You do not write ATS files. You do not get certifier SNI, url_sig C=, or hop-by-hop smuggling. Those are closed.
Adjacent surface that stays closed
| Surface | Why |
|---|---|
| No client prefix header | CONTROL none: origin REALUSER-ONLY |
| Single client prefix | CONTROL single: first field overwritten; witness does not survive |
| Auth helper 403 | origin not reached (control-403=yes) |
| Plugin not loaded | leftover does not apply |
Hop-by-hop / @ headers |
stripped before plugin hooks (CVE-2026-33267) |
| Empty auth-response header list | no longer asserts |
certifier SNI / url_sig C= |
closed on this pin |
The fix
Strip every client field whose name starts with --forward-header-prefix before overlay. HttpRemoveMimeHeader already walks TSMimeHdrFieldFind until none remain. Use it on the client request for the configured prefix, then set. Make HttpSetMimeHeader destroy TSMimeHdrFieldNextDup extras the way origin_server_auth set_header already does.
Until then, do not point origin ACL at authproxy prefix headers unless the origin first-wins, or reject duplicate prefix names at the origin.
I am not going to print a curl you can paste at someone else's cache. The missing strip is the useful part. If you own the proxy, run the proof of concept against loopback.
References
- Proof of concept: abraxas/ats-authproxy-prefix · ats-authproxy-prefix-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-444 · CWE-863
- Tree: apache/trafficserver 10.2.0 (
b1df7334b39a878971c376da849e8da4984918b6) ·authproxy.cc·utils.cc - authproxy admin guide
- Contrast:
origin_server_auth.ccset_headerwalks NextDup