diff --git a/src/native/pe/PeResourceParser.test.ts b/src/native/pe/PeResourceParser.test.ts index 300c054c13..ecf3ec4195 100644 --- a/src/native/pe/PeResourceParser.test.ts +++ b/src/native/pe/PeResourceParser.test.ts @@ -22,6 +22,59 @@ const parse = (bytes: Buffer, maxEntries = 4096, signal?: AbortSignal) => signal, ); +describe("word-aligned PE resource records", () => { + it.each([false, true])( + "preserves word-aligned resource records after UTF-16 names (PE32+ = %s)", + async (plus) => { + for (const identities of [ + { type: 10, name: 1, language: "AB" }, + { type: "AB", name: 1, language: 1033 }, + ]) { + const payload = Buffer.from("opaque resource"); + const fixture = peResourceFixture( + plus, + [{ ...identities, payload }], + 65536, + 2, + ); + const dataOffset = fixture.dataOffsets[0]; + if (dataOffset === undefined) + throw new Error("Missing fixture data entry"); + expect(fixture.dataOffsets.some((at) => at % 4 === 2)).toBe(true); + if (typeof identities.type === "string") + expect(fixture.directoryOffsets.some((at) => at % 4 === 2)).toBe( + true, + ); + const result = peResourcesSchema.parse(await parse(fixture.bytes)); + expect( + result.directories.map(({ location }) => location.offset), + ).toEqual(fixture.directoryOffsets); + expect(result.coverage).toMatchObject({ + status: "complete", + resources: 1, + }); + expect(result.resources[0]?.data_entry_location).toEqual({ + offset: dataOffset, + bytes: 16, + }); + expect(result.resources[0]?.payload.sha256).toBe( + createHash("sha256").update(payload).digest("hex"), + ); + expect(result.resources[0]?.payload.location.offset).toBe( + fixture.bytes.readUInt32LE(dataOffset) - 0x1c00, + ); + fixture.bytes.writeUInt32LE( + dataOffset - 0x400 + 15, + fixture.directoryAt + 4, + ); + await expect(parse(fixture.bytes)).rejects.toMatchObject({ + reason: "format", + }); + } + }, + ); +}); + describe("portable PE resource parsing", () => { it.each([false, true])( "preserves original identities, ranges, hashes and icon candidates (PE32+ = %s)", diff --git a/src/native/pe/PeResourceParser.ts b/src/native/pe/PeResourceParser.ts index 1f266defb3..6a56ac15c2 100644 --- a/src/native/pe/PeResourceParser.ts +++ b/src/native/pe/PeResourceParser.ts @@ -120,7 +120,6 @@ export const parsePeResources = async ( ancestors: ReadonlySet, ): Promise => { if (ancestors.has(at)) peFailure("Cyclic PE resource directory."); - if (at % 4 !== 0) peFailure("Unaligned PE resource directory."); const header = relative(at, 16); const named = bytes.readUInt16LE(header + 12); const ids = bytes.readUInt16LE(header + 14); @@ -177,7 +176,6 @@ export const parsePeResources = async ( "Unsupported resource leaf outside the type/name/language profile.", ); const data = relative(offset, 16); - if (offset % 4 !== 0) peFailure("Unaligned resource data entry."); const rva = bytes.readUInt32LE(data); const size = bytes.readUInt32LE(data + 4); const payloadOffset = layout.map(rva, size); diff --git a/src/native/pe/PeResources.fixture.ts b/src/native/pe/PeResources.fixture.ts index d50136e291..45c2e51f57 100644 --- a/src/native/pe/PeResources.fixture.ts +++ b/src/native/pe/PeResources.fixture.ts @@ -38,6 +38,7 @@ export const peResourceFixture = ( { type: 14, name: 42, language: 1033, payload: iconGroup() }, ], sectionBytes = 65536, + recordAlignment: 2 | 4 = 4, ) => { const tree: Tree = { children: new Map() }; for (const resource of resources) { @@ -61,7 +62,7 @@ export const peResourceFixture = ( } const section = Buffer.alloc(sectionBytes); let cursor = 0; - const allocate = (size: number, alignment = 4): number => { + const allocate = (size: number, alignment = recordAlignment): number => { cursor = Math.ceil(cursor / alignment) * alignment; const at = cursor; cursor += size; @@ -110,7 +111,7 @@ export const peResourceFixture = ( }; emit(tree); for (const { dataAt, resource } of leaves) { - const payloadAt = allocate(resource.payload.length); + const payloadAt = allocate(resource.payload.length, 4); resource.payload.copy(section, payloadAt); section.writeUInt32LE(0x2000 + payloadAt, dataAt); section.writeUInt32LE(resource.payload.length, dataAt + 4); diff --git a/tests/acceptance/applications/peResources.test.ts b/tests/acceptance/applications/peResources.test.ts index 5e2d7cc88d..73a49a144c 100644 --- a/tests/acceptance/applications/peResources.test.ts +++ b/tests/acceptance/applications/peResources.test.ts @@ -92,6 +92,59 @@ const expectPeInputFailuresAcrossAdapters = async (input: { const contract = toolContract("inspect_pe_resources"); +const expectPeWordAlignedRecordsAcrossAdapters = async (input: { + readonly cli: TestCli; + readonly client: Client; + readonly root: string; + readonly definition: Awaited< + ReturnType + >["tools"][number]; +}): Promise => { + const { cli, client, root, definition } = input; + for (const plus of [false, true]) { + for (const [index, identities] of [ + { type: 10, name: 1, language: "AB" }, + { type: "AB", name: 1, language: 1033 }, + ].entries()) { + const fixture = peResourceFixture( + plus, + [{ ...identities, payload: Buffer.from("opaque resource") }], + 65536, + 2, + ); + const path = join(root, `word-aligned-${plus}-${index}.exe`); + await writeFile(path, fixture.bytes); + const result = await cli.run({ + arguments: ["inspect-pe-resources", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + const report = peResourcesSchema.parse(evidence.normalized_result); + expect(report.coverage).toMatchObject({ + status: "complete", + resources: 1, + }); + expect(report.resources[0]?.data_entry_location).toEqual({ + offset: fixture.dataOffsets[0], + bytes: 16, + }); + expect(report.resources[0]?.payload.sha256).toBe( + createHash("sha256").update("opaque resource").digest("hex"), + ); + const response = await client.callTool( + { name: definition.name, arguments: { path } }, + { toolDefinition: definition }, + ); + expect(response.isError).not.toBe(true); + expect( + contract.outputSchema.parse(response.structuredContent) + .normalized_result, + ).toEqual(evidence.normalized_result); + expect(await readFile(path)).toEqual(fixture.bytes); + } + } +}; + const expectPeMappingOwnershipAcrossAdapters = async ( cli: TestCli, client: Client, @@ -284,6 +337,11 @@ cliTest( try { await client.connect(transport); const inventory = await client.listTools(); + const definition = inventory.tools.find( + ({ name }) => name === contract.name, + ); + if (definition === undefined) + throw new Error("Missing PE resource tool contract"); expect(inventory.tools).toContainEqual( expect.objectContaining({ name: contract.name, @@ -301,6 +359,12 @@ cliTest( "inspect_pe_resources", ); await expectPeInventoriesAcrossAdapters(cli, client, root); + await expectPeWordAlignedRecordsAcrossAdapters({ + cli, + client, + root, + definition, + }); await expectPeMappingOwnershipAcrossAdapters(cli, client, root); await expectPePaddingFailureAcrossAdapters({ cli,