fix(pe): inspect resource records at their declared offsets

Remove unsupported DWORD alignment refusals from resource data entries and adjacent directory tables. Preserve original record locations, UTF-16 name alignment, bounds, mapping, overlap checks and payload hashes.

Validation: 49 focused parser/provider/filesystem/lifecycle and compiled CLI/stdio MCP tests; check:ci; independent pefile agreement for the reported layout, padded control and PE32/PE32+ word-aligned directories. Public CLI and SDK-validated MCP retain truncated-input refusals and unchanged source hashes. Synthetic static inspection only; Windows loading was not tested.

Fixes #5750
This commit is contained in:
morluto committed 2026-10-21 12:46:29 +08:00
1 parent 264862bf2e
commit 22d5a40a2e
4 files changed
+120 -4

No files matched your search

+53
View File
@@ -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)",
-2
View File
@@ -120,7 +120,6 @@ export const parsePeResources = async (
ancestors: ReadonlySet<number>,
): Promise<void> => {
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);
+3 -2
View File
@@ -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);
@@ -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<Client["listTools"]>
>["tools"][number];
}): Promise<void> => {
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,