飞秋 3.0.0.2 在处理图片和文件的分片接收时,会用三个取自网络报文的数值做内存操作:分配大小、目的偏移、拷贝长度。这三项都没有边界校验。任意一台与目标网络可达的主机发送一个构造报文,就能造成堆越界写,进程随后以 0xC0000374(STATUS_HEAP_CORRUPTION)终止。

审计在受控实验网络内完成,目标是自己搭建的同一版本样本,没有涉及第三方资产。

项目 结论
漏洞类型 堆缓冲区溢出(CWE-787 / CWE-122)
触发前提 无。不需要认证、不需要交互、不需要目标侧任何操作
网络条件 与目标网段可达即可,走 UDP 2425
打击面 4 个 UDP 报文,合计约 32 KB
失效耗时 远程实测 2.9 秒
崩溃表现 0xC0000374,目标机事件日志可查
恢复方式 只能人工重启,飞秋没有自愈或自动重启机制
可否持续压制 可以,目标恢复后 8 秒内可再次打掉

样本

项 值
文件名 FeiQ.3.0.0.2.exe
格式 PE32(x86),GUI 子系统
编译器 MSVC 10.0
加壳 无
MD5 bfea3e87b66b4164badd346de8172a36
SHA256 480ff41b04bd8e93ce027b33a2d8dae9531fdf9e4651c0d728e9f490c93f1ae6
ImageBase 0x400000
调试信息 残留 PDB 路径 F:\FeiQ2012\source\Client\FeiQClient\Win32\Release\FeiQ.pdb

UDP 2425 是飞秋唯一不需要认证的数据入口,承载的报文类型也最多,本文的审计对象就是它。

报文格式与解析链

协议没有公开文档,是 IPMSG 的私有派生。审计从二进制里把报文的文本格式完整逆了出来,确保能手工构造合法报文。二进制里对应三条硬编码字符串:

1
2
3
偏移 0xEAE908 : "%d%s%d_%d#%ld#%s#%d#%d#%d#%s#%d:%ld:%s:%s:%ld:"
偏移 0xEAE938 : "_lbt"
偏移 0xE8511C : "4001"

由此得到线格式:

1
<ver>_lbt<gen>_<n>#<pktno>#<mac>#<f1>#<f2>#<f3>#<cmd>#4001#9:<cmd2>:<user>:<host>:<f4>:<body>

几个字段的含义是通过逐字段替换、观察回包变化确定的。<cmd> 是十进制,实际按位域使用,低位是命令号、高位是标志位。<user> 和 <host> 决定对端列表里的显示名。对端记录以源 IP 为键,同一个源 IP 只登记一个账户。报文尾部可以附加任意二进制数据,长度由报文总长与头部长度之差推出。

从收包到越界写的调用链见图一。

图一 调用链与到达危险分支的门槛

其中有一个细节在审计里容易漏掉。case -64 分支不是无条件可达的,它要求两个条件同时成立:ctx+0xD4 不为 0,也就是报文里 _lbt 后面的代号不能是 0;命令字必须带 0x200000 标志位。因此可达的命令值是 0x2000C0,而不是 0xC0。如果只按「命令字等于 0xC0」去构造报文,永远打不到这个函数。

漏洞成因

sub_6B8600 有 4800 余字节的栈帧,直接阅读很容易迷失,于是改用「先筛危险调用」的办法收敛:在函数内检索 GlobalAlloc、GlobalLock、memcpy、_wtoi 的位置,再从这些位置往上看参数来源。

筛完之后的结论是,参与内存操作的四个参数里只有一个是校验过的。

图二 分片接收分支里的四个内存操作参数

