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 <gabrieldiasnevess@outlook.com>
This commit is contained in:
morluto
2026-10-16 02:46:58 +08:00
parent 6b1c36e336
commit 045c84db76
3 changed files with 161 additions and 52 deletions
+12 -1
View File
@@ -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(
+69
View File
@@ -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);
@@ -90,6 +90,85 @@ const expectPeInputFailuresAcrossAdapters = async (input: {
});
};
const contract = toolContract("inspect_pe_resources");
const expectPeInventoriesAcrossAdapters = async (
cli: TestCli,
client: Client,
root: string,
): Promise<void> => {
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,