fix(native): validate NativeAOT PE virtual ownership

Reject contradictory headers, overlapping virtual sections, DOS header reuse and image intervals outside the 64-bit address domain before metadata interpretation. Preserve empty and adjacent ranges, source section order, and raw padding semantics. Advance the metadata profile revision and retain provider snapshot refusal facts.

Validation: 37 parser checks and 35 selected provider checks passed; check:fast and build:cached passed. Six tiny authored-fixture summary/detail tasks returned byte-identical 791-token replies. No real-engine or Windows loader qualification.

Fixes #4763
This commit is contained in:
morluto committed 2026-10-17 19:02:29 +08:00
1 parent 9c56d09c05
commit 3fc007534b
4 files changed
+252 -5

No files matched your search

@@ -0,0 +1,171 @@
import { expect, it } from "vitest";
import {
NATIVE_AOT_MAX_REPORT_BYTES,
parseNativeAotPe,
} from "./nativeAotPe.js";
import {
nativeAotPeDigest,
nativeAotPeFixture,
nativeAotPeVirtualSectionFixture,
} from "../../../tests/fixtures/nativeaotPe.js";
it.each(["before-data", "after-data"] as const)(
"rejects ambiguous NativeAOT virtual ownership with the conflicting section %s",
(position) => {
const bytes = nativeAotPeVirtualSectionFixture(0x2000, 0x400, position);
const before = Buffer.from(bytes);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary).toMatchObject({
status: "not_applicable",
method_tables: 0,
coverage: { basis: "unsupported-layout" },
});
expect(
result.readTypeDetail("0x140002280", NATIVE_AOT_MAX_REPORT_BYTES),
).toBeUndefined();
expect(bytes).toEqual(before);
},
);
it.each([0x100, 0xb00, 0x4000])(
"rejects a contradictory declared PE header extent %s",
(headers) => {
const bytes = nativeAotPeFixture();
bytes.writeUInt32LE(headers, 0x98 + 60);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary).toMatchObject({
status: "not_applicable",
method_tables: 0,
coverage: { basis: "unsupported-layout" },
});
},
);
it.each([
{ rva: 0x200, size: 0x80 },
{ rva: 0x2380, size: 0x20 },
])(
"rejects a virtual interval overlapping a mapped region ($rva/$size)",
({ rva, size }) => {
const bytes = nativeAotPeVirtualSectionFixture(rva, size);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary).toMatchObject({
status: "not_applicable",
reason: "PE virtual section overlaps headers or another section",
coverage: { basis: "unsupported-layout" },
});
},
);
it.each([
{ rva: 0x200, size: 0 },
{ rva: 0x2100, size: 0 },
{ rva: 0x1200, size: 0x80 },
{ rva: 0x2400, size: 0x80 },
])(
"retains empty or adjacent virtual intervals ($rva/$size)",
({ rva, size }) => {
const bytes = nativeAotPeVirtualSectionFixture(rva, size, "before-data");
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary).toMatchObject({
status: "complete",
method_tables: 3,
derived_overlay: { address: "0x140002380", size_bytes: 1 },
});
expect(
result.readTypeDetail("0x140002280", NATIVE_AOT_MAX_REPORT_BYTES),
).toMatchObject({ method_table_address: "0x140002280" });
},
);
it("preserves source section order while admitting a unique unsorted mapping", () => {
const bytes = nativeAotPeFixture();
const table = 0x98 + 0xf0;
const original = Buffer.from(bytes.subarray(table, table + 80));
original.copy(bytes, table, 40, 80);
original.copy(bytes, table + 40, 0, 40);
const before = Buffer.from(bytes);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary).toMatchObject({
status: "complete",
method_tables: 3,
});
expect(bytes).toEqual(before);
});
it("does not confuse raw alignment padding with virtual section ownership", () => {
const bytes = nativeAotPeVirtualSectionFixture(0x1200, 0x80);
bytes.writeUInt32LE(0x300, 0x98 + 0xf0 + 16);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary).toMatchObject({
status: "complete",
method_tables: 3,
});
});
it("rejects a PE header that reuses DOS header fields", () => {
const bytes = nativeAotPeFixture();
bytes.copy(bytes, 4, 0x80, 0x98 + 0xf0 + 80);
bytes.writeUInt32LE(4, 0x3c);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary).toMatchObject({
status: "not_applicable",
reason: "PE header overlaps the DOS header",
coverage: { basis: "unsupported-layout" },
});
});
it.each([
{ base: 0xffffffffffffe000n, admitted: false },
{ base: 0xffffffffffffd000n, admitted: true },
])(
"checks the complete 64-bit image interval ($base)",
({ base, admitted }) => {
const bytes = nativeAotPeFixture();
bytes.writeBigUInt64LE(base, 0x98 + 24);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
if (admitted) {
// Existing fixture pointers do not relocate to the new base. No directory
// recovery is claimed; only the image interval itself remains supported.
expect(result.summary.coverage.basis).toBe("no-rtr-header");
} else {
expect(result.summary).toMatchObject({
status: "not_applicable",
reason: "PE image exceeds the 64-bit address domain",
coverage: { basis: "unsupported-layout" },
});
}
},
);
+25 -1
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-v4";
"rtr-9.1-x64-pe-read-only-v5";
export type NativeAotParserLimits = {
readonly sourceBytes: number;
@@ -387,6 +387,7 @@ class PeImage {
if (bytes.length < 0x40 || bytes.toString("latin1", 0, 2) !== "MZ")
throw new Error("Not a PE image");
const peOffset = this.#u32(0x3c);
if (peOffset < 0x40) throw new Error("PE header overlaps the DOS header");
this.#range(peOffset, 24);
if (bytes.toString("latin1", peOffset, peOffset + 4) !== "PE\0\0")
throw new Error("Invalid PE signature");
@@ -413,8 +414,18 @@ class PeImage {
this.#sizeOfHeaders = this.#u32(optional + 60);
if (count < 1 || this.sizeOfImage === 0)
throw new Error("Unsupported PE image extent");
if (this.imageBase + BigInt(this.sizeOfImage) > 1n << 64n)
throw new Error("PE image exceeds the 64-bit address domain");
const sectionOffset = optional + optionalSize;
this.#range(sectionOffset, count * 40);
if (
this.#sizeOfHeaders < sectionOffset + count * 40 ||
this.#sizeOfHeaders > this.#bytes.length ||
this.#sizeOfHeaders > this.sizeOfImage
)
throw new Error(
"PE declared headers do not fit the captured image layout",
);
this.sections = Array.from({ length: count }, (_, index) => {
const p = sectionOffset + index * 40;
const name = bytes.toString("ascii", p, p + 8).replace(/\0.*$/u, "");
@@ -432,6 +443,19 @@ class PeImage {
if (section.rva + virtualExtent(section) > this.sizeOfImage)
throw new Error("PE section exceeds the declared image extent");
}
// Establish one virtual owner before any scan, read, permission query or
// hydration. Empty sections own no bytes; adjacent intervals remain valid.
const mapped = this.sections
.filter((section) => virtualExtent(section) > 0)
.sort((left, right) => left.rva - right.rva);
let mappedEnd = this.#sizeOfHeaders;
for (const section of mapped) {
if (section.rva < mappedEnd)
throw new Error(
"PE virtual section overlaps headers or another section",
);
mappedEnd = section.rva + virtualExtent(section);
}
}
addressForRva(rva: number): bigint {
+36 -4
View File
@@ -25,6 +25,7 @@ import {
nativeAotPeFixture,
nativeAotPeHeaderIndirectionFixture,
nativeAotPeOptionalHeaderFixture,
nativeAotPeVirtualSectionFixture,
} from "../../tests/fixtures/nativeaotPe.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
@@ -605,7 +606,7 @@ describe("Ghidra platform support", () => {
executable_role: target.executableRole,
managed: target.managed,
native_aot_metadata: {
contract_revision: "rtr-9.1-x64-pe-read-only-v4",
contract_revision: "rtr-9.1-x64-pe-read-only-v5",
source_bytes: 128 * 1024 * 1024,
work_units: 64 * 1024 * 1024,
working_memory_bytes: 64 * 1024 * 1024,
@@ -3621,17 +3622,32 @@ it.each([
{
layout: "crossed header interval",
fixture: () => nativeAotPeHeaderIndirectionFixture(0x3fc),
basis: "no-rtr-header",
},
{
layout: "short optional header",
fixture: () => nativeAotPeOptionalHeaderFixture(56),
basis: "unsupported-layout",
},
{
layout: "overlapping virtual sections",
fixture: () => nativeAotPeVirtualSectionFixture(0x2000, 0x400),
basis: "unsupported-layout",
},
{
layout: "section overlapping headers",
fixture: () => nativeAotPeVirtualSectionFixture(0x200, 0x80),
basis: "unsupported-layout",
},
])(
"retains NativeAOT unsupported-layout facts for a $layout through the provider",
async ({ fixture }) => {
async ({ fixture, basis }) => {
const bytes = fixture();
const observations = fixtureDosLoadImage().observation;
observations.image_base = "0x140000000";
const readSnapshot = vi.fn(async () =>
ok({ kind: "captured" as const, bytes }),
);
const factory: GhidraProviderClientFactory = () => ({
start: () =>
Promise.resolve(
@@ -3640,8 +3656,17 @@ it.each([
target: { ...sessionInfo().target, image_base: "0x140000000" },
}),
),
callTool: () => Promise.resolve(ok(jsonValueSchema.parse(observations))),
readTargetSnapshot: async () => ok({ kind: "captured" as const, bytes }),
callTool: (operation) =>
Promise.resolve(
ok(
jsonValueSchema.parse(
operation === "inspect_native_load_image"
? observations
: unavailableNativeAotDatabaseType("0x140002280"),
),
),
),
readTargetSnapshot: readSnapshot,
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
@@ -3663,12 +3688,19 @@ it.each([
header_address: null,
method_tables: 0,
analysis_artifact_sha256: target.sha256,
coverage: { basis },
},
],
},
},
},
});
const detail = await client.execute("inspect_native_data_type", {
address: "0x140002280",
});
if (!detail.ok) throw detail.error;
expect(detail.value.result).not.toHaveProperty("metadata_recovery");
expect(readSnapshot).toHaveBeenCalledTimes(1);
} finally {
await client.close();
}
+20
View File
@@ -108,3 +108,23 @@ export const nativeAotPeOptionalHeaderFixture = (
bytes.writeUInt16LE(optionalBytes, 0x94);
return bytes;
};
/** Add a zero-filled virtual section without changing the captured metadata bytes. */
export const nativeAotPeVirtualSectionFixture = (
rva: number,
virtualSize: number,
position: "before-data" | "after-data" = "after-data",
): Buffer => {
const bytes = nativeAotPeFixture();
const table = 0x98 + 0xf0;
const data = Buffer.from(bytes.subarray(table + 40, table + 80));
bytes.writeUInt16LE(3, 0x86);
const extra = position === "before-data" ? table + 40 : table + 80;
bytes.fill(0, extra, extra + 40);
bytes.write(".bss", extra, "ascii");
bytes.writeUInt32LE(virtualSize, extra + 8);
bytes.writeUInt32LE(rva, extra + 12);
bytes.writeUInt32LE(0xc0000080, extra + 36);
if (position === "before-data") data.copy(bytes, table + 80);
return bytes;
};