Skia 模糊测试(Fuzzing)完全指南:从 fuzz 复现到 OSS-Fuzz 持续集成

原创2026-09-23 16:28:141,725 阅读
文章标签:图形学

Skia 模糊测试(Fuzzing)完全指南:从 fuzz 复现到 OSS-Fuzz 持续集成

导读

本文以 fuzz/README.md 为骨架,系统讲解 Skia 图形库的模糊测试体系:什么是 API fuzzer 与 binary fuzzer、如何用 fuzz 可执行文件复现 ClusterFuzz/OSS-Fuzz 报告的崩溃、如何基于 libFuzzer 编写并运行新的 fuzzer,以及如何将 fuzzer 接入 OSS-Fuzz 持续运行。读完本文,你将掌握从"拿到一个崩溃测试用例"到"定位根因",再到"新增一个 fuzzer 并上线到自动化平台"的完整工作流。


一、认识 Skia 的 Fuzzers:两类输入模型

在 fuzz/ 目录中,Skia 维护了一批 fuzzers——即"接收随机输入、集中针对特定 API 执行代码"的小型程序。根据输入数据的语义,它们被划分为两类:

  • Binary fuzzer(二进制 fuzzer):输入数据本身就是某种格式的文件内容,fuzzer 直接消费这份数据。典型代表是 codec fuzzer——将一个被变异过的 PNG/JPEG 等文件尝试解码为 SkImage;以及 SkSL 解析类 fuzzer(把随机字节当作着色器源码字符串)。这类 fuzzer 对随机数据的格式高度敏感。
  • API fuzzer(接口 fuzzer):输入是一段随机字节流,fuzzer 将其"翻译"成对某个 API 的连续调用序列。典型代表是 canvas fuzzer——把随机字节解析成一系列 SkCanvas 绘制调用。

这两种分类在 fuzz/FuzzMain.cpp 中清晰可见:fuzz_api 通过 sk_tools::Registry<Fuzzable> 按名称查找 API fuzzer(见 fuzz/Fuzz.h 中 DEF_FUZZ 宏的注册机制),而 binary fuzzer 则直接读取 SkData 调用对应的 Fuzz* 入口函数。

二、两种可执行文件打包方式

根据根目录的 BUILD.gn,这些 fuzzer 被打包成两种形态:

形态 用途 构建方式
fuzz 可执行文件 包含所有 fuzzer,是复现 fuzzer 上报 bug 的便捷工具 GN 目标 fuzz
单 fuzzer 可执行文件 恰好包含一个 fuzzer,适合用 libFuzzer 驱动持续变异输入 GN 目标 libfuzzer_app

fuzz 聚合二进制的入口在 fuzz/FuzzMain.cpp:它解析 -b/-t/-n/--loops 等命令行参数,判断输入是文件还是目录,然后对每个输入执行指定(或自动推断出的)fuzzer 类型。

三、使用 fuzz 复现崩溃:从 ClusterFuzz 到本地重现

更完整的构建与运行说明见 site/docs/dev/testing/fuzz.md。

3.1 前置条件:构建 Skia

假设你已经能够构建 Skia。许多 fuzz 只有在 ASAN 或 MSAN 构建下才能复现(详见 site/docs/dev/testing/xsan.md)。为保证复现结果不依赖具体机器与平台,构建时建议在 BUILD.gn 中追加以下参数:

skia_use_fontconfig=false
skia_use_freetype=true
skia_use_system_freetype2=false
skia_use_wuffs=true
skia_enable_skottie=true
skia_enable_fontmgr_custom_directory=false
skia_enable_fontmgr_custom_embedded=false
skia_enable_fontmgr_custom_empty=true

3.2 最简复现命令

从 ClusterFuzz 或 oss-fuzz 下载一个 fuzz 测试用例后,只需:

out/ASAN/fuzz -b /path/to/downloaded/testcase

fuzz 会根据测试用例的文件名尽力猜测对应的 fuzzer 类型和名称(自动推断逻辑见 fuzz/FuzzMain.cpp 的 try_auto_detect,它用正则匹配 clusterfuzz-testcase(-minimized)?-<type>-<id> 与 api-<name>-<hex> 等命名模式)。你也可以手动指定类型和名称:

