VYPR
High severityNVD Advisory· Published Jun 15, 2026

CVE-2026-48854

CVE-2026-48854

Description

Unauthenticated attackers can exhaust BEAM memory and crash elixir-grpc servers by sending a large or slow-trickle unary request body with no size limit.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Unauthenticated attackers can exhaust BEAM memory and crash elixir-grpc servers by sending a large or slow-trickle unary request body with no size limit.

Vulnerability

The vulnerability resides in Elixir.GRPC.Server.Adapters.Cowboy.Handler.read_full_body/3 in lib/grpc/server/adapters/cowboy/handler.ex. This function accumulates received chunks into a single binary without any size cap. Additionally, when the client omits the grpc-timeout header, the per-chunk read timeout defaults to :infinity, allowing indefinite connection hold. This affects grpc versions from 0.3.1 before 1.0.0. [1][4]

Exploitation

An unauthenticated attacker can open an HTTP/2 connection to any unary RPC endpoint, omit the grpc-timeout header, and stream a large payload (e.g., 1 GiB) in chunks without sending the final END_STREAM flag. The server will continuously allocate memory for each chunk, and the lack of a timeout allows a slow-trickle attack to keep the connection alive indefinitely. A single connection is sufficient to exhaust memory. [4]

Impact

Successful exploitation leads to memory exhaustion of the BEAM virtual machine, causing the server to crash (denial of service). No authentication or special configuration is required; any unary RPC method is vulnerable. The CVSS v4.0 score is 8.7 (High) with vector AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N. [1][3][4]

Mitigation

The fix is implemented in commit 49e18c3 and released in version 1.0.0 of the grpc package. Users should upgrade to grpc >= 1.0.0. No workaround is available for earlier versions; the only mitigation is to upgrade. [2][4]

AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Elixir Grpc/Grpcreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: >=0.3.1,<1.0.0

Patches

1
49e18c3ec6bb

