这是一篇fuzzing日记…记录一下这两天用cargo-fuzz挖Rust库的过程,踩了不少坑,也有点收获。
使用环境:Windows11(WSL2虚拟机)、Ubuntu24.04LTS
工具:cargo-fuzz、Symphonia库、ffmpeg(生成种子用)、python3
1. 选目标库
随便找一个,我选了 Symphonia(github链接 https://github.com/pdeljanov/Symphonia)。

这是一个纯Rust写的音频解析库,支持MP3、AAC、FLAC、MKV等一堆格式。Star有3000多,用的人挺多。 音视频库天生就适合fuzzing,因为要解析各种奇怪的二进制格式,边界检查稍微不注意就容易出问题。
把库拉下来:
git clone https://github.com/pdeljanov/Symphonia.git
cd Symphonia
装一下cargo-fuzz(如果之前没装的话):
cargo install cargo-fuzz
2. 分析攻击
进去一看,作者其实已经准备好了fuzz目录,省事了。
cd symphonia/fuzz/fuzz_targets
ls
里面有两个现成的target:
decode_any.rs: 会尝试自动探测文件格式,然后解封装+解码。覆盖面最广。decode_mp3.rs: 专门针对MP3解码器,直接喂原始数据包,跳过格式探测。
看了一下 decode_any.rs 的代码,大概流程就是:
读取fuzzer传过来的 Vec<u8> -> 包装成 MediaSourceStream -> Probe 探测格式 -> 创建解码器 -> 循环 decode。

很标准的用法,跟正常用这个库没区别。所以直接用它就行,不用自己写harness了。
3. 种子和字典的问题
这里有个问题。如果直接用 cargo fuzz run decode_any 裸跑,直接喂字节流文件,效率特别低。
因为音频文件都有magic bytes(比如MP3是 0xFF 0xF1,WAV是 RIFF…),fuzzer生成的纯随机数据99.9%都会在Probe阶段就被拦截,根本进不到解码逻辑里。
所以要准备两个东西:
(1) 种子库(corpus)
不要去网上下那种几MB的完整音频文件,fuzzer跑不动。最好的办法是用ffmpeg生成一堆小的文件,每个0.1秒就够了。 文件结构完整,但是体积极小,fuzzer变异起来效率高。
让AI帮我写了个python脚本,调ffmpeg生成了11种格式的种子(MP3、AAC、FLAC、OGG、MKV、MP4、WAV等等),每个文件就几十到几百字节。 脚本大概长这样:
import subprocess
import os
import shutil
# Configuration
OUTPUT_DIR = "corpus"
DURATION = "0.1" # Seconds
Note = "sine=frequency=1000:duration=0.1"
# Formats to generate
# map: extension -> ffmpeg_encoding_args
FORMATS = {
"mp3": ["-c:a", "libmp3lame"],
"wav": ["-c:a", "pcm_s16le"],
"flac": ["-c:a", "flac"],
"ogg": ["-c:a", "libvorbis"],
"mkv": ["-c:a", "libvorbis"], # Matroska audio
"mp4": ["-c:a", "aac", "-movflags", "+faststart"],
"m4a": ["-c:a", "aac"],
"aac": ["-c:a", "aac"], # ADTS
"caf": ["-c:a", "pcm_s16be"], # Core Audio Format
"aiff": ["-c:a", "pcm_s16be"],
"wma": ["-c:a", "wmav2"], # Windows Media Audio
}
def check_ffmpeg():
try:
subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
print("ffmpeg found.")
except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: ffmpeg is not installed or not in PATH.")
exit(1)
def generate_seeds():
if os.path.exists(OUTPUT_DIR):
print(f"Directory '{OUTPUT_DIR}' already exists. Skipping creation to avoid overwriting user data.")
else:
os.makedirs(OUTPUT_DIR)
print(f"Created directory '{OUTPUT_DIR}'.")
print(f"Generating seeds into '{OUTPUT_DIR}'...")
for ext, args in FORMATS.items():
filename = os.path.join(OUTPUT_DIR, f"seed.{ext}")
# Command: unique sine wave generation for minimal file size
# ffmpeg -y -f lavfi -i sine=frequency=1000:duration=0.1 [args] filename
cmd = ["ffmpeg", "-y", "-f", "lavfi", "-i", Note] + args + [filename]
try:
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=True)
print(f"Generated: {filename}")
except subprocess.CalledProcessError as e:
print(f"Failed to generate {ext}: {e.stderr.decode('utf-8')}")
if __name__ == "__main__":
check_ffmpeg()
generate_seeds()
跑完之后corpus目录下就有11个微型文件了。
(2) 字典文件(audio.dict)
这个东西的作用是告诉fuzzer一些特殊字符串,比如各种文件头的magic bytes。
fuzzer在变异时会优先尝试插入这些字符串,提高通过格式探测的概率。
创建一个 audio.dict 文件,内容大概就是:
# RIFF/WAV
"RIFF""WAVE""fmt ""data"
# OGG
"OggS"
# FLAC
"fLaC"
# MP3 (ID3v2)
"ID3"
# MPEG-4 / ISO Base Media File Format (MP4, MOV, M4A)
"ftyp""ftypisom""ftypmp42""ftypM4A ""moov""trak""mdia""minf""stbl"
# MKV / WebM (EBML)
"\x1A\x45\xDF\xA3"
# AAC (ADTS)
"\xFF\xF0""\xFF\xF1""\xFF\xF9"
4. 开始fuzzing
配置环境变量:
export RUSTFLAGS="-Zsanitizer=address -C debuginfo=2"
这个是开启AddressSanitizer和调试信息,这样crash的时候能看到完整堆栈和行号。
export ASAN_OPTIONS="symbolize=1:detect_leaks=0"
symbolize=1自动翻译地址成函数名,detect_leaks=0是关掉内存泄漏检测(我们只关心crash,内存泄漏先不管)。
export RUST_BACKTRACE=1
开启Rust回溯。
然后就可以跑了,单核的话直接:
cargo fuzz run decode_any corpus/ -- -dict=audio.dict
但这样太慢了。为了榨干CPU,需要并行跑。
libFuzzer本身支持多worker,但是cargo fuzz的接口有点绕,所以写了个bash脚本 fuzz_cluster.sh:
#!/bin/bash
source "$HOME/.cargo/env"
WORKERS=${1:-16}
TARGET=${2:-decode_any}
export ASAN_OPTIONS="symbolize=1:detect_leaks=0"
echo "启动Fuzzing集群 (Symphonia)"
echo "目标: $TARGET"
echo "并发: $WORKERS"
mkdir -p "artifacts/$TARGET"
cargo fuzz run "$TARGET" corpus/ -- \
-dict=audio.dict \
-jobs=10000000 \
-workers="$WORKERS" \
-reload=1 \
-print_final_stats=1
跑起来:
./fuzz_cluster.sh 16
这样就会启动16个worker并行fuzzing了。
5. 第一波crash(浅层Bug)
跑了大概10分钟,artifacts目录下就出现了一堆crash文件,有195个。
手动一个个看肯定不现实,所以又写了个python脚本 auto_repro.py(代码太长就不写在这了),自动复现所有crash并去重分类。
脚本的思路就是:
- 遍历所有crash文件
- 用subprocess调
cargo fuzz run重新跑一遍 - 抓stderr里的panic信息
- 根据panic的文件名+行号做签名
- 相同签名的归为一类
- 输出每类bug的数量、原因、样例文件
跑完之后发现195个crash其实只对应2个独立的bug,都在 symphonia-codec-aac/src/adts.rs 里:
Bug #1 (116个crash): 整数下溢
位置: adts.rs:303
原因: attempt to subtract with overflow
看了一下代码,问题在 approximate_frame_count 函数里:
let step = (total_len - original_pos) / NUM_SAMPLE_POINTS;
这里 total_len 在前面已经被计算成 byte_len() - original_pos 了,这里又减了一次 original_pos,导致下溢。
Bug #2 (79个crash): step_by(0) panic
位置: adts.rs:306
原因: assertion failed: step != 0
如果文件太小,step 算出来是0,然后 .step_by(0) 会直接panic,因为Rust标准库不允许步长为0。
这两个bug都是在格式探测阶段的duration估算逻辑里,算是"浅层bug"。 浅层bug的特点就是fuzzer一碰到就挂,根本进不到后面的解码逻辑,所以会阻止fuzzer继续深入。
6. 修复浅层bug,继续挖
为了让fuzzer能往更深处跑,让大模型帮我把这两个bug先patch掉:
let step = total_len / NUM_SAMPLE_POINTS; // 去掉重复减法
if step == 0 {
return Ok(None); // 文件太小,不估算duration
}
重新编译,清空旧的crash,再跑一轮。
这次运气不错,跑了11分钟,又出了4个crash。
再用 auto_repro.py 分析,这次4个crash对应的是同一个bug:
Bug #3 (4个crash): 数组越界
位置: symphonia-codec-aac/src/aac/ics/mod.rs:365
原因: index out of bounds: the len is 42 but the index is 42

