fix(pe): retain TLS templates without complete file backing

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

Fixes #7400
Supersedes #7529

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
Rudy Celekliandgensokyo-dev authored and Ysopwnable committed 2026-11-14 16:31:24 +01:00
1 parent a7398cc9eb
commit 85198fd5cb
5 files changed
+114 -12

No files matched your search

+4 -2
View File
@@ -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));
+29
View File
@@ -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,
+17 -10
View File
@@ -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) {
+37
View File
@@ -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 }));
@@ -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",