首页
/ 基于 OneFuzz 为 .NET 工程搭建模糊测试:以 PowerToys Hosts 模块的 FuzzTests 实战为例

基于 OneFuzz 为 .NET 工程搭建模糊测试:以 PowerToys Hosts 模块的 FuzzTests 实战为例

2026-09-06 18:36:18作者:羿妍玫Ivan

导读

本文以 Hosts.FuzzTests/Fuzz.md 这份工程内文档为骨架,讲解如何在 PowerToys 的 .NET 模块中从零集成 OneFuzz 驱动的模糊测试(Fuzzing):包括按 *.FuzzTests 规范创建独立测试工程、编写以 ReadOnlySpan<byte> 为入口的 fuzz target、编写 OneFuzzConfig.json 作业描述文件、配置 Azure Pipeline 中的 job-fuzz.yml 模板,以及如何在 OneFuzz 平台复核结果。读完本文,你将掌握一套可直接套用到其他模块(如 AdvancedPaste)的完整 fuzz 接入流程,并理解仓库中对应源码(验证器、写入服务、文件系统模拟)为何与如何成为被 fuzz 的对象。

PowerToys 中的 Hosts 模块(Hosts File Editor)负责解析、校验和写回 Windows 的 hosts 文件,其核心逻辑位于纯托管代码中,是模糊测试的理想对象。为了不给用户造成意外损坏或崩溃,团队为它专门维护了一个 Hosts.FuzzTests 工程,通过 OneFuzz 持续对 IPv4/IPv6 地址校验、主机名校验与 hosts 文件异步写入三条代码路径进行输入轰炸。


1. 认识 FuzzTests 工程与 OneFuzz 接入的整体形态

