首页
/ PowerToys AdvancedPaste 模糊测试工程实战:使用 OneFuzz 对 .NET 代码开展 Fuzz 测试

PowerToys AdvancedPaste 模糊测试工程实战:使用 OneFuzz 对 .NET 代码开展 Fuzz 测试

2026-09-06 18:33:06作者:翟江哲Frasier

本指南以 src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/Fuzz.md 为核心,系统讲解微软 PowerToys 仓库中 AdvancedPaste 模块如何依托微软内部 OneFuzz 服务对其 .NET 代码(特别是剪贴板内容转 JSON 的 JsonHelper)进行持续模糊测试:包括模糊测试工程的组织方式、被测代码的链接策略、fuzz 入口函数的写法、OneFuzzConfig.json 作业配置逐字段解析,以及从本地运行到云端作业分发的完整流程。阅读本文后,你既能理解该工程"为何如此搭建",也能掌握在类似 .NET 项目中复刻一套 OneFuzz 模糊测试流水线的可操作方法。

一、工程背景:PowerToys 中为什么需要一个 .NET Fuzz 工程

PowerToys 的 AdvancedPaste(高级粘贴)是一个对剪贴板内容执行"转为纯文本 / Markdown / JSON / 自定义格式"等变换的 Windows 生产力工具,其核心逻辑位于 src/modules/AdvancedPaste/AdvancedPaste 下的托管代码。这类"吃任意文本输入、产出结构化数据"的解析器(尤其是 XML、INI、CSV 这类需要正则与引号转义处理的文本格式解析)天然适合用模糊测试来发现崩溃与未处理异常。

仓库因此在模块旁独立维护了一个专门的模糊测试工程 src/modules/AdvancedPaste/AdvancedPaste.FuzzTests,目录下四个文件构成完整闭环:

文件 职责
FuzzTests.cs fuzz 入口函数,OneFuzz 反复调用它并投喂随机字节
AdvancedPaste.FuzzTests.csproj 工程定义:把被测代码"链接"进工程而非复制
OneFuzzConfig.json 云端作业部署描述(libfuzzer .NET 目标、bug 上报、依赖清单)
Logger.cs 规避外部依赖的空实现日志占位

该工程已被纳入主解决方案 PowerToys.slnx(第 156 行声明 AdvancedPaste.FuzzTests/AdvancedPaste.FuzzTests.csproj),说明它不是一次性脚本,而是随 CI 一起构建、为 OneFuzz 持续摄取服务的常态化工程。

二、为什么用 .NET 8(以及仓库当前的演进状态)

原文档 Fuzz.md 的 "Why Use .NET 8 (Windows)" 一节给出两条理由,属于撰写该文档时点的官方现状说明:

  1. 当前支持范围:撰写时 OneFuzz 仅支持 .NET 8 工程,Fuzz 团队正在推进 .NET 9 支持;
  2. 过渡方案:在 .NET 9 支持落地前,.NET 8 是稳定且临时的选择,且支持"直接链接代码文件",便于高效开发。

对照仓库当前源码可以发现,这一约束已随上游演进被更新。src/Common.Dotnet.FuzzTest.props 的文件头注释完整记录了这段历史并给出了现状:

Fuzz test projects pin their target framework here so it can be managed independently of the main product TFM... This was historically .NET 8 because OneFuzz did not support newer runtimes. Per the current OneFuzz .NET fuzzing docs the service is runtime-agnostic (".NET Core targets are preferred") and keys off the build drop directory, so the fuzz projects now track net10 like the rest of the repo.

即:该 props 将模糊测试工程的 TFM 与主产品 TFM 解耦单独管理;历史上锁定为 .NET 8(OneFuzz 不支持更新的运行时);而按当前 OneFuzz .NET 模糊测试文档,服务已运行时无关,因此 AdvancedPaste.FuzzTests.csproj 这类工程现在跟随全仓库统一的目标框架 net10.0-windows10.0.26100.0(见 src/Common.Dotnet.FuzzTest.props 第 11 行)。

