The h3 FIN/EOM desync, and why your smuggling tool can't send it
HTTP/3 request smuggling is almost unploughed ground. There is essentially one public class so far, the EOM-versus-Content-Length disagreement (CVE-2026-33555, April 2026), and the reason is not that the surface is small. It is that almost every smuggling tool speaks HTTP/1.1 and HTTP/2 only, and the few that speak HTTP/3 do it through a conformant QUIC library that will not let you send the bug.
This post is about the primitive, and about the two things a tool needs to send it: hand-rolled QPACK, and byte-level control of the stream FIN.
The primitive
In HTTP/1.1, a message body ends where Content-Length (or the chunked terminator) says it ends. In HTTP/3, a request is a sequence of frames on a QUIC stream, and the message ends when the stream is closed. That close is the QUIC FIN. So you have two different notions of "the request is over," owned by two different layers.
Now consider an edge that terminates HTTP/3 and forwards to an HTTP/1.1 back-end, which is the overwhelmingly common deployment. The client sends:
HEADERS { :method POST, :path /, content-length: 10 }
<FIN> # stream closed, zero DATA frames
The headers promise ten body bytes. The stream ends with none. A correct edge notices the mismatch and rejects. But an edge whose fast path takes end-of-message from the QUIC FIN, reasoning that the stream closed so the request must be complete, forwards a request to the back-end that still says Content-Length: 10. The back-end then reads ten bytes that belong to the next request on the connection. That is the desync.
Why a conformant stack hides it
Two sanitisations stand between you and that wire image:
- QPACK. A compliant HTTP/3 encoder will refuse to emit
transfer-encodingat all (it is illegal in h3), and a higher-level client will reconcilecontent-lengthagainst the body you actually provide. The malformed field is the test, and the library fixes it out of existence. - The FIN. A normal client closes the stream after it has sent the body the headers promised. You need to close it before, so the FIN lands exactly where the disagreement lives. That is not an API most clients expose, because for any legitimate purpose it would be a bug.
So the tool has to own both. In Keith the QPACK encoder is hand-written, with literal field lines and names and values passed through byte-exact, so content-length: 10 (or transfer-encoding: chunked, or a duplicate) survives onto the wire. The frame bytes come out of a pure builder: the transport sends the HEADERS frame, then FINs the stream with no DATA frame. Everything above the QUIC transport is bytes we control. The transport itself (TLS 1.3 over UDP, loss recovery, packet protection) we borrow from a real QUIC library, the same way an HTTP/1 tool borrows the kernel's TCP. The line is simple: borrow the transport, own the framing.
Proving it without guessing
A timing oracle, where the back-end hangs waiting for body bytes, is a hint, not a proof. The honest confirmation is byte-exact. Stand up a back-end you control behind the edge under test, and log the literal bytes the edge forwards. Either it forwards a request whose framing disagrees with what you sent, or it does not. No inference.
We built exactly that, a local nginx-h3 front over a byte-logging collector, and pointed Keith's FIN/EOM probe at it. nginx did the right thing. It reconciled the QUIC end-of-message against content-length, rejected the mismatch, and stripped the h3-illegal transfer-encoding on the downgrade. A clean negative, proven over HTTP/3 rather than asserted. That discipline is the entire job. The h3 surface is wide open because it is new, and the way to mine it without drowning in false positives is to send the exact malformed image and read the exact downstream truth. The rest is permutations, and less-hardened terminators.
Related Posts
How we hunt request smuggling without breaking anything
A timing hunch is not a finding. The discipline that separates real desync research from noise is the part nobody photographs: a lab of real proxies, a back-end you own that logs the literal forwarded bytes, and a hard line about who you're allowed to point any of it at.
Meet Keith, and why we're keeping it closed
We built our own HTTP engine from scratch. No normalisation, no typed header map, no helpfulness at all, because a well-behaved client quietly fixes the exact malformations you need to send. Here is what Keith is, and why we changed our minds about open-sourcing it.
The same method, pointed at the packet path
We took the parser-family lens that finds HTTP smuggling bugs and pointed it at the Linux kernel's network receive path, through Google's sanctioned bug-bounty programs. Then we measured our own opportunity honestly, and the honest answer was 'thin, for now.'
