0%

Apache RocketMQ 远程代码执行(CVE-2023-37582)

CVE-2023-37582

产品介绍

RocketMQ是阿里巴巴在2012年开发的分布式消息中间件,后捐献给Apache软件基金会并成为Apache的顶级项目。RocketMQ专为万亿级超大规模的消息处理而设计,具有高吞吐量、低延迟、海量堆积、顺序收发等特点。

漏洞概述

当RocketMQ的NameServer组件暴露在外网,并且缺乏有效的身份认证机制时,攻击者可以利用nameServer更新配置功能,以RocketMQ运行的系统用户身份进行任意文件上传,甚至实现远程代码执行。

受影响版本

<RocketMQ 4.9.7

<RocketMQ 5.1.2

漏洞分析

RocketMQ基本概念

要想对该漏洞有较为清晰的认识,我们首先要了解RocketMQ的基本架构以及各部分的功能。

RocketMQ主要由四部分组成:

概念 说明
Producer Prducer负责消息的生产,支持分布式集群部署,通过MQ的负载均衡系统选择相应的Broker集群进行消息的存储与投递。
Consumer Consumer负责消息的消费,支持分布式集群部署,同时支持通过PUSH与PULL的方式对消息进行消费。
NameServer NameServer是一个Topic路由注册中心,其角色类似于Dubbo中的ZooKeeper,Hadoop中的NameNode,负责整个系统元数据的存储与管理,支持Broker的动态注册与发现。其主要包含两个功能。一是Broker的注册与管理,其接受Broker的信息并保存作为路由的基本信息,并提供心跳机制来确保每一个Broker集群的有效性;二是路由信息的管理,NameServer存储了整个系统的Broker路由信息,Producer与Consumer通过NameSerVer便可进行消息的投递与消费。
Broker Broker主要负责消息的存储、投递与消费并保证服务的高可用。

下图为各组件的关联方式:

image-20230724102920537

测试环境搭建

调试环境

访问项目官方仓库获取受影响漏洞版本源码(本文使用v4.9.4版本)

1
https://github.com/apache/rocketmq/releases

使用idea导入项目后需启动NameServerBroker,同时为BrokerStarupNamesrvStartup配置运行时环境变量ROCKETMQ_HOME,两者配置相同地址即可,为了让Broker能够找到NamesrvBroker的配置中需要额外指定命令行参数-c namesrv_endpoint

image-20230720155335454
image-20230724103128881

ROCKETMQ_HOME为ROCKETMQ运行家目录,配置好环境变量后在ROCKETMQ_HOME指向的目录行新建文件夹conf,进入conf目录并新建文件logback_broker.xmllogback_namesrv.xml,将以下内容复制到文件中:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<?xml version="1.0" encoding="UTF-8"?>

<!-- 配置文件修改时重新加载,默认true -->
<configuration scan="true">

<!--定义日志文件的存储地址 勿在 LogBack 的配置中使用相对路径-->
<property name="CATALINA_BASE" value="**/logs"></property>

