diff --git a/docs/ghidra-nativeaot.md b/docs/ghidra-nativeaot.md index 15393aa9db..2e81ad3ec0 100644 --- a/docs/ghidra-nativeaot.md +++ b/docs/ghidra-nativeaot.md @@ -125,11 +125,15 @@ budget is spent, including later sections that were never visited. MethodTable closure indexes each validated table's related pointer once and visits a work queue for inheritance and interface reachability. Its temporary index and queue consume the existing working-memory budget; the parser does not increase its -work limit. Discovery still must finish before claiming a unique System.Object +work limit. Candidate headers/counts are parsed once; unmapped table bodies are +rejected before charging table entries, and validation charges only each slot or +interface pointer actually examined. Declared counts and unvisited tails do not +become examined coverage. Discovery still must finish before claiming a unique System.Object root, so a discovery budget stop can return zero recovered tables without establishing their absence. Performance on the large bflat report has not been -measured; this change removes repeated closure scans rather than resolving -that separate discovery limitation. +measured. Indexed closure and candidate validation avoid separate repeated or +unperformed work, but do not establish that the fixed discovery budget admits +that artifact. Budget stops remain partial results with unknown type absence. If captured snapshot bytes no longer match their admitted digest, the parser retains both digests and the Ghidra owner reports `artifact_changed`, with the diff --git a/src/domain/native/nativeAotPe.discoveryAccounting.test.ts b/src/domain/native/nativeAotPe.discoveryAccounting.test.ts new file mode 100644 index 0000000000..6985e152af --- /dev/null +++ b/src/domain/native/nativeAotPe.discoveryAccounting.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + NATIVE_AOT_MAX_REPORT_BYTES, + parseNativeAotPe, +} from "./nativeAotPe.js"; +import { + nativeAotPeDigest, + nativeAotPeFixture, +} from "../../../tests/fixtures/nativeaotPe.js"; + +const extendedFixture = (length: number) => { + const bytes = Buffer.alloc(length); + nativeAotPeFixture().copy(bytes); + const data = 0x98 + 0xf0 + 40; + bytes.writeUInt32LE(length - 0x600, data + 8); + bytes.writeUInt32LE(length - 0x600, data + 16); + bytes.writeUInt32LE(0xb000, 0x98 + 56); + return bytes; +}; +const candidate = ( + bytes: Buffer, + offset: number, + slots: number, + interfaces: number, + related = 0n, +) => { + bytes.writeUInt32LE(0x50000000, offset); + bytes.writeUInt32LE(0x20, offset + 4); + bytes.writeBigUInt64LE(related, offset + 8); + bytes.writeUInt16LE(slots, offset + 16); + bytes.writeUInt16LE(interfaces, offset + 18); +}; + +describe("NativeAOT candidate work accounting", () => { + it("rejects an unmapped declared body without spending its unexamined item count", () => { + const bytes = nativeAotPeFixture(); + // Gap between authored System.Object and its existing derived table. + candidate(bytes, 0x850, 0xffff, 0xffff); + const before = Buffer.from(bytes); + const summary = parseNativeAotPe( + bytes, + nativeAotPeDigest(bytes), + NATIVE_AOT_MAX_REPORT_BYTES, + undefined, + { + sourceBytes: bytes.length, + workUnits: 64 * 1024, + workingMemoryBytes: 64 * 1024, + }, + ).summary; + expect(summary).toMatchObject({ + status: "complete", + method_tables: 3, + coverage: { + method_table_validation_items_examined: 4, + scan_bytes_omitted: 0, + }, + }); + expect(summary.coverage.work_units_examined).toBeLessThan(64 * 1024); + expect(bytes).toEqual(before); + }); + + it.each(["slots", "interfaces"] as const)( + "charges the first invalid %s pointer but no unvisited declared tail", + (kind) => { + const bytes = extendedFixture(0x9000); + candidate( + bytes, + 0x850, + kind === "slots" ? 4096 : 0, + kind === "interfaces" ? 4096 : 0, + ); + bytes.writeBigUInt64LE(0xdeadbeefn, 0x850 + 24); + const before = Buffer.from(bytes); + const summary = parseNativeAotPe( + bytes, + nativeAotPeDigest(bytes), + NATIVE_AOT_MAX_REPORT_BYTES, + ).summary; + expect(summary).toMatchObject({ + status: "complete", + method_tables: 3, + coverage: { + method_table_validation_items_examined: 5, + scan_bytes_omitted: 0, + }, + }); + expect(bytes).toEqual(before); + }, + ); + + it("still validates and charges every entry of a valid mapped table", () => { + const bytes = extendedFixture(0xc18); + candidate(bytes, 0xa00, 64, 0, 0x140002200n); + const before = Buffer.from(bytes); + const summary = parseNativeAotPe( + bytes, + nativeAotPeDigest(bytes), + NATIVE_AOT_MAX_REPORT_BYTES, + ).summary; + expect(summary).toMatchObject({ + status: "complete", + method_tables: 4, + coverage: { + method_table_validation_items_examined: 68, + scan_bytes_omitted: 0, + }, + }); + expect(summary.types.map(({ address }) => address)).toEqual([ + "0x140002200", + "0x140002280", + "0x1400022c0", + "0x140002400", + ]); + expect(bytes).toEqual(before); + }); +}); diff --git a/src/domain/native/nativeAotPe.ts b/src/domain/native/nativeAotPe.ts index 6b9984faca..6be281e3bb 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-v6"; + "rtr-9.1-x64-pe-read-only-v7"; export type NativeAotParserLimits = { readonly sourceBytes: number; @@ -832,31 +832,40 @@ const findDirectory = ( }; }; +/** One discovery owner counts only entries the validator actually examines. */ const parseMethodTable = ( image: PeImage, address: bigint, - remainingValidationItems: number, + budget: NativeAotParserBudget, + validation: { itemsExamined: number }, ): MethodTable | null => { - const header = image.read(address, 16); + const header = image.read(address, 24); if (header === null) return null; const flags = header.readUInt32LE(0), baseSize = header.readUInt32LE(4), related = header.readBigUInt64LE(8); - const counts = image.read(address + 16n, 4); - if (counts === null) return null; - const slotsCount = counts.readUInt16LE(0), - interfacesCount = counts.readUInt16LE(2); - if (baseSize > 0x10000000 || (baseSize !== 0 && baseSize < 16)) return null; - const itemCount = slotsCount + interfacesCount; - if (itemCount > remainingValidationItems) return null; - const tableBytes = 24 + itemCount * 8; + const elementType = (flags & 0x7c000000) >>> 26; + if ( + (elementType !== 0x14 && elementType !== 0x15) || + baseSize < 16 || + baseSize > 0x10000000 || + (related !== 0n && image.read(related, 24) === null) + ) + return null; + const slotsCount = header.readUInt16LE(16), + interfacesCount = header.readUInt16LE(18); + const tableBytes = 24 + (slotsCount + interfacesCount) * 8; const table = image.read(address, tableBytes); if (table === null) return null; for (let slot = 0; slot < slotsCount; slot++) { + if (!budget.consumeWork(8)) return null; + validation.itemsExamined++; const target = table.readBigUInt64LE(24 + slot * 8); if (target !== 0n && !image.isExecutable(target)) return null; } for (let index = 0; index < interfacesCount; index++) { + if (!budget.consumeWork(8)) return null; + validation.itemsExamined++; const iface = table.readBigUInt64LE(24 + (slotsCount + index) * 8); if (iface !== 0n && image.read(iface, 20) === null) return null; } @@ -1097,7 +1106,6 @@ const discoverTables = ( scanBytesExamined: number; scanBytesOmitted: number; methodTableValidationItemsExamined: number; - workBytesExamined: number; truncated: boolean; } => { const ranges: { start: bigint; end: bigint }[] = []; @@ -1147,9 +1155,8 @@ const discoverTables = ( let pointerCount = 0, pointerSlotsExamined = 0, scanBytesExamined = 0, - validationItems = 0, - workBytesExamined = 0, truncated = false; + const validation = { itemsExamined: 0 }; const candidates = new MethodTableIndex(budget); scan: for (const range of merged) { let address = (range.start + 7n) & ~7n; @@ -1159,48 +1166,27 @@ const discoverTables = ( break scan; } const value = readU64(image.read(address, 8)); + if (budget.truncationReason !== undefined) { + truncated = true; + break scan; + } pointerSlotsExamined++; scanBytesExamined += 8; - workBytesExamined += 8; if (value !== null && value !== 0n) pointerCount++; const candidateAddress = address - 8n; - const header = - candidateAddress >= image.imageBase - ? image.read(candidateAddress, 24) - : null; - if (header !== null) { - const flags = header.readUInt32LE(0); - const elementType = (flags & 0x7c000000) >>> 26; - const baseSize = header.readUInt32LE(4); - const related = header.readBigUInt64LE(8); - const plausibleHeader = - (elementType === 0x14 || elementType === 0x15) && - baseSize >= 16 && - baseSize <= 0x10000000 && - (related === 0n || image.read(related, 24) !== null); - if (!plausibleHeader) { - address += 8n; - continue; - } - const counts = image.read(candidateAddress + 16n, 4); - if (counts === null) { - address += 8n; - continue; - } - const itemCount = counts.readUInt16LE(0) + counts.readUInt16LE(2); - if (!budget.consumeWork(itemCount * 8)) { - truncated = true; - break scan; - } - validationItems += itemCount; - workBytesExamined += itemCount * 8; - const candidate = parseMethodTable(image, candidateAddress, itemCount); - if (candidate !== null) { - if (!candidates.append(candidate)) { - truncated = true; - break scan; - } - } + const candidate = parseMethodTable( + image, + candidateAddress, + budget, + validation, + ); + if (budget.truncationReason !== undefined) { + truncated = true; + break scan; + } + if (candidate !== null && !candidates.append(candidate)) { + truncated = true; + break scan; } address += 8n; } @@ -1232,6 +1218,7 @@ const discoverTables = ( objectCount++; } }); + truncated ||= budget.truncationReason !== undefined; if (objectCount !== 1 || objectRoot < 0 || truncated) return { tables: candidates, @@ -1239,8 +1226,7 @@ const discoverTables = ( pointerSlotsExamined, scanBytesExamined, scanBytesOmitted: Math.max(0, totalScanBytes - scanBytesExamined), - methodTableValidationItemsExamined: validationItems, - workBytesExamined, + methodTableValidationItemsExamined: validation.itemsExamined, truncated, }; truncated = !candidates.includeReachable(objectRoot, image); @@ -1250,8 +1236,7 @@ const discoverTables = ( pointerSlotsExamined, scanBytesExamined, scanBytesOmitted: Math.max(0, totalScanBytes - scanBytesExamined), - methodTableValidationItemsExamined: validationItems, - workBytesExamined, + methodTableValidationItemsExamined: validation.itemsExamined, truncated, }; }; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 29bc0dd876..e23c51885d 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -666,7 +666,7 @@ describe("Ghidra platform support", () => { executable_role: target.executableRole, managed: target.managed, native_aot_metadata: { - contract_revision: "rtr-9.1-x64-pe-read-only-v6", + contract_revision: "rtr-9.1-x64-pe-read-only-v7", source_bytes: 128 * 1024 * 1024, work_units: 64 * 1024 * 1024, working_memory_bytes: 64 * 1024 * 1024,