EEF-CVE-2026-113431: Unbounded decoded HTTP/2 header-list size in cowlib cow_http2_machine enables HPACK amplification memory exhaustion
Summary
Allocation of Resources Without Limits or Throttling vulnerability in ninenines cowlib allows an unauthenticated remote peer to exhaust the memory of an HTTP/2 endpoint via HPACK-indexed header fields.
cowhttp2machine:headersdecode/4 in src/cowhttp2machine.erl decodes every HEADERS block with cowhpack:decode/3. It bounds the compressed block size and the number of fields, but not the size of the decoded header list that RFC 9113 section 6.5.2 places under SETTINGSMAXHEADERLISTSIZE. A peer stores one header value of up to the dynamic table size (4096 bytes by default) and then references it with one-byte indexed fields, so about 100 bytes on the wire decode to a header list of about 390 KB. A consumer that copies the decoded values, as Cowboy does when cowboyhttp2:headerstomap/2 joins repeated cookie fields into one binary, allocates that amount for every stream. The total across streams and connections is not bounded.
This issue affects cowlib: from 2.7.0 onward.
Details
1. The decoder bounds everything but the decoded size. cowhttp2machine:headersdecode/4 passes each complete header block to cowhpack:decode/3. Two limits apply: maxfragmentedheaderblocksize (32768 bytes by default) on the compressed block, and maxheaders (100 by default, added in cowlib 2.17.0) on the number of fields. maxheaderlistsize appears in the module's option list with a default of infinity, next to a @todo comment, and is neither advertised in SETTINGS nor enforced on the decoded list, which is what RFC 9113 section 6.5.2 defines it for.
2. One-byte fields expand to kilobytes. The dynamic table holds entries of up to maxdecodetablesize (4096 bytes by default). The peer sends one literal field with incremental indexing carrying a 4000-byte value, which becomes table entry 62. Every later field is the single byte 0xBE, an indexed representation of that entry. A 110-byte block that carries four pseudo-headers and 96 such references decodes to a 100-field list whose values total 384,000 bytes. Before 2.17.0 no field count applied, so a 32 KB block referenced the entry about 32,000 times.
3. The allocation is realised by the consumer. The list that cowhpack returns is cheap, about 2 KB of heap, because every reference shares the one table binary. Cowboy's cowboyhttp2:headerstomap/2 joins repeated fields into a single new binary ("; " for cookie, ", " for other names), so each stream yields a 384,190-byte cookie value that lives in the request process for the life of the stream. Cowboy's HTTP/1.1 limits maxheadervaluelength and maxheaders do not apply to HTTP/2. With the default maxconcurrentstreams of 100 a connection holds about 38 MB, and only the Ranch connection limit bounds the total. Gun hands the decoded list to the owner process without copying the values, so the client side sees a small list per response and no amplification.
4. Measurement. On cowboy 2.20.0 with cowlib 2.21.0 on Erlang/OTP 29, one h2c connection on loopback sent 15,943 bytes of HEADERS for 100 streams. Binary memory grew by 38.4 MB and every handler received a 384,190-byte cookie header. A control with the same 100 streams and no indexed fields grew by 0.
Proof of concept
1. Start a Cowboy clear listener with default protocol options and a handler that takes a few seconds to reply, so streams stay open. 2. Open one TCP connection, send the HTTP/2 client preface and an empty SETTINGS frame. 3. On stream 1 send a HEADERS frame with ENDHEADERS and ENDSTREAM whose block contains :method, :scheme, :path and :authority, then one literal cookie field with incremental indexing carrying a 4000-byte value, then 95 indexed fields 0xBE referencing it. 4. On streams 3 through 199 send the same frame with the four pseudo-headers followed by 96 indexed fields 0xBE. Each block is 110 bytes. 5. Watch erlang:memory(binary) on the server. It grows by about 38 MB for about 16 KB sent, and cowboyreq:header(<<"cookie">>, Req) in each handler returns a 384,190-byte binary.
Impact
A remote unauthenticated client makes an HTTP/2 server built on cowlib, such as a Cowboy listener, allocate roughly 2,400 times the bytes it sends. One connection reaches about 38 MB with default settings, and an attacker holding many connections can drive the Erlang VM out of memory.
Workarounds
Cowboy servers that do not need HTTP/2 can set protocols => [http] in the protocol options. The listener then answers the HTTP/2 preface with 501 Not Implemented and the decoder is never reached.
Lowering maxheaders in the HTTP/2 protocol options shrinks the per-stream allocation in proportion, to about maxheaders times the 4096-byte table entry. This reduces the amplification but does not remove it.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Configuration
Set the protocol options to protocols => [http] to disable HTTP/2 when it is not required.
Cowboy listener protocols = [http] - Configuration
Lower max_headers from its default of 100 in the HTTP/2 protocol options to reduce the per-stream decoded header allocation.
Cowboy HTTP/2 max_headers = A value lower than 100