Now booking Q3 2026 red team engagements — limited operator availability. Request a slot →
← All writeups

CVE-2025-32433: the SSH server that never asked who you were

A perfect 10.0 is rare. This one earned it: send two SSH messages to a vulnerable Erlang/OTP server and your code runs—before you have authenticated, before you have offered a username, before the server has any idea who you are. The authentication state existed. The code that mattered simply never looked at it.

CVE
CVE-2025-32433
Class
Missing authentication for critical function (CWE-306)
Severity
Critical — CVSS 10.0
Affected
Erlang/OTP below 25.3.2.20, 26.0–26.2.5.10, 27.0–27.3.2 — any app calling ssh:daemon
Patched in
OTP-27.3.3, 26.2.5.11, 25.3.2.20
Attack cost
Two protocol messages. No credentials.

The bug in one sentence

Erlang/OTP's SSH server processed connection-protocol messages—the ones that open channels and execute commands—in every protocol state, including the states before authentication had happened.

What the protocol requires

SSH is not ambiguous about this. Message numbers are partitioned by phase: the transport layer owns the low numbers, user authentication owns 50–79, and 80 and above are reserved for protocols that run only after authentication (80–127 being the connection protocol itself). RFC 4252 states that a server receiving one of those messages before authentication completes must disconnect.

The two messages used in this attack are SSH_MSG_CHANNEL_OPEN (90) and SSH_MSG_CHANNEL_REQUEST (98). Both are firmly in the post-authentication range. A conforming server drops the connection. The vulnerable server opened a channel and ran the command.

Why the code looked reasonable

The SSH server is built as a state machine, and the routing clause that handled these messages looked like ordinary dispatch code. It received the current state name, and it used it—just not for anything protective. It read the state to work out whether this endpoint was acting as client or server, and to check whether a key renegotiation was in progress. It never asked whether the state was one in which the message should be legal at all.

The handler it called then received the record carrying the connection's authenticated flag… and discarded it:

