HAProxy HTTP/3 -> HTTP/1 Desync: Cross-Protocol Smuggling... TL;DR — A single QUIC STREAM frame with zero payload and the FIN bit set is enough to trick HAProxy into forwarding a Content-Length: N request with zero body bytes to the backend. The backend waits for N bytes that never come. The next user’s request on the same pooled TCP connection gets its first N bytes eaten as the missing body. Result: cross-user, cross-protocol HTTP request smuggling. CVE-2026-33555 . Affected: HAProxy 2.6 through 3.3.5 with USE_QUIC=1 . Fixed in 3.3.6 / 3.2.15 / 3.0.19 / 2.8.20 / 2.6.25. Table of contents 0. What this post is 1. Context 2. The networking foundation 2.1 TCP vs UDP sockets: what the kernel gives you 2.2 HTTP/1.1: one request at a time, per connection 2.3 HTTP/2: application-level multiplexing 2.4 The residual problem: transport-level HoL blocking 2.5 HTTP/3 / QUIC: streams at the transport layer 2.6 Why QUIC had to be on UDP 2.7 A QUIC packet on the wire 2.8 Offset: the tape metaphor 2.9 The FIN bit: one bit, wrong layer 3. The bug 3.1 From wire to h3_rcv_buf 3.2 Two layers, two notions of “done” 3.3 The HTX trust boundary 3.4 Where HAProxy validates body size 3.5 The fast-path that skips it 3.6 When does the fast-path trigger? Back to the protocol 4. The exploit 4.1 The connection pool problem 4.2 The PoC 4.3 Arbitrary request injection 4.4 Configuration required 5. On the CVSS score 6. The fix 7. Disclosure timeline 8. Takeaways Appendix: artifacts 0. What this post is This is the writeup of a vulnerability I found in HAProxy and reported through coordinated disclosure. The HAProxy team confirmed the issue and it was assigned CVE-2026-33555 . Most HTTP smuggling writeups jump straight to the exploit. This one starts from the ground up: what QUIC packets actually look like, how HAProxy processes them layer by layer, and why a single missing validation check in one fast-path creates a cross-user request smuggling primitive. If you’ve never looked at QUIC internals before, you should still be able to follow. If you just want the PoC: jump to section 4 . 1. Context I’ve been spending time this year on HTTP/2 and HTTP/3 attack surface in reverse proxies — specifically how protocol translation boundaries (H3→H1, H2→H1) can introduce semantic mismatches that neither side catches. HAProxy 3.x with USE_QUIC=1 was a natural target: a relatively young, hand-rolled H3 implementation bridging QUIC stream semantics to HTTP/1.1 wire format. Two fundamentally different framing models, stitched together. The research was done almost entirely with Claude Code (Opus 4.6), which turned out to be remarkably effective at navigating a C codebase of this size (~8000 lines across the relevant mux files). I don’t know C deeply, and I certainly couldn’t hold the full architecture of HAProxy in my head. But I could ask precise questions about code paths, and Claude Code would trace them through function calls, line by line, and explain what each piece did. The vulnerability was found this way: not by fuzzing, but by reading the source and asking “does this validation always run?” The rest of this post is structured so that you can follow the whole thing with zero prior knowledge of QUIC . If you already know QUIC internals, section 2 will be review. If you don’t, it’s the foundation you need — the bug only makes sense once you understand why QUIC’s FIN bit lives at a completely different layer than HTTP/2’s END_STREAM flag, and why that layering choice gives an attacker packet-level control that HTTP/2 simply doesn’t expose. 2. The networking foundation Please note that what you are about to read is my understanding of the QUIC protocol , based on my reading of RFCs and other documents. I may have misinterpreted some of the theory, so please let me know if you notice any errors. The world of network protocols is vast, and I certainly know less than 1% of it. Furthermore, QUIC is a new protocol that is conceptually different from others and also very complex. I hope that someone will find these concepts useful for either deepening their understanding or coming up with new ideas ❤❤ Before we can look at the bug, we need to understand three things: How TCP and UDP sockets differ at the kernel level — this is the substrate QUIC runs on. How HTTP evolved from 1.1 to 2 to 3, and what problem each version actually solved. What a QUIC packet looks like on the wire, and where the FIN bit lives. The four stages of HTTP evolution at a glance: from new-connection-per-request, to persistent connections, to H2 application-level streams over one TCP connection, to H3 streams riding directly on QUIC over UDP. Image credit: ByteByteGo . 2.1 TCP vs UDP sockets: what the kernel gives you A socket is a kernel object you access through a file descriptor. But what the kernel maintains behind that FD is fundamentally different for TCP and UDP. With TCP, each client connection gets its own FD on the server — the kernel maintains per-connection state (sequen...
This vulnerability (CVE-2026-33555, CVSS 4.0) is a cross-protocol HTTP request smuggling flaw in HAProxy where a single QUIC STREAM frame with no payload and the FIN bit set can trick the proxy into forwarding an HTTP/1.1 request with a `Content-Length` header but zero body to the backend. The backend then waits for the promised body bytes, causing the first part of the next client request on the same pooled TCP connection to be consumed as the missing body, leading to request smuggling. Affected versions are HAProxy 2.6 through 3.3.5 with `USE_QUIC=1`, and the fix is provided in versions 3.3.6, 3.2.15, 3.0.19, 2.8.20, and 2.6.25.