需要特别说明一个仓库内的"时间差":被测文件 FuzzTests.cs 顶部仍保留着 ".NET 8 / 临时方案" 时代的注释("OneFuzz currently does not support .NET 9 code testing, so this is a temporary solution. Create a .NET 8 project and use a file link to include the code for testing first."),这与 props 中已迁移到 net10 的现状并存。阅读时建议以 src/Common.Dotnet.FuzzTest.props 的说明为准:".NET 8 临时方案"是历史动机,工程当前实际编译目标已与仓库一致为 net10。无论 TFM 如何变化,"把被测试代码文件直接链接进 fuzz 工程"这一组织方式始终未变。

三、工程结构剖析:链接(Link)而非复制被测代码

AdvancedPaste.FuzzTests.csproj 的精髓在链接机制。其工程文件核心片段如下(src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/AdvancedPaste.FuzzTests.csproj):

<Project Sdk="Microsoft.NET.Sdk">
    <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
    <Import Project="$(RepoRoot)src\Common.Dotnet.FuzzTest.props" />
    <PropertyGroup>
        <LangVersion>latest</LangVersion>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <OutputType>Exe</OutputType>
        <TestingPlatformCommandLineArguments>$(TestingPlatformCommandLineArguments) --ignore-exit-code 8</TestingPlatformCommandLineArguments>
    </PropertyGroup>
    <PropertyGroup>
        <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\AdvancedPaste.FuzzTests\</OutputPath>
    </PropertyGroup>
    <ItemGroup>
        <Compile Include="..\AdvancedPaste\Helpers\JsonHelper.cs" Link="JsonHelper.cs" />
    </ItemGroup>
    ...
    <ItemGroup>
        <Content Include="OneFuzzConfig.json">
            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        </Content>
    </ItemGroup>
</Project>

要点逐条展开:

  • 链接被测文件<Compile Include="..\AdvancedPaste\Helpers\JsonHelper.cs" Link="JsonHelper.cs" /> 不复制代码,而是把主工程里的 src/modules/AdvancedPaste/AdvancedPaste/Helpers/JsonHelper.csJsonHelper.cs 的逻辑名编入本工程编译。这样 fuzz 永远针对最新真实实现,不会有"拷贝一份改坏了没人发现"的漂移问题——这正是原文档所称"direct code linking for efficient development"。
  • 独立于主产品 TFM:通过导入 src/Common.Dotnet.FuzzTest.props 单独钉住框架版本,避免与产品线(Common.Dotnet.CsWinRT.props)互相牵制。
  • 引入 WinRT 投影:导入 Common.Dotnet.CsWinRT.props 后获得 Microsoft.Windows.CsWinRT 支持,使 fuzz 代码能引用 Windows.ApplicationModel.DataTransferDataPackage/DataPackageView),这是被测函数签名所依赖的 WinRT 类型。
  • MSTest 宿主但无测试用例:引用 MSTest 包仅为获得可执行宿主;本工程没有任何 [TestMethod],因此会触发"无测试运行"的退出码 8,工程用 --ignore-exit-code 8 显式放行(文件内注释 "exit code 8 means no tests ran" 即说明这一点)。
  • 与 CI 集成Common.Dotnet.FuzzTest.props 中在 TF_BUILD(Azure Pipelines 环境变量)非空时设置 TestingPlatformDisableCustomTestTarget=true,避免 CI 的 Build;Test 全量阶段把这个 fuzz 工程当普通单测跑——它只为 OneFuzz 摄取而构建,不在常规测试阶段执行。
  • 配置随输出走OneFuzzConfig.jsonPreserveNewest 复制到输出目录,确保产物目录里总是带上最新作业描述。
  • 无外部依赖的 Logger 桩Logger.cs 刻意放在 ManagedCommon 命名空间内、提供全空实现的 LogTrace/LogInfo/LogWarning/LogError/LogDebug 方法。原因是 JsonHelper 源码 using ManagedCommon 并调用 Logger.LogTrace() 等(见 JsonHelper.cs 第 52、56 行等),fuzz 工程不希望为满足这一引用去链接整套日志基础设施,于是用同名空实现占位(其注释:"This is used for fuzz testing and ensures that the project links only to JsonHelper, avoiding unnecessary connections to additional files")。这是一种非常实用的最小依赖链接技巧。

四、fuzz 入口函数实现剖析:FuzzToJsonFromXmlOrCsv

