Add OneFuzz harness for ZipRangeExtractor and fix a bounds gap it found - #765
Conversation
ZipRangeExtractor is the only hand-written parser in winapp that consumes bytes originating off the network: WinDbgJsProviderAcquirer range-downloads the WinDbg .msixbundle and parses its ZIP64 central directory, and then a nested inner .msix, before the extracted DLL's Authenticode signature can be checked. The archive bytes are therefore untrusted at parse time. ParseCentralDirectory read three attacker-controlled 16-bit lengths (name, extra, comment) and sliced on all of them while only guarding the 46-byte fixed header. A seeded mutation run escaped 608 ArgumentOutOfRangeExceptions in 3000 iterations; it now rejects with InvalidDataException instead. Adds WinApp.Cli.Fuzz with two libFuzzer targets - the pure central-directory parser and the full outer/inner descent - plus a validated seed corpus generator, and guards that the harness signature and OneFuzzConfig stay in sync with the code. Fuzzing runs from a separate on-demand pipeline rather than on every build.
There was a problem hiding this comment.
Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
Note
This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.
Build Metrics ReportBinary Sizes
Test Results✅ 4596 passed, 5 skipped out of 4601 tests in 612.8s (+20 tests, -1.0s vs. baseline) Test Coverage✅ 89.1% line coverage, 82.4% branch coverage · ✅ no change vs. baseline CLI Startup Time49ms median (x64, Try This BuildInstalls the MSIX for your architecture, replacing any previously installed build. Needs the GitHub CLI — the command offers to install it and sign you in if it is missing. & ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) 765Switching between builds often?Put the tool on your PATH once: & ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) -AddToPathThen this build is just: winapp-pr 765Run Updated 2026-08-18 08:45:30 UTC · commit |
Path.Join replaces Path.Combine for the fixed relative segments: Combine treats a rooted later segment as a reset point, so it can silently discard the base directory. Same swap already applied elsewhere in this test project. Escape reporting now includes the first exception in full. The aggregate counts say how reachable a gap is, but only the stack says where it is. The catch in RunMutationCampaign stays unfiltered by design - it collects whatever the harness failed to absorb, and narrowing it would hide the very exception types the test exists to surface. Documented in place.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/winapp-CLI/WinApp.Cli.Fuzz/FuzzableCode.cs:118
- Suppressing every
ArgumentOutOfRangeExceptionmasks the same class of parser bounds gaps this harness is intended to find. For example, an input ending with only the EOCD signature reachestail.AsSpan(eocd + 10)inFindCentralDirectoryAsyncand throws this exception, but OneFuzz reports it as a normal rejection. Translate out-of-range reads to a dedicated expected rejection at the reader boundary, then let parser-originatedArgumentOutOfRangeExceptionescape.
or ArgumentOutOfRangeException; // range read outside the archive, which both range readers reject
src/winapp-CLI/WinApp.Cli.Fuzz/FuzzableCode.cs:81
BuildNestedBundleputsAppxMetadata/AppxBundleManifest.xmlbefore the inner MSIX. This first iteration treats the XML as an archive,FindCentralDirectoryAsyncrejects it, and the method-level catch exits the entire loop, so the seeded target never reaches the nested MSIX or its entries. Catch expected rejection per candidate and continue to the next outer entry.
This issue also appears on line 118 of the same file.
VisitAsInnerArchive(reader, inner);
Collecting an OOM and then continuing to allocate for the rest of the campaign is unsound. Everything else stays unfiltered, which is the point of the test.
Three related problems, all found in review: - IsExpectedArchiveRejection absorbed ArgumentOutOfRangeException, which conflated a range read past EOF (malformed input) with the parser indexing past a buffer it holds (a bounds bug). BoundedRangeReader now reports the former as InvalidDataException so the latter escapes. - FindCentralDirectoryAsync read the fixed EOCD fields without checking the 22-byte record actually fit, so any archive whose tail ends in PK05 06 walked off the buffer. 29 of 3000 seeded mutations reach it. - The try wrapped the whole entry loop, so the first non-archive entry ended the walk before it reached the nested msix. It is now per entry.
Follow-up on the Copilot reviewBoth of the suppressed comments were correct, and they were the two most valuable findings on this PR. Fixed in the latest push. 1. The archive target was swallowing the bug class it exists to find.
2. There was a second, real bounds gap behind it - the exact one called out.
This is not theoretical. Disabling only the new guard and re-running the mutation campaign: 29 of 3000 mutations reach it. With the guard, clean. That is also the proof that the harness is no longer blind to this class - before this change all 29 were silently absorbed as "expected". Production is not crashing on this today: 3. The archive target was also much shallower than intended.
This one has a consequence beyond the code: the OneFuzz run I recorded on The remaining inline comment about |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs:172
- These output directories are reused across test runs but never cleared. If a seed is renamed or removed, its old file remains under
bin, and the final file-count assertions fail until the developer manually cleans the build output. Delete the corpus root before recreating the two directories so generation is deterministic.
var root = Path.Join(AppContext.BaseDirectory, "fuzz-corpus");
var archiveDir = Path.Join(root, "ziprangeextractor-archive");
var directoryDir = Path.Join(root, "ziprangeextractor-centraldirectory");
Directory.CreateDirectory(archiveDir);
Directory.CreateDirectory(directoryDir);
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:69
- The last raw EOCD signature is not necessarily the EOCD record: an archive comment may legally contain
PK\x05\x06. If the comment ends with that sequence,eocdpoints at the final four bytes and this guard rejects an otherwise valid archive instead of continuing backward to the real record. Scan backward for a candidate with a complete fixed record whose declared comment length reaches the archive end, and add a regression test with the signature in a valid comment.
// The signature can appear in the last 21 bytes, leaving the fixed fields below truncated.
if (eocd + MinEocdSize > tail.Length)
{
throw new InvalidDataException(
$"End-of-central-directory record at offset {eocd} is truncated: {tail.Length - eocd} of {MinEocdSize} bytes present.");
src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs:107
- This only verifies that the array is nonempty, so changing it to a non-shipping binary such as
WinApp.Cli.Fuzz.dllwould still pass while OneFuzz no longer attributes the compliance claim towinapp.dll. Assert thatwinapp.dllis present, which is the invariant described by the message and PR.
This issue also appears on line 168 of the same file.
Assert.IsTrue(target.GetProperty("FuzzingTargetBinaries").GetArrayLength() > 0,
"FuzzingTargetBinaries must name the shipping binary, or OneFuzz generates no claim.");
A ZIP comment may legally contain PK05 06, so taking the last match landed inside the comment and rejected a valid archive. FindEocd now scans backward for a candidate with complete fixed fields whose declared comment length runs to the end of the archive, which also subsumes the truncated-record guard. Also assert FuzzingTargetBinaries actually names winapp.dll rather than merely being non-empty, and clear the seed corpus directory so a renamed seed cannot leave a stale file behind and break the counts.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:238
- This still accepts a complete EOCD-shaped sequence inside a legal ZIP comment. For example, append a 22-byte zero-field EOCD as the comment and set the real EOCD's comment length to 22: the reverse scan accepts the fake record at the start of the comment and returns offset/size zero, so a valid non-empty archive parses as empty. The added test covers only a four-byte truncated signature. Candidate selection needs to validate the referenced central directory (and ZIP64 chain where applicable) and continue scanning when that validation fails.
var commentLen = BinaryPrimitives.ReadUInt16LittleEndian(tail.Slice(i + 20));
if (i + MinEocdSize + commentLen == tail.Length)
{
return i;
| @@ -0,0 +1,166 @@ | |||
| // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. | |||
A whole zeroed EOCD placed in a real archive's comment satisfies the comment-length rule, so the backward scan returned it and a non-empty archive parsed as empty. Prefer a record that declares a directory, falling back to the empty one only when there is nothing better. Also strip a duplicated UTF-8 BOM from FuzzableCode.cs; it did not break either the local or CI build, but it was unintended and inconsistent with the sibling file.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs:188
- Every generated seed here is a small
ZipArchive, so it uses ZIP32 EOCD and 32-bit entry fields. The corpus therefore does not seed either the ZIP64 locator/record path or ZIP64 extra-field parsing, despite the harness targeting this ZIP64 parser and the remarks noting libFuzzer will not synthesize that structure. Add at least one valid ZIP64 archive seed (and its carved directory seed), ideally including the nested path.
var seeds = new Dictionary<string, byte[]>
{
["minimal-stored"] = BuildZip([("a.bin", Encoding.UTF8.GetBytes("stored"), CompressionLevel.NoCompression)]),
["deflate"] = BuildZip([("a.bin", FakePe(), CompressionLevel.Optimal)]),
["multi-entry"] = BuildZip([("a.bin", Encoding.UTF8.GetBytes("one"), CompressionLevel.NoCompression),
src/winapp-CLI/WinApp.Cli.Fuzz/FuzzableCode.cs:161
- The wrapped reader checks
offset + length, which can overflow for a positive ZIP64-derived offset nearlong.MaxValue; that bypasses its guard andArray.Copycan then throwArgumentException. This catch misses that range failure, so the harness reports a false parser crash even though its contract says out-of-archive reads becomeInvalidDataException. Catch the base argument exception from this byte-array reader, or validate the range with an overflow-safe comparison first.
catch (ArgumentOutOfRangeException ex)
| continue; | ||
| } | ||
|
|
||
| return i; |
Preferring a non-empty record only covered one fake shape: a comment can hold a record with any count/size/offset. Candidates are now walked backward and accepted only once the declared directory is inside the archive and starts with a central header, with a no-directory record kept as a fallback for a genuinely empty archive. Also make the memory reader's bounds check overflow-safe, since offset + length wraps for a ZIP64-sized offset and slipped past the guard into Array.Copy, and seed the corpus with ZIP64 archives so the locator/record path is reachable at all - ZipArchive never emits ZIP64 at these sizes and a mutator will not synthesise it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:74
- A legal ZIP comment can contain a fake EOCD with a ZIP64 marker. If that candidate has no valid locator/record,
ReadZip64DirectoryAsyncthrows and exits the loop, so the scanner never reaches the real earlier EOCD and rejects an otherwise valid archive. Treat an invalid ZIP64 record as a rejected candidate and continue scanning; only return after validating the resolved directory.
{
// The ZIP64 record carries its own signature, so that path rejects a fake for us.
(cdOffset, cdSize) = await ReadZip64DirectoryAsync(
reader, archiveBase, tail, tailStart, eocd, cancellationToken);
return (archiveBase + cdOffset, cdSize);
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:107
- Checking only the first four bytes does not confirm the candidate's declared directory. A fake EOCD in the comment can set
count = 1,cdSize = 4, andcdOffsetto the real directory; this check succeeds, the caller parses only four bytes as zero entries, and the real EOCD is never considered. Validate the complete declared size/count (or another structural relationship to the candidate EOCD) before accepting it.
return false;
}
var head = await reader.ReadAsync(archiveBase + cdOffset, 4, cancellationToken);
return BinaryPrimitives.ReadUInt32LittleEndian(head) == CentralHeaderSignature;
src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs:244
- These
ToZip64seeds exercise only the ZIP64 EOCD path. The helper leaves every central-directory size/offset as a normal 32-bit value and adds no0x0001extra field, soApplyZip64Extrais never reached despite the stated corpus goal. Add a seed whose central header uses0xFFFFFFFFmarkers and carries a valid ZIP64 extra field.
/// <see cref="ZipArchive"/> only emits ZIP64 for archives too large to build here, so without this
/// the corpus never reaches the ZIP64 locator/record path or the ZIP64 extra field — the parts a
/// mutator cannot synthesise on its own.
A comment-planted record carrying ZIP64 markers made ReadZip64DirectoryAsync throw straight out of the candidate loop, so a valid archive was rejected before the real record was reached. That is now a rejected candidate. Directory validation also required only a signature, which a record declaring count=1 and a 4-byte size could satisfy; it now requires room for a header and that the directory runs up to the record describing it. The ZIP64 branch keeps relying on its own record signature, since the resolved directory is not adjacent to the EOCD there. Adds a seed whose central header carries a 0x0001 extra field, since ToZip64 alone never reaches ApplyZip64Extra.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:86
- This ZIP64 path returns immediately without the confirmation applied to ZIP32 candidates. Because ZIP comments may contain arbitrary bytes, a comment can legally contain a forged ZIP64 EOCD record, locator, and marker-bearing EOCD; the signature checks all pass and the attacker-controlled directory offset/size wins over the real EOCD. Validate that the resolved directory is in the archive, begins with a central header, and ends at the referenced ZIP64 record before accepting this candidate.
return (archiveBase + cdOffset, cdSize);
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:286
- This leaves two consecutive
<summary>/<remarks>blocks onNextEocdCandidate; the first is stale documentation from the removed finder and causes generated API documentation to expose duplicate summaries. Keep only the block that documents the current method.
/// <summary>
/// Finds the end-of-central-directory record in the archive tail.
/// </summary>
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:83
- A fake marker-bearing candidate can fail with more than
InvalidDataException: its locator's 64-bit record offset is used forReadAsyncwithout bounds validation, so an invalid/overflowed offset producesArgumentOutOfRangeExceptionfor the memory reader and ultimatelyIOExceptionfor the HTTP reader. Those exceptions abort the scan instead of continuing to the genuine EOCD earlier in an otherwise valid archive comment. Validate the locator/record range and convert candidate-format failures toInvalidDataExceptionbefore reading, while preserving genuine transport failures.
This issue also appears on line 86 of the same file.
catch (InvalidDataException)
{
// A marker-bearing record planted in a comment has no usable locator or record;
// keep scanning rather than failing the whole archive.
continue;
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:337
- Although the new guard avoids overflowing
offset + length, the exception message still performs that same addition. For the newly tested near-long.MaxValuecase it reports a wrapped negative end offset, making the diagnostic misleading. Format the start and length separately instead.
if (offset < 0 || length < 0 || offset > data.Length || length > data.Length - offset)
The ZIP64 branch returned on signature checks alone, so a comment carrying a forged locator and record could supply the directory. It now gets the same confirmation as ZIP32, anchored on the record it points at rather than the EOCD. The locator's 64-bit record offset also went straight to the reader, turning a malformed archive into an out-of-range or transport error that aborted the scan; it is bounds-checked into a rejection first. The two ZIP64 tests now build a real central header instead of a placeholder. Also drops a stale duplicate doc block and stops the reader's message computing the sum its own guard avoids.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:101
- The empty fallback accepts an unvalidated offset and preserves the newest fake candidate because of
??=. For example, a zero-entry EOCD withcdSize == 0andcdOffset == uint.MaxValueis returned as a successful location even though it is outside the archive; in an empty archive with a fake EOCD in its comment, the fake also wins over the earlier real record. Require the empty directory to end at this EOCD and let the earlier candidate replace a later comment candidate.
if (count == 0 && cdSize == 0)
{
emptyFallback ??= (archiveBase + cdOffset, 0);
continue;
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:104
- Every syntactically valid candidate that reaches this branch performs another
IRangeReaderread before scanning continues. A 64 KiB tail can contain roughly 3,000 consecutive 22-byte EOCD candidates whose comment lengths all run to EOF and whose directory heads are invalid; with the productionHttpRangeReader, one malformed archive therefore triggers thousands of sequential HTTP range requests. Reuse the already-downloaded tail for heads that fall inside it and place a bound or batching strategy around any external candidate probes.
// In a ZIP32 archive the directory runs right up to the record describing it.
if (await IsCentralDirectoryAsync(reader, archiveBase, archiveSize, cdOffset, cdSize, tailStart + eocd, cancellationToken))
| var head = await reader.ReadAsync(archiveBase + cdOffset, 4, cancellationToken); | ||
| return BinaryPrimitives.ReadUInt32LittleEndian(head) == CentralHeaderSignature; |
Confirming candidates issued a read per candidate, so a 64 KiB comment packed with EOCD-shaped bytes could drive thousands of sequential range requests through HttpRangeReader. Heads inside the already-downloaded tail are now served from it, and reads outside it are capped. The empty fallback also kept the first candidate found scanning backward, which is the one nearest the comment, and never checked its offset. It now has to sit where an empty directory would, and an earlier record replaces a later one.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs:330
- These candidates do not exercise the ranged-read cap:
posremains within the last 20,000 bytes, sopos - 46is also inside the 65,557-byte tail andIsCentralDirectoryAsyncreturns withProbed == false. The test therefore passes with only the initial tail read even if the probe limiter is removed. Make the directory start outside the tail while preservingcdOffset + cdSize == pos.
WriteU32(data, pos + 12, 46);
WriteU32(data, pos + 16, (uint)(pos - 46));
| catch (InvalidDataException) | ||
| { | ||
| // A marker-bearing record planted in a comment has no usable locator or record; | ||
| // keep scanning rather than failing the whole archive. | ||
| continue; |
The test pointed each fake directory just before its own record, which is inside the tail, so every candidate resolved locally and the test passed with the limiter removed. The directories now start at offset 0, outside the tail: without the budget the scan issues 910 reads and the test fails. Failed ZIP64 candidates also skipped the budget even though resolving one can scan for a locator and issue a ranged read before throwing, so a comment full of marker-bearing records could still amplify.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:85
- A marker-bearing candidate is charged even when
ReadZip64DirectoryAsyncfails because no locator exists, which performs no range read. A valid ZIP comment containing eight ZIP64-shaped EOCD records therefore exhausts this budget and breaks before the real EOCD is examined. Track whether resolution actually read outside the tail (or handle the no-locator case without charging) so the network-read cap does not reject valid archives based only on comment bytes.
if (++probes >= MaxDirectoryProbes)
Charging every failed ZIP64 candidate was too blunt: a candidate whose locator is missing performs no read at all, so a legal comment holding a handful of ZIP64-shaped records could exhaust the budget and reject a valid archive. Resolution now reports failure instead of throwing, along with whether it actually read outside the tail, and only that is charged.
Why
ZipRangeExtractoris the only hand-written parser in winapp that consumes bytes originating off the network.WinDbgJsProviderAcquirerrange-downloads the WinDbg.msixbundleand parses its ZIP64 central directory — then a nested inner.msix— to locateJsProvider.dll. That parsing necessarily happens before the extracted DLL's Authenticode signature can be checked, so the archive bytes are untrusted at parse time.The SDL fuzzing requirement applies to memory-safe languages that use
unsafecode or P/Invoke, which winapp does extensively (pointer dereferencing inSlugGenerator/CrashDumpService, P/Invoke intowintrust.dll,d3d11.dll, catalog crypto APIs). This is the one parser sitting on an untrusted-input boundary.The bug this found
ParseCentralDirectoryread three attacker-controlled 16-bit lengths — name, extra, comment — and sliced on all of them while only guarding the 46-byte fixed header:It now rejects malformed directories with
InvalidDataException. To be precise about severity: .NET's bounds checks caught this, so it is not memory corruption, andTryAcquireCoreAsyncalready swallowed it and degraded gracefully. The value is a correct rejection contract, plus a clean baseline for the fuzzer.What's here
WinApp.Cli.Fuzz— two libFuzzer targets: the pure central-directory parser (high iteration rate) and the full outer → inner → extract descent mirroringExtractJsProviderAsync.OneFuzzConfig.json—fuzzer.$type,FuzzingTargetBinaries: winapp.dllso the compliance claim attributes to the shipping binary, andSdlWorkItemIdlinking claims and bugs to the SDL task..pipelines/fuzz.yml—trigger: none/pr: none. Fuzzing runs on demand, not per-build; jobs run for hours and file bugs.FuzzHarnessTests— guards that the target signature andOneFuzzConfig.jsonstay in sync with the code, generates a seed corpus, and asserts every seed is structurally valid.Verification
Both targets ran on OneFuzz against a seeded corpus:
ziprangeextractor-centraldirectoryf1646fe1-be66-48ed-b9d5-3de43e98dcd0ziprangeextractor-archive2c2fc7a1-4c80-409c-81ac-8660a4934659Submission
dc437f41-cbf4-49eb-af73-bfc24b224536, 2/2 job configs succeeded, no task errors.The seed corpus matters more than it looks. libFuzzer mutates existing inputs and will not synthesise a valid ZIP64 central directory from random bytes — an unseeded run looks busy and covers nothing.
GenerateSeedCorpusround-trips every seed through the parser and fails if any produces no entries, because a corpus the parser rejects at byte zero is worse than none.Local: 25/25 tests pass,
build-cli.ps1clean.