diff --git a/src/native/pe/PeLoadConfig.fixture.ts b/src/native/pe/PeLoadConfig.fixture.ts index a0f447c79e..cc34e39e07 100644 --- a/src/native/pe/PeLoadConfig.fixture.ts +++ b/src/native/pe/PeLoadConfig.fixture.ts @@ -70,6 +70,7 @@ export interface PeLoadConfigFixtureOptions { /** In-section callbacks when `callbacks` is present. Defaults to the rich trio. */ readonly callbackCount?: number; readonly zeroFillCallback?: boolean; + readonly rawDataRange?: readonly [number, number]; readonly securityCookie?: "mapped" | "zero"; readonly directoryCount?: number; readonly malformed?: PeLoadConfigFixtureMalformed; @@ -146,11 +147,12 @@ export const peLoadConfigImage = ( const rawStart = options.malformed === "raw-data-inverted" ? layout.rawDataRva + layout.rawDataSize - : layout.rawDataRva; + : (options.rawDataRange?.[0] ?? layout.rawDataRva); const rawEnd = options.malformed === "raw-data-inverted" ? layout.rawDataRva - : layout.rawDataRva + layout.rawDataSize; + : (options.rawDataRange?.[1] ?? + layout.rawDataRva + layout.rawDataSize); writeVa(tlsAt, vaOf(rawStart)); writeVa(tlsAt + pointerWidth, vaOf(rawEnd)); writeVa(tlsAt + pointerWidth * 2, vaOf(layout.indexRva)); diff --git a/src/native/pe/PeLoadConfigParser.test.ts b/src/native/pe/PeLoadConfigParser.test.ts index 692d0ac20a..7d57dbdabe 100644 --- a/src/native/pe/PeLoadConfigParser.test.ts +++ b/src/native/pe/PeLoadConfigParser.test.ts @@ -52,6 +52,35 @@ describe.each([32, 64] as const)("PE%s load configuration", (bits) => { expect(report.load_config?.inferences[0]?.evidence_kind).toBe("inferred"); expect(report.load_config?.se_handler_count).toBe(bits === 32 ? 2 : null); }); + it.each([ + [0x2100, 0x2200, 0x1700], + [0x2180, 0x2280, null], + [0x2200, 0x2300, null], + ] as const)( + "retains a TLS template at [%s, %s) without requiring file backing", + async (start, end, fileOffset) => { + const bytes = peLoadConfigImage({ + bits, + zeroFillCallback: true, + rawDataRange: [start, end], + }); + const base = bits === 64 ? 0x180000000n : 0x10000000n; + const report = await parsePeLoadConfig( + bytes, + { path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length }, + inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }), + ); + expect(report.tls).toMatchObject({ + start_address_of_raw_data: `0x${(base + BigInt(start)).toString(16)}`, + end_address_of_raw_data: `0x${(base + BigInt(end)).toString(16)}`, + raw_data_size: 256, + raw_data_file_offset: fileOffset, + raw_data_section: ".text", + }); + expect(report.tls?.callbacks).toHaveLength(1); + expect(report.load_config?.guard_flags?.raw).toBe(0x500); + }, + ); it("honors Size even when directory bytes contain newer fields", async () => { const report = await parse({ bits, diff --git a/src/native/pe/PeLoadConfigParser.ts b/src/native/pe/PeLoadConfigParser.ts index 9ea86dd00b..593abe4698 100644 --- a/src/native/pe/PeLoadConfigParser.ts +++ b/src/native/pe/PeLoadConfigParser.ts @@ -36,6 +36,21 @@ const PE_LOAD_CONFIG_DIRECTORY = 10; /** Callback slots decoded between event-loop yields and cancellation checks. */ const ENTRIES_PER_YIELD = 1024; +/** Derived locations may be unmapped; ambiguous mappings remain malformed. */ +const optionalFileOffset = ( + lookup: { offset(address: bigint, size: number): number }, + address: bigint, + size: number, +): number | null => { + try { + return lookup.offset(address, size); + } catch (cause) { + if (!(cause instanceof BinaryMappingFailure) || cause.reason !== "unmapped") + throw cause; + return null; + } +}; + /** The parser-owned allowance an image exhausted. */ export type PeLoadConfigLimit = "callbacks"; @@ -181,15 +196,7 @@ export const parsePeLoadConfig = async ( const rva = rvaOf(va); let fileOffset: number | null = null; if (rva !== null) { - try { - fileOffset = lookup.offset(va, 1); - } catch (cause) { - if ( - !(cause instanceof BinaryMappingFailure) || - cause.reason !== "unmapped" - ) - throw cause; - } + fileOffset = optionalFileOffset(lookup, va, 1); } return { va: hex(va), @@ -309,7 +316,7 @@ const readTls = async ({ const rva = rvaOf(start); rawSection = rva === null ? null : sectionName(rva); } else { - rawFile = lookup.offset(start, rawSize); + rawFile = optionalFileOffset(lookup, start, rawSize); const startRva = rvaOf(start); const last = startRva === null ? null : startRva + rawSize - 1; if (startRva !== null && last !== null && last <= 0xffffffff) { diff --git a/tests/acceptance/peLoadConfigCli.test.ts b/tests/acceptance/peLoadConfigCli.test.ts index c520828ff1..7f90f16bc6 100644 --- a/tests/acceptance/peLoadConfigCli.test.ts +++ b/tests/acceptance/peLoadConfigCli.test.ts @@ -58,6 +58,43 @@ for (const bits of [32, 64] as const) { }, ); + cliTest( + `retains PE${bits} TLS template metadata in virtual zero padding`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pe-config-padding-cli-"); + for (const range of [ + [0x2180, 0x2280], + [0x2200, 0x2300], + ] as const) { + await writeFile( + join(root, "app.exe"), + peLoadConfigImage({ + bits, + zeroFillCallback: true, + rawDataRange: range, + }), + ); + const result = await cli.run({ + arguments: ["inspect-pe-load-config", "app.exe", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract( + "inspect_pe_load_config", + ).outputSchema.parse(result.json); + expect(evidence.normalized_result.tls).toMatchObject({ + raw_data_size: 256, + raw_data_file_offset: null, + raw_data_section: ".text", + }); + expect(evidence.normalized_result.tls?.callbacks).toHaveLength(1); + expect(evidence.normalized_result.load_config?.guard_flags?.raw).toBe( + 0x500, + ); + expect(evidence.subject?.local_path).toBe(join(root, "app.exe")); + } + }, + ); cliTest(`reads PE${bits} TLS callbacks through the CLI`, async ({ cli }) => { const root = await createTestTempDirectory("rea-pe-config-cli-"); await writeFile(join(root, "app.exe"), peLoadConfigImage({ bits })); diff --git a/tests/boundary/mcp/peLoadConfigMcp.test.ts b/tests/boundary/mcp/peLoadConfigMcp.test.ts index 667cf90a1f..c6508bc430 100644 --- a/tests/boundary/mcp/peLoadConfigMcp.test.ts +++ b/tests/boundary/mcp/peLoadConfigMcp.test.ts @@ -58,6 +58,33 @@ mcpTest( PE_LOAD_CONFIG_FIXTURE.dynamicRelocSection, }); expect(Buffer.compare(await readFile(path), bytes)).toBe(0); + for (const range of [ + [0x2180, 0x2280], + [0x2200, 0x2300], + ] as const) { + const padded = peLoadConfigImage({ + zeroFillCallback: true, + rawDataRange: range, + }); + await writeFile(path, padded); + const reply = await client.callTool( + { name: "inspect_pe_load_config", arguments: { path } }, + { toolDefinition: definition }, + ); + expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true); + const retained = toolContract( + "inspect_pe_load_config", + ).outputSchema.parse(reply.structuredContent); + expect(retained.normalized_result.tls).toMatchObject({ + raw_data_size: 256, + raw_data_file_offset: null, + raw_data_section: ".text", + }); + expect(retained.normalized_result.load_config?.guard_flags?.raw).toBe( + 0x500, + ); + expect(Buffer.compare(await readFile(path), padded)).toBe(0); + } const bad = await client.callTool( { name: "inspect_pe_load_config",