OneFuzz 的 .NET fuzzer(libfuzzerDotNet 类型)要求被测工程暴露一个接收 ReadOnlySpan<byte> 的静态方法作为变异入口。本工程的入口在 src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/FuzzTests.cs

public static void FuzzToJsonFromXmlOrCsv(ReadOnlySpan<byte> input)
{
    string text = Encoding.UTF8.GetString(input);

    var dataPackage = new DataPackage();
    dataPackage.SetText(text);

    try
    {
        _ = Task.Run(async () => await JsonHelper.ToJsonFromXmlOrCsvAsync(dataPackage.GetView()))
                .GetAwaiter().GetResult();
    }
    catch (Exception ex) when (ex is ArgumentException)
    {
        throw;
    }
}

代码里藏着三个值得学习的工程细节:

1. 显式 UTF-8 解码而非字节直通。 代码注释明确指出:对 ReadOnlySpan<byte> 直接调用 ToString() 只会得到类型名(如 "System.ReadOnlySpan<Byte>[N]"),而不是实际字节内容,因此必须先用 Encoding.UTF8.GetString(input) 把 OneFuzz 产生的随机字节解码成真正会被解析的文本,否则 fuzz 就形同空转。

2. 用 GetAwaiter().GetResult() 而非 Task.Run(...).Result 被测的 ToJsonFromXmlOrCsvAsyncasync Task<string>,在 fuzz 入口里同步等待时若用 Task.Result,抛出的异常会被包装成 AggregateException,导致下方 when (ex is ArgumentException) 异常过滤器永远无法命中;GetAwaiter().GetResult() 则会原样上抛原始异常类型,从而让过滤器精确工作。

3. 异常过滤器的正确语义。 catch (Exception ex) when (ex is ArgumentException) { throw; } 是一段"声明式"代码:它不吞掉任何异常,只是借助 catch 子句的过滤语义明确表达意图——ArgumentException(如 INI 空节名、CSV 非法分隔符导致的 FormatException 之外的参数错误)是允许出现的预期路径,需要放行以便 OneFuzz 记录为"良性样本";而注释强调"捕获所有异常是反模式,可能掩盖 NullReferenceException 这类代码自身缺陷",因此最终一律 throw 重新抛出。由于 ToJsonFromXmlOrCsvAsync 的方法契约本就不应抛异常,重新抛出等于把任何真实缺陷都如实暴露给 fuzzer 统计与上报。

五、被测对象纵深:JsonHelper.ToJsonFromXmlOrCsvAsync 为什么值得被 Fuzz

被链接进来的 src/modules/AdvancedPaste/AdvancedPaste/Helpers/JsonHelper.cs 是 AdvancedPaste "粘贴为 JSON" 功能背后的格式转换器。ToJsonFromXmlOrCsvAsync(DataPackageView clipboardData) 的识别链依次为(可对照源码第 50–229 行):

  1. 读取剪贴板文本:先检查 clipboardData.Contains(StandardDataFormats.Text),再 GetTextAsync();读取失败(WinRT/COM 层异常,如剪贴板被占用、其他应用注入畸形负载)按契约返回 string.Empty,绝不外抛;
  2. 已是 JSON 则原样返回IsJsonSystem.Text.JsonJsonDocument.Parse 试探性解析;
  3. XML → JSONXmlDocument.LoadXml 后经 Newtonsoft.JsonJsonConvert.SerializeXmlNode 转换;
  4. INI → JSON:要求首行为节名 [...]、次行为节名或 键=值(正则见 IniSectionNameRegex/IniValueLineRegex),跳过 ; 注释行,非法行抛 FormatException(如"Empty section name");
  5. CSV → JSON:先按 sep=X 指令或 ,;\t 候选集做分隔符探测GetCsvDelimiter,以首两行的分隔符出现次数一致性防误判),再对每行做引号配对校验与分隔符切分,最后用 ReplaceQuotationMarksInCsvData 三连正则清理包裹/转义引号;
  6. 纯文本兜底:整段按行拆分后序列化为 JSON 字符串数组。

这段代码包含大量手写正则、边界条件与"尝试-捕获-降级"链路(XML 失败降级 INI、再降级 CSV、再降级纯文本),任何一步的正则回溯、索引越界、引号配对误判都可能制造崩溃或死循环——这正是模糊测试的理想靶标,也是该 FuzzTests 工程选择其作为 FuzzToJsonFromXmlOrCsv 被测方法的原因。可以推断:通过随机字节持续轰炸该入口,相当于自动化验证了这条格式识别链在畸形输入下的健壮性。