Simplified illustration of the vulnerable pattern
%% The state name is bound, but never constrained to an authenticated state
handle_event(internal, {conn_msg, Msg}, StateName, Data) ->
    Role = role(StateName),         %% used only to tell client from server
    ssh_connection:handle_msg(Msg, Connection, Role, Data#data.ssh_params);

%% ...and downstream, the record carrying `authenticated` is bound to a
%% discarded name. The underscore silences the compiler's unused warning.
handle_msg(#ssh_msg_channel_open{}, Connection, server, _SSH) ->
    %% opens the channel — no authentication check anywhere on this path

That leading underscore is the detail worth sitting with. In Erlang, _SSH means "I know this variable is unused—stop warning me." The authentication state was delivered to the exact function that needed it, and the code explicitly silenced the one automated signal that would have flagged its absence. The decision point had everything it needed and used none of it.

There is a mechanical tell here that any reviewer could have found without knowing Erlang or SSH: searching the entire state machine for the word "authenticated" returned nothing. A protocol implementation that decodes attacker-controlled input in every state, and never once mentions the auth flag in its routing layer, is telling you something.

The pattern to remember: passing a security context into a function is not the same as enforcing it. When you review authorization code, a deliberately ignored security parameter is a finding in its own right—grep for the auth-bearing struct being bound to a discarded name, not just for one that was never passed.

How the exploit worked

Two messages, both sent before authenticating:

  1. SSH_MSG_CHANNEL_OPEN with channel type session. The server allocates a channel and confirms it.
  2. SSH_MSG_CHANNEL_REQUEST with request type exec, carrying an Erlang term as the payload. The server evaluates it.

No memory corruption, no heap grooming, no timing window. It is a pure logic flaw, which means exploitation is deterministic and crash-safe—the reason the Metasploit module for it carries Metasploit's top "excellent" rank, and the reason weaponization took roughly two days rather than two months.

The consequence is worth stating precisely, because "remote code execution" undersells it. Against a default daemon the payload is not shell-injected; it is evaluated as an Erlang term inside the BEAM virtual machine, in-process, with no privilege separation and nothing resembling OpenSSH's unprivileged child. That inherits the full authority of the Erlang node: every ETS and Mnesia table, every running process, and the distribution cookie—which means lateral movement to clustered nodes comes free. Whether it also runs as root depends on the deployment, and on appliances it frequently does.

That outcome is not universal, and the difference matters when you are scoping impact. Deployments that install a custom exec handler, a restricted CLI, or none at all do not hand an attacker the built-in evaluator—which is precisely the configuration difference Cisco cites for the products it says are not exploitable for code execution. The pre-authentication channel flaw is still present in all of them; what varies is what you can do once the channel is open.

Who was actually exposed

This is where a 10.0 needs context, because the score and the blast radius point in different directions.

Erlang's SSH server is a library, not a service. It listens only where a developer explicitly started it. A stock Erlang or Elixir installation exposes nothing, which is why internet-wide scans found exposure measured in the low thousands rather than the millions—Censys counted a couple of hundred hosts advertising a generic Erlang SSH banner, while runZero put the total of exposed, likely-vulnerable systems near 3,000 once vendor-rebranded banners such as Cisco's ConfD were included. Anyone who read "CVSS 10.0 in a language runtime" and pictured Log4Shell had the wrong mental model.

Worth correcting one widely repeated claim while we are here: RabbitMQ was not affected. It ships on Erlang but does not enable the Erlang SSH daemon. Plenty of coverage said otherwise.

The real concentration was in embedded, telecom, and OT equipment—places where the daemon is present by product design rather than developer choice. Cisco's ConfD and NSO were the most widely fingerprinted affected products online, though Cisco's own advisory adds an important carve-out: while both accept unauthenticated channel-request messages, their product configuration means they are not vulnerable to code execution, and both were patched in May 2025. Separately, several other affected Cisco products—Ultra Services Platform, iNode Manager, and the Small Business RV Series routers—had reached end of software maintenance and will receive no fix, with no workarounds listed. Palo Alto's telemetry later found roughly 70% of exploitation attempts hitting firewalls that protect OT networks. A vulnerability with modest internet exposure landed squarely on the assets that are hardest to patch.

It was added to CISA's Known Exploited Vulnerabilities catalog on 9 June 2025.

What to do about it

  1. Patch to your branch's fixed release. OTP 27.x to 27.3.3, 26.x to 26.2.5.11, 25.x to 25.3.2.20. Older branches should move onto one of those lines—the vendor treats every version running the SSH server as affected.
  2. Do not trust the banner. Version-check the installed ssh application on the host. Banners have been observed reporting the SSH application version rather than the OTP version, and distribution backports break banner inference entirely.
  3. Do not scope your hunt to port 22. Embedded Erlang SSH daemons are routinely bound to non-standard ports. Firewall rules and scans written around port 22 will miss real exposure.
  4. For appliances, use the vendor's fixed build, not upstream OTP. And where the vendor has stated no fix is planned, the only remaining controls are network isolation of the management plane or replacement.
  5. Know the difference between hardening and remediation. Disabling the exec path removes what the public exploits use, but the underlying flaw remains: channels still open pre-authentication and other request types still reach their handlers. Only the version upgrade actually fixes it.

The lesson: read the dispatch, not just the handler

Reviewers tend to audit the function that performs a sensitive action and ask whether it checks permissions. This bug lived one layer up, in the code that decided which handler to call. The handler was reachable in states where it should not have existed at all, and no amount of reading the handler in isolation would have revealed that.

There is a second, subtler failure worth naming. The rule that was broken is stated in the RFC as a numeric range—messages 80 and above are illegal before authentication—but the implementation encoded it as a set of message types that merely happened to correspond to that range. When a specification expresses an invariant one way and the code expresses it another, there is no single place to audit the rule and no way for it to fail loudly. Look for spec invariants that exist nowhere as an executable check.

Both lessons come from the same activity: reading code and asking who is allowed to reach it. That is what we do on every engagement, and it is why we built LearnCodeReview—because "missing authentication" as a category teaches you nothing about where to look for it.

Do you know what's listening on your network?

Embedded services on non-standard ports are exactly what gets missed—by inventories and by scanners. Our external network testing starts by finding what you didn't know was exposed.

All communications encrypted · NDA available · Free retest included · Response within 24h