Unbounded body accumulation in HTTP/3 response loop in hackney
Description
Allocation of Resources Without Limits or Throttling vulnerability in benoitc hackney allows Flooding. hackney_h3:await_response_loop/6 accumulates the HTTP/3 response body in memory without any size cap. The after Timeout clause is a per-message inactivity timer that resets on every received chunk, housekeeping message, or settings frame — it is not a wall-clock deadline. A malicious HTTP/3 server that emits one small chunk every Timeout - 1 ms with Fin = false and never sends a final frame keeps the loop alive indefinitely while the accumulation buffer grows linearly without bound, eventually exhausting the BEAM process heap and causing an out-of-memory condition.
This issue affects hackney: from 2.0.0 before 4.0.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Unbounded HTTP/3 response body accumulation in hackney allows a slow-drip server to exhaust memory, leading to denial of service.
Vulnerability
In hackney versions 2.0.0 through 4.0.0, the hackney_h3:await_response_loop/6 function accumulates the HTTP/3 response body in memory without any size cap [1][2][4]. The after Timeout clause is a per-message inactivity timer that resets on every received chunk, housekeeping message, or settings frame, not a wall-clock deadline [2][4]. This allows a malicious server to keep the loop alive indefinitely by sending small chunks at intervals just under the timeout, causing the buffer to grow without bound [2][4].
Exploitation
An attacker controlling an HTTP/3 server can exploit this by sending a 200 OK response with Fin = false, then emitting one small stream_data chunk every Timeout - 1 ms with Fin = false indefinitely [4]. No authentication or user interaction is required; the attacker only needs network access to the client [2]. The client process heap grows monotonically until it is killed by max_heap_size or the OS OOM killer [4].
Impact
Successful exploitation leads to remote denial of service via unbounded memory consumption, exhausting the BEAM process heap and causing an out-of-memory condition [2][4]. This affects only applications using the HTTP/3 transport (either directly via hackney_h3 or by passing {transport, h3} to hackney:request/5) [3][4]. The default TCP/TLS transport is not vulnerable [3].
Mitigation
The vulnerability is fixed in hackney 4.0.1, released on 2026-05-25 [1][2]. The fix introduces a max_body_size option defaulting to 512 MiB and a monotonic deadline check [1]. Users unable to upgrade should avoid the HTTP/3 transport or use the streaming API for large downloads [1][4]. No workaround exists for the unbounded accumulation in the non-streaming path [4].
AI Insight generated on May 25, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
13d25f9fea26cfix(security): bound HTTP/3 response buffering (GHSA-jq4m)
2 files changed · +85 −17
src/hackney_h3.erl+47 −17 modified@@ -66,6 +66,7 @@ -ifdef(TEST). -export([maybe_strip_redirect_headers/4]). +-export([body_within_limit/2, remaining/1]). -endif. -record(state, { @@ -77,6 +78,13 @@ -define(CONN_TABLE, hackney_h3_conns). +%% GHSA-jq4m: default ceiling on a buffered HTTP/3 response body. The +%% non-streaming await path holds the whole body in memory, so without a cap +%% a peer that trickles data forever drives the node to OOM. Configurable via +%% the `max_body_size' option (`infinity' disables it); very large downloads +%% should use the streaming API instead. +-define(DEFAULT_MAX_BODY_SIZE, 16#20000000). %% 512 MiB + -type h3_conn() :: reference(). -type stream_id() :: non_neg_integer(). -type method() :: get | post | put | delete | head | options | patch | atom() | binary(). @@ -141,10 +149,11 @@ do_request_with_redirect(Method, Url, Headers, Body, Opts, FollowRedirect, MaxRe _ -> <<PathBin/binary, "?", Qs/binary>> end, Timeout = maps:get(timeout, Opts, 30000), + MaxBodySize = maps:get(max_body_size, Opts, ?DEFAULT_MAX_BODY_SIZE), case connect(HostBin, Port, Opts) of {ok, Conn} -> try - case do_request(Conn, Method, HostBin, FullPath, Headers, Body, Timeout) of + case do_request(Conn, Method, HostBin, FullPath, Headers, Body, Timeout, MaxBodySize) of {ok, Status, RespHeaders, RespBody} when Status >= 301, Status =< 308 -> %% Redirect response handle_redirect(Status, RespHeaders, RespBody, Method, Url, Headers, Body, Opts, @@ -311,7 +320,7 @@ connect(Host, Port, Opts) when is_binary(Host) -> -spec await_response(reference(), non_neg_integer()) -> {ok, integer(), headers(), binary()} | {error, term()}. await_response(ConnRef, StreamId) -> - await_response_loop(ConnRef, StreamId, 30000, undefined, [], <<>>). + await_response_loop(ConnRef, StreamId, 30000, ?DEFAULT_MAX_BODY_SIZE). %%==================================================================== %% Request operations (used by hackney_conn) @@ -444,33 +453,41 @@ wait_connected(ConnRef, Timeout, StartTime) -> {error, timeout} end. -do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout) -> +do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout, MaxBodySize) -> AllHeaders = build_request_headers(Method, Host, Path, Headers), HasBody = Body =/= <<>> andalso Body =/= [], Fin = not HasBody, case send_request(ConnRef, AllHeaders, Fin) of {ok, StreamId} when HasBody -> case send_data(ConnRef, StreamId, Body, true) of ok -> - await_response_loop(ConnRef, StreamId, Timeout, undefined, [], <<>>); + await_response_loop(ConnRef, StreamId, Timeout, MaxBodySize); {error, _} = Error -> Error end; {ok, StreamId} -> - await_response_loop(ConnRef, StreamId, Timeout, undefined, [], <<>>); + await_response_loop(ConnRef, StreamId, Timeout, MaxBodySize); {error, _} = Error -> Error end. %% @private Wait for HTTP/3 response. -%% Timeout is per-chunk - resets each time data is received. -%% This allows large responses to complete as long as data keeps flowing. -%% Note: For very large responses, use hackney_conn with streaming mode instead. -await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody) -> +%% GHSA-jq4m: `Timeout' is an absolute wall-clock deadline for the whole +%% response, not a per-chunk timer; a peer that dribbles a byte just before +%% each chunk deadline used to reset it forever. The accumulated body is also +%% capped at MaxBodySize so a slow- or fast-drip cannot exhaust memory. +await_response_loop(ConnRef, StreamId, Timeout, MaxBodySize) -> + Deadline = case Timeout of + infinity -> infinity; + _ -> erlang:monotonic_time(millisecond) + Timeout + end, + await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, undefined, [], <<>>). + +await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody) -> receive {select, _Resource, _Ref, ready_input} -> _ = process(ConnRef), - await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody); + await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody); {h3, ConnRef, {stream_headers, StreamId, RespHeaders, Fin}} -> NewStatus = get_status(RespHeaders), FilteredHeaders = filter_pseudo_headers(RespHeaders), @@ -479,30 +496,43 @@ await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody) -> %% Headers with Fin=true means no body (e.g., HEAD response, 204, 304) {ok, NewStatus, FilteredHeaders, AccBody}; false -> - await_response_loop(ConnRef, StreamId, Timeout, NewStatus, FilteredHeaders, AccBody) + await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, NewStatus, FilteredHeaders, AccBody) end; {h3, ConnRef, {stream_data, StreamId, Data, Fin}} -> NewBody = <<AccBody/binary, Data/binary>>, - case Fin of - true -> - {ok, Status, Headers, NewBody}; + case body_within_limit(byte_size(NewBody), MaxBodySize) of false -> - await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, NewBody) + {error, body_too_large}; + true -> + case Fin of + true -> + {ok, Status, Headers, NewBody}; + false -> + await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, NewBody) + end end; {h3, ConnRef, {stream_reset, StreamId, _ErrorCode}} -> {error, stream_reset}; {h3, ConnRef, {closed, Reason}} -> {error, {connection_closed, Reason}}; {h3, ConnRef, {settings, _Settings}} -> %% HTTP/3 SETTINGS frame - ignore and continue waiting - await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody); + await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody); {h3, ConnRef, {goaway, _StreamId2}} -> %% GOAWAY frame - connection is shutting down {error, goaway} - after Timeout -> + after remaining(Deadline) -> {error, timeout} end. +%% @private Milliseconds left until the absolute deadline. +remaining(infinity) -> infinity; +remaining(Deadline) -> max(0, Deadline - erlang:monotonic_time(millisecond)). + +%% @private GHSA-jq4m: bound the buffered body size. +body_within_limit(_Size, infinity) -> true; +body_within_limit(Size, Max) -> Size =< Max. + %% @private Build HTTP/3 request headers including pseudo-headers. -spec build_request_headers(method(), binary(), binary(), headers()) -> headers(). build_request_headers(Method, Host, Path, Headers) ->
test/hackney_h3_redirect_tests.erl+38 −0 modified@@ -238,6 +238,44 @@ cross_origin_header_strip_test_() -> end} ]. +%%==================================================================== +%% GHSA-jq4m: response body cap + wall-clock deadline +%%==================================================================== + +body_cap_test_() -> + [ + {"accepts body at or under the cap", + fun() -> + ?assert(hackney_h3:body_within_limit(0, 100)), + ?assert(hackney_h3:body_within_limit(100, 100)) + end}, + {"rejects body over the cap", + fun() -> + ?assertNot(hackney_h3:body_within_limit(101, 100)) + end}, + {"infinity disables the cap", + fun() -> + ?assert(hackney_h3:body_within_limit(1 bsl 40, infinity)) + end} + ]. + +deadline_test_() -> + [ + {"infinity stays infinity", + fun() -> ?assertEqual(infinity, hackney_h3:remaining(infinity)) end}, + {"past deadline clamps to zero", + fun() -> + Past = erlang:monotonic_time(millisecond) - 1000, + ?assertEqual(0, hackney_h3:remaining(Past)) + end}, + {"future deadline returns a positive remaining", + fun() -> + Future = erlang:monotonic_time(millisecond) + 5000, + R = hackney_h3:remaining(Future), + ?assert(R > 0 andalso R =< 5000) + end} + ]. + %%==================================================================== %% TLS Option Tests %%====================================================================
Vulnerability mechanics
Root cause
"Missing size cap on accumulated HTTP/3 response body combined with a per-chunk inactivity timer that resets on every message, allowing a slow-drip server to cause unbounded memory growth."
Attack vector
An attacker controls a malicious HTTP/3 server that sends a 200 OK response with `Fin = false`, then emits one small `stream_data` chunk every `Timeout - 1` ms with `Fin = false` indefinitely [ref_id=2]. Because the `after Timeout` clause resets on every received message, the timeout never fires [ref_id=2]. The client accumulates the body without bound via `<<AccBody/binary, Data/binary>>`, causing linear heap growth until the BEAM process heap is exhausted, resulting in an out-of-memory condition [ref_id=1][ref_id=2]. The attacker needs only to serve a response that never sets `Fin = true` and trickles data just within the per-chunk timeout window [ref_id=2].
Affected code
The vulnerable function is `await_response_loop/6` in `src/hackney_h3.erl` [ref_id=2]. It accumulates the HTTP/3 response body via `NewBody = <<AccBody/binary, Data/binary>>` with no size cap, and the `after Timeout` clause is a per-message inactivity timer that resets on every received chunk, settings frame, or housekeeping message rather than a wall-clock deadline [ref_id=2]. Only the HTTP/3 transport is affected; applications using the default TCP/TLS hackney transport are not vulnerable [ref_id=2].
What the fix does
The patch [patch_id=2473705] makes two changes. First, `Timeout` is converted to an absolute wall-clock `Deadline` at the start of `await_response_loop/4` using `erlang:monotonic_time(millisecond) + Timeout`, and the `after` clause uses a new `remaining/1` helper that returns the milliseconds left until that deadline (clamped to zero) [patch_id=2473705]. This prevents a slow-drip attacker from resetting the timer indefinitely. Second, a `MaxBodySize` parameter (default 512 MiB, configurable via the `max_body_size` option, `infinity` to disable) is threaded through the loop; after each chunk is appended, `body_within_limit/2` checks whether the accumulated body exceeds the cap and returns `{error, body_too_large}` if so [patch_id=2473705].
Preconditions
- configThe client must use the HTTP/3 transport (hackney_h3 directly or passing {transport, h3} to hackney:request/5)
- networkThe attacker must control or influence the HTTP/3 server to which the client connects
- inputThe server sends a response that never sets Fin = true and dribbles data within the per-chunk timeout window
Reproduction
Stand up an HTTP/3 server that responds with 200 OK headers (Fin = false), then emits a small stream_data chunk every Timeout - margin ms with Fin = false indefinitely [ref_id=2]. Issue `hackney:request(get, Url, [], <<>>, [{transport, h3}])` against it [ref_id=2]. Watch the client process heap grow monotonically; the configured timeout never fires and the process is eventually killed by max_heap_size or the OS OOM killer [ref_id=2].
Generated on May 25, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/benoitc/hackney/commit/3d25f9fea26c90609de9d64366fedfe5065413bcmitrepatch
- github.com/benoitc/hackney/security/advisories/GHSA-jq4m-q6p2-8gwcmitrevendor-advisoryrelated
- cna.erlef.org/cves/CVE-2026-47077.htmlmitrerelated
- osv.dev/vulnerability/EEF-CVE-2026-47077mitrerelated
News mentions
0No linked articles in our index yet.