六、OneFuzzConfig.json:云端作业配置逐字段拆解

OneFuzzConfig.json 是 OIP(OneFuzz Ingestion Preparation)工具与摄取服务部署 fuzz 作业时读取的核心描述文件。原文档强调其整体结构为一个配置条目数组,数组之外仅保留 configVersion 字段用于追踪配置 schema 的变更版本。仓库中 src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/OneFuzzConfig.json 是一份可参照的完整 V3 实例,全文如下:

{
  "configVersion": 3,
  "entries": [
    {
      "fuzzer": {
        "$type": "libfuzzerDotNet",
        "dll": "AdvancedPaste.FuzzTests.dll",
        "class": "AdvancedPaste.FuzzTests.FuzzTests",
        "method": "FuzzToJsonFromXmlOrCsv",
        "FuzzingTargetBinaries": [
          "PowerToys.AdvancedPaste.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": "AdvancedPaste",
          "targetName": "AdvancedPaste-dotnet-fuzzer"
        }
      ],
      "jobDependencies": [
        "AdvancedPaste.FuzzTests.dll",
        "AdvancedPaste.FuzzTests.pdb",
        "Microsoft.Windows.SDK.NET.dll",
        "Newtonsoft.Json.dll",
        "WinRT.Runtime.dll"
      ],
      "SdlWorkItemId": 49911822
    }
  ]
}

对照官方 V3 schema 与仓库实际使用,各字段语义如下:

  • fuzzer:声明 fuzzer 类型与 .NET 目标。
    • $type: libfuzzerDotNet:选择 .NET 托管 fuzzer;
    • dll:承载 fuzz 入口的程序集,即本工程产物 AdvancedPaste.FuzzTests.dll
    • class / method:fuzz 入口的完全限定类型与方法名,必须与 FuzzTests.csAdvancedPaste.FuzzTests.FuzzTests.FuzzToJsonFromXmlOrCsv 一致;
    • FuzzingTargetBinaries:声明被测的主产品二进制 PowerToys.AdvancedPaste.dll,便于 OneFuzz 关联符号与覆盖率。
  • adoTemplate:指定缺陷上报到哪个 Azure DevOps 工作项模板——仓库用 microsoft/OS 项目,并预设 AssignedToAreaPathIterationPath(注释明确提示:应按项目自身填值,bug 将据此自动归档)。配置里属于微软内部路径,其他组织复用时需整体替换。
  • jobNotificationEmail:作业通知邮箱,仓库中为模块团队别名 PowerToys@microsoft.com
  • skip:是否跳过该条目的作业调度(false 表示正常参与摄取)。
  • rebootAfterSetup:虚拟机在完成环境准备后是否需要重启再开跑。
  • oneFuzzJobs:至少一项,定义 OneFuzz 侧的作业归属,此处为 AdvancedPaste 项目下的 AdvancedPaste-dotnet-fuzzer 目标。
  • jobDependencies:摄入到 fuzz 节点的文件清单,注释要求至少包含 DLL 与 PDB,且支持 glob。此例中的五项可逐一对上依赖链:
    • AdvancedPaste.FuzzTests.dll / .pdb:fuzz 宿主及其符号;
    • Microsoft.Windows.SDK.NET.dll + WinRT.Runtime.dll:WinRT/CsWinRT 投影运行时,因为入口与 JsonHelper 都触碰 DataPackage/DataPackageView
    • Newtonsoft.Json.dll:被测 JsonHelperJsonConvert.SerializeXmlNode/SerializeObject 的实际依赖。
  • SdlWorkItemId:关联的 SDL(安全开发生命周期)工作项编号,用于追踪该 fuzz 目标的安全审核记录。

由此也可以反向印证 src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/AdvancedPaste.FuzzTests.csprojOutputPath 刻意指向 tests\AdvancedPaste.FuzzTests\ 输出目录的用意——便于摄取流水线把上述 dll/pdb 连同 OneFuzzConfig.jsonPreserveNewest 复制)作为一个自洽的产物集打包下发。

