VYPR
High severityNVD Advisory· Published Jun 8, 2026

CVE-2026-43974

CVE-2026-43974

Description

Unexpected Status Code or Return Value vulnerability in ninenines gun (gun_http module) allows a malicious HTTP server to force the client into raw protocol mode via an unsolicited 101 Switching Protocols response.

In gun_http:handle_inform/8, when a 101 Switching Protocols response is received over HTTP/1.1, the function verifies only that the Upgrade header is syntactically valid and that the stream reference is a plain reference(). It does not check whether the client ever sent an Upgrade or Connection: upgrade header on the corresponding request. Because this check is absent, any 101 response (solicited or not) causes gun to dispatch a gun_upgrade message to the caller and transition the entire connection to raw protocol mode.

A malicious or compromised HTTP server can send an unsolicited 101 response to any HTTP/1.1 request, causing the gun client to abandon HTTP framing for that connection. Once in raw mode, gun_raw applies no flow control (flow=infinity) and re-arms socket active mode after every received packet, so the server can flood the client with arbitrary bytes. These are forwarded as unbounded gun_data messages to the owner process, exhausting its mailbox and BEAM memory, ultimately crashing the VM.

This issue affects gun: from 2.0.0 before 2.4.0.

Affected products

2
  • Ninenines/Gunreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: >=2.0.0 <2.4.0

Patches

1
5b48068c29ce

Reject 101 responses when no upgrade was requested