这个就比较深了。堆栈显示是在AAC解码的decode_spectrum阶段,也就是已经通过了Probe、FormatReader,进入到Decoder内部了,可以尝试对这个漏洞进行深入分析。
看代码:
for sfb in 0..self.info.max_sfb {
let start = bands[sfb];
let end = bands[sfb + 1]; // 这里会越界
...
}
问题在于 max_sfb 是从bitstream里读出来的,但是没有校验它是否超过 bands 数组的实际长度。
如果恶意文件里声明了一个超大的 max_sfb,就会触发越界读。
这个bug比前两个严重,因为它在解码核心逻辑里,可以用来构造DoS攻击。
7. 构造POC
现在有了crash文件,怎么验证它确实能触发漏洞呢?
fuzzer生成的crash文件就是原始的字节流,可以直接用 cargo fuzz run 重放:
cargo fuzz run decode_any artifacts/decode_any/crash-4c827550...
确实会panic,堆栈和之前一样。

但是如果要提交issue或者给别人演示,不能让人装cargo-fuzz吧。
所以写了个独立的reproducer(repro_crash.rs),逻辑就是模仿fuzz target的代码:
读文件 -> 包装成Cursor -> MediaSourceStream -> Probe -> Decode
编译成可执行文件:
cargo build --release --bin repro_crash
然后任何人都可以直接跑:
./target/release/repro_crash crash文件路径
当我试图使用 repro_crash 来运行crash文件时,却遇到了问题。
运行结果显示:Probe failed: IoError(Custom { kind: UnexpectedEof, error: "end of stream" })