七、从本地验证到云端作业的完整流程

原文档把端到端流程组织为四步,虽其具体的登录页、操作手册均为微软内部 OneFuzz 服务链接,但流程骨架与仓库现状完全自洽,可按下述方式落地:

1. 申请访问(Requesting Access)。 OneFuzz 生产实例的 CLI 登录受权限管控:文档明确要求先通过微软内部 MyAccess 访问包申请页完成审批,获批后才能获得 CLI 登录凭据。此步对应原文档 "Requesting Access" 一节;对 PowerToys 这类微软仓库,访问主体是具备内部账号的维护者。

2. 配置并构建 fuzz 工程(前提条件)。 在仓库根目录下把 AdvancedPaste.FuzzTests 作为解决方案成员编译(它已登记在 PowerToys.slnx),得到带 OneFuzzConfig.json 的输出目录;若本机同样安装了 .NET SDK 与 MSTest 运行环境,理论上可用 dotnet run --project src/modules/AdvancedPaste/AdvancedPaste.FuzzTests 之类方式验证入口能被正常加载(工程无测试用例,需接受退出码 8 的放行逻辑)。

3. 本地运行 .NET fuzz 目标(Running a .NET Fuzz Target Locally)。 原文档强调:在本地验证一个 .NET fuzz 目标需要特定配置,不能想当然地直接执行。通常意味着要用与 OneFuzz 相同的 libfuzzer 驱动宿主加载 AdvancedPaste.FuzzTests.dll 并以少量种子(seed corpus)跑一轮冒烟,确认入口方法无启动期崩溃——对应文档指向的官方手册中该专项小节。

4. 用 OIP 工具 + OneFuzz CLI 分发云端作业。 文档 "Tools" 一节明确了两类工具的分工:

  • OIP(OneFuzz Ingestion Preparation)工具:负责读取 OneFuzzConfig.json、打包 jobDependencies 产物并准备摄取数据;
  • OneFuzz CLI:提供管理、执行作业的命令(创建作业、查询状态、下载崩溃复现样本等),需按其指引下载与初始化登录。

最后依据 oneFuzzJobs 中声明的项目/目标名把作业注册进 OneFuzz,由云端在装有 Windows 的 fuzz VM 上运行 libfuzzerDotNet,持续对 FuzzToJsonFromXmlOrCsv 投喂变异输入;一旦触发未被异常过滤器接受的缺陷(如 NullReferenceException),OneFuzz 将按 adoTemplate 在 Azure DevOps 中登记 bug,并按 jobNotificationEmail 通知维护团队——这就构成了"代码提交 → 构建 → 摄取 → 云端持续 fuzz → 缺陷自动回流"的闭环。

八、小结:可复用的工程范式

纵观 AdvancedPaste.FuzzTests 目录与 src/Common.Dotnet.FuzzTest.props,PowerToys 在 AdvancedPaste 上的做法可提炼为四条可移植经验:

  1. 共享 TFM 管理:把 fuzz 工程的框架版本通过公共 props 与产品 TFM 解耦,历史上随 OneFuzz 的 .NET 8 限制锁定、当前跟随仓库升级到 net10,规避上游能力漂移对主工程的冲击;
  2. 链接而非拷贝:用 MSBuild Compile/Link 把被测源文件(此处为 JsonHelper.cs)直接编入 fuzz 工程,配合 ManagedCommon.Logger 空实现桩切断无关依赖,保证"fuzz 的就是产品里真实在跑的那份代码";
  3. 最小可测入口 + 精确异常语义:只暴露 void Fuzz(ReadOnlySpan<byte>) 静态入口,显式 UTF-8 解码、GetAwaiter().GetResult() 保真异常类型、用 when 过滤器只放行预期异常并一律重新抛出,把良性输入与真实缺陷区分交给 OneFuzz 统计;
  4. 作业描述与产物同源OneFuzzConfig.jsonconfigVersion + entries[])声明 fuzzer 类型、上报模板、作业归属与依赖清单,并随构建输出目录同步发布,使 OIP 摄取与云端调度无需额外人工编排。

对希望在自有 .NET 工程(尤其是含手写文本解析器、正则密集型代码的模块)中引入持续模糊测试的团队,本仓库这份工程即是一份开箱可对照的蓝本。

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