Apache RocketMQ 远程代码执行漏洞(CVE-2023-33246)漏洞分析
本文转自Sunflower@知道创宇404实验室 并作补充
1.漏洞介绍
Apache RocketMQ 存在远程命令执行漏洞(CVE-2023-33246)。RocketMQ的NameServer、Broker、Controller等多个组件暴露在外网且缺乏权限验证,攻击者可以利用该漏洞利用更新配置功能以RocketMQ运行的系统用户身份执行命令。
2.漏洞版本
5.0.0 <= Apache RocketMQ < 5.1.1
4.0.0 <= Apache RocketMQ < 4.9.6
3.环境搭建
使用docker拉取漏洞环境
1 docker pull apache/rocketmq:4.9.5运行docker run命令,搭建docker环境
1
2 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.confdocker ps检查docker正常启动即可
4.RocketMQ简介
我们平时使用一些体育新闻软件,会订阅自己喜欢的一些球队板块,当有作者发表文章到相关的板块,我们就能收到相关的新闻推送。
发布-订阅(Pub/Sub)是一种消息范式,消息的发送者(称为发布者、生产者、Producer)会将消息直接发送给特定的接收者(称为订阅者、消费者、Comsumer)。而RocketMQ的基础消息模型就是一个简单的Pub/Sub模型[1]。
4.1 RocketMQ的部署模型
Producer、Consumer又是如何找到Topic和Broker的地址呢?消息的具体发送和接收又是怎么进行的呢?
4.2 名字服务器 NameServer
NameServer是一个简单的 Topic 路由注册中心,支持 Topic、Broker 的动态注册与发现。
主要包括两个功能:
Broker管理,NameServer接受Broker集群的注册信息并且保存下来作为路由信息的基本数据。然后提供心跳检测机制,检查Broker是否还存活;
路由信息管理,每个NameServer将保存关于 Broker 集群的整个路由信息和用于客户端查询的队列信息。Producer和Consumer通过NameServer就可以知道整个Broker集群的路由信息,从而进行消息的投递和消费。
4.3 代理服务器 Broker
Broker主要负责消息的存储、投递和查询以及服务高可用保证。
NameServer几乎无状态节点,因此可集群部署,节点之间无任何信息同步。Broker部署相对复杂。
在 Master-Slave 架构中,Broker 分为 Master 与 Slave。一个Master可以对应多个Slave,但是一个Slave只能对应一个Master。Master 与 Slave 的对应关系通过指定相同的BrokerName,不同的BrokerId 来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。
4.4 消息收发
在进行消息收发之前,我们需要告诉客户端NameServer的地址,RocketMQ有多种方式在客户端中设置NameServer地址,举例三个,优先级由高到低,高优先级会覆盖低优先级。
代码中指定Name Server地址,多个namesrv地址之间用分号分割
1
2 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地址
1 -Drocketmq.namesrv.addr=192.168.0.1:9876;192.168.0.2:9876环境变量指定Name Server地址
1 export NAMESRV_ADDR=192.168.0.1:9876;192.168.0.2:9876
4.5 漏洞主要涉及的类的介绍
4.5.1 DefaultMQAdminExt
DefaultMQAdminExt是 RocketMQ 提供的一个扩展类。它提供了一些管理和操作 RocketMQ 的工具方法,可以用于管理主题(Topic)、消费者组(Consumer Group)、订阅关系等。
DefaultMQAdminExt类提供了一些常用的方法,包括创建和删除主题、查询主题信息、查询消费者组信息、更新订阅关系等。它可以通过与 NameServer 交互来获取和修改相关配置信息,并提供了对 RocketMQ 的管理功能。
例如DefaultMQAdminExt更新broker配置的一个方法(更新的配置文件为broker.conf):
1
2
3
4
5 public void updateBrokerConfig(String brokerAddr,
Properties properties) throws RemotingConnectException, RemotingSendRequestException,
RemotingTimeoutException, UnsupportedEncodingException, InterruptedException, MQBrokerException {
defaultMQAdminExtImpl.updateBrokerConfig(brokerAddr, properties);
}
4.5.2 FilterServerManager
在 Apache RocketMQ 中,FilterServerManager 类是用于管理过滤服务器(Filter Server)的类。过滤服务器是 RocketMQ 中的一种组件,用于支持消息过滤功能。过滤服务器负责处理消息过滤规则的注册、更新和删除,以及消息过滤的评估和匹配。
5.漏洞分析
补丁文件[2]中直接将Filter Server模块全部移除,所以我们可以直接来看FilterServerManager,简要分析一下FilterServerManager的调用流程:
在Broker启动时执行sh mqbroker…,调用到BrokerStartup类:
在该类中继续调用到BrokerController中的start()方法
继续跟进
最终到了FilterServerManager类中,其中FilterServerUtil.callShell();存在命令执行:
1 | public void start() { |
根据start()方法内部可知createFilterServer方法每隔30秒都会被调用一次,
1 | public void start() { |
到了这一步,很明显我们只需要控制BrokerConfig进行命令拼接,等待触发createFilterServer即可造成RCE。
但是要想成功触发命令执行还有两个问题需要解决一下:
1、在createFilterServer方法中,more的值要大于0才会触发callShell方法
1 | public void createFilterServer() { |
这里只需要通过DefaultMQAdminExt设置filterServerNums的值即可,大致为:
1 | Properties properties = new Properties(); |
2、callshell方法传入命令时,shellString会被splitShellString方法使用空格进行拆分为一个cmdArray数组。
1 | public static void callShell(final String shellString, final InternalLogger log) { |
意味着传入的命令如果带了空格,都会被拆分为数组,而数组在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 $@|sh . echo curl 127.0.0.1;
$@
作为一个特殊变量,它表示传递给脚本或命令的所有参数,直接将echo后面的值作为一个整体传递给$@,解决了拆分命令的问题。感谢longofo@知道创宇404实验室带我探讨出第二个绕过方法:
顺便一提,这个绕过的核心点在于这里如果不使用bash,则无法成功使用${IFS}以及{}进行绕过空格限制,这里就不再进行细节讲解,感兴趣的师傅可以动手试试:
-c bash${IFS}-c${IFS}\"{echo,dG91Y2ggL3RtcC9kZGRkZGRkYWE=}|{base64,-d}|{bash,-i}\";
5.1 payload构造
根据上面的知识,最终构造的payload为:
1 | Properties properties = new Properties(); |
5.2 漏洞验证
使用payload进行curl dnslog,每隔30s左右收到一次请求:
5.3 漏洞修复
在修复版本4.9.6和5.1.1中都是直接删除了filter server模块
5.4 影响范围统计
使用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