<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder charset="UTF-8">
<!-- 输出日志记录格式 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- 第一个文件输出,每天产生一个文件 -->
<appender name="FILE1" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 输出文件路径+文件名 -->
<fileNamePattern>${CATALINA_BASE}/aa.%d{yyyyMMdd}.log</fileNamePattern>
<!-- 保存30天的日志 -->
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder charset="UTF-8">
<!-- 输出日志记录格式 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- 第二个文件输出,每天产生一个文件 -->
<appender name="FILE2" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${CATALINA_BASE}/bb.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${CATALINA_BASE}/bb.%d{yyyyMMdd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder charset="UTF-8">
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<appender name="CUSTOM" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${CATALINA_BASE}/custom.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- daily rollover -->
<fileNamePattern>${CATALINA_BASE}/custom.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- keep 30 days' worth of history -->
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder charset="UTF-8">
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- 设置日志输出级别 -->
<root level="ERROR">
<appender-ref ref="CONSOLE" />
</root>
<logger name="file1" level="DEBUG">
<appender-ref ref="FILE1" />
</logger>
<logger name="file1" level="INFO">
<appender-ref ref="FILE2" />
</logger>
<!-- 自定义logger -->
<logger name="custom" level="INFO">
<appender-ref ref="CUSTOM" />
</logger>
</configuration>

环境配置完成后运行主类org.apache.rocketmq.broker.NameSrvStartup启动ROCKETMQ NameServer,控制台输出以下内容则NameServer启动成功

image-20230724103631325

运行主类org.apache.rocketmq.broker.BrokerStartup启动ROCKETMQ Broker,控制台输出以下内容则Broker启动成功

image-20230724103711506

日志中提示了Broker监听地址与NameServer地址。

靶场搭建

调试环境可用于测试PoC

详情

为了更清晰地了解该漏洞,我们仍需了解NameServer的启动过程。另一方面,该漏洞是针对NameServer的攻击与Broker关系不大,了解NameServer的启动过程将更有助于我们分析漏洞。

image-20230724104157525

org.apache.rocketmq.namesrv.NamesrvStartup类中,启动阶段的关键代码出现在第57与58行。启动时首先要做的是创建NameSrvController进行初始化,然后再执行start流程。

创建Controller过程中主要进行了命令行参数解析、配置文件解析、日志配置解析并将相关的配置映射到注册namesrvConfignettyServerConfig两个对象中并向Controller注册

image-20230724105127793

start启动过程中最终会调用到org.apache.rocketmq.remoting.netty.NettyRemotingServerstart方法

image-20230724105757412

image-20230724105828274

该方法主要完成了各类处理器的初始化、Netty解码器与消息处理器的注册、监听服务的启动。

其中encoder负责RocketMQ协议消息头的解析与消息体的提取,最终形成字节流传递给serverHandler进行处理。

image-20230724110057582

serverHandler会根据不同的消息类型选择不同的处理方式

image-20230724110245393

在进行Request类型消息处理时会先选择处理器类型,然后调用其processRequest方法进行具体的消息处理

image-20230724110444420

默认使用的消息处理器类为org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor,其processRequest方法会根据请求中的code参数选择不同的处理方式。

image-20230724110816397

在监听服务启动后,netty将按照每秒1次的频率不断获取监听端口信息,然后一次调用decoderhandler对消息进行处理,上面就是Namesrv的简单启动流程。

有趣的是当org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest 第88行request.getCode方法的结果为318时则进入到配置更新流程

image-20230724111434267

updateConfig方法中首先会获取请求体对象然后转换为字符串,然后将字符串转换为properties对象,最终将properties更新到configuration对象中

image-20230724111817726

this.namesrvController.getConfiguration()获取到的是一个Configuration对象,然后调用其update方法进行配置更新

image-20230724112214708

org.apache.rocketmq.common.Configuration#update方法首先将用户请求体的配置信息合并到this.allconfigs中,然后遍历configObjectList对象成员,再将配置信息合并到其中。从上图我们知道configObjectList包含两个对象分别是程序启动过程中对configuration初始化时传入的NamesrvConfigNettyServerConfig对象。完成配置在内存态的更新后调用persist方法对配置进行持久化。

image-20230724112353518

persist方法完成了在内存中实时的配置信息的持久化,其首先会调用getAllConfigsInternal方法获取所有配置,然后调用getStorePath方法获取到配置的存储路径,最终调用string2File方法以前两个要素为参数配置持久化到硬盘。

image-20230724134008018

一般来讲,任意文件上传漏洞需要满足两个条件,一是文件的后缀名可控,二是文件的内容可控。对于该漏洞来说我们通过getAllConfigsInternal获取到文件内容,通过getStorePath获取到文件路径。那么这两者是否可控呢?

我们来看getAllConfigsInternal方法,该方法主要做了两件事,一是遍历configObjectList对象然后将其成员转换为properties对象,二是将properties对象序列化为字符串。从前面我们知道configObjectList对象可以被用户通过code318的请求修改,也就是说文件内容是可控的。

image-20230724134512621

再看getStorePath方法,在第156行有很明显的反射调用,获取的值是NamesrvConfig对象中configStorePath字段的值。

image-20230724140738246

update方法中185行我们知道,用户请求体中的配置会修改NamesrvConfig的属性,故持久化的配置文件的存储路径也是可变的,于是便构成了任意文件上传的两个要素。

我们使用互联网流传的PoC对漏洞进行验证,因为本文的复现环境为Windows所以需要对PoC进行适当的修改,可以看到在PoC中,用户发送给NameServer的请求体数据为两个通过换行符分割的键值对类型数据,分别为configStorePathproductEnvName,其中configStorePath负责指定配置文件的存储路径,productEnvName只是用于验证配置是否写入成功。

image-20230724142757605

执行验证脚本

image-20230724142220826

到启动目录查看发现文件写入成功

image-20230724142657752

若需要进行命令执行只需要向启动目录里写入bat文件即可

image-20230724151317005

或者向.ssh文件夹下的authorized_keys中写入新的公钥(未验证)

最后放上调用栈

1
2
3
4
5
6
7
8
9
10
11
12
13
persist:208, Configuration (org.apache.rocketmq.common)
update:198, Configuration (org.apache.rocketmq.common)
updateConfig:584, DefaultRequestProcessor (org.apache.rocketmq.namesrv.processor)
processRequest:131, DefaultRequestProcessor (org.apache.rocketmq.namesrv.processor)
asyncProcessRequest:26, AsyncNettyRequestProcessor (org.apache.rocketmq.remoting.netty)
run:227, NettyRemotingAbstract$1 (org.apache.rocketmq.remoting.netty)
run:80, RequestTask (org.apache.rocketmq.remoting.netty)
call:511, Executors$RunnableAdapter (java.util.concurrent)
run$$$capture:266, FutureTask (java.util.concurrent)
run:-1, FutureTask (java.util.concurrent)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:748, Thread (java.lang)

修复措施

1、非必须勿向互联网开放不必要的服务端口,控制暴露面;

2、关键服务增加用户权限校验;

3、升级软件到不受影响的版本或最新版。

参考链接

软安解决方案

Buy me a coffee.

欢迎关注我的其它发布渠道