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:
1 parent
e38550462e
commit
76296c5a38
5 files changed
+144
-41
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user