fix(native): bound NativeAOT reads to declared PE headers

Validate the consumed optional-header extent before interpreting mapping fields. Require complete header-backed read intervals and prevent failed header reads from falling through to section mappings. Advance NativeAOT profile interpretation identity to v4.

Refs #4750
This commit is contained in:
morluto committed 2026-10-17 18:07:29 +08:00
1 parent d9099190ab
commit 4a076f368c
4 files changed
+150 -3

No files matched your search

@@ -0,0 +1,56 @@
import { expect, it } from "vitest";
import {
parseNativeAotPe,
NATIVE_AOT_MAX_REPORT_BYTES,
} from "./nativeAotPe.js";
import {
nativeAotPeHeaderIndirectionFixture,
nativeAotPeOptionalHeaderFixture,
nativeAotPeDigest,
} from "../../../tests/fixtures/nativeaotPe.js";
it.each([
{ rva: 0x3f8, admitted: true },
{ rva: 0x3fc, admitted: false },
{ rva: 0x400, admitted: false },
])(
"admits a header-backed TypeManager pointer only when all eight bytes are mapped ($rva)",
({ rva, admitted }) => {
const bytes = nativeAotPeHeaderIndirectionFixture(rva);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary.header_address !== null).toBe(admitted);
if (!admitted)
expect(result.summary).toMatchObject({
status: "not_applicable",
method_tables: 0,
reason:
"No supported ReadyToRun directory signature was found in initialized non-executable PE sections.",
});
},
);
it.each([
{ optionalBytes: 56, admitted: false },
{ optionalBytes: 64, admitted: true },
])(
"reads mapping fields only from the declared optional header ($optionalBytes bytes)",
({ optionalBytes, admitted }) => {
const bytes = nativeAotPeOptionalHeaderFixture(optionalBytes);
const result = parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
NATIVE_AOT_MAX_REPORT_BYTES,
);
expect(result.summary.header_address !== null).toBe(admitted);
if (!admitted)
expect(result.summary).toMatchObject({
status: "not_applicable",
method_tables: 0,
reason: "PE optional header is too short for image mapping fields",
});
},
);
+14 -2
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-v3";
"rtr-9.1-x64-pe-read-only-v4";
export type NativeAotParserLimits = {
readonly sourceBytes: number;
@@ -399,6 +399,12 @@ class PeImage {
const optionalSize = this.#u16(peOffset + 20);
const optional = peOffset + 24;
this.#range(optional, optionalSize);
// Mapping fields through SizeOfHeaders must belong to this header,
// independently of whether the following file bytes are available.
if (optionalSize < 64)
throw new Error(
"PE optional header is too short for image mapping fields",
);
const magic = this.#u16(optional);
if (magic !== 0x20b || this.#machine !== 0x8664)
throw new Error("Only x64 PE32+ is supported");
@@ -454,8 +460,14 @@ class PeImage {
const rva = address - this.imageBase;
if (rva > BigInt(Number.MAX_SAFE_INTEGER)) return null;
const relative = Number(rva);
if (relative < this.#sizeOfHeaders && relative <= this.#bytes.length - size)
if (relative < this.#sizeOfHeaders) {
if (
size > this.#sizeOfHeaders - relative ||
relative > this.#bytes.length - size
)
return null;
return this.#bytes.subarray(relative, relative + size);
}
for (const section of this.sections) {
if (this.#budget !== undefined && !this.#budget.consumeWork(1))
return null;
+61 -1
View File
@@ -19,6 +19,8 @@ import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js
import {
nativeAotPeDigest,
nativeAotPeFixture,
nativeAotPeHeaderIndirectionFixture,
nativeAotPeOptionalHeaderFixture,
} from "../../tests/fixtures/nativeaotPe.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
@@ -599,7 +601,7 @@ describe("Ghidra platform support", () => {
executable_role: target.executableRole,
managed: target.managed,
native_aot_metadata: {
contract_revision: "rtr-9.1-x64-pe-read-only-v3",
contract_revision: "rtr-9.1-x64-pe-read-only-v4",
source_bytes: 128 * 1024 * 1024,
work_units: 64 * 1024 * 1024,
working_memory_bytes: 64 * 1024 * 1024,
@@ -3237,3 +3239,61 @@ it("rejects an absent profile before probing Java and attributes it to the reque
await ghidra.closeOwnedResources();
}
});
it.each([
{
layout: "crossed header interval",
fixture: () => nativeAotPeHeaderIndirectionFixture(0x3fc),
},
{
layout: "short optional header",
fixture: () => nativeAotPeOptionalHeaderFixture(56),
},
])(
"retains NativeAOT unsupported-layout facts for a $layout through the provider",
async ({ fixture }) => {
const bytes = fixture();
const observations = fixtureDosLoadImage().observation;
observations.image_base = "0x140000000";
const factory: GhidraProviderClientFactory = () => ({
start: () =>
Promise.resolve(
ok({
...sessionInfo(),
target: { ...sessionInfo().target, image_base: "0x140000000" },
}),
),
callTool: () => Promise.resolve(ok(jsonValueSchema.parse(observations))),
readTargetSnapshot: async () => ok({ kind: "captured" as const, bytes }),
close: () => Promise.resolve(ok(null)),
});
const ghidra = provider(installationHost(), factory);
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted PE profile");
const client = ghidra.createClient(target, profile.value.profile);
try {
const result = await client.execute("inspect_native_load_image", {});
expect(result).toMatchObject({
ok: true,
value: {
result: {
observations: {
metadata_recovery: [
{
status: "not_applicable",
header_address: null,
method_tables: 0,
analysis_artifact_sha256: target.sha256,
},
],
},
},
},
});
} finally {
await client.close();
}
},
);
+19
View File
@@ -89,3 +89,22 @@ export const nativeAotPeFixture = (major = 9, minor = 1): Buffer => {
bytes.writeUInt16LE(0, frozen + 12 + value.length);
return bytes;
};
/** An RTR indirection into PE headers; its whole pointer interval must map. */
export const nativeAotPeHeaderIndirectionFixture = (rva: number): Buffer => {
const bytes = nativeAotPeFixture();
bytes.writeUInt32LE(204, 0x600 + 40);
bytes.writeBigUInt64LE(IMAGE_BASE + BigInt(rva), 0x600 + 48);
bytes.writeBigUInt64LE(0n, 0x600 + 56);
return bytes;
};
/** Keep section-table placement consistent with the selected optional header. */
export const nativeAotPeOptionalHeaderFixture = (
optionalBytes: number,
): Buffer => {
const bytes = nativeAotPeFixture();
bytes.copy(bytes, 0x98 + optionalBytes, 0x98 + 0xf0, 0x98 + 0xf0 + 80);
bytes.writeUInt16LE(optionalBytes, 0x94);
return bytes;
};