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:
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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Vendored
+20
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user