https://github.com/ninenines/gunLoïc HoguinMay 29, 2026via body-scan
2 files changed · +161 25
  • src/gun_http.erl+57 25 modified
    @@ -63,6 +63,9 @@
     	authority :: iodata(),
     	path :: iodata(),
     
    +	%% Non-empty when an upgrade was requested.
    +	upgrade = [] :: [binary()],
    +
     	is_alive :: boolean(),
     	handler_state :: undefined | gun_content_handler:state()
     }).
    @@ -366,7 +369,7 @@ handle_connect(Rest, State=#http_state{
     
     %% @todo We probably shouldn't send info messages if the stream is not alive.
     handle_inform(Rest, State=#http_state{
    -		streams=[#stream{ref=StreamRef, reply_to=ReplyTo}|_]},
    +		streams=[Stream=#stream{ref=StreamRef, reply_to=ReplyTo}|_]},
     		CookieStore, EvHandler, EvHandlerState0, Version, Status, Headers) ->
     	EvHandlerState = EvHandler:response_inform(#{
     		stream_ref => stream_ref(State, StreamRef),
    @@ -377,26 +380,41 @@ handle_inform(Rest, State=#http_state{
     	case {Version, Status, StreamRef} of
     		{'HTTP/1.1', 101, #websocket{}} ->
     			{ws_handshake(Rest, State, StreamRef, Headers), CookieStore, EvHandlerState};
    -		%% Any other 101 response results in us switching to the raw protocol.
    -		%% @todo We should check that we asked for an upgrade before accepting it.
    +		%% Any other 101 response results in us switching to the raw protocol,
    +		%% except when we didn't request an upgrade.
     		{'HTTP/1.1', 101, _} when is_reference(StreamRef) ->
    -			try
    -				{_, Upgrade0} = lists:keyfind(<<"upgrade">>, 1, Headers),
    -				Upgrade = cow_http_hd:parse_upgrade(Upgrade0),
    -				gun:reply(ReplyTo, {gun_upgrade, self(), stream_ref(State, StreamRef), Upgrade, Headers}),
    -				%% @todo We probably need to add_stream_ref?
    -				{{switch_protocol, raw, ReplyTo, Rest}, CookieStore, EvHandlerState0}
    -			catch _:_ ->
    -				%% When the Upgrade header is missing or invalid we treat
    -				%% the response as any other informational response.
    -				gun:reply(ReplyTo, {gun_inform, self(), stream_ref(State, StreamRef), Status, Headers}),
    -				handle(Rest, State, CookieStore, EvHandler, EvHandlerState)
    +			case is_expected_upgrade_response(Headers, Stream) of
    +				{true, Upgrade} ->
    +					gun:reply(ReplyTo, {gun_upgrade, self(), stream_ref(State, StreamRef), Upgrade, Headers}),
    +					%% @todo We probably need to add_stream_ref?
    +					{{switch_protocol, raw, ReplyTo, Rest}, CookieStore, EvHandlerState0};
    +				false ->
    +					gun:reply(ReplyTo, {gun_error, self(),
    +						{connection_error, protocol_error,
    +							"Unexpected 101 Switching Protocols response."}}),
    +					{close, CookieStore, EvHandlerState}
     			end;
     		_ ->
     			gun:reply(ReplyTo, {gun_inform, self(), stream_ref(State, StreamRef), Status, Headers}),
     			handle(Rest, State, CookieStore, EvHandler, EvHandlerState)
     	end.
     
    +is_expected_upgrade_response(_, #stream{upgrade=[]}) ->
    +	false;
    +is_expected_upgrade_response(Headers, #stream{upgrade=Requested}) ->
    +	try
    +		{_, Conn0} = lists:keyfind(<<"connection">>, 1, Headers),
    +		Conn = cow_http_hd:parse_connection(Conn0),
    +		true = lists:member(<<"upgrade">>, Conn),
    +		{_, Upgrade0} = lists:keyfind(<<"upgrade">>, 1, Headers),
    +		%% We only support upgrading to a single protocol at a time.
    +		[Protocol] = cow_http_hd:parse_upgrade(Upgrade0),
    +		true = lists:member(Protocol, Requested),
    +		{true, [Protocol]}
    +	catch _:_ ->
    +		false
    +	end.
    +
     handle_response(Rest, State=#http_state{version=ClientVersion, opts=Opts, connection=Conn,
     		streams=[Stream=#stream{ref=StreamRef, reply_to=ReplyTo, method=Method, is_alive=IsAlive}|Tail]},
     		CookieStore, EvHandler, EvHandlerState0, Version, Status, Headers) ->
    @@ -573,14 +591,14 @@ headers(State, StreamRef, ReplyTo, _, _, _, _, _, _, CookieStore, _, EvHandlerSt
     headers(State=#http_state{opts=Opts, out=head},
     		StreamRef, ReplyTo, Method, Host, Port, Path, Headers,
     		InitialFlow0, CookieStore0, EvHandler, EvHandlerState0) ->
    -	{SendResult, Authority, Conn, Out, CookieStore, EvHandlerState} = send_request(State,
    +	{SendResult, Authority, Conn, Upgrade, Out, CookieStore, EvHandlerState} = send_request(State,
     		StreamRef, ReplyTo, Method, Host, Port, Path, Headers, undefined,
     		CookieStore0, EvHandler, EvHandlerState0, ?FUNCTION_NAME),
     	Command = case SendResult of
     		ok ->
     			InitialFlow = initial_flow(InitialFlow0, Opts),
     			{state, new_stream(State#http_state{connection=Conn, out=Out}, StreamRef,
    -				ReplyTo, Method, Authority, Path, InitialFlow)};
    +				ReplyTo, Method, Authority, Path, Upgrade, InitialFlow)};
     		Error={error, _} ->
     			Error
     	end,
    @@ -594,14 +612,14 @@ request(State, StreamRef, ReplyTo, _, _, _, _, _, _, _, CookieStore, _, EvHandle
     request(State=#http_state{opts=Opts, out=head}, StreamRef, ReplyTo,
     		Method, Host, Port, Path, Headers, Body,
     		InitialFlow0, CookieStore0, EvHandler, EvHandlerState0) ->
    -	{SendResult, Authority, Conn, Out, CookieStore, EvHandlerState} = send_request(State,
    +	{SendResult, Authority, Conn, Upgrade, Out, CookieStore, EvHandlerState} = send_request(State,
     		StreamRef, ReplyTo, Method, Host, Port, Path, Headers, Body,
     		CookieStore0, EvHandler, EvHandlerState0, ?FUNCTION_NAME),
     	Command = case SendResult of
     		ok ->
     			InitialFlow = initial_flow(InitialFlow0, Opts),
     			{state, new_stream(State#http_state{connection=Conn, out=Out}, StreamRef,
    -				ReplyTo, Method, Authority, Path, InitialFlow)};
    +				ReplyTo, Method, Authority, Path, Upgrade, InitialFlow)};
     		Error={error, _} ->
     			Error
     	end,
    @@ -620,6 +638,7 @@ send_request(State=#http_state{socket=Socket, transport=Transport, version=Versi
     	end,
     	%% We use Headers2 because this is the smallest list.
     	Conn = conn_from_headers(Version, Headers2),
    +	Upgrade = upgrade_from_headers(Headers2),
     	Out = case Body of
     		undefined when Function =:= ws_upgrade -> head;
     		undefined -> request_io_from_headers(Headers2);
    @@ -666,7 +685,19 @@ send_request(State=#http_state{socket=Socket, transport=Transport, version=Versi
     		_ ->
     			EvHandlerState2
     	end,
    -	{SendResult, Authority, Conn, Out, CookieStore, EvHandlerState}.
    +	{SendResult, Authority, Conn, Upgrade, Out, CookieStore, EvHandlerState}.
    +
    +upgrade_from_headers(Headers) ->
    +	try
    +		{_, Conn0} = lists:keyfind(<<"connection">>, 1, Headers),
    +		%% @todo We are parsing this header twice, improve this.
    +		Conn = cow_http_hd:parse_connection(iolist_to_binary(Conn0)),
    +		true = lists:member(<<"upgrade">>, Conn),
    +		{_, Upgrade} = lists:keyfind(<<"upgrade">>, 1, Headers),
    +		cow_http_hd:parse_upgrade(iolist_to_binary(Upgrade))
    +	catch _:_ ->
    +		[]
    +	end.
     
     host_header(TransportName, Host0, Port) ->
     	Host = case Host0 of
    @@ -822,7 +853,7 @@ connect(State=#http_state{socket=Socket, transport=Transport, opts=Opts, version
     			EvHandlerState = EvHandler:request_end(RequestEndEvent, EvHandlerState2),
     			InitialFlow = initial_flow(InitialFlow0, Opts),
     			{{state, new_stream(State, {connect, StreamRef, Destination},
    -				ReplyTo, <<"CONNECT">>, Authority, <<>>, InitialFlow)},
    +				ReplyTo, <<"CONNECT">>, Authority, <<>>, [], InitialFlow)},
     				CookieStore, EvHandlerState};
     		Error={error, _} ->
     			{Error, CookieStore, EvHandlerState1}
    @@ -885,7 +916,8 @@ conn_from_headers(Version, Headers) ->
     			close;
     		false ->
     			keepalive;
    -		{_, ConnHd} ->
    +		{_, ConnHd0} ->
    +			ConnHd = iolist_to_binary(ConnHd0),
     			conn_from_header(cow_http_hd:parse_connection(ConnHd))
     	end.
     
    @@ -939,11 +971,11 @@ stream_ref(#websocket{ref=StreamRef}) -> StreamRef;
     stream_ref(StreamRef) -> StreamRef.
     
     new_stream(State=#http_state{streams=Streams}, StreamRef, ReplyTo,
    -		Method, Authority, Path, InitialFlow) ->
    +		Method, Authority, Path, Upgrade, InitialFlow) ->
     	State#http_state{streams=Streams
     		++ [#stream{ref=StreamRef, reply_to=ReplyTo, flow=InitialFlow,
     			method=iolist_to_binary(Method), authority=Authority,
    -			path=iolist_to_binary(Path), is_alive=true}]}.
    +			path=iolist_to_binary(Path), is_alive=true, upgrade=Upgrade}]}.
     
     is_stream(#http_state{streams=Streams}, StreamRef) ->
     	lists:keymember(StreamRef, #stream.ref, Streams).
    @@ -995,7 +1027,7 @@ ws_upgrade(State=#http_state{out=head}, StreamRef, ReplyTo,
     		{<<"sec-websocket-key">>, Key}
     		|Headers2
     	],
    -	{SendResult, Authority, Conn, Out, CookieStore, EvHandlerState} = send_request(State,
    +	{SendResult, Authority, Conn, _Upgrade, Out, CookieStore, EvHandlerState} = send_request(State,
     		StreamRef, ReplyTo, <<"GET">>, Host, Port, Path, Headers, undefined,
     		CookieStore0, EvHandler, EvHandlerState0, ?FUNCTION_NAME),
     	Command = case SendResult of
    @@ -1004,7 +1036,7 @@ ws_upgrade(State=#http_state{out=head}, StreamRef, ReplyTo,
     			{state, new_stream(State#http_state{connection=Conn, out=Out},
     				#websocket{ref=StreamRef, reply_to=ReplyTo, key=Key,
     					extensions=GunExtensions, opts=WsOpts},
    -				ReplyTo, <<"GET">>, Authority, Path, InitialFlow)};
    +				ReplyTo, <<"GET">>, Authority, Path, [], InitialFlow)};
     		Error={error, _} ->
     			Error
     	end,
    
  • test/raw_SUITE.erl+104 0 modified
    @@ -302,6 +302,78 @@ do_http11_upgrade_raw(OriginTransport, RawData) ->
     	} = gun:info(ConnPid),
     	gun:close(ConnPid).
     
    +http11_upgrade_raw_headers_as_list(_) ->
    +	doc("Use the HTTP Upgrade mechanism to switch to the raw protocol over TCP. "
    +		"Connection and upgrade headers given as list of characters."),
    +	{ok, OriginPid, OriginPort} = init_origin(tcp, raw,
    +		fun (Parent, ListenSocket, ClientSocket, ClientTransport) ->
    +			{ok, _} = ClientTransport:recv(ClientSocket, 0, 5000),
    +			ClientTransport:send(ClientSocket,
    +				"HTTP/1.1 101 Switching Protocols\r\n"
    +				"Connection: upgrade\r\n"
    +				"Upgrade: custom/1.0\r\n"
    +				"\r\n"),
    +			do_echo(Parent, ListenSocket, ClientSocket, ClientTransport)
    +		end),
    +	{ok, ConnPid} = gun:open("localhost", OriginPort),
    +	{ok, http} = gun:await_up(ConnPid),
    +	handshake_completed = receive_from(OriginPid),
    +	%% Use a list (non-binary) value for the upgrade header.
    +	StreamRef = gun:get(ConnPid, "/", [
    +		{<<"connection">>, "upgrade"},
    +		{<<"upgrade">>, "custom/1.0"}
    +	]),
    +	{upgrade, [<<"custom/1.0">>], _} = gun:await(ConnPid, StreamRef),
    +	gun:close(ConnPid).
    +
    +http11_upgrade_raw_missing_connection_header(_) ->
    +	doc("A missing Connection header in the 101 response results "
    +		"in a protocol_error connection error. (RFC9110 7.8)"),
    +	{ok, OriginPid, OriginPort} = init_origin(tcp, raw,
    +		fun (Parent, ListenSocket, ClientSocket, ClientTransport) ->
    +			{ok, _} = ClientTransport:recv(ClientSocket, 0, 5000),
    +			ClientTransport:send(ClientSocket,
    +				"HTTP/1.1 101 Switching Protocols\r\n"
    +				"Upgrade: custom/1.0\r\n"
    +				"\r\n"),
    +			do_echo(Parent, ListenSocket, ClientSocket, ClientTransport)
    +		end),
    +	{ok, ConnPid} = gun:open("localhost", OriginPort),
    +	{ok, http} = gun:await_up(ConnPid),
    +	handshake_completed = receive_from(OriginPid),
    +	StreamRef = gun:get(ConnPid, "/", #{
    +		<<"connection">> => <<"upgrade">>,
    +		<<"upgrade">> => <<"custom/1.0">>
    +	}),
    +	{error, {connection_error, {connection_error, protocol_error, _}}}
    +		= gun:await(ConnPid, StreamRef),
    +	do_http_is_down(ConnPid),
    +	gun:close(ConnPid).
    +
    +http11_upgrade_raw_missing_upgrade_header(_) ->
    +	doc("A missing Upgrade header in the 101 response results "
    +		"in a protocol_error connection error. (RFC9110 7.8)"),
    +	{ok, OriginPid, OriginPort} = init_origin(tcp, raw,
    +		fun (Parent, ListenSocket, ClientSocket, ClientTransport) ->
    +			{ok, _} = ClientTransport:recv(ClientSocket, 0, 5000),
    +			ClientTransport:send(ClientSocket,
    +				"HTTP/1.1 101 Switching Protocols\r\n"
    +				"Connection: upgrade\r\n"
    +				"\r\n"),
    +			do_echo(Parent, ListenSocket, ClientSocket, ClientTransport)
    +		end),
    +	{ok, ConnPid} = gun:open("localhost", OriginPort),
    +	{ok, http} = gun:await_up(ConnPid),
    +	handshake_completed = receive_from(OriginPid),
    +	StreamRef = gun:get(ConnPid, "/", #{
    +		<<"connection">> => <<"upgrade">>,
    +		<<"upgrade">> => <<"custom/1.0">>
    +	}),
    +	{error, {connection_error, {connection_error, protocol_error, _}}}
    +		= gun:await(ConnPid, StreamRef),
    +	do_http_is_down(ConnPid),
    +	gun:close(ConnPid).
    +
     http11_upgrade_raw_reply_to(_) ->
     	doc("When upgrading an HTTP/1.1 connection with the reply_to option set, "
     		"Gun must honor this option in the raw protocol."),
    @@ -336,6 +408,28 @@ http11_upgrade_raw_reply_to(_) ->
     	gun:data(ConnPid, undefined, nofin, <<"Hello world!">>),
     	receive {ReplyTo, ok} -> gun:close(ConnPid) after 1000 -> error(timeout) end.
     
    +http11_upgrade_raw_unrequested(_) ->
    +	doc("Receiving a 101 response when no upgrade was requested "
    +		"results in a protocol_error connection error. (RFC9110 7.8)"),
    +	{ok, OriginPid, OriginPort} = init_origin(tcp, raw,
    +		fun (Parent, ListenSocket, ClientSocket, ClientTransport) ->
    +			{ok, _} = ClientTransport:recv(ClientSocket, 0, 5000),
    +			ClientTransport:send(ClientSocket,
    +				"HTTP/1.1 101 Switching Protocols\r\n"
    +				"Connection: upgrade\r\n"
    +				"Upgrade: custom/1.0\r\n"
    +				"\r\n"),
    +			do_echo(Parent, ListenSocket, ClientSocket, ClientTransport)
    +		end),
    +	{ok, ConnPid} = gun:open("localhost", OriginPort),
    +	{ok, http} = gun:await_up(ConnPid),
    +	handshake_completed = receive_from(OriginPid),
    +	StreamRef = gun:get(ConnPid, "/", []),
    +	{error, {connection_error, {connection_error, protocol_error, _}}}
    +		= gun:await(ConnPid, StreamRef),
    +	do_http_is_down(ConnPid),
    +	gun:close(ConnPid).
    +
     http2_connect_tcp_raw_tcp(_) ->
     	doc("Use CONNECT over clear HTTP/2 to connect to a remote endpoint using the raw protocol over TCP."),
     	do_http2_connect_raw(tcp, <<"http">>, tcp).
    @@ -407,3 +501,13 @@ do_echo(Parent, ListenSocket, ClientSocket, ClientTransport) ->
     		{error, closed} ->
     			ok
     	end.
    +
    +%% Internal.
    +
    +do_http_is_down(ConnPid) ->
    +	receive
    +		{gun_down, ConnPid, http, _, _} ->
    +			ok
    +	after 5000 ->
    +		error(timeout)
    +	end.
    

Vulnerability mechanics

Root cause

"The gun_http module incorrectly transitions to raw protocol mode upon receiving an unsolicited 101 Switching Protocols response."

Attack vector

An attacker controls an HTTP server and sends an unsolicited 101 Switching Protocols response to a client using the gun_http module over HTTP/1.1. The client's gun_http module, specifically the `handle_inform/8` function, does not verify if the client initiated an upgrade request. This leads the client to enter raw protocol mode, allowing the attacker to flood the client with arbitrary data, exhausting its memory and crashing the VM.

Affected code

The vulnerability lies within the `handle_inform/8` function in `src/gun_http.erl`. Specifically, the code handling a 101 status code for HTTP/1.1 responses did not adequately check if an upgrade was requested by the client before transitioning to raw protocol mode. The patch introduces the `is_expected_upgrade_response/2` helper function and modifies the logic to use it.

What the fix does

The patch modifies the `handle_inform/8` function in `src/gun_http.erl` to introduce a check for an expected upgrade response using the new `is_expected_upgrade_response/2` function. This function verifies that the `Connection` and `Upgrade` headers are present and valid in the 101 response, and that the upgrade protocol was requested by the client. If the response is not an expected upgrade, the connection is now closed with a `protocol_error` [patch_id=5216903].

Preconditions

  • networkThe attacker must control an HTTP server that can respond to client requests.
  • inputThe attacker must send an HTTP/1.1 request to the vulnerable client.

Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.