crash文件在 cargo fuzz run 下明明可以触发panic,怎么用普通程序跑就不行了?
想了想,问题出在Probe阶段。Symphonia的格式探测器会检查文件开头的魔数,对于ADTS格式(AAC),它期望的是 0xff 0xf1 这个字节序列。
我用hexdump看了一下crash文件:
00000000 0d 44 73 04 ff ff ff f1 49 44 73 70 ...
开头是 0x0d 0x44…根本不是 0xff 0xf1 难怪Probe会拒绝。
那为什么 cargo fuzz run 能触发呢?可能是libfuzzer的harness对I/O有特殊处理,或者fuzzing构建的编译flags导致了一些边界检查行为不一样。总之,在普通cargo build环境下,这个crash文件过不了Probe这一关。
这就尴尬了。fuzzing找到的crash,不能直接拿来攻击程序?
8. 从crash到真正的PoC
这很正常。fuzzer生成的crash数据是随机变异出来的,格式乱七八糟很正常。能触发bug只是因为碰巧走到了某个代码路径,但这不代表它是一个"合法"的输入文件。
真正能在生产环境中攻击程序的PoC,必须满足两个条件:
- 格式上要合法,能通过所有前置检查(魔数检测、格式解析等)
- 内容上要恶意,能触发漏洞代码
这和SQL注入很像:你不能直接往输入框里打 '; DROP TABLE users;-- 就完事了,你得先让这个字符串作为"合法输入"进入SQL语句拼接的地方,然后才能发挥破坏作用。
所以crash文件的价值不在于直接使用,而在于告诉我们:
- 漏洞在哪里:
ics/mod.rs:365 - 错误是什么:
index out of bounds: len=42, idx=42 - 触发条件:
max_sfb=42,而bands数组长度也是42
有了这些信息,就可以根据代码逻辑来构造一个真正的PoC了。
9. 分析漏洞代码,从crash log倒推触发条件
这部分是整个PoC构造的核心,我尽量写清楚推理过程。
首先回顾一下crash log给的信息:
thread 'main' panicked at symphonia-codec-aac/src/aac/ics/mod.rs:365:27:
index out of bounds: the len is 42 but the index is 42
这告诉我们三件事:
- 出问题的文件是
ics/mod.rs,第365行第27列 - 错误类型是数组越界
- 数组长度是42,但访问的索引也是42(合法索引应该是0-41)
去看这个位置的代码(ics/mod.rs 第354-397行的 decode_spectrum 函数):
fn decode_spectrum(...) {
let bands = self.get_bands(); // 获取一个叫bands的数组
for g in 0..self.info.window_groups {
for sfb in 0..self.info.max_sfb { // sfb从0到max_sfb-1循环
let start = bands[sfb]; // 第364行
let end = bands[sfb + 1]; // 第365行 ← panic在这里
...
}
}
}
现在来推理:
sfb 的取值范围是 0, 1, 2, ..., max_sfb-1
当 sfb = max_sfb-1 时,bands[sfb + 1] = bands[max_sfb]
crash log说"index is 42",那就是 max_sfb = 42
crash log说"len is 42",那就是 bands 数组长度 = 42
bands[42] 访问的是第43个元素,但数组只有42个元素(索引0-41),所以越界了
好,现在确定问题出在 max_sfb 这个变量。它是从哪来的?为什么会等于42?
继续在代码里搜索 max_sfb,找到它的赋值位置(同文件的 IcsInfo::decode 函数,140-157行):
if self.window_sequence == EIGHT_SHORT_SEQUENCE {
// 短窗口模式,读4位
self.max_sfb = bs.read_bits_leq32(4)? as usize; // 范围0-15
} else {
// 长窗口模式,读6位
self.max_sfb = bs.read_bits_leq32(6)? as usize; // 范围0-63
}
max_sfb 是从比特流(bs)里读出来的,也就是从输入文件里读的。
在长窗口模式下,它读6个bit,范围是0-63。
但是代码完全没做校验。直接就拿去当数组索引用了。
所以如果输入文件里写了 max_sfb=42,而 bands 数组恰好长度是42,那就会越界。
接下来的问题是:bands数组什么时候长度是42?
搜索 get_bands 函数,发现它返回的是 self.sbinfo.long_bands 或者 short_bands。
再追溯 sbinfo 是从哪来的,最终找到 common.rs 里的静态数组定义:

// 不同采样率对应不同的bands数组
pub const SWB_OFFSET_96K_LONG: [usize; 41 + 1] = [...] // 长度 = 41+1 = 42
pub const SWB_OFFSET_48K_LONG: [usize; 49 + 1] = [...] // 长度 = 49+1 = 50
pub const SWB_OFFSET_8K_LONG: [usize; 40 + 1] = [...] // 长度 = 40+1 = 41
这里 SWB_OFFSET_96K_LONG 的长度正好是42
再看这些数组是怎么选择的(common.rs 第124-129行):
const AAC_SUBBAND_INFO: [GASubbandInfo; 12] = [
GASubbandInfo {
min_srate: 92017,
long_bands: &SWB_OFFSET_96K_LONG, // 采样率>=92017时用这个
...
},
...
];
所以当采样率>=92017Hz时(比如96000Hz),就会使用长度为42的bands数组。
现在总结一下触发条件:
- 采样率设为96000Hz(使bands数组长度为42)
- 使用长窗口模式(使max_sfb从6 bits读取)
- max_sfb设为42(使访问bands[42]越界)
下一个问题:在ADTS文件里怎么设置采样率和 max_sfb?
10. 构造PoC,逆向推导文件格式
这是最麻烦的部分。正常来说应该去看AAC的官方标准(ISO/IEC 14496-3),但那个要花钱买。网上能搜到的资料要么不完整,要么是讲MP4容器的,不是讲ADTS的。
我的办法是:直接看Symphonia的源码,从解析代码反推文件格式。
ADTS头部
先搞定ADTS头部(比较简单,资料多)。ADTS头部结构(共7字节=56位):
| 位置 | 字段 | 位数 | 值 |
|---|---|---|---|
| 0-11 | Syncword | 12 | 0xFFF |
| 12 | ID | 1 | 0=MPEG-4 |
| 13-14 | Layer | 2 | 00 |
| 15 | Protection absent | 1 | 1=无CRC |
| 16-17 | Profile | 2 | 01=AAC-LC |
| 18-21 | Sampling freq index | 4 | 0=96000Hz ←关键 |
| 22 | Private | 1 | 0 |
| 23-25 | Channel config | 3 | 2=立体声 |
| 26-29 | 其他标志 | 4 | 0 |
| 30-42 | Frame length | 13 | 帧总长度 |
| 43-53 | Buffer fullness | 11 | 0x7FF |
| 54-55 | Number of frames - 1 | 2 | 0 |
重点是第18-21位的Sampling frequency index,设为0就是96000Hz。
但这里有个坑:Symphonia检查的魔数不是 0xFFF,而是 0xFFF1
看 adts.rs 代码:
// 格式检测时找的魔数
&[&[0xff, 0xf1]]
// sync函数里也是找0xfff1
while sync != 0xfff1 { ... }
0xfff1 拆开来看:
0xff = 1111 1111 (syncword前8位)
0xf1 = 1111 0001 (syncword后4位 + ID=0 + layer=00 + protection=1)
所以ADTS头必须是 0xff 0xf1 开头,不然Probe就会拒绝。
ff f1 40 80 01 bf fc
AAC Raw Data Block
接下来是AAC Raw Data Block,这个网上资料很少。我直接看Symphonia怎么解析的。
从 cpe.rs 的 decode_ga_sce 函数开始追:它调用 ics0.decode()
再看 ics/mod.rs 的 Ics::decode 函数:
先读 global_gain(8位)
再读 ics_info
再读 section_data、scale_factor_data、spectral_data…
ics_info 的结构(从 IcsInfo::decode 函数反推):
| 字段 | 位数 | 说明 |
|---|---|---|
| ics_reserved_bit | 1 | 保留位,填0 |
| window_sequence | 2 | 0=长窗口,2=短窗口 |
| window_shape | 1 | 填0 |
| max_sfb (长窗口) | 6 | 恶意值42在这里 |
| predictor_data_present | 1 | 填0 |
所以要在这6位里写入42。
但是AAC帧不只有这些字段,还有 section_data 等一堆东西。怎么构造呢?
技巧是使用 ZERO_HCB 码本。AAC有一种特殊的哈夫曼码本叫 ZERO_HCB,意思是"这个频带全是零,没有频谱数据"。如果所有频带都用ZERO_HCB,那后面的 spectral_data 就可以省略。
section_data 的结构(从代码反推的):
sect_cb: 4位,码本索引,0=ZERO_HCB
sect_len: 5位增量编码,如果>=31就再读5位
最后再写一个 ID_END(3位,值=7)标记结束。
把这些拼起来,完整的AAC Raw Data Block结构:
| 字段 | 位数 | 值 |
|---|---|---|
| id_syn_ele | 3 | 0 (ID_SCE,单声道) |
| element_instance_tag | 4 | 0 |
| global_gain | 8 | 128 (任意值) |
| ics_reserved_bit | 1 | 0 |
| window_sequence | 2 | 0 (长窗口) |
| window_shape | 1 | 0 |
| max_sfb | 6 | 42 ← 恶意值 |
| predictor_data_present | 1 | 0 |
| sect_cb | 4 | 0 (ZERO_HCB) |
| sect_len | 5+5 | 31, 11 (编码42) |
| pulse_data_present | 1 | 0 |
| tns_data_present | 1 | 0 |
| gain_control_present | 1 | 0 |
| id_syn_ele (结束) | 3 | 7 (ID_END) |
用Python的BitWriter按这个顺序写入就行了。
但还有最后一个坑:单帧文件Probe会失败
因为ADTS的 try_new 函数里调用了 approximate_frame_count 函数,它会尝试解析多个帧来估算时长。如果文件只有一帧且刚好结束,会返回EOF错误。
解决办法是:写多帧。实测发现至少需要2帧(1个正常帧 + 1个恶意帧)才能Probe成功。正常帧的 max_sfb 设一个安全的值(比如10),只有最后一帧是恶意的。
最终的PoC文件结构:
[ADTS头1][正常AAC数据] [ADTS头2][恶意AAC数据]
构造的字节序列:
ff f1 40 80 01 bf fc 01 20 05 01 43 80
ff f1 40 80 01 bf fc 01 20 15 03 eb 1c
测试一下:
python3 poc_generator_v2.py poc.aac
./target/release/repro_crash poc.aac
输出:
Read 26 bytes from "poc.aac"
Probe succeeded!
Track codec: CodecType(4100)
Decoder created, starting decode loop...
Decoded 1024 frames
thread 'main' panicked at symphonia-codec-aac/src/aac/ics/mod.rs:365:27:
index out of bounds: the len is 42 but the index is 42
成功了。26字节,Probe通过,解码时崩溃。