fix: limit request body size (#542)

https://github.com/elixir-grpc/grpcPaulo ValenteJun 15, 2026via body-scan
6 files changed · +228 8
  • grpc_server/lib/grpc/server/adapters/cowboy/handler.ex+37 6 modified
    @@ -14,6 +14,10 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
       @default_trailers HTTP2.server_trailers()
       @trailers_flag 0b1000_0000
     
    +  # 4 MB – matches gRPC-Go's default max receive message size.
    +  # Override per-server with the :max_body_size option (bytes).
    +  @default_max_body_size 4 * 1024 * 1024
    +
       @type init_state :: {
               endpoint :: atom(),
               server :: {name :: String.t(), module()},
    @@ -96,6 +100,8 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
               )
             end
     
    +      max_body_size = Map.get(opts, :max_body_size, @default_max_body_size)
    +
           {
             :cowboy_loop,
             req,
    @@ -105,7 +111,8 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
               pending_reader: nil,
               access_mode: access_mode,
               codec: codec,
    -          exception_log_filter: exception_log_filter
    +          exception_log_filter: exception_log_filter,
    +          max_body_size: max_body_size
             }
           }
         else
    @@ -341,13 +348,19 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
       # APIs end
     
       def info({:read_full_body, ref, pid}, req, state) do
    -    {s, body, req} = read_full_body(req, "", state[:handling_timer])
    +    {s, body, req} = read_full_body(req, <<>>, state[:handling_timer], state.max_body_size)
         send(pid, {ref, {s, body}})
         {:ok, req, state}
       catch
         :exit, :timeout ->
           Logger.warning("Timeout when reading full body")
           info({:handling_timeout, self()}, req, state)
    +
    +    :throw, {:body_too_large, _size} ->
    +      Logger.warning("Request body exceeded max_body_size (#{state.max_body_size} bytes)")
    +      error = RPCError.exception(status: :resource_exhausted, message: "Request body too large")
    +      req = send_error(req, error, state, :body_too_large)
    +      {:stop, req, state}
       end
     
       def info({:read_body, ref, pid}, req, state) do
    @@ -617,12 +630,27 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
         end
       end
     
    -  defp read_full_body(req, body, timer) do
    +  defp read_full_body(req, body, timer, max_bytes) do
         result = :cowboy_req.read_body(req, timeout_left_opt(timer))
     
         case result do
    -      {:ok, data, req} -> {:ok, body <> data, req}
    -      {:more, data, req} -> read_full_body(req, body <> data, timer)
    +      {:ok, data, req} ->
    +        total = body <> data
    +
    +        if byte_size(total) > max_bytes do
    +          throw({:body_too_large, byte_size(total)})
    +        else
    +          {:ok, total, req}
    +        end
    +
    +      {:more, data, req} ->
    +        total = body <> data
    +
    +        if byte_size(total) > max_bytes do
    +          throw({:body_too_large, byte_size(total)})
    +        else
    +          read_full_body(req, total, timer, max_bytes)
    +        end
         end
       end
     
    @@ -664,7 +692,10 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
       defp timeout_left_opt(timer, opts \\ %{}) do
         case timer do
           nil ->
    -        Map.put(opts, :timeout, :infinity)
    +        # No grpc-timeout header was supplied. Do not override cowboy's built-in
    +        # per-chunk read timeout (15 s by default) with :infinity, which would
    +        # allow a slow-trickle client to hold the connection open indefinitely.
    +        opts
     
           timer ->
             case Process.read_timer(timer) do
    
  • grpc_server/lib/grpc/server.ex+8 1 modified
    @@ -434,7 +434,14 @@ defmodule GRPC.Server do
       @spec start_endpoint(atom(), non_neg_integer(), Keyword.t()) ::
               {atom(), any(), non_neg_integer()}
       def start_endpoint(endpoint, port, opts \\ []) do
    -    opts = Keyword.validate!(opts, adapter: GRPC.Server.Adapters.Cowboy)
    +    opts =
    +      Keyword.validate!(opts,
    +        adapter: GRPC.Server.Adapters.Cowboy,
    +        adapter_opts: [],
    +        exception_log_filter: nil,
    +        max_body_size: nil
    +      )
    +
         adapter = opts[:adapter]
         servers = endpoint.__meta__(:servers)
         servers = GRPC.Server.servers_to_map(servers)
    
  • grpc_server/mix.exs+1 0 modified
    @@ -41,6 +41,7 @@ defmodule GRPC.Server.MixProject do
           {:flow, "~> 1.2"},
           {:protobuf_generate, "~> 0.1.3", only: [:dev, :test]},
           {:ex_parameterized, "~> 1.3.7", only: :test},
    +      {:gun, "~> 2.0", only: :test},
           {:mox, "~> 1.2", only: :test},
           {:ex_doc, "~> 0.39", only: [:dev, :docs], runtime: false},
           {:makeup, "~> 1.2.1", only: [:dev, :docs], runtime: false},
    
  • grpc_server/test/grpc/server/adapters/cowboy/handler_test.exs+178 0 added
    @@ -0,0 +1,178 @@
    +defmodule GRPC.Server.Adapters.Cowboy.HandlerTest do
    +  use ExUnit.Case, async: false
    +
    +  import ExUnit.CaptureLog
    +
    +  # --------------------------------------------------------------------------
    +  # Minimal server used across all tests
    +  # --------------------------------------------------------------------------
    +
    +  defmodule HelloServer do
    +    use GRPC.Server, service: Helloworld.Greeter.Service
    +
    +    def say_hello(req, _stream) do
    +      %Helloworld.HelloReply{message: "Hello, #{req.name}"}
    +    end
    +  end
    +
    +  # --------------------------------------------------------------------------
    +  # Helpers
    +  # --------------------------------------------------------------------------
    +
    +  # Build a gRPC length-prefixed message frame (no compression).
    +  defp grpc_frame(proto_binary) do
    +    <<0::8, byte_size(proto_binary)::32, proto_binary::binary>>
    +  end
    +
    +  defp grpc_request_headers do
    +    [
    +      {"content-type", "application/grpc+proto"},
    +      {"te", "trailers"}
    +    ]
    +  end
    +
    +  # Open an HTTP/2 cleartext connection to the server and return the conn pid.
    +  defp open_h2(port) do
    +    {:ok, conn} = :gun.open(~c"localhost", port, %{protocols: [:http2]})
    +    {:ok, :http2} = :gun.await_up(conn, 5_000)
    +    conn
    +  end
    +
    +  # Collect all gun frames for *stream_ref* until END_STREAM, then return the
    +  # final grpc-status value found in either the response headers or trailers.
    +  defp collect_grpc_status(conn, stream_ref) do
    +    collect_grpc_status(conn, stream_ref, nil)
    +  end
    +
    +  defp collect_grpc_status(conn, stream_ref, last_status) do
    +    case :gun.await(conn, stream_ref, 5_000) do
    +      {:response, :fin, _http_status, headers} ->
    +        find_grpc_status(headers) || last_status
    +
    +      {:response, :nofin, _http_status, headers} ->
    +        collect_grpc_status(conn, stream_ref, find_grpc_status(headers))
    +
    +      {:data, :fin, _data} ->
    +        last_status
    +
    +      {:data, :nofin, _data} ->
    +        collect_grpc_status(conn, stream_ref, last_status)
    +
    +      {:trailers, trailers} ->
    +        find_grpc_status(trailers) || last_status
    +
    +      {:error, reason} ->
    +        flunk("gun error: #{inspect(reason)}")
    +    end
    +  end
    +
    +  defp find_grpc_status(headers) do
    +    case List.keyfind(headers, "grpc-status", 0) do
    +      {"grpc-status", v} -> v
    +      nil -> nil
    +    end
    +  end
    +
    +  # --------------------------------------------------------------------------
    +  # Tests: max_body_size enforcement
    +  # --------------------------------------------------------------------------
    +
    +  describe "max_body_size" do
    +    test "rejects a body that exceeds max_body_size with RESOURCE_EXHAUSTED (8)" do
    +      capture_log(fn ->
    +        run_server_with_opts([HelloServer], [max_body_size: 64], fn port ->
    +          # Build a gRPC frame whose total size is well above the 64-byte cap.
    +          large_name = String.duplicate("x", 200)
    +
    +          body =
    +            grpc_frame(Protobuf.encode(%Helloworld.HelloRequest{name: large_name}))
    +
    +          assert byte_size(body) > 64,
    +                 "test body (#{byte_size(body)} bytes) must exceed max_body_size: 64"
    +
    +          conn = open_h2(port)
    +          ref = :gun.post(conn, "/helloworld.Greeter/SayHello", grpc_request_headers(), body)
    +
    +          assert collect_grpc_status(conn, ref) == "8"
    +
    +          :gun.close(conn)
    +        end)
    +      end)
    +    end
    +
    +    test "allows a body within max_body_size and returns OK (0)" do
    +      run_server_with_opts([HelloServer], [max_body_size: 4096], fn port ->
    +        body = grpc_frame(Protobuf.encode(%Helloworld.HelloRequest{name: "hi"}))
    +
    +        assert byte_size(body) < 4096,
    +               "test body (#{byte_size(body)} bytes) must fit within max_body_size: 4096"
    +
    +        conn = open_h2(port)
    +        ref = :gun.post(conn, "/helloworld.Greeter/SayHello", grpc_request_headers(), body)
    +
    +        assert collect_grpc_status(conn, ref) == "0"
    +
    +        :gun.close(conn)
    +      end)
    +    end
    +
    +    test "default max_body_size is 4 MB – normal requests succeed without explicit option" do
    +      run_server_with_opts([HelloServer], [], fn port ->
    +        body = grpc_frame(Protobuf.encode(%Helloworld.HelloRequest{name: "default limit"}))
    +
    +        conn = open_h2(port)
    +        ref = :gun.post(conn, "/helloworld.Greeter/SayHello", grpc_request_headers(), body)
    +
    +        assert collect_grpc_status(conn, ref) == "0"
    +
    +        :gun.close(conn)
    +      end)
    +    end
    +  end
    +
    +  # --------------------------------------------------------------------------
    +  # Tests: read timeout – no :infinity when grpc-timeout is absent
    +  # --------------------------------------------------------------------------
    +
    +  describe "read timeout" do
    +    test "omitting grpc-timeout header still completes a normal request" do
    +      # If timeout_left_opt/1 incorrectly passed :infinity to cowboy for a
    +      # nil timer, normal unary requests would still succeed – the regression
    +      # is that a slow-trickle attack could hold the connection indefinitely.
    +      # This smoke-test verifies the nil-timer path doesn't break normal calls.
    +      run_server_with_opts([HelloServer], [], fn port ->
    +        # Deliberately omit the grpc-timeout header.
    +        headers = grpc_request_headers()
    +        body = grpc_frame(Protobuf.encode(%Helloworld.HelloRequest{name: "no timeout header"}))
    +
    +        conn = open_h2(port)
    +        ref = :gun.post(conn, "/helloworld.Greeter/SayHello", headers, body)
    +
    +        assert collect_grpc_status(conn, ref) == "0"
    +
    +        :gun.close(conn)
    +      end)
    +    end
    +  end
    +
    +  # --------------------------------------------------------------------------
    +  # Private helper: start a server with specific opts and run a test function
    +  # --------------------------------------------------------------------------
    +
    +  defp run_server_with_opts(servers, opts, func) do
    +    {:ok, _pid, port} =
    +      start_supervised(%{
    +        id: {GRPC.Server, System.unique_integer([:positive])},
    +        start: {GRPC.Server, :start, [servers, 0, opts]},
    +        type: :worker,
    +        restart: :permanent,
    +        shutdown: 500
    +      })
    +
    +    try do
    +      func.(port)
    +    after
    +      GRPC.Server.stop(servers)
    +    end
    +  end
    +end
    
  • grpc/test/grpc/integration/server_test.exs+2 0 modified
    @@ -557,6 +557,8 @@ defmodule GRPC.Integration.ServerTest do
     
             {:ok, conn_pid} = :gun.open(~c"localhost", port)
     
    +        assert_receive {:gun_up, ^conn_pid, :http}
    +
             stream_ref =
               :gun.get(conn_pid, "/v1/messages/#{name}", [
                 {"accept", "application/json"}
    
  • interop/script/run.exs+2 1 modified
    @@ -20,7 +20,8 @@ alias GRPC.Client.Adapters.Gun
     alias GRPC.Client.Adapters.Mint
     alias Interop.Client
     
    -{:ok, _pid, port} = GRPC.Server.start_endpoint(Interop.Endpoint, port)
    +# large_unary2! sends an 8 MB payload; allow up to 32 MB to keep headroom.
    +{:ok, _pid, port} = GRPC.Server.start_endpoint(Interop.Endpoint, port, max_body_size: 32 * 1024 * 1024)
     
     defmodule InteropTestRunner do
       def run(_cli, adapter, port, rounds) do
    

Vulnerability mechanics

Root cause

"Missing size cap on accumulated request body and missing per-chunk read timeout when grpc-timeout header is absent allow unbounded memory consumption."

Attack vector

An unauthenticated attacker opens an HTTP/2 connection to any gRPC unary endpoint and sends a POST request with `Content-Type: application/grpc+proto`, omitting the `grpc-timeout` header. The server's `read_full_body/3` loop in `lib/grpc/server/adapters/cowboy/handler.ex` concatenates every received chunk into a single binary with no size cap [ref_id=1]. Because `timeout_left_opt/1` returns `:infinity` when the timer is `nil`, the per-chunk read has no deadline, so a slow-trickle client can keep the connection alive indefinitely while memory grows [CWE-770]. A single connection is sufficient to exhaust BEAM memory and crash the node [ref_id=1].

What the fix does

The patch introduces a configurable `max_body_size` option (default 4 MB) and checks the accumulated body size against this limit after every chunk in `read_full_body/3` [patch_id=6113717]. When the limit is exceeded, the function throws `{:body_too_large, size}`, which is caught in the `info/3` handler to return a `RESOURCE_EXHAUSTED` gRPC error and stop the process. Additionally, `timeout_left_opt/1` no longer maps `nil` to `:infinity`; instead it returns the options unchanged, allowing cowboy's built-in per-chunk read timeout (15 s by default) to apply even when the client omits the `grpc-timeout` header.

Preconditions

  • authNo authentication required; any unary gRPC endpoint is reachable
  • networkAttacker must be able to open an HTTP/2 connection to the server
  • inputAttacker sends a POST with Content-Type: application/grpc+proto and omits the grpc-timeout header

Generated on Jun 16, 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.