我的AFL++fuzzing入门之路(4)

00 引言:跨越鸿沟

回顾前三天:

  • Day 1:在"教科书式test.c"上跑通AFL++,理解状态界面
  • Day 2:深入插桩原理,发现"平墙式memcmp让AFL失明"的致命问题
  • Day 3:用ASAN将"隐形漏洞"转化为"显性崩溃"

提出问题: 真实项目不是afl-cc test.c这么简单。面对libpng这类真实解析器:

  • 如何编译带依赖的开源项目?
  • 如何应对无处不在的"平墙式"魔数检查(memcmp(header, "\x89PNG", 4))?
  • 如何让AFL++不再"瞎猜"?

本章目标: 以libpng 1.6.54为演示目标,掌握实战前的三大核心准备:

  1. 编译真实项目(带ASAN插桩)
  2. 构建高质量种子语料库
  3. 用字典破解"平墙式"困境

01 实战目标编译:以libpng为例

1st. 为什么选libpng

理由说明
经典攻击面图片解析器天生处理不可信输入(用户上传)
代码规模适中约3万行,比内核简单,比test.c真实
历史漏洞丰富CVE-2018-14550、CVE-2019-7317等,有参考案例
格式相对简单PNG结构清晰:文件头+Chunk(IHDR/IDAT/IEND)
官方支持完善Google OSS-Fuzz已集成,有现成字典参考

选什么版本?看了一下,最后决定选择1.6.54,存在已知CVE,CVE-2026-25646(png_set_quantize()堆溢出),1.6.55修复

CVE-2026-25646速览:png_set_quantize()函数在处理调色板(PLTE Chunk)时存在堆缓冲区溢出,存在30年之久。这正是ASAN的检测目标,也是我们Fuzzing的"猎物"。

2nd. 下载与依赖

# 下载源码
wget https://download.sourceforge.net/libpng/libpng-1.6.54.tar.gz
tar -xzf libpng-1.6.54.tar.gz && cd libpng-1.6.54

# 验证版本
head -5 README

README for libpng version 1.6.54
================================

# 安装依赖
sudo apt update
sudo apt install zlib1g-dev  # libpng依赖zlib

3rd. 用afl-cc + ASAN编译

命令如下:

./configure CC=afl-cc CXX=afl-c++ \
            CFLAGS="-fsanitize=address -g -O2" \
            --disable-shared --enable-static

make -j$(nproc)

参数解析:

参数作用
CC=afl-cc指定AFL++编译器,自动插桩
-fsanitize=address集成ASAN
-g保留调试符号,崩溃分析用
--disable-shared静态链接,避免动态库路径问题
--enable-static生成静态库

常见问题与解决:

  1. zlib not found错误:
sudo apt install zlib1g-dev
  1. libpng16.so: cannot open shared object file:
# 重新配置并强制静态链接
./configure ... --disable-shared --enable-static
  1. ASAN运行时错误:
# 设置环境变量(例如检测泄漏)
export ASAN_OPTIONS=detect_leaks=1

4th. 验证插桩成功

# 方法:检查AFL符号
nm pngtest | grep -i afl | head -5

image-20260311205854239

02 种子语料库:从"test"到"有效样本"

1st. 为什么echo "test"不够了

在 我的AFL++fuzzing之路(1) 中,我们用 echo "test" > seed.txt 作为种子,因为目标程序非常简单。但在真实场景中,程序往往有格式检查(如文件头魔数),无效种子会导致fuzzer在解析入口处就被“过滤”,无法深入代码路径。

在 我的AFL++fuzzing之路(2) 中,我们有提到:平墙式memcmp让AFL"失明"。

而libpng的"平墙式"检查:

// png.c 中的魔数检查(8字节文件头)
if (png_sig_cmp(buf, 0, 8)) {  // 检查 \x89PNG\r\n\x1a\n
    png_error(png_ptr, "Not a PNG file");
}

后果:用"test"作为种子,AFL需要随机变异出\x89PNG...,概率(1/256)^8,几乎不可能。

2nd. 收集真实PNG样本

来源:

  • libpng 1.6.54源码自带的contrib/pngsuite/测试图片(官方推荐)
  • AFL++ fuzzdata 公开种子库
  • 自己的截图(控制大小<50KB)
