Summary
Aperture EdgeProxy is a popular L7 reverse proxy that terminates client TLS over HTTP/1.1 and multiplexes upstream traffic to an internal control plane over HTTP/2. We found that the two parsers disagree on how a folded, over-long X-Forwarded-Host header is normalized. That disagreement is enough to inject a fully attacker-controlled header into the admin backend — which happens to render it through a Jinja-style template. The result is unauthenticated remote code execution as root on the proxy node.
This is an illustrative advisory using a fictional vendor and product. The techniques are real; Aperture EdgeProxy is not.
The desync
The front end accepts obs-fold (line-folded) headers for backward compatibility and rewrites them into a single logical header before forwarding. The rewrite loop truncates at an internal 8 KiB scratch buffer but never re-validates the terminator. When a header value crosses the buffer boundary at exactly the wrong offset, the trailing \r\n of the folded line survives into the HTTP/2 HEADERS frame as literal bytes.
Because the HTTP/2 encoder treats the value as opaque, those embedded CRLF bytes are re-interpreted by the backend as a new header field:
GET / HTTP/1.1
Host: edge.internal
X-Forwarded-Host: aaaa...(8150 bytes)...aaaa
\r\nX-Admin-Template: {{ config }}
The front end sees one folded header. The backend sees two — and the second lands in the admin router's trusted namespace.
From injected header to RCE
The control plane exposes a diagnostics endpoint that echoes X-Admin-Template through its templating engine to render node status banners. It was never meant to be reachable pre-auth; the whole design assumed only the front end could set X-Admin-*. Server-side template injection follows directly:
X-Admin-Template: {{ ''.__class__.__mro__[1].__subclasses__() }}
Enumerating subclasses gives us a Popen gadget. The final payload:
X-Admin-Template: {{ cycler.__init__.__globals__.os.popen('id').read() }}
Response banner:
node-status: uid=0(root) gid=0(root) groups=0(root)
No credentials, no second request, no ASLR to defeat. The template engine runs in the same privileged process that binds :443.
Why the parsers disagreed
The root cause is a classic split-brain. Two properties combined to make it exploitable:
- Length-truncation without re-parse. The scratch buffer bounded the copy but the CRLF scan ran on the original stream offset, so a boundary-straddling fold was silently preserved.
- Implicit trust of a header prefix.
X-Admin-*was authenticated by topology ("only the front end can reach the backend") rather than by a signature or token.
Either one alone is survivable. Together they are a pre-auth RCE.
Detection
You can hunt this in access logs without the patch. Look for folded headers whose reconstructed length lands within a few bytes of 8192, especially on X-Forwarded-*:
awk '/X-Forwarded-Host/ { if (length($0) > 8100 && length($0) < 8300) print }' access.log
At the network layer, a Suricata rule flagging embedded CRLF in HTTP/2 header block fragments forwarded from an HTTP/1.1 origin catches the smuggling primitive regardless of the final gadget.
Remediation
Upgrade to EdgeProxy 4.7.2, which:
- Re-scans the normalized value for control bytes after buffering, rejecting any header containing
\ror\npost-fold. - Signs internal
X-Admin-*headers with a per-boot HMAC so topology is no longer the trust boundary. - Sandboxes the diagnostics renderer with no
os/subprocessglobals.
If you cannot patch immediately, disable obs-fold acceptance (http.legacy_folding = off) and add a WAF rule dropping any request whose header block exceeds 8 KiB.
Disclosure timeline
- 2026-01-06 — Reported to vendor with PoC.
- 2026-01-08 — Triaged, severity confirmed (CVSS 9.8).
- 2026-01-29 — Patch shipped in 4.7.2.
- 2026-02-11 — Public advisory (this post).
Takeaway
Proxy chains multiply parsers, and every parser boundary is a place where two programs can disagree about where a header ends. When one side of that boundary is your control plane, a normalization bug stops being cosmetic. We keep finding these by fuzzing the seam between components rather than either component alone — the interesting bugs live in the disagreement.