基于 OneFuzz 为 .NET 工程搭建模糊测试:以 PowerToys Hosts 模块的 FuzzTests 实战为例
导读
本文以 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.cs、HostsService.cs、Entry.cs、HostsData.cs、BackupManager.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.props 的 TestingPlatformDisableCustomTestTarget 控制 |
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置为true、Mock<IBackupManager>,再用复用自Hosts.Tests\Mocks的CustomMockFileSystem构造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.dll、WinRT.Runtime.dll(Windows SDK 投影运行时);而 FuzzWriteAsync 额外列入了 Castle.Core.dll、Moq.dll、System.IO.Abstractions*.dll、TestableIO.*.dll、CommunityToolkit.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/**
它接收 configuration、platform、inputArtifactStem 三个参数,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 平台复核结果
管道执行后:
onefuzz-task@0依据onefuzzDropDirectory指向的tests目录、读取其中的OneFuzzConfig.json(因 csproj 设置了CopyToOutputDirectory,它总是与 dll 一起出现)自动完成 OneFuzz 作业提交,目标 OS 为 Windows;- OneFuzz 平台按
oneFuzzJobs中的projectName/targetName创建任务并开始大规模变异输入; - 按文档说明,关注收件箱:任务启动后你会收到包含作业链接的邮件,点击链接即可进入 OneFuzz 平台查看崩溃样本(crash repro)、覆盖率与运行统计;
- 一旦 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 等边界; - 文件写入链路:
WriteAsync(HostsService.cs)在真实产品中会处理备份、权限提升与编码转换。fuzz 工程借助CustomMockFileSystem把它搬进内存执行,让云端任务无需管理员权限即可轰炸写入逻辑,且能安全验证“任何输入都不该让服务抛非预期异常”。
此外,FuzzTests.cs 中 // Since the WriteAsync method does not involve content parsing, we won't fuzz the additionalLines... 这类注释还揭示了一个工程原则:只为真正“消费输入并做复杂处理”的代码路径编写 fuzz 目标,避免把成本浪费在不解析输入的无意义参数上。
7. 接入实践自查清单
将上述四步固化为可复用的检查表,供新增模块参考:
- 工程:模块下新建
xxx.FuzzTests工程,AssemblyName 与 dll 名一致;导入Common.Dotnet.FuzzTest.props与Common.Dotnet.CsWinRT.props;确认输出落在x64\<Config>\tests\<Name>.FuzzTests\<TFM>\,TFM 以 props 当前值为准(当前仓库为net10.0-windows10.0.26100.0)。 - 目标代码:每个 fuzz 目标都是
public static void X(ReadOnlySpan<byte>);只过滤预期异常并throw;重抛,绝不无差别吞异常;优先通过源码链接复用真实业务实现与既有测试 mock。 - 配置:
OneFuzzConfig.json中dll/class/method/FuzzingTargetBinaries与代码严格对应;AssignedTo、jobNotificationEmail、AreaPath/IterationPath替换为实际归属;jobDependencies至少含 dll+pdb 且覆盖运行期全部依赖(必要时可加 glob)。 - 管道:核对
job-fuzz.yml(或引用它的流水线)的 artifact patterns 能命中新工程的tests产物目录。 - 验证:提交管道,收到 OneFuzz 作业邮件后登录平台复核崩溃样本与覆盖率,确保 bug 能按
adoTemplate正确归档。
遵循这套流程,任何一个以 .NET 实现、具备输入解析/校验/写入面的 PowerToys 模块(Hosts、AdvancedPaste 等均已落地)都能获得持续的、自动化的、与正式构建集成的安全模糊测试覆盖。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00