diff --git a/src/domain/native/nativeAotPe.sectionRanges.test.ts b/src/domain/native/nativeAotPe.sectionRanges.test.ts new file mode 100644 index 0000000000..6efaf1caf6 --- /dev/null +++ b/src/domain/native/nativeAotPe.sectionRanges.test.ts @@ -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" }, + }); + } + }, +); diff --git a/src/domain/native/nativeAotPe.ts b/src/domain/native/nativeAotPe.ts index 12c6c70390..7edac548a5 100644 --- a/src/domain/native/nativeAotPe.ts +++ b/src/domain/native/nativeAotPe.ts @@ -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 { diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 5904c89a88..a6f6e51a42 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -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(); } diff --git a/tests/fixtures/nativeaotPe.ts b/tests/fixtures/nativeaotPe.ts index ca1dba4774..09c712ec68 100644 --- a/tests/fixtures/nativeaotPe.ts +++ b/tests/fixtures/nativeaotPe.ts @@ -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; +};