VYPR
Unrated severityNVD Advisory· Published May 25, 2026

Unbounded body accumulation in HTTP/3 response loop in hackney

CVE-2026-47077

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

2
  • Benoitc/Hackneyinferred2 versions
    >=2.0.0,<4.0.1+ 1 more
    • (no CPE)range: >=2.0.0,<4.0.1
    • (no CPE)range: >= 2.0.0, < 4.0.1

Patches

1
3d25f9fea26c

fix(security): bound HTTP/3 response buffering (GHSA-jq4m)

https://github.com/benoitc/hackneyBenoit ChesneauMay 20, 2026via body-scan
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 `&lt;&lt;AccBody/binary, Data/binary&gt;&gt;`, 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 = &lt;&lt;AccBody/binary, Data/binary&gt;&gt;` 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, [], &lt;&lt;&gt;&gt;, [{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

News mentions

0

No linked articles in our index yet.