对应的代码片段如下,变量名与反编译输出一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ── 1. 从正文 TLV 里解析字段(字段以 '|' 分隔,末尾 '#')──
v382 = wcstoul(field[0], 0, 16); // 资源 ID(十六进制)
dwBytes= wcstoul(field[1], 0, 10); // (1) 声明总大小 -> GlobalAlloc 参数
v374 = _wtoi (field[2]); // (2) 目的偏移 -> memcpy 目的增量(有符号)
v368 = _wtoi (field[3]);
v376 = _wtoi (field[4]); // (3) 分片序号(唯一被校验的字段)
/* …… 中间若干行省略 …… */
if ( v376 < 1 || v376 > 8000 ) // 仅此一处范围检查
goto fail;

Size = *((_DWORD *)a2 + 8262); // (4) 拷贝长度 -> ctx+0x8118,由报文总长推出

// ── 2. 分配 ──
hMem = GlobalAlloc(0x40u, dwBytes); // GMEM_ZEROINIT | 网络可控大小,无上限
if (!hMem) return;
if (!GlobalLock(hMem)) return; // entry+0x10 存句柄,entry+0x14 存锁定基址

// ── 3. 拷贝(每个分片序号一次)── 汇点 0x6B9D2C
if (!*(BYTE *)(entry + v376 + 0x2F)) { // 分片去重位图
memcpy((void *)(v374 + *(DWORD *)(entry + 0x14)), // 目的 = 基址 + 偏移
&a2[*((DWORD *)a2 + 68) + 280], // 源 = 附加数据
Size); // 长度 = 由报文总长推出
*(BYTE *)(entry + v376 + 0x2F) = 1;
}

越界写发生在最后那个 memcpy。它缺少的检查可以写成一个条件:

说人话:程序应当先确认写入起点落在分配区里、写完不越出分配区。现在这两个条件一个都没有。

dwBytes 来自 wcstoul 且没有上界,v374 是 _wtoi 出来的有符号数,Size 由报文总长决定。三个都来自网络,都没有约束。

为什么可以反复施加

唯一被校验的 v376 只用来做分片去重。去重位图位于 entry + 0x2F + seq,seq 的合法区间是 1 到 8000,因此每个不同的 seq 都会独立触发一次 memcpy,同一条连接条目上最多可以写入 8000 次,每次的偏移和长度都能不同。

所以一次攻击可以在这条连接上连续施加,每次换一个偏移,不需要重新建立连接。

崩溃机制

越界写落在堆上之后,进程不会立刻终止。观察到两种终止路径:

异常码 含义 触发条件
0xC0000374 STATUS_HEAP_CORRUPTION 越界写落在已提交页内,破坏了堆块结构,堆管理器在后续的分配、释放或校验中发现并主动终止进程
0xC0000005 STATUS_ACCESS_VIOLATION 越界写落到未提交页,或破坏了随后立即被解引用的指针

两者是同一处溢出的不同落点,这也解释了为什么单个报文有时能打崩、有时不能:取决于落点是否踩到了已提交且正在使用的堆结构。

多次复现的崩溃特征一致,崩溃模块为 ntdll.dll,异常码 0xc0000374,偏移 0x000fd5cf。0xfd5cf 是堆管理器的错误处理路径,可以当作堆结构被破坏的指纹。

验证

本地

在本地运行同版本样本,连续施加多个不同分配尺寸的分片写,5 次全部成功。

远程差分对照

在同一台远程靶机、同一次运行里先后做两次打击,唯一变量是发包数量。

图三 远程差分对照与目标机事件日志

基线探测得到 5 个回包。只发 1 个报文(8115 字节)时,目标仍存活,回包还有 4 个。发 4 个报文(约 32 KB)时,目标在 2.9 秒内失联。

同一个进程、同一条网络路径,只有包数不同,结果从存活变成宕机。这组对照排除了「目标本来就要挂了」和「报文本身打断了服务」这两种解释。

目标机事件日志

在目标虚拟机上用 Get-WinEvent 取出 Application Error 记录:

1
2
3
Get-WinEvent -FilterHashtable @{LogName='Application';ProviderName='Application Error'} -MaxEvents 20 |
Where-Object { $_.Message -like '*FeiQ*' } |
ForEach-Object { $_.TimeCreated.ToString() + ' ' + ($_.Message -split "`n" | Select-String '异常代码') }

输出是:

1
2
2026/10/16 17:45:03  异常代码: 0xc0000374
2026/10/16 15:44:34 异常代码: 0xc0000005

第一条的时间与失联时刻完全对齐。前面几项都是我们在外面观察到的现象,这一项是目标机自己记下的 STATUS_HEAP_CORRUPTION,证据强度最高。

主机层对照

1
2
3
目标地址     ARP 状态:Reachable(反复)
网关地址 ARP 状态:Stale
不存在的地址 ARP 状态:Unreachable

目标机 ARP 保持 Reachable,而飞秋不再应答,说明是应用进程死亡,不是断网或断电。

成功率与测量口径

单个报文一次打崩并不可靠。按远程等效条件测量,单个报文的成功率是 3/5,平均耗时 7.0 秒;连续施加多个不同分配尺寸的分片写之后,成功率是 5/5,平均 2.1 秒。之所以要打多个落点,是因为一次崩溃取决于越界写的落点是否踩中正在使用的堆结构,不同分配尺寸会让分配落到不同的堆大小类,从而覆盖不同的堆状态。

测量口径需要说明。远程读不到目标进程的状态,只能靠 UDP 探测有没有回包来判断存活,而探测报文本身会为目标建立对端记录,改变目标的堆状态。这里的成功率都是在这个条件下测的。

把附加数据缩到 64 字节、整包 177 字节的「极限最小包」成功率只有 2/5,只适合做最小打击面演示。

影响

维度 评估
攻击成本 极低,4 个 UDP 报文,无认证、无交互、无前置条件
影响范围 与目标同网段任意主机均可发起;飞秋在局域网内使用广泛且默认监听
服务影响 即时通讯完全中断,消息、文件传输、插件服务一并失效
恢复成本 必须人工重启;无自愈机制

配合持续压制时,服务恢复后 8 秒内可以再次打掉,管理员无法维持飞秋可用。

修复建议

核心是在 memcpy 之前把两个数卡住。

1
2
3
4
5
if (offset >= 0 && (unsigned)offset + Size <= dwBytes) {
memcpy(base + offset, src, Size);
} else {
// 丢弃该分片,并记录异常
}

三个要点。

offset 必须按无符号比较。它是 _wtoi 出来的有符号数,如果直接写成 offset + Size <= dwBytes,负的 offset 会绕过检查。

dwBytes 要给上限。当前它由 wcstoul 得来且没有上界,超大值会导致 GlobalAlloc 失败并提前返回,虽然不崩,但属于异常输入。

分片序号与位图索引要对齐校验。现在 seq 只校验了 1 到 8000,而位图写入位置是 entry + 0x2F + seq,应确认该位置确实落在位图区间内。

其他建议:网络数据接收路径启用 /GS、CFG、SafeSEH;为目标进程配置异常重启守护,缩短中断窗口。飞秋已长期停止维护,建议整体迁移到仍在维护的即时通讯方案。

工具

审计产出的工具在下面的仓库里,最终交付物是一个单文件 Win32 程序,12 KB,零 CRT 依赖,兼容 Windows XP SP3 及以上,提供单次、循环压制、定时三种模式和命令行接口。

https://github.com/Moxin1044/feiq-dos-tool

结论

成因是三个来自网络的内存操作参数只校验了一个。它不需要攻击技巧就能稳定触发,在局域网环境下具备完整的拒绝服务能力。

审计里花时间较多的是把偶发崩溃做成稳定可用:单个报文打不稳,需要覆盖不同的堆落点,才能把成功率提上去。整个过程靠「构造报文、观察进程行为」的闭环推进。

本文列出的数字与现象都可以复现。文中测试在受控实验网络内完成,目标是自己搭建的同版本样本,未涉及任何第三方资产。