<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Smuggling · XB Field Notes</title><link>https://www.xbcatm.com/tags/smuggling/</link><description>Illustrated field notes on HTTP, DNS, TLS, Caddy, SSH, releases, and recovery.</description><generator>Hugo</generator><language>en-us</language><atom:link href="https://www.xbcatm.com/tags/smuggling/index.xml" rel="self" type="application/rss+xml"/><item><title>Request smuggling desyncs the proxy chain</title><link>https://www.xbcatm.com/posts/request-smuggling-desyncs-the-proxy-chain/</link><pubDate>Tue, 19 Oct 2027 00:00:00 +0000</pubDate><guid>https://www.xbcatm.com/posts/request-smuggling-desyncs-the-proxy-chain/</guid><description>When front and back servers disagree about a request's length, one request can be disguised inside another and replayed out of order.</description><content:encoded>&lt;p&gt;An HTTP/1.1 request does not describe where it ends. Its boundary is computed — from Content-Length or the chunked coding — and every hop computes it independently. Request smuggling is what happens when two hops compute different answers from the same bytes.&lt;/p&gt;
&lt;p&gt;RFC 9112 has since made the rule explicit: Transfer-Encoding overrides Content-Length, a server may reject a message carrying both, and either way it must close the connection. That closes the gap on paper. Real chains still contain hops that predate the rule, hops that speak HTTP/1.0 downstream, and an edge that terminates HTTP/2 and re-frames to HTTP/1.1 inward. The disagreement is where the attack lives.&lt;/p&gt;
&lt;h2 id="two-parsers-one-message-boundary"&gt;Two parsers, one message boundary&lt;/h2&gt;
&lt;p&gt;If Transfer-Encoding is present and chunked is the final coding, the body runs to the terminating zero-length chunk; otherwise a valid Content-Length gives the octet count; a request with neither has no body. When both arrive, the specification says Transfer-Encoding wins — but a hop that only reads Content-Length stops early and forwards the remainder: the attack.&lt;/p&gt;
&lt;p&gt;The fixture puts both on a real socket: the front-end frames by Content-Length and forwards the bytes verbatim, the back-end frames by Transfer-Encoding. The attacker&amp;rsquo;s POST declares &lt;code&gt;Content-Length: 57&lt;/code&gt; and &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt;, with a body of &lt;code&gt;0\r\n\r\n&lt;/code&gt; plus a complete second request. To the front-end that is one 57-byte body; to the back-end the chunked body is zero bytes and the second request is a fresh message. One request became two, and the front-end never saw it.&lt;/p&gt;
&lt;h2 id="the-four-shapes-of-disagreement"&gt;The four shapes of disagreement&lt;/h2&gt;
&lt;p&gt;CL.TE is the fixture&amp;rsquo;s case: the front-end counts bytes, the back-end trusts chunked. TE.CL is the mirror: the front-end decodes chunked, the back-end counts bytes, so a request hides inside what the back-end reads as a chunk. TE.TE is two hops that both honour Transfer-Encoding but disagree about whether the header is valid: &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt; against &lt;code&gt;Transfer-Encoding: xchunked&lt;/code&gt;, a duplicated header, or an obs-fold continuation. One declines to decode, and the chunked bytes become a request. CL.CL is two conflicting Content-Length values where the hops pick different ones.&lt;/p&gt;
&lt;p&gt;Every variant is the same failure in a different costume: more than one defensible interpretation, and no agreement on which. That is why &amp;ldquo;normalise the headers&amp;rdquo; is the wrong mental model: you cannot normalise a parser you do not control, only refuse to forward ambiguity. The fixture&amp;rsquo;s strict front-end does exactly that: a request carrying both headers gets a 400 and is never relayed, and the back-end stays in sync.&lt;/p&gt;
&lt;h2 id="the-next-request-on-the-connection-is-the-payload"&gt;The next request on the connection is the payload&lt;/h2&gt;
&lt;p&gt;A smuggled request does nothing alone. It sits in the back-end&amp;rsquo;s receive buffer until the next request arrives on that connection, at which point the back-end answers the smuggled one and the victim gets that response. In the fixture the victim asks for &lt;code&gt;/home&lt;/code&gt; and receives the response for the smuggled &lt;code&gt;GET /admin&lt;/code&gt;. The attacker never authenticated, never passed the front-end&amp;rsquo;s routing rules, and never knew the victim.&lt;/p&gt;
&lt;p&gt;That is what makes it worse than a parser bug. The front-end&amp;rsquo;s ACLs, rate limits, WAF rules and audit log all describe the request it parsed, and the smuggled one is not it. It also poisons whatever comes next on that connection: a user whose request lands behind the smuggled one can be answered with someone else&amp;rsquo;s response, turning a desync into a session-data leak. Fixing one hop is not enough — a desync needs two parsers, but it only needs them to differ by one byte.&lt;/p&gt;
&lt;h2 id="when-it-breaks"&gt;When it breaks&lt;/h2&gt;
&lt;p&gt;The signature is two access logs that disagree. The front-end records two requests, the back-end three, and the extra one is the anomaly: a URL in the back-end log with no matching front-end entry, a request for a path the edge never routed, or a 400 from one layer only. Diff request counts and paths per layer over the same window, and test the framing rules directly instead of trusting vendor documentation.&lt;/p&gt;
&lt;p&gt;The second failure is a defence that covers one hop. A header check at the edge does nothing about a hop that re-frames HTTP/2 to HTTP/1.1 downstream, because the ambiguity is created after the check. Prefer HTTP/2 end to end — it has no chunked coding and forbids the connection-specific fields — or, where a downgrade is unavoidable, reject ambiguous framing at the edge and never reuse a connection across a trust boundary. Hardening the front-end while the back-end stays tolerant only changes which hop the attacker picks.&lt;/p&gt;
&lt;h2 id="check-it-quickly"&gt;Check it quickly&lt;/h2&gt;
&lt;p&gt;Send the ambiguous message and count the responses:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;printf &amp;#39;POST / HTTP/1.1\r\nHost: edge.example.com\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: edge.example.com\r\n\r\n&amp;#39; | nc -q1 edge.example.com 80 | tee /tmp/smuggle.out
grep -c &amp;#39;^HTTP/1.1&amp;#39; /tmp/smuggle.out
curl -s -o /dev/null -w &amp;#39;h2 -&amp;gt; %{http_code} %{http_version}\n&amp;#39; https://edge.example.com/
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A hardened edge answers exactly one status line — a 400 for the ambiguous message, or a 200 for the POST alone — and never a second response for &lt;code&gt;/admin&lt;/code&gt;. Two status lines, or a body answering the smuggled path, means they disagree. The third command should report &lt;code&gt;2&lt;/code&gt;: if the edge downgrades to HTTP/1.1 internally, framing rules still apply whatever the client negotiated.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9112.html"&gt;RFC 9112: HTTP/1.1&lt;/a&gt;, message body length, and the requirement to reject or close on ambiguous framing, checked 2026-09-16.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://portswigger.net/web-security/request-smuggling"&gt;PortSwigger: HTTP request smuggling&lt;/a&gt;, the CL.TE and TE.CL classes and their impact, checked 2026-09-16.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9113.html"&gt;RFC 9113: HTTP/2&lt;/a&gt;, why chunked coding and connection-specific fields do not exist in HTTP/2, checked 2026-09-16.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>