out/ASAN/fuzz -t filter_fuzz -b /path/to/downloaded/testcase
out/ASAN/fuzz -t api -n RasterN32Canvas -b /path/to/downloaded/testcase

3.3 枚举所有类型与名称

out/ASAN/fuzz --help   # 列出所有 type
out/ASAN/fuzz -t api   # 列出所有 api name

--help 中列举的完整 type 清单(对应 fuzz/FuzzMain.cpp 的 g_type_message)包括:android_codec、animated_image_decode、api、color_deserialize、colrv1、filter_fuzz(等价于 Chrome 的 filter_fuzz_stub)、hdr_agtm、image_decode、image_decode_incremental、image_mode、image_scale、json、path_deserialize、region_deserialize、region_set_path、skdescriptor_deserialize、skmeshspecification、skottie_json(需 SK_ENABLE_SKOTTIE)、skp、skruntimeblender、skruntimecolorfilter、skruntimeeffect、sksl2glsl、sksl2metal、sksl2pipeline、sksl2spirv、sksl2wgsl、svg_dom(需 SK_ENABLE_SVG)、textblob。

3.4 复现失败时的兜底:--loops

如果崩溃没有立即出现(例如竞态或需要多次迭代才触发的路径),可以加大运行次数:

out/ASAN/fuzz -b /path/to/downloaded/testcase --loops <times-to-run>

在 fuzz/FuzzMain.cpp 中,loopCount 被强制 max(FLAGS_loops, 1),且无论输入是单个文件还是目录,每个输入都会重复执行 loopCount 次。

3.5 其他实用命令行参数

fuzz 还支持(见 fuzz/FuzzMain.cpp):

  • -d, --dump <name>:非空时,将 image* 或 skp 类型的结果以 PNG 形式导出(dump_png 通过 SkPngEncoder/SkPngRustEncoder 实现,取决于构建时的 SK_CODEC_DECODES_PNG_WITH_RUST 宏)。
  • -v, --verbose:打印更多 fuzzing 过程信息。
  • -b, --bytes:指向文件或目录;目录时逐个文件依次作为输入喂给 fuzzer。

四、用 libFuzzer 编写新的 fuzzer

libFuzzer 是编写新 fuzzer 最便捷的方式,也是 OSS-Fuzz 驱动 Skia fuzzer 的运行方式。你的 fuzzer 入口必须实现如下 API:

extern "C" int LLVMFuzzerTestOneInput(const uint8_t*, size_t);

4.1 环境准备

安装带 libFuzzer 的 Clang(示例为 clang-19):

sudo apt install clang-19

在 BUILD.gn 中配置:

cc="clang-19"
cxx="clang++-19"
skia_build_fuzzers=true

其中 skia_build_fuzzers 开关在 gn/skia.gni 定义,默认值为 false。随后在 BUILD.gn 中使用 libfuzzer_app 目标添加新 fuzzer 条目。

4.2 构建并运行

ninja -C out/fuzz my_new_fuzzer   # 构建单个目标
out/fuzz/my_new_fuzzer            # 直接运行,libFuzzer 开始变异输入

4.3 与 fuzz/FuzzMain.cpp 对接

新增 fuzzer 后,还需在 fuzz/FuzzMain.cpp 中登记,才能被聚合版 fuzz 二进制调用,然后再同步更新 OSS-Fuzz 配置使其自动运行(见第五节)。

API fuzzer 的接入方式:在 fuzz/Fuzz.h 中 DEF_FUZZ(name, f) 宏通过 sk_tools::Registry<Fuzzable> 注册一个可发现条目;fuzz_api(fuzz/FuzzMain.cpp)遍历该注册表,按 -n 指定的名字调用 Fuzzable::fn(&fuzz)。一个典型的 libFuzzer 包装(以 ImageFilter 为例,见 fuzz/oss_fuzz/FuzzAPIImageFilter.cpp):

#include "fuzz/Fuzz.h"

void fuzz_ImageFilter(Fuzz* f);

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    if (size > 4000) {   // 限制输入长度,防止超大输入拖慢 fuzzing
        return 0;
    }
    auto fuzz = Fuzz(data, size);
    fuzz_ImageFilter(&fuzz);
    return 0;
}

