From 045c84db76963a0d05bbfaf0a534ba8a84de7129 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:46:58 +0800 Subject: [PATCH] fix(pe): retain empty resources at virtual section endpoints Map a zero-byte payload at a section endpoint only when no section contains that RVA, preserving adjacent-section ownership and raw backing checks. Extend the real CLI and stdio MCP workflow across PE32/PE32+ empty and nonempty resources. The portable parser and public adapter regressions ran locally; Windows API loader behavior and GNU-produced images were not independently rerun. Refs #3970 Co-authored-by: Gabriel Dias --- src/native/pe/PeResourceLayout.ts | 13 +- src/native/pe/PeResourceParser.test.ts | 69 +++++++++ .../applications/peResources.test.ts | 131 +++++++++++------- 3 files changed, 161 insertions(+), 52 deletions(-) diff --git a/src/native/pe/PeResourceLayout.ts b/src/native/pe/PeResourceLayout.ts index 6f4f8bbd91..833c651947 100644 --- a/src/native/pe/PeResourceLayout.ts +++ b/src/native/pe/PeResourceLayout.ts @@ -94,10 +94,21 @@ export const readPeResourceLayout = (bytes: Buffer) => { } const map = (rva: number, size: number): number => { if (rva + size > 0x100000000) peFailure("Resource RVA range overflows."); - const candidates = sections.filter( + const containing = sections.filter( (section) => rva >= section.rva && rva - section.rva < virtualExtent(section), ); + // An empty interval reads no bytes, so the Windows resource loader also + // resolves it exactly at a section's virtual end. A section starting at + // that RVA still owns it. + const candidates = + size === 0 && containing.length === 0 + ? sections.filter( + (section) => + virtualExtent(section) > 0 && + rva - section.rva === virtualExtent(section), + ) + : containing; if (rva < headerSize) { if ( sections.some( diff --git a/src/native/pe/PeResourceParser.test.ts b/src/native/pe/PeResourceParser.test.ts index 2e221430b8..dfedb27d26 100644 --- a/src/native/pe/PeResourceParser.test.ts +++ b/src/native/pe/PeResourceParser.test.ts @@ -286,6 +286,75 @@ describe("PE resource section virtual extent", () => { }, ); + it("retains an empty payload that ends its section's virtual extent", async () => { + const fixture = peResourceFixture(); + const { virtualSize } = sectionSizes(fixture); + fixture.bytes.writeUInt32LE(0x2000 + virtualSize, fixture.dataOffsets[0]); + fixture.bytes.writeUInt32LE(0, (fixture.dataOffsets[0] ?? 0) + 4); + + const result = peResourcesSchema.parse(await parse(fixture.bytes)); + expect(result.coverage).toMatchObject({ status: "complete", resources: 5 }); + expect(result.resources[0]).toMatchObject({ + type: { kind: "id", id: 3 }, + name: { kind: "id", id: 1 }, + language: { kind: "id", id: 1033 }, + payload: { + rva: 0x2000 + virtualSize, + location: { offset: 0x400 + virtualSize, bytes: 0 }, + sha256: createHash("sha256").digest("hex"), + }, + }); + }); + + it("rejects an empty payload past its section's virtual extent", async () => { + const fixture = peResourceFixture(); + const { virtualSize } = sectionSizes(fixture); + fixture.bytes.writeUInt32LE( + 0x2000 + virtualSize + 1, + fixture.dataOffsets[0], + ); + fixture.bytes.writeUInt32LE(0, (fixture.dataOffsets[0] ?? 0) + 4); + await expect(parse(fixture.bytes)).rejects.toMatchObject({ + reason: "format", + message: "Resource RVA has missing or overlapping section mappings.", + }); + }); + + it("rejects an empty virtual-end payload without a file-backed location", async () => { + const fixture = peResourceFixture(); + const { rawSize } = sectionSizes(fixture); + fixture.bytes.writeUInt32LE(rawSize + 1, fixture.sectionAt + 8); + fixture.bytes.writeUInt32LE(0x2000 + rawSize + 1, fixture.dataOffsets[0]); + fixture.bytes.writeUInt32LE(0, (fixture.dataOffsets[0] ?? 0) + 4); + await expect(parse(fixture.bytes)).rejects.toMatchObject({ + reason: "format", + message: "Resource range is not entirely file-backed in one section.", + }); + }); + + it("maps an empty payload at a section boundary into the section that starts there", async () => { + const fixture = peResourceFixture(); + const { virtualSize } = sectionSizes(fixture); + const laterRva = 0x2000 + virtualSize; + const rawOffset = fixture.bytes.length; + const payload = Buffer.from("LATE"); + const bytes = Buffer.concat([fixture.bytes, payload]); + bytes.writeUInt16LE(2, 0x86); + const later = fixture.sectionAt + 40; + bytes.writeUInt32LE(payload.length, later + 8); + bytes.writeUInt32LE(laterRva, later + 12); + bytes.writeUInt32LE(payload.length, later + 16); + bytes.writeUInt32LE(rawOffset, later + 20); + bytes.writeUInt32LE(laterRva, fixture.dataOffsets[0]); + bytes.writeUInt32LE(0, (fixture.dataOffsets[0] ?? 0) + 4); + + const result = peResourcesSchema.parse(await parse(bytes)); + expect(result.resources[0]?.payload.location).toEqual({ + offset: rawOffset, + bytes: 0, + }); + }); + it("maps raw bytes when VirtualSize is zero, as the image loader does", async () => { const fixture = peResourceFixture(); fixture.bytes.writeUInt32LE(0, fixture.sectionAt + 8); diff --git a/tests/acceptance/applications/peResources.test.ts b/tests/acceptance/applications/peResources.test.ts index e6428d2001..ea8ec302df 100644 --- a/tests/acceptance/applications/peResources.test.ts +++ b/tests/acceptance/applications/peResources.test.ts @@ -90,6 +90,85 @@ const expectPeInputFailuresAcrossAdapters = async (input: { }); }; +const contract = toolContract("inspect_pe_resources"); + +const expectPeInventoriesAcrossAdapters = async ( + cli: TestCli, + client: Client, + root: string, +): Promise => { + for (const [plus, empty] of [ + [false, false], + [true, false], + [false, true], + [true, true], + ]) { + const fixture = peResourceFixture(plus); + const virtualSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 8); + if (empty) { + fixture.bytes.writeUInt32LE(0x2000 + virtualSize, fixture.dataOffsets[0]); + fixture.bytes.writeUInt32LE(0, (fixture.dataOffsets[0] ?? 0) + 4); + } + const path = join( + root, + `resource${plus ? "64" : "32"}${empty ? "-empty" : ""}.exe`, + ); + await writeFile(path, fixture.bytes); + const result = await cli.run({ + arguments: ["inspect-pe-resources", path, "--json"], + }); + expect(result.exitCode).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + const report = peResourcesSchema.parse(evidence.normalized_result); + expect(evidence.subject?.digest).toMatchObject({ + sha256: createHash("sha256").update(fixture.bytes).digest("hex"), + }); + expect(report.coverage).toMatchObject({ + status: "complete", + resources: 5, + }); + expect(report.resources[0]?.payload.sha256).toBe( + createHash("sha256") + .update(empty ? "" : "ICON") + .digest("hex"), + ); + if (empty) + expect(report.resources[0]?.payload.location).toEqual({ + offset: 0x400 + virtualSize, + bytes: 0, + }); + expect( + report.resources.find(({ name }) => name.kind === "name")?.name, + ).toMatchObject({ kind: "name", name: "7", utf16le_hex: "3700" }); + expect(report.icon_groups[0]?.images).toMatchObject([ + { + resource_id: 1, + candidate_resource_indices: [0, 1], + same_language_resource_index: 0, + }, + { + resource_id: 99, + candidate_resource_indices: [], + same_language_resource_index: null, + }, + ]); + const response = await client.callTool({ + name: contract.name, + arguments: { path }, + }); + expect(response.isError).not.toBe(true); + const mcp = contract.outputSchema.parse(response.structuredContent); + expect(mcp.evidence_id).toBe(evidence.evidence_id); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + const text = response.content.find(({ type }) => type === "text"); + if (text?.type !== "text") throw new Error("Missing MCP text Evidence"); + expect(contract.outputSchema.parse(JSON.parse(text.text)).evidence_id).toBe( + evidence.evidence_id, + ); + expect(await readFile(path)).toEqual(fixture.bytes); + } +}; + cliTest( "inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target", async ({ cli }) => { @@ -102,7 +181,6 @@ cliTest( stderr: "pipe", }); const client = new Client({ name: "pe-resource-acceptance", version: "1" }); - const contract = toolContract("inspect_pe_resources"); try { await client.connect(transport); const inventory = await client.listTools(); @@ -122,56 +200,7 @@ cliTest( expect(JSON.stringify(session.structuredContent)).toContain( "inspect_pe_resources", ); - for (const plus of [false, true]) { - const fixture = peResourceFixture(plus); - const path = join(root, plus ? "resource64.exe" : "resource32.exe"); - await writeFile(path, fixture.bytes); - const result = await cli.run({ - arguments: ["inspect-pe-resources", path, "--json"], - }); - expect(result.exitCode).toBe(0); - const evidence = contract.outputSchema.parse(result.json); - const report = peResourcesSchema.parse(evidence.normalized_result); - expect(evidence.subject?.digest).toMatchObject({ - sha256: createHash("sha256").update(fixture.bytes).digest("hex"), - }); - expect(report.coverage).toMatchObject({ - status: "complete", - resources: 5, - }); - expect(report.resources[0]?.payload.sha256).toBe( - createHash("sha256").update("ICON").digest("hex"), - ); - expect( - report.resources.find(({ name }) => name.kind === "name")?.name, - ).toMatchObject({ kind: "name", name: "7", utf16le_hex: "3700" }); - expect(report.icon_groups[0]?.images).toMatchObject([ - { - resource_id: 1, - candidate_resource_indices: [0, 1], - same_language_resource_index: 0, - }, - { - resource_id: 99, - candidate_resource_indices: [], - same_language_resource_index: null, - }, - ]); - const response = await client.callTool({ - name: contract.name, - arguments: { path }, - }); - expect(response.isError).not.toBe(true); - const mcp = contract.outputSchema.parse(response.structuredContent); - expect(mcp.evidence_id).toBe(evidence.evidence_id); - expect(mcp.normalized_result).toEqual(evidence.normalized_result); - const text = response.content.find(({ type }) => type === "text"); - if (text?.type !== "text") throw new Error("Missing MCP text Evidence"); - expect( - contract.outputSchema.parse(JSON.parse(text.text)).evidence_id, - ).toBe(evidence.evidence_id); - expect(await readFile(path)).toEqual(fixture.bytes); - } + await expectPeInventoriesAcrossAdapters(cli, client, root); await expectPePaddingFailureAcrossAdapters({ cli, client,