这就是一个可以在真实环境中触发DoS的PoC。任何使用Symphonia库解码AAC的程序,只要加载这个文件就会panic。
接下来就是愉快的交issue了~

11. 结束
这次fuzzing的完整流程:
- 选目标:选了Symphonia这个音频解析库。
- 准备环境:种子文件(ffmpeg生成的微型音频)+ 字典(各种魔数)。
- 第一轮fuzzing:发现2个浅层bug(整数溢出、step_by(0)),都在ADTS的duration估算逻辑。
- 修复浅层bug:大模型帮忙patch掉,让fuzzer能进入更深的代码路径。
- 第二轮fuzzing:发现1个深层bug(数组越界),在AAC解码器核心逻辑。
- 尝试复现:发现crash文件不能直接用,被Probe拦住了。
- 分析原因:crash文件格式不合法,只是碰巧在fuzzing环境下能触发。
- 构造真正的PoC:分析漏洞代码+ADTS格式规范,手写一个格式合法但内容恶意的文件。
几点体会:
- Rust虽然内存安全,但逻辑bug还是会导致panic/DoS。
- 浅层bug会阻挡fuzzer深入,有时候需要先patch掉。
- fuzzing的crash不等于可用的PoC,需要根据代码逻辑重新构造。
- 构造PoC需要理解目标程序的输入处理流程和文件格式规范。
- 攻击思维:恶意数据必须披着"合法"的外衣才能送到漏洞点。
- 大模型真的很好用,甚至可以实现全流程自动化。
下一步打算在56核服务器上跑久一点,看能不能挖到更深的bug。另外其他几个crash(MKV整数溢出、ID3v2 unreachable等)也可以用类似方法构造PoC。