fix(native): bind NativeAOT metadata to declared PE section extents

Section headers distinguish the declared virtual extent from the on-disk raw extent, which may include alignment padding. Resource RVA ownership and overlap use VirtualSize, falling back to SizeOfRawData when VirtualSize is zero, and payload reads still require file-backed bytes.

The NativeAOT snapshot reader applies the same range rule to RVA reads, metadata scans, executable targets, and derived overlay bounds. Page-rounded loader mappings may expose tail bytes, but those bytes lie outside the declared section range used by these parsers.
This commit is contained in:
Kaoru0822 authored and morluto committed 2026-10-11 11:07:15 +08:00
1 parent e38550462e
commit 76296c5a38
5 files changed
+144 -41

No files matched your search

+56
View File
@@ -72,6 +72,62 @@ describe("read-only NativeAOT PE parser", () => {
});
});
it("does not discover an RTR directory in raw padding beyond VirtualSize", () => {
const bytes = nativeAotPeFixture();
const sectionTable = 0x98 + 0xf0;
const dataSection = sectionTable + 40;
bytes.writeUInt32LE(0x20, dataSection + 8);
const parsed = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
);
expect(parsed.summary).toMatchObject({
status: "not_applicable",
reason:
"No supported ReadyToRun directory signature was found in initialized non-executable PE sections.",
method_tables: 0,
});
});
it("uses raw size as the virtual extent when VirtualSize is zero", () => {
const bytes = nativeAotPeFixture();
const sectionTable = 0x98 + 0xf0;
bytes.writeUInt32LE(0, sectionTable + 40 + 8);
const parsed = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
);
expect(parsed.summary).toMatchObject({
status: "complete",
method_tables: 3,
derived_overlay: { address: "0x140002380", size_bytes: 1 },
});
});
it("accepts executable-section targets in the zero-filled virtual tail", () => {
const bytes = nativeAotPeFixture();
const sectionTable = 0x98 + 0xf0;
bytes.writeUInt32LE(0x300, sectionTable + 8);
const slotOffset = 0x600 + (0x2280 - 0x2000) + 24;
// RVA 0x1200 is executable-section zero fill: virtual size 0x300, raw size 0x200.
bytes.writeBigUInt64LE(0x140001200n, slotOffset);
const parsed = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
"0x140000000",
);
expect(parsed.summary).toMatchObject({
status: "complete",
method_tables: 3,
});
expect(parsed.readTypeDetail("0x140002280", SUMMARY_BUDGET)).toMatchObject({
virtual_slots: [{ slot: 0, target_address: "0x140001200" }],
});
});
it("does not claim an image mapping when Ghidra rebased the image", () => {
const bytes = nativeAotPeFixture();
expect(
+21 -24
View File
@@ -13,7 +13,7 @@ export const NATIVE_AOT_MAX_WORK_UNITS = 64 * 1024 * 1024;
export const NATIVE_AOT_MAX_WORKING_MEMORY_BYTES = 64 * 1024 * 1024;
/** Version of the built-in read-only PE metadata interpretation in profiles. */
export const NATIVE_AOT_PROFILE_CONTRACT_REVISION =
"rtr-9.1-x64-pe-read-only-v1";
"rtr-9.1-x64-pe-read-only-v3";
export type NativeAotParserLimits = {
readonly sourceBytes: number;
@@ -81,6 +81,12 @@ type Section = {
readonly rawSize: number;
readonly characteristics: number;
};
// Compatible PE loaders use raw size when VirtualSize is zero. Otherwise,
// RVA containment follows the declared VirtualSize, independent of file size.
const virtualExtent = (section: Section): number =>
section.virtualSize === 0 ? section.rawSize : section.virtualSize;
const fileBackedVirtualExtent = (section: Section): number =>
Math.min(virtualExtent(section), section.rawSize);
type RtrSection = {
readonly type: number;
readonly flags: number;
@@ -417,10 +423,7 @@ class PeImage {
});
for (const section of this.sections) {
this.#range(section.rawOffset, section.rawSize);
if (
section.rva + Math.max(section.virtualSize, section.rawSize) >
this.sizeOfImage
)
if (section.rva + virtualExtent(section) > this.sizeOfImage)
throw new Error("PE section exceeds the declared image extent");
}
}
@@ -456,7 +459,7 @@ class PeImage {
for (const section of this.sections) {
if (this.#budget !== undefined && !this.#budget.consumeWork(1))
return null;
const extent = Math.max(section.virtualSize, section.rawSize);
const extent = virtualExtent(section);
if (relative < section.rva || relative - section.rva > extent - size)
continue;
const delta = relative - section.rva;
@@ -466,6 +469,7 @@ class PeImage {
}
return null;
}
// This checks executable section membership, not file-backed instruction bytes.
isExecutable(address: bigint): boolean {
if (address < this.imageBase) return false;
const rva = address - this.imageBase;
@@ -475,7 +479,7 @@ class PeImage {
if (
(section.characteristics & 0x20000000) !== 0 &&
rva >= BigInt(section.rva) &&
rva < BigInt(section.rva + section.rawSize)
rva < BigInt(section.rva + virtualExtent(section))
)
return true;
}
@@ -492,10 +496,7 @@ class PeImage {
if (this.#budget !== undefined && !this.#budget.consumeWork(1)) return 0;
if (
rva >= BigInt(candidate.rva) &&
rva <
BigInt(
candidate.rva + Math.max(candidate.virtualSize, candidate.rawSize),
)
rva < BigInt(candidate.rva + virtualExtent(candidate))
) {
section = candidate;
break;
@@ -503,9 +504,7 @@ class PeImage {
}
return section === undefined
? 0
: section.rva +
Math.max(section.virtualSize, section.rawSize) -
Number(rva);
: section.rva + virtualExtent(section) - Number(rva);
}
#range(offset: number, size: number): void {
if (
@@ -602,7 +601,7 @@ const findDirectory = (
)
continue;
const begin = section.rawOffset;
const end = begin + section.rawSize;
const end = begin + fileBackedVirtualExtent(section);
const first = (begin + 7) & ~7;
const candidateBytes =
first <= end - 16 ? (Math.floor((end - 16 - first) / 8) + 1) * 8 : 0;
@@ -970,10 +969,11 @@ const discoverTables = (
(section.characteristics & 0x20000000) !== 0
)
continue;
if (section.rawSize > 0) {
const extent = fileBackedVirtualExtent(section);
if (extent > 0) {
ranges.push({
start: image.addressForRva(section.rva),
end: image.addressForRva(section.rva + section.rawSize),
end: image.addressForRva(section.rva + extent),
});
}
}
@@ -984,9 +984,7 @@ const discoverTables = (
(candidate.characteristics & 0x20000000) === 0 &&
hydrated.base >= image.addressForRva(candidate.rva) &&
hydrated.base + BigInt(hydrated.sizeBytes) <=
image.addressForRva(
candidate.rva + Math.max(candidate.virtualSize, candidate.rawSize),
),
image.addressForRva(candidate.rva + virtualExtent(candidate)),
);
if (section !== undefined)
ranges.push({
@@ -1592,11 +1590,10 @@ export const parseNativeAotPe = (
? "raw-image-pointer-scan"
: "raw-image-and-rehydrated-pointer-scan",
},
diagnostics: [
...(hydrationError === null
diagnostics:
hydrationError === null
? []
: [`Read-only hydration stopped: ${hydrationError}`]),
],
: [`Read-only hydration stopped: ${hydrationError}`],
limitations: reportLimitations,
};
if (Buffer.byteLength(JSON.stringify(summary)) > maximumSummaryBytes)
+1 -1
View File
@@ -226,7 +226,7 @@ describe("Ghidra platform support", () => {
executable_role: target.executableRole,
managed: target.managed,
native_aot_metadata: {
contract_revision: "rtr-9.1-x64-pe-read-only-v1",
contract_revision: "rtr-9.1-x64-pe-read-only-v3",
source_bytes: 128 * 1024 * 1024,
work_units: 64 * 1024 * 1024,
working_memory_bytes: 64 * 1024 * 1024,
+3 -2
View File
@@ -28,8 +28,9 @@ interface Section {
readonly size: number;
}
// The image loader maps VirtualSize bytes, using SizeOfRawData only when
// VirtualSize is zero. Raw bytes past that extent are file-alignment padding.
// RVA ownership follows the declared virtual extent. Raw size describes
// initialized file bytes and can include file-alignment padding; loaders may
// map page-rounded tails, but those bytes are outside the declared section.
const virtualExtent = (section: Section): number =>
section.virtualSize === 0 ? section.size : section.virtualSize;
@@ -39,6 +39,57 @@ const expectPeInputFailureAcrossAdapters = async (input: {
expect(parseMcpToolError(response)).toEqual({ error: result.json });
};
const expectPePaddingFailureAcrossAdapters = async (input: {
readonly cli: TestCli;
readonly client: Client;
readonly toolName: string;
readonly path: string;
}) => {
const { cli, client, toolName, path } = input;
const fixture = peResourceFixture();
const virtualSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 8);
fixture.bytes.writeUInt32LE(
0x2000 + virtualSize,
fixture.dataOffsets[0] ?? 0,
);
fixture.bytes.writeUInt32LE(4, (fixture.dataOffsets[0] ?? 0) + 4);
await writeFile(path, fixture.bytes);
const cliResult = await cli.run({
arguments: ["inspect-pe-resources", path, "--json"],
});
expect(cliResult.exitCode).not.toBe(0);
const mcpResult = await client.callTool({
name: toolName,
arguments: { path },
});
expect(mcpResult.isError).toBe(true);
expect(peResourcesSchema.safeParse(mcpResult.structuredContent).success).toBe(
false,
);
};
const expectPeInputFailuresAcrossAdapters = async (input: {
readonly cli: TestCli;
readonly client: Client;
readonly toolName: string;
readonly root: string;
}) => {
const { cli, client, toolName, root } = input;
const malformedPath = join(root, "malformed.exe");
await writeFile(malformedPath, Buffer.from("not a PE image"));
for (const [path, reason] of [
[join(root, "missing.exe"), "invalid_value"],
[malformedPath, "invalid_format"],
] as const)
await expectPeInputFailureAcrossAdapters({
cli,
client,
toolName,
path,
reason,
});
};
cliTest(
"inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target",
async ({ cli }) => {
@@ -121,20 +172,18 @@ cliTest(
).toBe(evidence.evidence_id);
expect(await readFile(path)).toEqual(fixture.bytes);
}
const malformedPath = join(root, "malformed.exe");
await writeFile(malformedPath, Buffer.from("not a PE image"));
for (const [path, reason] of [
[join(root, "missing.exe"), "invalid_value"],
[malformedPath, "invalid_format"],
] as const) {
await expectPeInputFailureAcrossAdapters({
cli,
client,
toolName: contract.name,
path,
reason,
});
}
await expectPePaddingFailureAcrossAdapters({
cli,
client,
toolName: contract.name,
path: join(root, "raw-padding.exe"),
});
await expectPeInputFailuresAcrossAdapters({
cli,
client,
toolName: contract.name,
root,
});
const absent = peResourceFixture();
absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8);
const path = join(root, "absent.exe");