mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user