mkdir -p raw_seeds/
cp /path/to/libpng-1.6.54/contrib/pngsuite/*.png raw_seeds/
cp ~/Pictures/*.png raw_seeds/  # 自己的截图
ls raw_seeds/ | wc -l           # 查看总文件数

image-20260311211154931

3rd. 用afl-showmap评估种子质量

核心实验:对比不同种子的覆盖率

# 坏种子(Day 1)
echo "test" > seed_bad.txt
afl-showmap -o coverage_bad.map -- ./libpng-1.6.54/pngtest seed_bad.txt
wc -l coverage_bad.map

# 真实PNG(来自pngsuite)
afl-showmap -o coverage_good.map -- ./libpng-1.6.54/pngtest 

那么执行完我们应该可以看到以下两个结果:

image-20260311212627343

image-20260311212755749

我们可以看到,bad_seed只触发了135条边,而good_seed触发了527条边,覆盖效率近几3倍

种子类型Captured tuples覆盖率
坏种子 (seed_bad.txt)135 tuples极低
好种子 (某个PNG)527 tuples约3倍提升

关键发现:真实PNG比"test"字符串多触发了392条新边,直接证明了种子质量决定Fuzzing效果——再好的变异策略,也无法弥补坏种子的先天不足。

4th. 使用 afl-cmin/afl-tmin 剪裁语料库

# 1. 准备目录
mkdir -p  min_seeds/

# 3. 语料库最小化(去重)
afl-cmin -i raw_seeds/ -o min_seeds/ -- ./libpng-1.6.54/pngtest @@

image-20260311214003000

可以看到种子的数量从55裁剪到了40

image-20260311214602311

# 4. 单样本最小化(精简单个体积)
afl-tmin -i min_seeds/basn0g01.png -o min_seeds/minimal.png -- ./libpng-1.6.54/pngtest @@

image-20260311214224260

意外发现:afl-tmin报告"文件大小减少0.00%",说明这个PNG已经是最优状态——164字节,无冗余数据。

这恰恰说明pngsuite是专业测试集,每个字节都有意义。实际从网上下载的图片通常会有大量压缩空间。

# 5. 测试
afl-showmap -o coverage_min.map -- ./libpng-1.6.54/pngtest min_seeds/minimal.png

# 6. 查看结果
wc -l coverage_min.map

image-20260311214318724

种子类型文件大小tuples数量
seed_bad.txt5B135
原始PNG (basi0g01.png)164B527
afl-tmin优化后164B527

由上面的操作和结果,我们可以得到:

  • afl-cmin:去重不丢覆盖,52→40文件,效率提升30%
  • afl-tmin:精简约束体积,本例中已达最优(164字节触发527边)
  • 质量 » 数量:164字节的专业种子 > 5B的垃圾数据

03 字典加速:攻克“平墙式”困境

1st. 回顾平墙式检验的噩梦

test_wall.c的教训:

if (memcmp(buf, "AFL++", 5) == 0)
    __builtin_trap();
  • 无反馈:匹配0-4字节时,AFL无法区分"接近目标"和"完全无关"
  • 结果:30分钟,681万次执行,0崩溃
  • 根因:memcmp是"平墙式"检查——要么全对,要么全错,没有中间状态给AFL学习

libpng的同样困境:

// png.c 中的魔数检查(8字节)
if (png_sig_cmp(buf, 0, 8))  // 检查 \x89PNG\r\n\x1a\n
    png_error(png_ptr, "Not a PNG file");

// Chunk类型检查(4字节)
if (memcmp(chunk_type, "IHDR", 4) == 0) ...
if (memcmp(chunk_type, "PLTE", 4) == 0) ...  // CVE-2026-25646关键!
if (memcmp(chunk_type, "IDAT", 4) == 0) ...
if (memcmp(chunk_type, "IEND", 4) == 0) ...

后果:没有字典时,AFL需要随机变异出\x89PNG或PLTE,概率(1/256)^4起步,几乎不可能突破。

2nd. PNG字典构造:赋予AFL"领域知识"

原理:字典让AFL将预定义字符串视为原子token,直接插入而非逐字节猜测。

获取官方字典(从Google OSS-Fuzz):

# PNG文件格式字典
# 用于AFL++ Fuzzing libpng,破解平墙式魔数检查
# 来源:基于Google OSS-Fuzz官方字典整理
# 适用版本:libpng 1.6.54(存在CVE-2026-25646)

# ==================== 文件头魔数 ====================
# PNG签名:0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
header_png="\x89PNG\r\n\x1a\n"

# ==================== 关键Chunk类型(4字节) ====================
# IHDR - 图像头(必须第一个出现)
section_IHDR="IHDR"

# PLTE - 调色板(CVE-2026-25646关键,png_set_quantize()溢出点)
section_PLTE="PLTE"

# IDAT - 图像数据(可多个)
section_IDAT="IDAT"

# IEND - 图像结束(必须最后一个)
section_IEND="IEND"

# ==================== 辅助Chunk类型 ====================
# tRNS - 透明
section_tRNS="tRNS"

# gAMA - Gamma校正
section_gAMA="gAMA"

# cHRM - 主色度和白点
section_cHRM="cHRM"

# sRGB - 标准RGB颜色空间
section_sRGB="sRGB"

# iCCP - ICC颜色配置文件
section_iCCP="iCCP"

# tEXt - 文本信息
section_tEXt="tEXt"

# zTXt - 压缩文本
section_zTXt="zTXt"

# iTXt - 国际化文本
section_iTXt="iTXt"

# bKGD - 背景颜色
section_bKGD="bKGD"

# pHYs - 物理像素尺寸
section_pHYs="pHYs"

# sBIT - 有效位数
section_sBIT="sBIT"

# hIST - 图像直方图
section_hIST="hIST"

# tIME - 最后修改时间
section_tIME="tIME"

# ==================== 颜色类型(1字节) ====================
# 0 - 灰度
color_type_gray="\x00"

# 2 - RGB真彩色
color_type_rgb="\x02"

# 3 - 索引颜色(使用PLTE调色板,CVE-2026-25646相关)
color_type_indexed="\x03"

# 4 - 灰度+Alpha
color_type_gray_alpha="\x04"

# 6 - RGBA真彩色
color_type_rgba="\x06"

# ==================== 位深度(1字节) ====================
# 1位
bit_depth_1="\x01"

# 2位
bit_depth_2="\x02"

# 4位
bit_depth_4="\x04"

# 8位(最常见,CVE-2026-25646高风险配置)
bit_depth_8="\x08"

# 16位
bit_depth_16="\x10"

# ==================== 压缩/过滤/交错方法 ====================
# deflate压缩(标准)
compression_deflate="\x00"

# 自适应过滤(标准)
filter_adaptive="\x00"

# 无交错
interlace_none="\x00"

# Adam7交错
interlace_adam7="\x01"

# ==================== 常用字节组合 ====================
# 大端4字节长度0(Chunk长度字段)
length_zero="\x00\x00\x00\x00"

# 大端4字节CRC初始值
crc_placeholder="\x00\x00\x00\x00"

# PNG结束序列:IEND + 0长度 + CRC
iend_sequence="IEND\x00\x00\x00\x00\xae\x42\x60\x82"

关键提示:PLTE是触发CVE-2026-25646的核心chunk。png_set_quantize()函数在处理调色板时存在堆溢出,字典中的section_PLTE让AFL直接构造包含该chunk的输入,而非随机猜测4字节。

3rd. 对比实验

我们来做一组实验来观察有/无字典时fuzzing的进展

# 组1:无字典
mkdir -p out_nodict
afl-fuzz -i min_seeds/ -o out_nodict -m none -- ./libpng-1.6.54/pngtest @@

# 组2:有字典
mkdir -p out_dict
afl-fuzz -i min_seeds/ -o out_dict -x png.dict -m none -- ./libpng-1.6.54/pngtest @@

tips:可以使用tmux和screen等命令同时持久化窗口处理fuzzing

这里为了方便也懒得安装就直接开两个终端了

image-20260311220519585

然后咱稍微往床上一躺,睡一觉,

这是咱中间按照不同时间节点截取的进展:

16min:

image-20260311231453659

45min:

image-20260311231457936

1h17min:

image-20260311234539250

15h后……:

image-20260312134257344

4th.比对分析与结果

1.核心发现:strategy 分歧

无字典 (左)有字典 (右)
Strategyexploreexploit
含义还在探索新路径已进入利用模式深挖

这是决定性证据:AFL 自动判断有字典的实例覆盖已足够,切换到 exploit 模式专注找漏洞;而无字典的还在 explore 模式苦苦寻找新路径。

2.最有力的证明:变异策略数据

Dictionary 行:
无字典:  0/0, 0/0, 0/17.9k, 0/17.9k  ← 完全未使用
有字典:  28/52.2k, 0/52.5k, 0/3003, 0/3022  ← 28次成功

Dictionary 策略产生了28次有效变异,这是无字典完全无法做到的。

再看其他策略的基数:

策略无字典有字典
Bit flips142k149k
Arithmetics1.25M128k(更精准,少浪费)
Known ints160k16.3k(更精准)

有字典用更少的"笨办法"尝试(arithmetics/known ints),更多依赖智能变异。

3.最终数据对比(15小时)

指标无字典有字典优势
New edges106139+31% ⭐
Favored %13.39%15.94%+19% ⭐
Corpus560596+36
Levels1517+2级
Total execs3.87M3.81M相近
Last new find8分钟前1小时50分钟前左近期活跃
Cycles129115左多14轮

4.效率对比

相同执行量 (~3.8M),不同产出:

无字典: 106 edges / 3.87M = 2.74 edges/10万 有字典: 139 edges / 3.81M = 3.65 edges/10万

效率差距: (3.65-2.74)/2.74 = 33%

有字典用相近的执行量,多发现 33% 的代码路径。

5.Strategy 切换的意义

模式无字典有字典
explore✅ 还在探索❌ 已离开
exploit❌ 未进入✅ 已深耕

有字典的实例更早完成了探索阶段,说明:

  • 字典帮助更快达到覆盖饱和
  • 现在专注深挖已知路径找崩溃
  • 无字典还在浪费执行在找新路径上

6.近期活跃度假象

左边 last new find: 8分钟前 看起来很活跃,但:

  • 跑了 129 cycles(比右边多14轮)
  • 才达到和无字典 115 cycles 相近的 corpus 规模
  • 说明无字典的 cycle 效率更低,很多轮是在重复探索

右边虽然 1小时50分钟 无新发现,但:

  • 已进入 exploit 模式,这是设计行为
  • 在已知路径上组合变异找漏洞,而非盲目扩张

7.总结

在种子库都足够优质(相同的裁剪案例)并且资源分配相近的情况下:

有字典用更少的 cycles(115 vs 129),更早进入 exploit 模式,多发现 31% 的代码路径(139 vs 106),且语料库优质率高 19%。

04 结语:从"test.c"到"libpng"的 fuzzing 启蒙

写到这里,我的 AFL++ fuzzing 入门之路算是暂时告一段落了。

回看这四天的学习轨迹,从最初对着 afl-fuzz 状态界面一脸茫然,到现在能对着 libpng 的插桩汇编和覆盖率数据侃侃而谈(误),这个系列记录了一个安全初学者真实的认知爬坡过程——不是 polished 的教程,而是带着坑、带着疑问、带着"原来如此"的摸索记录。

我们走过了哪些路

篇章核心问题关键收获
Day 1怎么让 AFL++ 跑起来?环境搭建、状态界面解读、Ubuntu apport 的坑
Day 2插桩到底插了什么?为什么 memcmp 会让 AFL “失明”?LLVM SanitizerCoverage 机制、阶梯式 vs 平墙式反馈、GDB 崩溃分析
Day 3怎么让"隐形"漏洞现形?ASAN 影子内存与红区、MSAN/TSAN/UBSAN 的分工、Sanitizer 与 fuzzing 的协同
Day 4真实项目怎么搞?libpng 编译、语料库剪裁、afl-cmin/afl-tmin、字典破解平墙式困境

四篇下来,最深刻的体会是:fuzzing 不是"跑起来就完事"的工程,而是"覆盖率反馈→变异策略→种子质量→检测工具"的系统调优。任何一个环节掉链子,都会导致你从"智能 fuzzing"退化回"随机瞎猜"——Day 2 的平墙式实验和 Day 4 的字典对比,都是活生生的教训。

那些还没填的坑

作为入门系列,本文刻意(其实是能力有限)避开了一些深水区,留待后续探索:

  • CmpLog/Redqueen:Day 2 末尾埋的伏笔,AFL++ 如何 hook 比较指令来自动绕过 memcmp 类检查
  • 多核并行与主从模式:afl-fuzz -M/-S 的分布式 fuzzing,以及如何同步语料库
  • 结构化 fuzzing:libpng 这种固定格式的目标还能靠字典,那 protobuf、JavaScript AST 这类复杂结构怎么办?
  • 真实漏洞复现:CVE-2026-25646 的堆溢出到底怎么触发?ASAN 报告出来后如何定位到 png_set_quantize() 的具体代码?

这些不是"进阶内容",而是从"入门"到"能干活"的必经之路。如果后续有时间(和实习 offer),可能会开"进阶之路"系列继续填坑。

写给同样想入门的你

如果你也是安全方向的初学者,想通过 fuzzing 扩充技术栈,我的建议是:

  1. 别急着跑大型项目。先拿 test.c 这种玩具程序把状态界面看熟,把插桩原理搞懂,否则面对真实目标时,你分不清是"没漏洞"还是"没配置对"。
  2. 重视反馈机制。Day 2 的阶梯式 vs 平墙式实验值得亲手复现——理解了"为什么 AFL 需要覆盖率反馈",你才知道怎么设计种子、怎么写字典。
  3. 善用工具链。afl-showmap 看覆盖、afl-cmin 去重、afl-tmin 精简、GDB 定位崩溃,这些工具的组合拳比单纯跑 afl-fuzz 重要得多。
  4. 接受"跑不出崩溃"的结果。Fuzzing 是概率游戏,配置正确、种子优质、时间充足,也不保证一定能找到漏洞。但配置错误,一定找不到。

最后

写这四篇的初衷,其实是缓解投简历前的焦虑——总觉得技术栈太薄,想赶紧学点"前沿"的东西充门面。但写完后反而平静了:安全是个需要沉淀的领域,AFL++ 再强大,也只是工具;真正值钱的,是理解工具背后的原理,以及用工具解决真实问题的能力。

希望这个系列对你有帮助。如果有问题或勘误,欢迎交流。

——2026年3月16日,于宿舍书桌前,一边等 fuzzing 结果一边写结语