binary fuzzer 的接入方式:直接实现 FuzzXxx(const uint8_t* data, size_t size) 并同样在 SK_BUILD_FOR_LIBFUZZER 下暴露 LLVMFuzzerTestOneInput。例如图片解码 fuzzer fuzz/oss_fuzz/FuzzImage.cpp 将输入字节交给 SkImages::DeferredFromEncodedData 解码后绘制到 128×128 光栅 surface;SkSL→GLSL 编译 fuzzer fuzz/oss_fuzz/FuzzSKSL2GLSL.cpp 则把输入当作 fragment shader 源码交给 SkSL::Compiler 编译并转译。

五、Fuzzing 专用编译宏(Defines)

以下宏能引导 fuzzer 更高效地运行(例如避免 OOM、避免落入不必要地慢的代码路径):

// 使用 afl-fuzz 时必须定义,防止 OOM 给结果引入噪声
SK_BUILD_FOR_AFL_FUZZ

// 使用 libFuzzer 时必须定义
SK_BUILD_FOR_LIBFUZZER

// 添加守卫,在判定某条代码路径将耗时过长或占用大量内存时中止执行;
// 当上面两个宏任一被定义时,该宏默认同时生效
SK_BUILD_FOR_FUZZER

从源码可见,SK_BUILD_FOR_LIBFUZZER 还承担着条件编译职责:各单 fuzzer 目标只有在它被定义时才会编译出 LLVMFuzzerTestOneInput 入口(见 fuzz/oss_fuzz/FuzzImage.cpp 与 fuzz/oss_fuzz/FuzzSKSL2GLSL.cpp 的 #if defined(SK_BUILD_FOR_LIBFUZZER) 代码块),确保同一份源码既能用于聚合的 fuzz 二进制,又能用于 libFuzzer 单目标。

六、Fuzz 数据源与 Fuzz 类:API fuzzer 的输入语义

API fuzzer 通过 fuzz/Fuzz.h 中的 Fuzz 类消费随机字节流,它本质上是一个"字节游标":

  • next(T* t):按 sizeof(T) 取下一段字节填充变量。类注释特别强调用指针传参而非返回值,是为了避免不同编译器(GCC 与 Clang)对函数参数求值顺序不同导致的平台相关行为。
  • nextRange(T*, Min, Max):将值钳制在 [min, max] 区间内(内部对枚举做 sk_strip_enum 剥离后比较,规避 UBSAN 对越界枚举的告警)。
  • nextEnum(T*, T max):枚举安全取值,同样规避 UBSAN。
  • nextN(T* ptr, int n):连续填充 n 个元素。
  • nextBool()、next(SkRegion*):针对布尔与 SkRegion 的特化版本。
  • signalBug():调用 raise(SIGSEGV) 显式向 fuzzer 报告"这些输入触发了 bug"。
  • size()/exhausted()/remainingSize()/remainingData()/deplete():游标状态管理。

一个典型的 API fuzzer 定义(fuzz/FuzzCanvas.cpp):

DEF_FUZZ(RasterN32Canvas, fuzz) {
    auto surface = SkSurfaces::Raster(
            SkImageInfo::MakeN32Premul(kCanvasSize.width(), kCanvasSize.height()));
    if (!surface || !surface->getCanvas()) { fuzz->signalBug(); }
    FuzzCanvas(fuzz, surface->getCanvas());
}

FuzzCanvas 会把 Fuzz 游标吐出的字节流解析为对 SkCanvas 的绘制调用序列,从而覆盖画布 API 的各类输入组合。

另外值得注意 fuzz/FuzzMain.cpp 中的 calculate_option:它把输入前 1024 个字节求和得到一个 8 位整数,客户端用取模将其映射为不同选项(如缩放倍数、解码模式),从而让 image_scale/image_mode 等单个 fuzzer 就能覆盖多个配置维度,无需为每个选项各建一个 fuzzer。

七、接入 OSS-Fuzz:持续模糊测试与自动提 bug

Skia 的 fuzzer 运行在 OSS-Fuzz 之上,由它驱动 libfuzzer、afl-fuzz、hong-fuzz 等引擎持续执行,并在发现问题时自动向 OSS-Fuzz issue 跟踪器提交并关闭 bug。Skia 相关的构建配置存放在 OSS-Fuzz 仓库的 projects/skia 目录中,增减/修改自动运行的 fuzzer 时都改这里。

7.1 启用新 fuzzer 的典型步骤

带 * 的步骤仅针对强依赖随机数据格式的 fuzzer(即 binary fuzzer,如图像解码、SkSL 解析)。

  1. *添加种子语料(seed corpus)到 gs://skia-cdn/oss-fuzz/(位于 Skia Buildbots 项目下);上传该 bucket 需要 "breakglass" 权限。
  2. *更新 OSS-Fuzz 的 Dockerfile,将种子语料下载到构建镜像中。
  3. 更新 build.sh:构建目标 fuzzer 并把产物移入 $OUT;若有种子语料,移入 $OUT 并命名为"fuzzer 可执行文件名 + _seed_corpus.zip"后缀。

可参考的添加 fuzzer 的示例 PR:binary、API。

7.2 本地测试 OSS-Fuzz 构建

OSS-Fuzz 新项目指南描述了如何用 Docker 在本地验证 OSS-Fuzz 构建与 fuzzer 运行。另有针对 skcms 仓库的 OSS-Fuzz 配置(projects/skcms),其构建流程类似,区别在于 build.sh 直接编译 fuzz 可执行文件而非使用 GN 目标。

7.3 OSS-Fuzz 仪表盘与覆盖率

正常情况下,被 fuzzing 的 Skia 版本约每天更新 2 次;如遇 OSS-Fuzz 问题,可向对应 component 提交 issue(googler 专有)。

八、当前仓库中的 fuzzer 全貌

fuzz/oss_fuzz/ 目录收录了约 50 个与 OSS-Fuzz 对应的单 fuzzer 目标,覆盖多个子系统:

  • 编解码:FuzzAndroidCodec、FuzzAnimatedImage、FuzzImage、FuzzIncrementalImage、FuzzBMPRustDecoder、FuzzJPEGRustDecoder、FuzzPNGRustEncoder、FuzzWEBPEncoder 等;
  • SkSL 编译器:FuzzSKSL2GLSL、FuzzSKSL2Metal、FuzzSKSL2Pipeline、FuzzSKSL2SPIRV、FuzzSKSL2WGSL、FuzzSkRuntimeEffect、FuzzSkRuntimeBlender、FuzzSkRuntimeColorFilter;
  • 序列化/反序列化:FuzzPathDeserialize、FuzzImageFilterDeserialize、FuzzRegionDeserialize、FuzzSkDescriptorDeserialize、FuzzTextBlobDeserialize;
  • API 级绘制:FuzzNullCanvas、FuzzRasterN32Canvas、FuzzAPISVGCanvas、FuzzMockGPUCanvas、FuzzDrawFunctions、FuzzAPICreateDDL、FuzzDDLThreading、FuzzPrecompile;
  • 路径/几何运算:FuzzParsePath、FuzzPathop、FuzzPathMeasure、FuzzPolyUtils、FuzzQuadRoots、FuzzCubicRoots、FuzzTriangulation;
  • 其他:FuzzCOLRv1、FuzzColorspace、FuzzJSON、FuzzSkParagraph、FuzzSVG、FuzzHdrAgtm、FuzzSkMeshSpecification、FuzzRegionOp、FuzzRegionSetPath 等。

fuzz/FuzzMain.cpp 中的 cf_api_map 与 cf_map 两张映射表,将 ClusterFuzz/OSS-Fuzz 的测试用例命名映射回 Skia 内部的 type/name(例如 api_raster_n32_canvas → api 类型 + RasterN32Canvas 名称、image_filter_deserialize → filter_fuzz),这正是 try_auto_detect 能自动猜中类型的基础。

九、进阶参考资料

小结:Skia 的 fuzzing 体系是一条完整链路——fuzz/ 下的 Fuzz 类与 DEF_FUZZ 注册机制是 API fuzzer 的通用底座,fuzz/FuzzMain.cpp 是聚合复现的指挥中枢,fuzz/oss_fuzz/ 则是面向 libFuzzer/OSS-Fuzz 的标准化入口集合。理解这条链路,你就能既快速复现线上报告的崩溃,又能为 Skia 贡献新的模糊测试目标,把漏洞消灭在发布之前。

登录后查看全文
skia