在深入分步操作前,先建立整体认知:

  • 什么是 libFuzzer .NET 目标:OneFuzz 以 libfuzzerDotNet 类型调度 .NET 模糊任务,fuzz target 是一个接收原始字节串(ReadOnlySpan<byte>)的静态方法。框架会反复用变异/生成的字节序列调用该方法,触发被测代码中的解析、校验与写入逻辑。
  • FuzzTests 与被测代码的关系Hosts.FuzzTests 并不复制业务代码,而是通过**源文件链接(linked source)**方式,把 HostsUILib 的真实实现直接编进 fuzz 工程,从而保证模糊测试命中线上真实逻辑(见 HostsEditor.FuzzTests.csproj)。
  • 作业描述文件OneFuzzConfig.json 描述“哪个 dll、哪个类、哪个方法作为入口,发现 bug 后把工作项建到哪个 ADO 项目、结果通知发给谁”。
  • CI 管道.pipelines/v2/templates/job-fuzz.yml 负责把构建产物中的 tests/*.FuzzTests/** 下载下来,交给 onefuzz-task@0 提交到 OneFuzz 平台。

文档给出的整体接入流程共四步,下面逐一展开,并结合仓库真实文件补充细节。


2. Step 1:在模块目录下新增 FuzzTests 测试工程

在要保护的模块目录内新建一个独立测试工程,工程名必须遵循 *.FuzzTests 格式,以便后续被 CI 的下载 pattern(见 Step 3)自动命中。

以 Hosts 模块为例,真实目录结构为:

src/modules/Hosts/
├── Hosts.FuzzTests/          # 模糊测试工程(本文主角)
│   ├── FuzzTests.cs
│   ├── OneFuzzConfig.json
│   ├── HostsEditor.FuzzTests.csproj
│   └── MSTestSettings.cs
├── Hosts.Tests/              # 常规单元测试(非 fuzz)
├── HostsUILib/               # 被测业务库
└── HostsModuleInterface/

工程文件的关键约定

阅读 HostsEditor.FuzzTests.csproj 可提炼出写 fuzz 工程 csproj 时的约定:

关注点 仓库中的做法 说明
公共属性导入 <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /><Import Project="$(RepoRoot)src\Common.Dotnet.FuzzTest.props" /> 前者统一 CsWinRT/Windows SDK 投影配置,后者统一 TFM 管理与 CI 行为
编译常量 <DefineConstants>TESTONLY</DefineConstants> 标记仅供测试的编译,避免产物进入正式包
输出目录 <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Hosts.FuzzTests\</OutputPath> 输出到仓库级 x64\<Config>\tests\<Name>.FuzzTests\,对应文档中 PowerToys\x64\Debug\tests\Hosts.FuzzTests\... 的路径示例
链接真实源码 一整组 <Compile Include="..\HostsUILib\...\*.cs" Link="..."/> ValidationHelper.csHostsService.csEntry.csHostsData.csBackupManager.cs 等直接链接编译
复用测试基建 <Compile Include="..\Hosts.Tests\Mocks\CustomMockFileSystem.cs" .../> fuzz 可复用单元测试中定义的内存文件系统等 mock
配置随产物分发 <Content Include="OneFuzzConfig.json"><CopyToOutputDirectory>PreserveNewest</...> OneFuzz 提交时需要读取该 json
引用包 Moq、MSTest、System.IO.Abstractions、System.IO.Abstractions.TestingHelpers、CommunityToolkit.Mvvm Moq/System.IO.Abstractions 为 FuzzWriteAsync 提供依赖模拟
CI 中禁用测试执行 Common.Dotnet.FuzzTest.propsTestingPlatformDisableCustomTestTarget 控制 fuzz 工程只用于构建并交给 OneFuzz,不在常规 CI 中以 MSTest 跑用例(注释还说明 ignore-exit code 8 是因为“该工程本来就不含常规测试”)

TFM(目标框架)由谁决定?

src/Common.Dotnet.FuzzTest.props 是全部 fuzz 工程的 TFM 唯一权威来源,注释给出了清晰的演进脉络:

  • 历史上 fuzz 工程被锁定在 .NET 8(即文档示例目录中的 net8.0-windows10.0.19041.0),原因是当时的 OneFuzz 服务不支持更新的运行时;
  • 现在 OneFuzz 的 .NET fuzzing 已成为运行时无关(runtime-agnostic,“.NET Core targets are preferred”),因此仓库将 fuzz 工程改为 net10.0-windows10.0.26100.0,与主仓其余工程保持一致。

因此在当前仓库中,构建输出实际落在类似 x64\Debug\tests\Hosts.FuzzTests\net10.0-windows10.0.26100.0\ 的 TFM 子目录内(把文档示例中的 net8.0-windows10.0.19041.0 按 props 当前值替换即可)。


3. Step 2:编写 Fuzz 目标与 OneFuzzConfig.json

文档指出,可参照 AdvancedPaste 的 AdvancedPaste.FuzzTests/Fuzz.md(该文档阐述了 OneFuzz 对 .NET 的支持背景、OneFuzzConfig 结构以及 OIP/CLI 工具)来规范化本模块的 fuzz 工程。核心交付物是两个文件:**FuzzTests.cs(fuzz 目标代码)**与 OneFuzzConfig.json(作业描述)

3.1 编写 fuzz target:FuzzTests.cs

Hosts.FuzzTests/FuzzTests.cs 定义了 4 个 fuzz 目标,全部满足 OneFuzz/libFuzzer .NET 入口要求——public static、无返回值、唯一参数 ReadOnlySpan<byte> input

Fuzz 目标方法 输入解码方式 实际触达的被测代码 行号参考
FuzzValidIPv4 UTF8 → string ValidationHelper.ValidIPv4 FuzzTests.cs L21-L31
FuzzValidIPv6 UTF8 → string ValidationHelper.ValidIPv6 FuzzTests.cs L35-L46
FuzzValidHosts UTF8 → string ValidationHelper.ValidHosts(含 validateHostsLength: true FuzzTests.cs L49-L64
FuzzWriteAsync UTF8 → string,构造 Entry 后调用 HostsService.WriteAsync 全链路(含备份、权限判断、文件写入) FuzzTests.cs L66-L97

结合被测源码看,四个目标恰好覆盖了 Hosts 编辑器最危险的输入面:

  • ValidIPv4 / ValidIPv6 内部使用巨型手写正则(见 ValidationHelper.cs),是典型的易受病态输入攻击(如灾难性回溯、超长匹配)的位置;
  • ValidHosts 依赖 Uri.CheckHostName 并把空格分割结果与 Consts.MaxHostsCount 比较,涉及字符串分割与长度校验边界;
  • WriteAsync 是真正动文件系统的路径。因此 FuzzWriteAsync 先用 Moq 装配依赖:Mock<IUserSettings>、把 Mock<IElevationHelper>.IsElevated 置为 trueMock<IBackupManager>,再用复用自 Hosts.Tests\MocksCustomMockFileSystem 构造 HostsService,最后把模糊输入同时塞进 hosts、address、comments 三个字段构建 Entry 并异步触发写入。

值得注意的是目标中异常的过滤—重抛策略(FuzzTests.cs 中每处 catch 都是先按类型过滤再 throw;):

catch (Exception ex) when (ex is RegexMatchTimeoutException)
{
    throw;
}

注释点明了设计意图(见 FuzzValidHosts 的 L57-L63):只应放行预期且无害的异常,而捕获全部异常是反模式,它会掩盖被测代码真正的缺陷(例如本不该抛出的 NullReferenceException)。正则校验场景下,RegexMatchTimeoutException 需要让模糊器感知,因此在代码里仍被重新抛出,交由框架层判定。

3.2 编写 OneFuzzConfig.json

Hosts.FuzzTests/OneFuzzConfig.json 顶部用 "configVersion": 3 声明配置文件 schema 版本,主体是一个 entries 数组,Hosts 模块在其中声明了 4 条 fuzz 作业(IPv4、IPv6、hosts、WriteAsync 各一条),结构高度一致。以第一条为例:

{
  "fuzzer": {
    "$type": "libfuzzerDotNet",
    "dll": "HostsEditor.FuzzTests.dll",
    "class": "Hosts.FuzzTests.FuzzTests",
    "method": "FuzzValidIPv4",
    "FuzzingTargetBinaries": [ "PowerToys.Hosts.dll" ]
  },
  "adoTemplate": {
    "org": "microsoft",
    "project": "OS",
    "AssignedTo": "leilzh@microsoft.com",
    "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT",
    "IterationPath": "OS\\Future"
  },
  "jobNotificationEmail": "PowerToys@microsoft.com",
  "skip": false,
  "rebootAfterSetup": false,
  "oneFuzzJobs": [ { "projectName": "Hosts", "targetName": "Hosts-dotnet-fuzzer-Ipv4" } ],
  "jobDependencies": [
    "HostsEditor.FuzzTests.dll",
    "HostsEditor.FuzzTests.pdb",
    "Microsoft.Windows.SDK.NET.dll",
    "WinRT.Runtime.dll"
  ],
  "SdlWorkItemId": 49911822
}

按文档要求,接入新模块时需逐项替换以下字段:

字段 所属对象 作用与填写要点
dll fuzzer 承载 fuzz 方法的程序集,如 HostsEditor.FuzzTests.dll(对应 csproj 的 AssemblyName)
class fuzzer 含 fuzz 方法的完整类型名(含命名空间),如 Hosts.FuzzTests.FuzzTests
method fuzzer 被调用的 fuzz 入口方法名,须与代码中 static void X(ReadOnlySpan<byte>) 一一对应
FuzzingTargetBinaries fuzzer 被测业务程序集清单,此处统一为 PowerToys.Hosts.dll(由链接源码重新编译而成),OneFuzz 据此做覆盖率追踪
$type fuzzer 固定为 libfuzzerDotNet,声明使用 libFuzzer 引擎驱动 .NET 目标
org / project adoTemplate Bug 工作项提交到的 ADO 组织与项目,Hosts 为 microsoft / OS
AssignedTo adoTemplate 工作项指派对象的邮箱,需替换为本人/团队负责人
AreaPath / IterationPath adoTemplate 工作项归属的区域与迭代路径
jobNotificationEmail 顶层 结果通知邮箱,按文档要求替换为开发者自己的 Microsoft 邮箱
projectName / targetName oneFuzzJobs 定义 OneFuzz 侧的项目名与目标名(同一项目下可用不同 targetName 区分多条 job,如 Hosts-dotnet-fuzzer-Ipv4-Ipv6-hosts-WriteAsync);每个 fuzzer 至少声明一条 job
jobDependencies 顶层 提交到 OneFuzz 时必须随行的文件清单,至少包含被测/入口 dll 与其 pdb,其余按运行需要补充,支持 glob
skip / rebootAfterSetup 顶层 是否跳过该作业、装机后是否重启(当前均为 false
SdlWorkItemId 顶层 关联的 SDL(安全开发生命周期)工作项编号

Hosts 配置中另一个值得学习的细节是 4 条 job 的依赖清单不一致FuzzValidIPv4/IPv6/hosts 只需入口 dll、pdb 与 Microsoft.Windows.SDK.NET.dllWinRT.Runtime.dll(Windows SDK 投影运行时);而 FuzzWriteAsync 额外列入了 Castle.Core.dllMoq.dllSystem.IO.Abstractions*.dllTestableIO.*.dllCommunityToolkit.Mvvm.dll 等一大串依赖。原因在于该目标运行时实例化了 HostsService,Moq/System.IO.Abstractions 家族都会被真实加载,缺失将导致目标在云端崩溃。这提示接入新模块时:务必把被测目标运行时触达的所有依赖 dll 都写进 jobDependencies


4. Step 3:在 job-fuzz.yml 中配置 OneFuzz 管道

所有 FuzzTests 工程统一由一个参数化管道模板驱动:.pipelines/v2/templates/job-fuzz.yml。该模板核心内容如下:

- download: current
  displayName: Download artifacts
  artifact: $(ArtifactName)
  patterns: |-
    **/tests/*.FuzzTests/**

它接收 configurationplatforminputArtifactStem 三个参数,ArtifactName 形如 build-$(platform)-$(configuration)...,然后只把本次构建产物中命中 **/tests/*.FuzzTests/** 的目录下载下来,交由 onefuzz-task@0 处理:

- task: onefuzz-task@0
  inputs:
    onefuzzOSes: Windows
  env:
    onefuzzDropDirectory: $(Pipeline.Workspace)\$(ArtifactName)\x64\Release\x64\Release\tests
    SYSTEM_ACCESSTOKEN: $(System.AccessToken)

接入新模块时需要关注的差异点:

  • 当前仓库已用通配 ***/tests/*.FuzzTests/** 能覆盖任意 *.FuzzTests 工程,因此新增模块(只要按 Step 1 规范命名)通常无需改动模板本身。而 Fuzz.md 中给出的写法是精确到工程名的 **/tests/Hosts.FuzzTests/**,二者作用等价,若只希望在某条流水线单独调度某个模块,可像文档示例那样把 pattern 收窄到具体工程目录。
  • 文档要求“把 job steps 中的 patterns 修改为与你 fuzz 工程名匹配”,即核对下载 pattern 是否覆盖 src/modules/Hosts/Hosts.FuzzTests 编译出的 tests/Hosts.FuzzTests/ 产物目录;若采用通配符写法则天然覆盖。

5. Step 4:提交管道并在 OneFuzz 平台复核结果

管道执行后:

  1. onefuzz-task@0 依据 onefuzzDropDirectory 指向的 tests 目录、读取其中的 OneFuzzConfig.json(因 csproj 设置了 CopyToOutputDirectory,它总是与 dll 一起出现)自动完成 OneFuzz 作业提交,目标 OS 为 Windows;
  2. OneFuzz 平台按 oneFuzzJobs 中的 projectName/targetName 创建任务并开始大规模变异输入;
  3. 按文档说明,关注收件箱:任务启动后你会收到包含作业链接的邮件,点击链接即可进入 OneFuzz 平台查看崩溃样本(crash repro)、覆盖率与运行统计;
  4. 一旦 fuzz 目标抛出了非预期异常或触发崩溃,平台会依据 adoTemplate(org/project/AssignedTo/AreaPath/IterationPath)自动在 ADO 中创建 bug 工作项,并与 SdlWorkItemId 关联,形成“发现—登记—修复”闭环。

6. 从源码看:这些 fuzz 目标为什么值得写

把 fuzz 目标与被测代码对照,能更清楚理解每个 job 的价值:

  • IPv4 正则(八位组逐段校验)ValidIPv4 的正则要求 0-255 的四段十进制(见 ValidationHelper.cs)。fuzz 关注超长数字、空段、异常分隔符等导致正则引擎出现 RegexMatchTimeoutException 的病态输入;
  • IPv6 正则(一长串多分支交替)ValidIPv6 的正则覆盖了缩写、IPv4-mapped、链路本地 fe80::...%zone 等十余种形态(L36)。分支多、量词复杂,是测试正则鲁棒性的最佳靶点;
  • 主机名组合ValidHosts(L43-L66)把输入按空格拆分并与 Consts.MaxHostsCount 比较,再逐个调用 Uri.CheckHostName;fuzz 重点考察拆分后条目数量上限、超长主机名、IDN 等边界;
  • 文件写入链路WriteAsyncHostsService.cs)在真实产品中会处理备份、权限提升与编码转换。fuzz 工程借助 CustomMockFileSystem 把它搬进内存执行,让云端任务无需管理员权限即可轰炸写入逻辑,且能安全验证“任何输入都不该让服务抛非预期异常”。

此外,FuzzTests.cs 中 // Since the WriteAsync method does not involve content parsing, we won't fuzz the additionalLines... 这类注释还揭示了一个工程原则:只为真正“消费输入并做复杂处理”的代码路径编写 fuzz 目标,避免把成本浪费在不解析输入的无意义参数上。


7. 接入实践自查清单

将上述四步固化为可复用的检查表,供新增模块参考:

  1. 工程:模块下新建 xxx.FuzzTests 工程,AssemblyName 与 dll 名一致;导入 Common.Dotnet.FuzzTest.propsCommon.Dotnet.CsWinRT.props;确认输出落在 x64\<Config>\tests\<Name>.FuzzTests\<TFM>\,TFM 以 props 当前值为准(当前仓库为 net10.0-windows10.0.26100.0)。
  2. 目标代码:每个 fuzz 目标都是 public static void X(ReadOnlySpan<byte>);只过滤预期异常并 throw; 重抛,绝不无差别吞异常;优先通过源码链接复用真实业务实现与既有测试 mock。
  3. 配置OneFuzzConfig.jsondll/class/method/FuzzingTargetBinaries 与代码严格对应;AssignedTojobNotificationEmailAreaPath/IterationPath 替换为实际归属;jobDependencies 至少含 dll+pdb 且覆盖运行期全部依赖(必要时可加 glob)。
  4. 管道:核对 job-fuzz.yml(或引用它的流水线)的 artifact patterns 能命中新工程的 tests 产物目录。
  5. 验证:提交管道,收到 OneFuzz 作业邮件后登录平台复核崩溃样本与覆盖率,确保 bug 能按 adoTemplate 正确归档。

遵循这套流程,任何一个以 .NET 实现、具备输入解析/校验/写入面的 PowerToys 模块(Hosts、AdvancedPaste 等均已落地)都能获得持续的、自动化的、与正式构建集成的安全模糊测试覆盖。

登录后查看全文
热门项目推荐
相关项目推荐