feat(pe): inspect TLS callbacks and load configuration (#7008)

* feat(pe): inspect TLS callbacks and load configuration

Adds inspect-pe-load-config and inspect_pe_load_config for PE32 and
PE32+ images, with artifact hashes and file ranges in the returned
evidence. Reports callback addresses and mappings, plus size-covered
security-cookie, SafeSEH, CFG, EH-continuation and XFG fields;
flag-derived mitigation claims are explicitly labeled inferred. Missing
directories remain null, and malformed storage or exhausted read budgets
return errors without partial success. Adds documentation and tests for
service validation, CLI/MCP access, malformed inputs and stable-file
handling.

x86 images that declare the legacy 64-byte load-config directory are
read to the structure's own Size when those bytes are file-backed, as
the Windows loader does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016y53PdpyMAp9RSW65fatHe

* fix(pe): retain snapshot facts and bound load-config section lookup

Keep the completed stable-read path, SHA-256 and byte count as a
file-snapshot-hash partial observation when descriptor close fails or is
cancelled. Report the checked Size span as structure_location and add it
to Evidence locations beside the declared directory range. Resolve
callback sections through a sorted segment index built once per image,
keeping the ambiguity failure. Pin every load-config field to offsets
transcribed from the PE/COFF spec and llvm COFF.h at its exact Size
boundary for PE32 and PE32+.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016y53PdpyMAp9RSW65fatHe

---------

Co-authored-by: Matt Van Horn <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-24 22:06:41 +08:00
1 parent 02e049744f
commit 3d84215bb2
33 files changed
+2717 -2

No files matched your search

@@ -262,6 +262,14 @@ explicit path or endpoint and do not need it.
proof the image never imported APIs through another table. Bound snapshots
and loaded IAT contents remain unobserved. See the
[PE import guide](https://github.com/morluto/rea/blob/main/docs/pe-imports.md).
- PE TLS callbacks and load configuration: `inspect_pe_load_config` with the
explicit local PE32/PE32+ file. This built-in target-free inspection requires
no Windows SDK. Preserve callback VAs as strings with their RVA, file offset
and section, and the size-covered security cookie, SafeSEH, CFG,
EH-continuation and XFG fields. Guard-flag mitigation claims are inferred
linker declarations, not proof of enforcement. A missing directory is observed
absence of that declaration. See the
[PE load configuration guide](https://github.com/morluto/rea/blob/main/docs/pe-load-config.md).
- ELF note tables: `inspect_elf_notes` with the explicit local ELF32/ELF64
file. This built-in target-free inspection requires no disassembler. Preserve
PT_NOTE and SHT_NOTE ranges, owners, types, descriptor SHA-256, and GNU
@@ -125,6 +125,19 @@ table or overlay. Bound snapshots, loaded IAT contents, and export-forward
resolution remain unobserved. See
[PE import and delay-load tables](https://github.com/morluto/rea/blob/main/docs/pe-imports.md).
## PE TLS callbacks and load configuration
Use `inspect_pe_load_config` for an explicit local PE32/PE32+ image when the
question concerns code that runs before the entry point through TLS callbacks,
or the security cookie, SafeSEH, CFG, EH-continuation and XFG declarations in
the load configuration. No `open_binary`, Windows SDK, or target execution is
required. The CLI equivalent is `inspect-pe-load-config PATH`. Preserve artifact
SHA-256, directory and callback-array file ranges, and callback VAs as
hexadecimal strings. Fields outside the declared `Size` are `null`. Guard-flag
mitigations carry `evidence_kind: "inferred"`; enforcement remains unobserved.
See
[PE TLS callbacks and load configuration](https://github.com/morluto/rea/blob/main/docs/pe-load-config.md).
## ELF note tables
Use `inspect_elf_notes` for an explicit local ELF32/ELF64 image when the
+1
View File
@@ -34,6 +34,7 @@ export default defineConfig({
{ text: "Rust dependencies", link: "/rust-binaries" },
{ text: "PE certificates", link: "/pe-certificates" },
{ text: "PE imports", link: "/pe-imports" },
{ text: "PE TLS/load config", link: "/pe-load-config" },
{ text: "ELF notes", link: "/elf-notes" },
{ text: "Apple IPS crash reports", link: "/apple-ips" },
{ text: "CPython pyc files", link: "/python-pyc" },
+10
View File
@@ -1026,3 +1026,13 @@ Counting retains the existing bounded formatted-string allocation after capacity
admission; omit the flag to stream large JSON documents without materializing
the complete formatted text. Exhausted token windows display an empty range at
the total token count and have no next offset.
### PE TLS and load configuration
```sh
rea inspect-pe-load-config /absolute/path/to/application.exe --json
```
Reports TLS callbacks and size-covered load-config fields without execution.
Use `--max-callbacks` and `--max-file-bytes` to set read budgets. See
[PE TLS and load configuration](pe-load-config.md).
+1 -1
View File
@@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
| Task | Guides |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
+52
View File
@@ -0,0 +1,52 @@
# PE TLS callbacks and load configuration
Use `inspect_pe_load_config` to locate declared TLS callbacks and inspect the load
configuration of an explicit local PE32 or PE32+ image. No disassembler, Windows
host, or target execution is required.
```sh
rea inspect-pe-load-config /absolute/path/to/application.exe --json
rea inspect-pe-load-config /absolute/path/to/application.exe --max-callbacks 256 --json
```
MCP accepts `{ "path": "/absolute/path/to/application.exe" }`. CLI paths may be
relative to the current directory; MCP paths must be absolute.
The complete Evidence includes the artifact path, SHA-256, directory file ranges,
and callback-array file ranges. `tls` reports the raw data range, index address,
zero-fill size, characteristics and null-terminated callback array. Callback VAs
are hexadecimal strings to preserve 64-bit precision. RVA, mapped file offset,
and section are derived locations; unknown mappings are `null`.
`load_config` reports fields only when its declared `Size` covers the entire
field. MSVC x86 images often declare a 64-byte load-config data directory while
the structure's own `Size` is larger; like the Windows loader, REA reads `Size`
bytes when they are file-backed in one run, and reports both
`directory.size` and `size`. `structure_location` is the file range of the
`Size` bytes REA checked; Evidence locations include it beside the declared
directory range when the two differ. Unavailable fields are `null`; a covered zero pointer remains `"0x0"`.
The report includes the security cookie, PE32 SafeSEH table/count, CFG pointers
and table/count, address-taken IAT and long-jump tables/counts, EH continuation
table/count, and XFG check pointer. CHPE and dynamic relocation pointers are raw
values; their targets and mitigation-table entries are not decoded.
Guard flags retain the raw DWORD, recognized names, unknown bits, and the CFG
entry stride. Mitigation claims derived from those flags carry
`evidence_kind: "inferred"`; they do not prove runtime enforcement. The image is
never executed. Missing directories are observed absence of those declarations,
not proof that no callback or mitigation exists by another mechanism.
The default read budget is 64 MiB (`max_file_bytes`, ceiling 512 MiB). The default
callback budget is 64 (`max_callbacks`, ceiling 4096). An exact-budget array is
accepted if its next slot is null. Truncated, ambiguous or unmapped directory and
callback-array storage fails closed, as does budget exhaustion; no partial
success is returned. An unmapped callback **target** retains its VA/RVA with an
unknown file offset. Load-config versions beyond the supported fields remain
undecoded.
Layout references: [Microsoft PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format)
and [IMAGE_LOAD_CONFIG_DIRECTORY64](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_load_config_directory64).
For a real cross-compiled fixture, run `npm run verify:pe:load-config` with Zig
on PATH (or `REA_ZIG` set to its path). The script builds PE32 and PE32+ samples
and inspects them through the public CLI without running them.
@@ -0,0 +1,34 @@
# PE TLS/load-config verification - 2026-10-24
All runs used macOS arm64 and Node 26.5.0. No target was executed.
## Cross-compiled lane
`npm run verify:pe:load-config` with Zig 0.16.0 compiled freestanding C samples
for `x86_64-windows-gnu` and `x86-windows-gnu`, then invoked the public
`inspect-pe-load-config --json` CLI.
| Target | Callback VA | RVA | File offset | Section |
| ------ | ------------- | -------- | ----------- | ------- |
| PE32+ | `0x140001000` | `0x1000` | `0x400` | `.text` |
| PE32 | `0x401000` | `0x1000` | `0x400` | `.text` |
Both images declare one callback and no load-config directory.
## Linker-produced and vendor images (not committed)
- A MinGW-linked PE32+ sample with two `.CRT$XL*` callbacks (`zig cc -target
x86_64-windows-gnu`): four callbacks reported in `.text` at `0x140001000`,
`0x140001020`, `0x140001470` and `0x140001500`, matching the callback array
at `AddressOfCallBacks` `0x14002B8C8` shown by `llvm-readobj
--coff-tls-directory`.
- Sysinternals `strings64.exe` (PE32+, MSVC): load-config fields match
`llvm-readobj --coff-load-config`.
- Sysinternals `strings.exe` (PE32, MSVC): the data directory declares 64 bytes
while the structure `Size` is 188 (`0xBC`). REA reports `size: 188`,
security cookie `0x4560dc` in `.data`, SafeSEH table `0x453b28` with 7
handlers and guard flags `0x100`, matching `llvm-readobj`.
Sized fields beyond these samples, EH-continuation and XFG declarations, and
malformed ranges are covered by synthetic fixtures. Runtime enforcement of any
mitigation is not verified.
+1
View File
@@ -191,6 +191,7 @@
"verify:architecture-guards": "node scripts/verify/architecture-guards.mjs",
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
"verify:pcap": "npm run build:cached && node scripts/verify-pcap.mjs",
"verify:pe:load-config": "npm run build:cached && node scripts/verify-pe-load-config.mjs",
"verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs",
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
+7
View File
@@ -268,6 +268,13 @@ export const providerCatalog = (sources) => {
({ name }) => name === "inspect_pe_imports",
),
},
{
identity: sources.artifactProviders.PE_LOAD_CONFIG_PROVIDER,
contracts:
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
({ name }) => name === "inspect_pe_load_config",
),
},
{
identity: sources.artifactProviders.ELF_NOTES_PROVIDER,
contracts:
+82
View File
@@ -0,0 +1,82 @@
/** Build real PE TLS samples with Zig, then inspect through the public CLI. */
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const zig = process.env.REA_ZIG ?? "zig";
const root = await mkdtemp(join(tmpdir(), "rea-pe-load-config-"));
try {
const source = join(root, "tls.c");
await writeFile(
source,
`
#include <stdint.h>
char tls_start __attribute__((section(".tls$AAA"))) = 0;
__declspec(thread) volatile int tls_value __attribute__((section(".tls$BBB"))) = 7;
char tls_end __attribute__((section(".tls$ZZZ"))) = 0;
unsigned long _tls_index;
static void __stdcall callback(void *module, unsigned long reason, void *reserved) {
(void)module; (void)reason; (void)reserved;
}
typedef void (__stdcall *callback_type)(void *, unsigned long, void *);
callback_type callbacks[] __attribute__((section(".CRT$XLB"), used)) = {callback, 0};
struct tls_directory {
uintptr_t start, end, index, callbacks;
uint32_t zero_fill, characteristics;
};
const struct tls_directory _tls_used __attribute__((used)) = {
(uintptr_t)&tls_start, (uintptr_t)&tls_end,
(uintptr_t)&_tls_index, (uintptr_t)callbacks, 0, 0
};
void entry(void) {}
`,
);
for (const [target, bits] of [
["x86_64-windows-gnu", 64],
["x86-windows-gnu", 32],
]) {
const binary = join(root, `${target}.exe`);
execFileSync(
zig,
[
"cc",
"-target",
target,
"-fms-extensions",
"-nostdlib",
"-Wl,--entry,entry",
source,
"-o",
binary,
],
{
stdio: "inherit",
timeout: 120000,
env: { ...process.env, ZIG_GLOBAL_CACHE_DIR: join(root, "zig-cache") },
},
);
const output = execFileSync(
process.execPath,
[resolve("scripts/rea.mjs"), "inspect-pe-load-config", binary, "--json"],
{ encoding: "utf8", timeout: 30000 },
);
const evidence = JSON.parse(output);
const report = evidence.normalized_result;
assert.equal(report.bits, bits);
assert.ok(report.tls.callbacks.length > 0);
assert.ok(
report.tls.callbacks.every((callback) => callback.file_offset !== null),
);
console.log(
JSON.stringify({
target,
callbacks: report.tls.callbacks,
load_config_present: report.load_config !== null,
}),
);
}
} finally {
await rm(root, { recursive: true, force: true });
}
+1 -1
View File
@@ -532,7 +532,7 @@ const workflowAvailabilityFor = ({
return { reason: "available", remediation: null };
if (name === "inspect_pe_certificates" || name === "inspect_pe_imports")
return { reason: "available", remediation: null };
if (name === "inspect_elf_notes")
if (name === "inspect_elf_notes" || name === "inspect_pe_load_config")
return { reason: "available", remediation: null };
if (name === "inspect_evm_interface") {
const profile = hostRestrictedAvailability(
@@ -0,0 +1,20 @@
import type {
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
InspectPeLoadConfigInput,
PeLoadConfig,
} from "../../domain/native/peLoadConfig.js";
import type { Result } from "../../domain/result.js";
/** Stable read and PE TLS/load-config-table decoding, independent of a disassembler session. */
export interface PeLoadConfigPort {
readonly identity: ProviderIdentity;
close?(): Promise<void>;
inspect(
input: InspectPeLoadConfigInput,
options?: ExecutionOptions,
): Promise<Result<PeLoadConfig, AnalysisError>>;
}
@@ -0,0 +1,122 @@
import { expect, it } from "vitest";
import { PeLoadConfigService } from "./PeLoadConfigService.js";
import { PE_LOAD_CONFIG_PROVIDER } from "../../domain/investigationProviders.js";
import type { PeLoadConfig } from "../../domain/native/peLoadConfig.js";
import { ok } from "../../domain/result.js";
const report: PeLoadConfig = {
artifact: { path: "/artifacts/app.exe", sha256: "a".repeat(64), bytes: 512 },
format: "pe32-plus",
machine: 0x8664,
bits: 64,
architecture: "x86_64",
tls: null,
load_config: null,
examined_callbacks: 0,
limitations: [],
};
const legacyX86: PeLoadConfig = {
...report,
artifact: { ...report.artifact, bytes: 0x400 },
format: "pe32",
machine: 0x14c,
bits: 32,
architecture: "386",
load_config: {
directory: { rva: 0x1040, size: 64, file_offset: 0x240 },
size: 188,
structure_location: { offset: 0x240, bytes: 188 },
security_cookie: null,
se_handler_table: null,
se_handler_count: 7,
guard_cf_check_function_pointer: null,
guard_cf_dispatch_function_pointer: null,
guard_cf_function_table: null,
guard_cf_function_count: null,
guard_flags: null,
guard_address_taken_iat_entry_table: null,
guard_address_taken_iat_entry_count: null,
guard_long_jump_target_table: null,
guard_long_jump_target_count: null,
dynamic_value_reloc_table: null,
chpe_metadata_pointer: null,
guard_eh_continuation_table: null,
guard_eh_continuation_count: null,
guard_xfg_check_function_pointer: null,
inferences: [],
},
};
const service = (value: PeLoadConfig = report) =>
new PeLoadConfigService({
identity: PE_LOAD_CONFIG_PROVIDER,
inspect: () => Promise.resolve(ok(value)),
});
it("retains absent directories and artifact provenance", async () => {
const result = await service().inspect({ path: report.artifact.path });
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
confidence: "observed",
subject: { format: "pe", architecture: "x86_64" },
normalized_result: report,
});
});
it("locates both the declared directory and the checked Size span", async () => {
const result = await service(legacyX86).inspect({
path: report.artifact.path,
});
if (!result.ok) throw result.error;
expect(result.value.locations).toEqual([
{ kind: "artifact-path", path: report.artifact.path },
{ kind: "file-offset-range", start: 0x240, end: 0x240 + 64 },
{ kind: "file-offset-range", start: 0x240, end: 0x240 + 188 },
]);
});
it("rejects input and cancellation before reading", async () => {
const boundary = new PeLoadConfigService({
identity: PE_LOAD_CONFIG_PROVIDER,
inspect: () => {
throw new Error("Unexpected read");
},
});
for (const input of [
{ path: "relative.exe" },
{ path: report.artifact.path, extra: true },
{ path: report.artifact.path, max_callbacks: 4097 },
]) {
const result = await boundary.inspect(input);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("AnalysisInputError");
}
const result = await boundary.inspect(
{ path: report.artifact.path },
{ signal: AbortSignal.abort() },
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
});
it.each([
{ ...report, examined_callbacks: 1 },
{ ...report, bits: 32 as const },
{ ...report, artifact: { ...report.artifact, path: "/another.exe" } },
{ ...report, artifact: { ...report.artifact, bytes: 67108865 } },
...[
{ offset: 0x241, bytes: 188 },
{ offset: 0x240, bytes: 187 },
].map((structure_location) => ({
...legacyX86,
load_config: { ...legacyX86.load_config!, structure_location },
})),
{
...legacyX86,
load_config: {
...legacyX86.load_config!,
size: 0x400,
structure_location: { offset: 0x240, bytes: 0x400 },
},
},
])("rejects inconsistent provider output", async (value) => {
const result = await service(value).inspect({ path: report.artifact.path });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
});
@@ -0,0 +1,185 @@
import { isAbsolute } from "node:path";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { PeLoadConfigPort } from "./PeLoadConfigPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
inspectPeLoadConfigInputSchema,
peLoadConfigSchema,
type PeLoadConfig,
} from "../../domain/native/peLoadConfig.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import {
decodePeMachine,
peOptionalHeaderMagic,
type PeArchitecture,
} from "../../domain/peMachine.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
const OPERATION = "inspect_pe_load_config";
/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */
export class PeLoadConfigService {
constructor(readonly provider: PeLoadConfigPort) {}
close(): Promise<void> {
return this.provider.close?.() ?? Promise.resolve();
}
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
);
const input = inspectPeLoadConfigInputSchema.safeParse(rawInput);
if (!input.success)
return err(
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
cause: input.error,
}),
);
if (!isAbsolute(input.data.path))
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["path"],
reason: "invalid_format",
message: "Expected an absolute filesystem path on this host.",
},
]),
);
const result = await this.provider.inspect(input.data, options);
if (!result.ok) return result;
const report = peLoadConfigSchema.safeParse(result.value);
if (
!report.success ||
report.data.artifact.path !== input.data.path ||
!validReport(report.data, input.data)
)
return err(
new AnalysisOutputError(
OPERATION,
"PE TLS/load-config provider returned malformed output or changed artifact identity.",
),
);
const value = report.data;
const architecture = subjectArchitecture(value);
const locations = [
{ kind: "artifact-path" as const, path: value.artifact.path },
...fileRange(value.tls?.directory ?? null),
...fileRange(value.load_config?.directory ?? null),
...structureRange(value.load_config),
...(value.tls?.callback_array_locations ?? []).map(
({ offset, bytes }) => ({
kind: "file-offset-range" as const,
start: offset,
end: offset + bytes,
}),
),
];
return completeProviderEvidence(
createEvidence(
{
path: value.artifact.path,
format: "pe",
sha256: value.artifact.sha256,
...(architecture === undefined ? {} : { architecture }),
},
this.provider.identity,
{
operation: OPERATION,
parameters: input.data,
result: value,
confidence: "observed",
limitations: value.limitations,
locations,
},
),
options?.signal,
);
}
}
const subjectArchitecture = (
report: PeLoadConfig,
): PeArchitecture | undefined => {
const machine = decodePeMachine(report.machine);
if (machine === undefined || machine.nativeOsOverride) return undefined;
const magic = report.format === "pe32-plus" ? 0x20b : 0x10b;
return magic === peOptionalHeaderMagic(machine.architecture)
? machine.architecture
: undefined;
};
const fileRange = (
directory: { file_offset: number; size: number } | null,
): { kind: "file-offset-range"; start: number; end: number }[] =>
directory === null
? []
: [
{
kind: "file-offset-range",
start: directory.file_offset,
end: directory.file_offset + directory.size,
},
];
/** The checked `Size` span, when it differs from the declared directory range. */
const structureRange = (
loadConfig: PeLoadConfig["load_config"],
): { kind: "file-offset-range"; start: number; end: number }[] =>
loadConfig === null ||
loadConfig.structure_location.bytes === loadConfig.directory.size
? []
: [
{
kind: "file-offset-range",
start: loadConfig.structure_location.offset,
end:
loadConfig.structure_location.offset +
loadConfig.structure_location.bytes,
},
];
const validReport = (
report: PeLoadConfig,
input: { max_callbacks: number; max_file_bytes: number },
): boolean => {
if (
report.artifact.bytes > input.max_file_bytes ||
report.examined_callbacks !== (report.tls?.callbacks.length ?? 0) ||
report.examined_callbacks > input.max_callbacks ||
report.bits !== (report.format === "pe32" ? 32 : 64)
)
return false;
const ranges = [
...fileRange(report.tls?.directory ?? null),
...fileRange(report.load_config?.directory ?? null),
];
const structure = report.load_config?.structure_location;
return (
(structure === undefined ||
(structure.offset === report.load_config?.directory.file_offset &&
structure.bytes === report.load_config.size &&
structure.offset <= report.artifact.bytes - structure.bytes)) &&
ranges.every(
({ start, end }) => start <= end && end <= report.artifact.bytes,
) &&
(report.tls?.callback_array_locations ?? []).every(
({ offset, bytes }) => offset <= report.artifact.bytes - bytes,
) &&
(report.tls?.callbacks ?? []).every(
({ file_offset }) =>
file_offset === null || file_offset < report.artifact.bytes,
)
);
};
+37
View File
@@ -1,3 +1,4 @@
import { inspectPeLoadConfigInputSchema } from "../domain/native/peLoadConfig.js";
import { inspectByteEntropyInputSchema } from "../domain/native/byteEntropy.js";
import { z } from "incur";
import {
@@ -6,6 +7,7 @@ import {
createBinaryLayoutService,
createElfNotesService,
createAppleIpsService,
createPeLoadConfigService,
createPeResourcesService,
createPeCertificatesService,
createPeImportsService,
@@ -118,6 +120,41 @@ export const registerBinaryDiagnosticsCommands = (
);
}),
});
cli.command(CLI_COMMANDS.inspectPeLoadConfig, {
description:
"Inspect PE TLS callbacks and load configuration without executing the image",
args: z.object({
path: localPathStringSchema.describe("Local PE32/PE32+ image"),
}),
options: z.object({
maxFileBytes: inspectPeLoadConfigInputSchema.shape.max_file_bytes,
maxCallbacks: inspectPeLoadConfigInputSchema.shape.max_callbacks,
}),
run: ({ args, options }) =>
withCommandCancellation(async (signal) => {
const elf = createPeLoadConfigService();
return await logCliCommand(
logger,
CLI_COMMANDS.inspectPeLoadConfig,
async () =>
finishAnalysisCommand(
() =>
elf.inspect(
{
path: absoluteCallerPath(args.path),
max_file_bytes: options.maxFileBytes,
max_callbacks: options.maxCallbacks,
},
{ signal },
),
elf,
"rea-pe-load-config",
"inspect_pe_load_config",
signal,
),
);
}),
});
const entropy = createByteEntropyService();
cli.command(CLI_COMMANDS.inspectByteEntropy, {
description:
+1
View File
@@ -75,6 +75,7 @@ export const CLI_COMMANDS = Object.freeze({
inspectNativeExports: "inspect-native-exports",
inspectElfNotes: "inspect-elf-notes",
inspectAppleIps: "inspect-apple-ips",
inspectPeLoadConfig: "inspect-pe-load-config",
inspectAnalysisView: "inspect-analysis-view",
listFridaDevices: "list-frida-devices",
listFridaProcesses: "list-frida-processes",
+5
View File
@@ -1,3 +1,5 @@
import { PeLoadConfigService } from "../application/binaryDiagnostics/PeLoadConfigService.js";
import { PeLoadConfigProvider } from "../native/pe/PeLoadConfigProvider.js";
import { ByteEntropyService } from "../application/binaryDiagnostics/ByteEntropyService.js";
import { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js";
import { PwntoolsRecordedCrashProvider } from "../native/pwntools/PwntoolsRecordedCrashProvider.js";
@@ -59,3 +61,6 @@ export const createRecordedCrashService = (
export const createByteEntropyService = (
resources?: ArtifactResourceScope,
): ByteEntropyService => new ByteEntropyService(undefined, resources);
export const createPeLoadConfigService = (): PeLoadConfigService =>
new PeLoadConfigService(new PeLoadConfigProvider());
@@ -1,3 +1,7 @@
import {
inspectPeLoadConfigInputSchema,
peLoadConfigSchema,
} from "../../domain/native/peLoadConfig.js";
import { inspectByteEntropyInputSchema } from "../../domain/native/byteEntropy.js";
import { publicInputStrictObject } from "../../domain/publicInput.js";
import { byteEntropyOutputSchema } from "../toolOutputSchemaGroups.js";
@@ -62,6 +66,22 @@ export const inspectByteEntropyRequestSchema = publicInputStrictObject(
/** Offline binary diagnostics are independent of macOS process/UI providers. */
export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
{
name: "inspect_pe_load_config",
...toolContractMetadata("inspect_pe_load_config"),
kind: "native-provider",
description:
"Inspect PE32/PE32+ TLS callbacks and sized load configuration without executing the image. Returns callback VA/RVA/file offsets and sections, security cookie, SafeSEH (PE32), CFG flags and table pointers/counts, EH continuation and XFG fields. Flag-derived mitigation claims are labeled inferred. Absent directories are null; malformed structures and exhausted budgets fail without partial success. Reads a stable local regular file without a disassembler.",
inputSchema: inspectPeLoadConfigInputSchema,
outputSchema: evidenceResultOf(peLoadConfigSchema),
examples: [
{
title: "Inspect PE TLS callbacks and load configuration",
input: { path: "/artifacts/program" },
},
],
},
{
name: "inspect_elf_notes",
...toolContractMetadata("inspect_elf_notes"),
+1
View File
@@ -30,6 +30,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
inspect_native_exports: "reads the export table without executing it",
inspect_elf_notes: "states that it does not execute the image",
inspect_apple_ips: "states that it does not execute a target",
inspect_pe_load_config: "states that it does not execute the image",
inspect_desktop_application:
"states that directory triage performs no target execution",
inspect_pe_certificates: "states that it does not execute the image",
+1
View File
@@ -262,6 +262,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
inspect_native_exports: evidence,
inspect_elf_notes: evidence,
inspect_apple_ips: evidence,
inspect_pe_load_config: evidence,
inspect_analysis_view: evidence,
inspect_firmware_regions: effects({
mutatesSession: true,
+6
View File
@@ -165,3 +165,9 @@ export const RECONSTRUCTION_PROVIDER = {
name: "REA reconstruction verifier",
version: "1",
} as const;
export const PE_LOAD_CONFIG_PROVIDER = Object.freeze({
id: "rea-pe-load-config",
name: "REA PE TLS/load-config parser",
version: "1",
} as const);
+393
View File
@@ -0,0 +1,393 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { absoluteLocalPathStringSchema } from "../localPath.js";
const uint = z.number().int().min(0).max(0xffffffff);
const safeCount = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
const hexVa = z.string().regex(/^0x[0-9a-f]+$/u);
const range = z.strictObject({ offset: uint, bytes: uint });
/** Hard ceiling on the caller-selected input read budget. */
export const MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES = 536870912;
/** Hard ceiling on non-null TLS callbacks examined in one image. */
export const MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS = 4096;
/** IMAGE_TLS_DIRECTORY byte width for each optional-header magic. */
export const TLS_STRUCT_BYTES = { 32: 24, 64: 40 } as const;
/**
* IMAGE_LOAD_CONFIG_DIRECTORY size through GuardMemcpyFunctionPointer.
* Later fields stay unread; a larger Size does not fail the inspection.
*/
export const LOAD_CONFIG_KNOWN_STRUCT_BYTES = { 32: 192, 64: 320 } as const;
/** Complete bounded inspection of one PE TLS directory and load configuration. */
export const inspectPeLoadConfigInputSchema = z.strictObject({
path: absoluteLocalPathStringSchema.describe(
"Absolute filesystem path to a PE image",
),
max_file_bytes: z
.number()
.int()
.min(64)
.max(MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES)
.default(67108864)
.describe("Maximum stable artifact bytes to read (default 64 MiB)"),
max_callbacks: z
.number()
.int()
.min(1)
.max(MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS)
.default(64)
.describe(
"Maximum non-null TLS callbacks to examine; exceeding the budget fails without partial success",
),
});
export type InspectPeLoadConfigInput = z.infer<
typeof inspectPeLoadConfigInputSchema
>;
/** Virtual address plus the file location this inspection could establish. */
const locatedSchema = z.strictObject({
va: hexVa,
rva: uint.nullable(),
file_offset: uint.nullable(),
section: z.string().min(1).nullable(),
});
export type PeLoadConfigLocated = z.infer<typeof locatedSchema>;
const directorySchema = z.strictObject({
rva: uint,
size: uint,
file_offset: uint,
});
/**
* Guard flag names are observations of set IMAGE_GUARD_* bits.
* The high nibble is the CFG function-table stride, not one of these names.
*/
export const PE_GUARD_FLAG_NAMES = [
"IMAGE_GUARD_CF_INSTRUMENTED",
"IMAGE_GUARD_CFW_INSTRUMENTED",
"IMAGE_GUARD_CF_FUNCTION_TABLE_PRESENT",
"IMAGE_GUARD_SECURITY_COOKIE_UNUSED",
"IMAGE_GUARD_PROTECT_DELAYLOAD_IAT",
"IMAGE_GUARD_DELAYLOAD_IAT_IN_ITS_OWN_SECTION",
"IMAGE_GUARD_CF_EXPORT_SUPPRESSION_INFO_PRESENT",
"IMAGE_GUARD_CF_ENABLE_EXPORT_SUPPRESSION",
"IMAGE_GUARD_CF_LONGJUMP_TABLE_PRESENT",
"IMAGE_GUARD_RF_INSTRUMENTED",
"IMAGE_GUARD_RF_ENABLE",
"IMAGE_GUARD_RF_STRICT",
"IMAGE_GUARD_RETPOLINE_PRESENT",
"IMAGE_GUARD_EH_CONTINUATION_TABLE_PRESENT",
"IMAGE_GUARD_XFG_ENABLED",
"IMAGE_GUARD_CASTGUARD_PRESENT",
"IMAGE_GUARD_MEMCPY_PRESENT",
] as const;
const PE_GUARD_FLAG_BITS: Record<(typeof PE_GUARD_FLAG_NAMES)[number], number> =
{
IMAGE_GUARD_CF_INSTRUMENTED: 0x100,
IMAGE_GUARD_CFW_INSTRUMENTED: 0x200,
IMAGE_GUARD_CF_FUNCTION_TABLE_PRESENT: 0x400,
IMAGE_GUARD_SECURITY_COOKIE_UNUSED: 0x800,
IMAGE_GUARD_PROTECT_DELAYLOAD_IAT: 0x1000,
IMAGE_GUARD_DELAYLOAD_IAT_IN_ITS_OWN_SECTION: 0x2000,
IMAGE_GUARD_CF_EXPORT_SUPPRESSION_INFO_PRESENT: 0x4000,
IMAGE_GUARD_CF_ENABLE_EXPORT_SUPPRESSION: 0x8000,
IMAGE_GUARD_CF_LONGJUMP_TABLE_PRESENT: 0x10000,
IMAGE_GUARD_RF_INSTRUMENTED: 0x20000,
IMAGE_GUARD_RF_ENABLE: 0x40000,
IMAGE_GUARD_RF_STRICT: 0x80000,
IMAGE_GUARD_RETPOLINE_PRESENT: 0x100000,
IMAGE_GUARD_EH_CONTINUATION_TABLE_PRESENT: 0x400000,
IMAGE_GUARD_XFG_ENABLED: 0x800000,
IMAGE_GUARD_CASTGUARD_PRESENT: 0x1000000,
IMAGE_GUARD_MEMCPY_PRESENT: 0x2000000,
};
const PE_GUARD_FLAG_KNOWN_MASK = PE_GUARD_FLAG_NAMES.reduce(
(mask, name) => mask | PE_GUARD_FLAG_BITS[name],
0,
);
const GUARD_STRIDE_MASK = 0xf0000000;
/** Linker claims derived from GuardFlags. They are not observations of code. */
export const PE_LOAD_CONFIG_INFERENCE_IDS = [
"cfg_instrumented",
"cf_write_instrumented",
"rf_instrumented",
"retpoline_present",
"xfg_enabled",
"castguard_present",
] as const;
const PE_LOAD_CONFIG_INFERENCE_BITS: Record<
(typeof PE_LOAD_CONFIG_INFERENCE_IDS)[number],
number
> = {
cfg_instrumented: 0x100,
cf_write_instrumented: 0x200,
rf_instrumented: 0x20000,
retpoline_present: 0x100000,
xfg_enabled: 0x800000,
castguard_present: 0x1000000,
};
const inferenceSchema = z.strictObject({
id: z.enum(PE_LOAD_CONFIG_INFERENCE_IDS),
evidence_kind: z.literal("inferred"),
basis: z.string().min(1),
});
export type PeLoadConfigInference = z.infer<typeof inferenceSchema>;
/** Decode one GuardFlags DWORD into observed bits and labeled inferences. */
export const guardFlagRecord = (
raw: number,
): {
readonly flags: (typeof PE_GUARD_FLAG_NAMES)[number][];
readonly unrecognized: number;
readonly cf_function_table_stride: number;
readonly inferences: PeLoadConfigInference[];
} => ({
flags: PE_GUARD_FLAG_NAMES.filter(
(name) => (raw & PE_GUARD_FLAG_BITS[name]) !== 0,
),
unrecognized:
(raw >>> 0) & ~((PE_GUARD_FLAG_KNOWN_MASK | GUARD_STRIDE_MASK) >>> 0),
cf_function_table_stride: 4 + (raw >>> 28),
inferences: PE_LOAD_CONFIG_INFERENCE_IDS.filter(
(id) => (raw & PE_LOAD_CONFIG_INFERENCE_BITS[id]) !== 0,
).map((id) => ({
id,
evidence_kind: "inferred" as const,
basis: `GuardFlags has ${id} set. That bit is a linker claim; this inspection did not verify the corresponding instructions.`,
})),
});
const guardFlagsSchema = z.strictObject({
raw: uint,
flags: z.array(z.enum(PE_GUARD_FLAG_NAMES)),
unrecognized: uint,
cf_function_table_stride: z.number().int().min(4).max(19),
});
/** Located load-config slots. SafeSEH slots are reported for PE32 only. */
export const PE_LOAD_CONFIG_LOCATED_FIELDS = [
"security_cookie",
"se_handler_table",
"guard_cf_check_function_pointer",
"guard_cf_dispatch_function_pointer",
"guard_cf_function_table",
"guard_address_taken_iat_entry_table",
"guard_long_jump_target_table",
"guard_eh_continuation_table",
"guard_xfg_check_function_pointer",
] as const;
export type PeLoadConfigLocatedField =
(typeof PE_LOAD_CONFIG_LOCATED_FIELDS)[number];
/** Counts stored beside those tables. Presence follows Size, not the count. */
export const PE_LOAD_CONFIG_COUNT_FIELDS = [
"se_handler_count",
"guard_cf_function_count",
"guard_address_taken_iat_entry_count",
"guard_long_jump_target_count",
"guard_eh_continuation_count",
] as const;
export type PeLoadConfigCountField =
(typeof PE_LOAD_CONFIG_COUNT_FIELDS)[number];
/** Pointers reported as raw virtual addresses. Their targets stay undecoded. */
export const PE_LOAD_CONFIG_RAW_FIELDS = [
"dynamic_value_reloc_table",
"chpe_metadata_pointer",
] as const;
export type PeLoadConfigRawField = (typeof PE_LOAD_CONFIG_RAW_FIELDS)[number];
export type PeLoadConfigField =
| PeLoadConfigLocatedField
| PeLoadConfigCountField
| PeLoadConfigRawField
| "guard_flags";
type FieldLayout = { readonly offset: number; readonly width: 4 | 8 };
/**
* Offsets of IMAGE_LOAD_CONFIG_DIRECTORY32. A field is present when Size
* reaches offset + width. Code integrity occupies 12 bytes at offset 92 and
* is not itself a reported field; it shifts every later slot.
*/
const PE32_LOAD_CONFIG_FIELDS: Record<PeLoadConfigField, FieldLayout> = {
security_cookie: { offset: 60, width: 4 },
se_handler_table: { offset: 64, width: 4 },
se_handler_count: { offset: 68, width: 4 },
guard_cf_check_function_pointer: { offset: 72, width: 4 },
guard_cf_dispatch_function_pointer: { offset: 76, width: 4 },
guard_cf_function_table: { offset: 80, width: 4 },
guard_cf_function_count: { offset: 84, width: 4 },
guard_flags: { offset: 88, width: 4 },
guard_address_taken_iat_entry_table: { offset: 104, width: 4 },
guard_address_taken_iat_entry_count: { offset: 108, width: 4 },
guard_long_jump_target_table: { offset: 112, width: 4 },
guard_long_jump_target_count: { offset: 116, width: 4 },
dynamic_value_reloc_table: { offset: 120, width: 4 },
chpe_metadata_pointer: { offset: 124, width: 4 },
guard_eh_continuation_table: { offset: 164, width: 4 },
guard_eh_continuation_count: { offset: 168, width: 4 },
guard_xfg_check_function_pointer: { offset: 172, width: 4 },
};
/**
* Offsets of IMAGE_LOAD_CONFIG_DIRECTORY64. ProcessAffinityMask precedes
* ProcessHeapFlags, unlike PE32. SafeSEH names exist as slots but are not a
* SafeSEH table on PE32+.
*/
const PE64_LOAD_CONFIG_FIELDS: Record<PeLoadConfigField, FieldLayout> = {
security_cookie: { offset: 88, width: 8 },
se_handler_table: { offset: 96, width: 8 },
se_handler_count: { offset: 104, width: 8 },
guard_cf_check_function_pointer: { offset: 112, width: 8 },
guard_cf_dispatch_function_pointer: { offset: 120, width: 8 },
guard_cf_function_table: { offset: 128, width: 8 },
guard_cf_function_count: { offset: 136, width: 8 },
guard_flags: { offset: 144, width: 4 },
guard_address_taken_iat_entry_table: { offset: 160, width: 8 },
guard_address_taken_iat_entry_count: { offset: 168, width: 8 },
guard_long_jump_target_table: { offset: 176, width: 8 },
guard_long_jump_target_count: { offset: 184, width: 8 },
dynamic_value_reloc_table: { offset: 192, width: 8 },
chpe_metadata_pointer: { offset: 200, width: 8 },
guard_eh_continuation_table: { offset: 264, width: 8 },
guard_eh_continuation_count: { offset: 272, width: 8 },
guard_xfg_check_function_pointer: { offset: 280, width: 8 },
};
/** Byte offset and width of one load-config field for this image width. */
export const loadConfigFieldLayout = (
bits: 32 | 64,
name: PeLoadConfigField,
): FieldLayout =>
(bits === 32 ? PE32_LOAD_CONFIG_FIELDS : PE64_LOAD_CONFIG_FIELDS)[name];
/**
* Whether `Size` covers the field. PE32+ never covers SafeSEH slots: those
* names in the 64-bit struct are not a handler table.
*/
export const loadConfigFieldCovered = (
bits: 32 | 64,
size: number,
name: PeLoadConfigField,
): boolean => {
if (
bits === 64 &&
(name === "se_handler_table" || name === "se_handler_count")
)
return false;
const field = loadConfigFieldLayout(bits, name);
return size >= field.offset + field.width;
};
/**
* IMAGE_SCN_ALIGN nibble in TLS Characteristics bits 20–23.
* Index 0 and 15 mean the alignment was not specified.
*/
const TLS_ALIGNMENT_BYTES = [
null,
1,
2,
4,
8,
16,
32,
64,
128,
256,
512,
1024,
2048,
4096,
8192,
null,
] as const;
/** Decode the TLS characteristics alignment nibble. Null means unspecified. */
export const tlsAlignment = (characteristics: number): number | null =>
TLS_ALIGNMENT_BYTES[(characteristics >>> 20) & 0xf] ?? null;
const tlsSchema = z
.strictObject({
directory: directorySchema,
start_address_of_raw_data: hexVa,
end_address_of_raw_data: hexVa,
raw_data_size: uint,
raw_data_file_offset: uint.nullable(),
raw_data_section: z.string().min(1).nullable(),
address_of_index: locatedSchema,
address_of_callbacks: hexVa,
size_of_zero_fill: uint,
characteristics: uint,
alignment: z.number().int().positive().nullable(),
callbacks: z.array(locatedSchema),
callback_array_locations: z.array(range),
})
.nullable();
const loadConfigSchema = z
.strictObject({
directory: directorySchema,
size: uint,
/** File span of the `Size` bytes checked as file-backed; may exceed `directory`. */
structure_location: range,
security_cookie: locatedSchema.nullable(),
se_handler_table: locatedSchema.nullable(),
se_handler_count: safeCount.nullable(),
guard_cf_check_function_pointer: locatedSchema.nullable(),
guard_cf_dispatch_function_pointer: locatedSchema.nullable(),
guard_cf_function_table: locatedSchema.nullable(),
guard_cf_function_count: safeCount.nullable(),
guard_flags: guardFlagsSchema.nullable(),
guard_address_taken_iat_entry_table: locatedSchema.nullable(),
guard_address_taken_iat_entry_count: safeCount.nullable(),
guard_long_jump_target_table: locatedSchema.nullable(),
guard_long_jump_target_count: safeCount.nullable(),
dynamic_value_reloc_table: hexVa.nullable(),
chpe_metadata_pointer: hexVa.nullable(),
guard_eh_continuation_table: locatedSchema.nullable(),
guard_eh_continuation_count: safeCount.nullable(),
guard_xfg_check_function_pointer: locatedSchema.nullable(),
inferences: z.array(inferenceSchema),
})
.nullable();
/** Limits that apply to every successful TLS and load-config report. */
export const PE_LOAD_CONFIG_LIMITATIONS = [
"TLS callbacks and load-config fields reproduce IMAGE_DIRECTORY_ENTRY_TLS and IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG. They do not establish that a callback runs or that a mitigation is enforced at load.",
"Claims such as CFG instrumentation are inferred from GuardFlags bits and are not observations of instrumented instructions.",
"A missing TLS or load-config directory is observed absence of that table, not proof the image never registered callbacks or mitigations through another mechanism.",
"SafeSEH table and count are reported only for PE32. PE32+ struct slots that share those names are not a SafeSEH table.",
"CHPE metadata and dynamic value relocation pointers are raw virtual addresses. Their targets are not decoded.",
"Load-config fields newer than GuardXFGCheckFunctionPointer stay unread. A Size larger than the fields this version knows does not fail the read.",
] as const;
/** Static TLS and load-config inventory of one PE32/PE32+ image. */
export const peLoadConfigSchema = z.strictObject({
artifact: z.strictObject({
path: z.string().min(1),
sha256: digestSchema,
bytes: uint,
}),
format: z.enum(["pe32", "pe32-plus"]),
machine: uint,
bits: z.union([z.literal(32), z.literal(64)]),
architecture: z.string().min(1),
tls: tlsSchema,
load_config: loadConfigSchema,
examined_callbacks: uint,
limitations: z.array(z.string().min(1)),
});
export type PeLoadConfig = z.infer<typeof peLoadConfigSchema>;
export type PeLoadConfigTls = NonNullable<PeLoadConfig["tls"]>;
export type PeLoadConfigDirectory = NonNullable<PeLoadConfig["load_config"]>;
+371
View File
@@ -0,0 +1,371 @@
import {
LOAD_CONFIG_KNOWN_STRUCT_BYTES,
loadConfigFieldLayout,
} from "../../domain/native/peLoadConfig.js";
/** File and RVA layout shared by the synthetic PE32 and PE32+ images. */
export const PE_LOAD_CONFIG_FIXTURE = {
dataRva: 0x1000,
dataFile: 0x600,
dataBytes: 0x1000,
textRva: 0x2000,
textFile: 0x1600,
textBytes: 0x200,
tlsRva: 0x1000,
loadConfigRva: 0x1040,
callbackArrayRva: 0x1200,
indexRva: 0x1300,
cookieRva: 0x1310,
sehTableRva: 0x1320,
cfTableRva: 0x1330,
iatTableRva: 0x1340,
longjmpTableRva: 0x1350,
ehTableRva: 0x1360,
rawDataRva: 0x1400,
rawDataSize: 0x80,
cfCheckRva: 0x2040,
cfDispatchRva: 0x2050,
xfgRva: 0x2060,
outsideRva: 0x9000,
zeroFillRva: 0x2200,
callbackRvas: [0x2000, 0x2010, 0x9000],
chpe: 0x33333333,
dynamicReloc: 0x22222222,
guardFlags: 0x500,
sehCount: 2,
ignoredSeh: 0x44444444,
cfCount: 3,
iatCount: 1,
longjmpCount: 0,
ehCount: 1,
zeroFill: 0x20,
characteristics: 4 << 20,
} as const;
const FILE_BYTES =
PE_LOAD_CONFIG_FIXTURE.textFile + PE_LOAD_CONFIG_FIXTURE.textBytes;
export type PeLoadConfigFixtureMalformed =
| "tls-unmapped"
| "tls-truncated"
| "tls-half"
| "load-config-oversize"
| "load-config-truncated"
| "load-config-half"
| "raw-data-inverted"
| "callback-unterminated"
| "ambiguous-section";
export interface PeLoadConfigFixtureOptions {
readonly bits?: 32 | 64;
readonly tls?: boolean;
readonly loadConfig?: boolean;
/** Declared IMAGE_LOAD_CONFIG_DIRECTORY.Size. Defaults to the known struct. */
readonly loadConfigSize?: number;
/** Data-directory size. Defaults to Size; x86 images may declare less. */
readonly loadConfigDirectorySize?: number;
readonly callbacks?: "present" | "empty" | "absent";
/** In-section callbacks when `callbacks` is present. Defaults to the rich trio. */
readonly callbackCount?: number;
readonly zeroFillCallback?: boolean;
readonly securityCookie?: "mapped" | "zero";
readonly directoryCount?: number;
readonly malformed?: PeLoadConfigFixtureMalformed;
}
const imageBaseFor = (bits: 32 | 64): bigint =>
bits === 64 ? 0x180000000n : 0x10000000n;
/** Synthetic PE32/PE32+ image with a TLS directory and a sized load config. */
export const peLoadConfigImage = (
options: PeLoadConfigFixtureOptions = {},
): Buffer => {
const bits = options.bits ?? 64;
const wide = bits === 64;
const layout = PE_LOAD_CONFIG_FIXTURE;
const bytes = Buffer.alloc(FILE_BYTES);
const base = imageBaseFor(bits);
const pointerWidth = wide ? 8 : 4;
const directoriesAt = wide ? 112 : 96;
const directoryCount = options.directoryCount ?? 16;
const optionalSize = directoriesAt + 16 * 8;
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(128, 60);
bytes.write("PE\0\0", 128, "ascii");
bytes.writeUInt16LE(wide ? 0x8664 : 0x14c, 132);
bytes.writeUInt16LE(2, 134);
bytes.writeUInt16LE(optionalSize, 148);
const optional = 152;
bytes.writeUInt16LE(wide ? 0x20b : 0x10b, optional);
if (wide) bytes.writeBigUInt64LE(base, optional + 24);
else bytes.writeUInt32LE(Number(base), optional + 28);
bytes.writeUInt32LE(0x1000, optional + 32);
bytes.writeUInt32LE(0x200, optional + 36);
bytes.writeUInt32LE(layout.dataFile, optional + 60);
bytes.writeUInt32LE(directoryCount, optional + directoriesAt - 4);
const section = optional + optionalSize;
const textVirtual = options.zeroFillCallback ? 0x400 : layout.textBytes;
writeSection(bytes, section, ".data", {
virtualSize: layout.dataBytes,
virtualAddress: layout.dataRva,
rawSize: layout.dataBytes,
rawOffset: layout.dataFile,
characteristics: 0xc0000040,
});
writeSection(bytes, section + 40, ".text", {
virtualSize: textVirtual,
virtualAddress:
options.malformed === "ambiguous-section"
? layout.dataRva
: layout.textRva,
rawSize: layout.textBytes,
rawOffset: layout.textFile,
characteristics: 0x60000020,
});
const at = (rva: number): number => rva - layout.dataRva + layout.dataFile;
const writeVa = (fileOffset: number, va: bigint): void => {
if (wide) bytes.writeBigUInt64LE(va, fileOffset);
else bytes.writeUInt32LE(Number(va), fileOffset);
};
const vaOf = (rva: number): bigint => base + BigInt(rva);
if (options.tls !== false) {
const tlsSize = options.malformed === "tls-truncated" ? 8 : wide ? 40 : 24;
const tlsRva =
options.malformed === "tls-unmapped" ? 0xffff0000 : layout.tlsRva;
const tlsDirectorySize = tlsSize;
const tlsDirectoryRva = options.malformed === "tls-half" ? 0 : tlsRva;
if (
options.malformed !== "tls-unmapped" &&
options.malformed !== "tls-half"
) {
const tlsAt = at(layout.tlsRva);
const rawStart =
options.malformed === "raw-data-inverted"
? layout.rawDataRva + layout.rawDataSize
: layout.rawDataRva;
const rawEnd =
options.malformed === "raw-data-inverted"
? layout.rawDataRva
: layout.rawDataRva + layout.rawDataSize;
writeVa(tlsAt, vaOf(rawStart));
writeVa(tlsAt + pointerWidth, vaOf(rawEnd));
writeVa(tlsAt + pointerWidth * 2, vaOf(layout.indexRva));
const callbacks = options.callbacks ?? "present";
let callbackVa = 0n;
if (options.malformed === "callback-unterminated") {
const slotRva = layout.textRva + layout.textBytes - pointerWidth;
callbackVa = vaOf(slotRva);
writeVa(
layout.textFile + layout.textBytes - pointerWidth,
vaOf(layout.textRva),
);
} else if (callbacks === "present" || options.zeroFillCallback === true) {
const targets =
options.zeroFillCallback === true
? [layout.zeroFillRva]
: options.callbackCount === undefined
? [...layout.callbackRvas]
: Array.from(
{ length: options.callbackCount },
() => layout.textRva,
);
callbackVa = vaOf(layout.callbackArrayRva);
targets.forEach((rva, index) => {
writeVa(
at(layout.callbackArrayRva) + index * pointerWidth,
vaOf(rva),
);
});
} else if (callbacks === "empty") {
callbackVa = vaOf(layout.callbackArrayRva);
}
writeVa(tlsAt + pointerWidth * 3, callbackVa);
bytes.writeUInt32LE(layout.zeroFill, tlsAt + pointerWidth * 4);
bytes.writeUInt32LE(layout.characteristics, tlsAt + pointerWidth * 4 + 4);
bytes[layout.textFile] = 0xc3;
bytes[layout.textFile + 0x10] = 0xc3;
}
writeDirectory(
bytes,
optional + directoriesAt,
9,
tlsDirectoryRva,
options.malformed === "tls-unmapped" ? 40 : tlsDirectorySize,
);
}
writeLoadConfig(bytes, options, {
bits,
base,
directories: optional + directoriesAt,
});
return bytes;
};
const writeSection = (
bytes: Buffer,
offset: number,
name: string,
section: {
readonly virtualSize: number;
readonly virtualAddress: number;
readonly rawSize: number;
readonly rawOffset: number;
readonly characteristics: number;
},
): void => {
bytes.write(name, offset, "ascii");
bytes.writeUInt32LE(section.virtualSize, offset + 8);
bytes.writeUInt32LE(section.virtualAddress, offset + 12);
bytes.writeUInt32LE(section.rawSize, offset + 16);
bytes.writeUInt32LE(section.rawOffset, offset + 20);
bytes.writeUInt32LE(section.characteristics, offset + 36);
};
const writeDirectory = (
bytes: Buffer,
directories: number,
index: number,
rva: number,
size: number,
): void => {
bytes.writeUInt32LE(rva, directories + index * 8);
bytes.writeUInt32LE(size, directories + index * 8 + 4);
};
const writeLoadConfig = (
bytes: Buffer,
options: PeLoadConfigFixtureOptions,
{
bits,
base,
directories,
}: { bits: 32 | 64; base: bigint; directories: number },
): void => {
const wide = bits === 64;
const layout = PE_LOAD_CONFIG_FIXTURE;
const at = (rva: number): number => rva - layout.dataRva + layout.dataFile;
const vaOf = (rva: number): bigint => base + BigInt(rva);
if (options.loadConfig !== false) {
const known = LOAD_CONFIG_KNOWN_STRUCT_BYTES[bits];
const declaredSize =
options.malformed === "load-config-oversize"
? 0x10000
: (options.loadConfigSize ?? known);
const directorySize =
options.malformed === "load-config-oversize"
? 16
: options.malformed === "load-config-truncated"
? 2
: options.malformed === "load-config-half"
? 0
: (options.loadConfigDirectorySize ?? declaredSize);
const directoryRva = layout.loadConfigRva;
if (directorySize >= 4 && options.malformed !== "load-config-half") {
const configAt = at(layout.loadConfigRva);
bytes.writeUInt32LE(declaredSize, configAt);
const writeField = (
name: Parameters<typeof loadConfigFieldLayout>[1],
value: bigint,
): void => {
const field = loadConfigFieldLayout(bits, name);
const written = Math.max(directorySize, Math.min(declaredSize, known));
if (field.offset + field.width > written) return;
const atField = configAt + field.offset;
if (field.width === 8) bytes.writeBigUInt64LE(value, atField);
else bytes.writeUInt32LE(Number(value), atField);
};
const cookie =
options.securityCookie === "zero" ? 0n : vaOf(layout.cookieRva);
writeField("security_cookie", cookie);
writeField("se_handler_table", vaOf(layout.sehTableRva));
writeField("se_handler_count", BigInt(layout.sehCount));
if (wide) {
writeField("se_handler_table", BigInt(layout.ignoredSeh));
writeField("se_handler_count", 7n);
}
writeField("guard_cf_check_function_pointer", vaOf(layout.cfCheckRva));
writeField(
"guard_cf_dispatch_function_pointer",
vaOf(layout.cfDispatchRva),
);
writeField("guard_cf_function_table", vaOf(layout.cfTableRva));
writeField("guard_cf_function_count", BigInt(layout.cfCount));
writeField("guard_flags", BigInt(layout.guardFlags));
writeField(
"guard_address_taken_iat_entry_table",
vaOf(layout.iatTableRva),
);
writeField(
"guard_address_taken_iat_entry_count",
BigInt(layout.iatCount),
);
writeField("guard_long_jump_target_table", vaOf(layout.longjmpTableRva));
writeField("guard_long_jump_target_count", BigInt(layout.longjmpCount));
writeField("dynamic_value_reloc_table", BigInt(layout.dynamicReloc));
writeField("chpe_metadata_pointer", BigInt(layout.chpe));
writeField("guard_eh_continuation_table", vaOf(layout.ehTableRva));
writeField("guard_eh_continuation_count", BigInt(layout.ehCount));
writeField("guard_xfg_check_function_pointer", vaOf(layout.xfgRva));
}
writeDirectory(bytes, directories, 10, directoryRva, directorySize);
}
};
/**
* PE32+ image with `sectionCount` sections and `callbackCount` TLS callbacks.
* `.data` holds the TLS directory and callback array; every other section is
* virtual-only, and every callback targets the last one.
*/
export const peManySectionsImage = (
sectionCount: number,
callbackCount: number,
): Buffer => {
const optional = 152;
const optionalSize = 112 + 16 * 8;
const table = optional + optionalSize;
const headers = Math.ceil((table + sectionCount * 40) / 0x200) * 0x200;
// Sections start past the header mapping so it cannot overlap `.data`.
const dataRva = Math.ceil(headers / 0x1000) * 0x1000;
const dataBytes =
Math.ceil(((callbackCount + 1) * 8 + 0x100) / 0x200) * 0x200;
const virtualRva = dataRva + Math.ceil(dataBytes / 0x1000) * 0x1000;
const base = 0x180000000n;
const bytes = Buffer.alloc(headers + dataBytes);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(128, 60);
bytes.write("PE\0\0", 128, "ascii");
bytes.writeUInt16LE(0x8664, 132);
bytes.writeUInt16LE(sectionCount, 134);
bytes.writeUInt16LE(optionalSize, 148);
bytes.writeUInt16LE(0x20b, optional);
bytes.writeBigUInt64LE(base, optional + 24);
bytes.writeUInt32LE(0x1000, optional + 32);
bytes.writeUInt32LE(0x200, optional + 36);
bytes.writeUInt32LE(headers, optional + 60);
bytes.writeUInt32LE(16, optional + 108);
writeSection(bytes, table, ".data", {
virtualSize: dataBytes,
virtualAddress: dataRva,
rawSize: dataBytes,
rawOffset: headers,
characteristics: 0xc0000040,
});
for (let index = 1; index < sectionCount; index++)
writeSection(bytes, table + index * 40, ".v", {
virtualSize: 0x1000,
virtualAddress: virtualRva + (index - 1) * 0x1000,
rawSize: 0,
rawOffset: 0,
characteristics: 0x60000020,
});
const target = base + BigInt(virtualRva + (sectionCount - 2) * 0x1000);
const callbacksRva = dataRva + 0x100;
bytes.writeBigUInt64LE(base + BigInt(callbacksRva), headers + 24);
for (let index = 0; index < callbackCount; index++)
bytes.writeBigUInt64LE(target, headers + 0x100 + index * 8);
writeDirectory(bytes, optional + 112, 9, dataRva, 40);
return bytes;
};
+286
View File
@@ -0,0 +1,286 @@
import { describe, expect, it } from "vitest";
import {
inspectPeLoadConfigInputSchema,
peLoadConfigSchema,
type PeLoadConfigField,
} from "../../domain/native/peLoadConfig.js";
import {
parsePeLoadConfig,
PeLoadConfigLimitFailure,
} from "./PeLoadConfigParser.js";
import {
peLoadConfigImage,
peManySectionsImage,
type PeLoadConfigFixtureOptions,
} from "./PeLoadConfig.fixture.js";
const parse = (
options: PeLoadConfigFixtureOptions = {},
max_callbacks = 64,
) => {
const bytes = peLoadConfigImage(options);
return parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({
path: "/sample.exe",
max_callbacks,
}),
);
};
describe.each([32, 64] as const)("PE%s load configuration", (bits) => {
it("locates callbacks and separates flag claims", async () => {
const report = await parse({ bits });
expect(peLoadConfigSchema.safeParse(report).success).toBe(true);
expect(report.tls?.callbacks).toHaveLength(3);
expect(report.tls?.callbacks[0]).toMatchObject({
rva: 0x2000,
file_offset: 0x1600,
section: ".text",
});
expect(report.tls?.callbacks[2]).toMatchObject({
rva: 0x9000,
file_offset: null,
section: null,
});
expect(report.load_config?.guard_flags?.raw).toBe(0x500);
expect(report.load_config?.inferences[0]?.evidence_kind).toBe("inferred");
expect(report.load_config?.se_handler_count).toBe(bits === 32 ? 2 : null);
});
it("honors Size even when directory bytes contain newer fields", async () => {
const report = await parse({
bits,
loadConfigSize: 64,
loadConfigDirectorySize: 320,
});
expect(report.load_config?.guard_flags).toBeNull();
expect(report.load_config?.security_cookie === null).toBe(bits === 64);
});
it.each(["empty", "absent"] as const)(
"accepts %s callbacks",
async (callbacks) => {
expect((await parse({ bits, callbacks })).tls?.callbacks).toEqual([]);
},
);
it("accepts exactly the callback budget and rejects overflow", async () => {
expect(
(await parse({ bits, callbackCount: 1 }, 1)).examined_callbacks,
).toBe(1);
await expect(parse({ bits }, 1)).rejects.toBeInstanceOf(
PeLoadConfigLimitFailure,
);
});
it("reports absent directories", async () => {
expect(await parse({ bits, tls: false, loadConfig: false })).toMatchObject({
tls: null,
load_config: null,
});
});
it.each([
"tls-unmapped",
"tls-truncated",
"tls-half",
"load-config-oversize",
"load-config-truncated",
"load-config-half",
"raw-data-inverted",
"callback-unterminated",
"ambiguous-section",
] as const)("rejects %s", async (malformed) => {
await expect(parse({ bits, malformed })).rejects.toThrow();
});
});
it("reads Size bytes past a legacy 64-byte PE32 directory", async () => {
const report = await parse({ bits: 32, loadConfigDirectorySize: 64 });
expect(report.load_config?.directory.size).toBe(64);
expect(report.load_config?.size).toBeGreaterThan(64);
expect(report.load_config?.structure_location).toEqual({
offset: report.load_config?.directory.file_offset,
bytes: report.load_config?.size,
});
expect(report.load_config?.se_handler_count).toBe(2);
expect(report.load_config?.guard_flags?.raw).toBe(0x500);
});
it("preserves pointer precision and unknown flag bits", async () => {
const bytes = peLoadConfigImage();
// PE64 load config at 0x640; XFG slot at +280, GuardFlags at +144.
bytes.writeBigUInt64LE(0xfedcba9876543210n, 0x640 + 280);
bytes.writeUInt32LE(0xf0200100, 0x640 + 144);
const report = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }),
);
expect(report.load_config?.guard_xfg_check_function_pointer).toEqual({
va: "0xfedcba9876543210",
rva: null,
file_offset: null,
section: null,
});
expect(report.load_config?.guard_flags).toMatchObject({
unrecognized: 0x200000,
cf_function_table_stride: 19,
});
});
it("honors cancellation before parsing", async () => {
const bytes = peLoadConfigImage();
await expect(
parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }),
AbortSignal.abort(),
),
).rejects.toThrow();
});
it("retains a callback in virtual-only section storage with unknown file offset", async () => {
expect(
(await parse({ zeroFillCallback: true })).tls?.callbacks[0],
).toMatchObject({ rva: 0x2200, file_offset: null, section: ".text" });
});
it("resolves callback sections in bounded time for the largest section table", async () => {
const bytes = peManySectionsImage(65535, 4096);
const started = performance.now();
const report = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({
path: "/sample.exe",
max_callbacks: 4096,
}),
);
// A per-callback scan of every section takes several seconds here.
expect(performance.now() - started).toBeLessThan(1_000);
expect(report.tls?.callbacks).toHaveLength(4096);
expect(report.tls?.callbacks.every(({ section }) => section === ".v")).toBe(
true,
);
});
/**
* IMAGE_LOAD_CONFIG_DIRECTORY32/64 member offsets transcribed from the PE/COFF
* specification and llvm/Object/COFF.h (coff_load_configuration32/64), kept
* independent of the parser's layout table.
*/
const SPEC_FIELDS: Record<
32 | 64,
readonly (readonly [PeLoadConfigField, number, 4 | 8])[]
> = {
32: [
["security_cookie", 60, 4],
["se_handler_table", 64, 4],
["se_handler_count", 68, 4],
["guard_cf_check_function_pointer", 72, 4],
["guard_cf_dispatch_function_pointer", 76, 4],
["guard_cf_function_table", 80, 4],
["guard_cf_function_count", 84, 4],
["guard_flags", 88, 4],
["guard_address_taken_iat_entry_table", 104, 4],
["guard_address_taken_iat_entry_count", 108, 4],
["guard_long_jump_target_table", 112, 4],
["guard_long_jump_target_count", 116, 4],
["dynamic_value_reloc_table", 120, 4],
["chpe_metadata_pointer", 124, 4],
["guard_eh_continuation_table", 164, 4],
["guard_eh_continuation_count", 168, 4],
["guard_xfg_check_function_pointer", 172, 4],
],
64: [
["security_cookie", 88, 8],
["se_handler_table", 96, 8],
["se_handler_count", 104, 8],
["guard_cf_check_function_pointer", 112, 8],
["guard_cf_dispatch_function_pointer", 120, 8],
["guard_cf_function_table", 128, 8],
["guard_cf_function_count", 136, 8],
["guard_flags", 144, 4],
["guard_address_taken_iat_entry_table", 160, 8],
["guard_address_taken_iat_entry_count", 168, 8],
["guard_long_jump_target_table", 176, 8],
["guard_long_jump_target_count", 184, 8],
["dynamic_value_reloc_table", 192, 8],
["chpe_metadata_pointer", 200, 8],
["guard_eh_continuation_table", 264, 8],
["guard_eh_continuation_count", 272, 8],
["guard_xfg_check_function_pointer", 280, 8],
],
};
/** Load-config file offset in the synthetic image: `.data` 0x600 + RVA 0x40. */
const LOAD_CONFIG_AT = 0x640;
/** A distinct sentinel per field that fills its whole width. */
const sentinel = (index: number, width: 4 | 8): bigint =>
width === 8
? 0x0000_0102_0304_0500n + BigInt(index)
: BigInt(0x01020300 + index);
/** Rewrite the struct from spec offsets alone, then declare `size`. */
const specImage = (bits: 32 | 64, size: number): Buffer => {
const known = bits === 32 ? 192 : 320;
const bytes = peLoadConfigImage({
bits,
loadConfigSize: known,
loadConfigDirectorySize: known,
});
bytes.fill(0, LOAD_CONFIG_AT, LOAD_CONFIG_AT + known);
bytes.writeUInt32LE(size, LOAD_CONFIG_AT);
SPEC_FIELDS[bits].forEach(([, offset, width], index) => {
const value = sentinel(index, width);
if (width === 8) bytes.writeBigUInt64LE(value, LOAD_CONFIG_AT + offset);
else bytes.writeUInt32LE(Number(value), LOAD_CONFIG_AT + offset);
});
return bytes;
};
/** The report value a field takes when Size covers it. */
const expectedField = (name: PeLoadConfigField, value: bigint): unknown => {
if (name === "guard_flags")
return expect.objectContaining({ raw: Number(value) });
if (name.endsWith("_count")) return Number(value);
if (name === "dynamic_value_reloc_table" || name === "chpe_metadata_pointer")
return `0x${value.toString(16)}`;
return expect.objectContaining({ va: `0x${value.toString(16)}`, rva: null });
};
describe.each([32, 64] as const)(
"PE%s load-config fields at specification offsets",
(bits) => {
it.each(
SPEC_FIELDS[bits].map(([name, offset, width], index) => ({
name,
index,
end: offset + width,
})),
)("exposes $name exactly when Size reaches $end", async ({ name, end }) => {
for (const size of [end - 1, end]) {
const bytes = specImage(bits, size);
const report = await parsePeLoadConfig(
bytes,
{ path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length },
inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }),
);
const config = report.load_config;
if (config === null) throw new Error("Expected a load configuration");
expect(config.size).toBe(size);
SPEC_FIELDS[bits].forEach(([field, offset, width], fieldIndex) => {
// PE32+ SafeSEH-named slots are never a handler table.
const covered =
offset + width <= size &&
!(bits === 64 && field.startsWith("se_handler"));
expect(config[field], `${field} at Size ${String(size)}`).toEqual(
covered ? expectedField(field, sentinel(fieldIndex, width)) : null,
);
});
expect(config[name] === null).toBe(
size < end || (bits === 64 && name.startsWith("se_handler")),
);
}
});
},
);
+498
View File
@@ -0,0 +1,498 @@
import { setImmediate } from "node:timers/promises";
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
import {
BinaryFormatFailure,
BinaryMappingFailure,
BinaryResourceFailure,
createFileMappingIndex,
imageFileMappings,
} from "../metadata/BinaryContainer.js";
import { readPeImage, type PeImage } from "../metadata/PeImage.js";
import {
guardFlagRecord,
loadConfigFieldCovered,
loadConfigFieldLayout,
PE_LOAD_CONFIG_LIMITATIONS,
TLS_STRUCT_BYTES,
tlsAlignment,
type InspectPeLoadConfigInput,
type PeLoadConfig,
type PeLoadConfigCountField,
type PeLoadConfigDirectory,
type PeLoadConfigField,
type PeLoadConfigLocated,
type PeLoadConfigLocatedField,
type PeLoadConfigRawField,
type PeLoadConfigTls,
} from "../../domain/native/peLoadConfig.js";
const PE_TLS_DIRECTORY = 9;
const PE_LOAD_CONFIG_DIRECTORY = 10;
const PE_RVA_END = 0x100000000;
/** Callback slots decoded between event-loop yields and cancellation checks. */
const ENTRIES_PER_YIELD = 1024;
/** The parser-owned allowance an image exhausted. */
export type PeLoadConfigLimit = "callbacks";
/** Structured parser allowance refusal; consumers branch on `limit`, never on prose. */
export class PeLoadConfigLimitFailure extends BinaryResourceFailure {
constructor(
readonly limit: PeLoadConfigLimit,
readonly allowance: number,
message: string,
) {
super("structure", allowance, message);
this.name = "PeLoadConfigLimitFailure";
}
}
const hex = (value: bigint | number): string => `0x${value.toString(16)}`;
const fitsRva = (rva: number, size: number): boolean =>
Number.isSafeInteger(rva) &&
rva >= 0 &&
Number.isSafeInteger(size) &&
size > 0 &&
rva <= PE_RVA_END - size;
/** Append one consumed file span, merging it when the previous span abuts it. */
const consume = (
spans: { offset: number; bytes: number }[],
offset: number,
bytes: number,
): void => {
const last = spans.at(-1);
if (last !== undefined && last.offset + last.bytes === offset)
last.bytes += bytes;
else spans.push({ offset, bytes });
};
type DirectorySpan = PeLoadConfigTls["directory"];
/** One run of RVAs with a constant set of owning sections. */
interface SectionSegment {
readonly start: number;
readonly owners: number;
readonly name: string;
}
/**
* Build the RVA-to-section-name lookup once per image. Section spans are split
* into disjoint segments so each lookup is a binary search; an RVA owned by
* more than one section still fails as ambiguous.
*/
const sectionNameIndex = (
sections: PeImage["sections"],
): ((rva: number) => string | null) => {
const events: { at: number; index: number; open: boolean }[] = [];
sections.forEach((section, index) => {
if (section.format !== "pe") return;
const span =
section.virtual_size === 0 ? section.raw_size : section.virtual_size;
if (span <= 0) return;
events.push(
{ at: section.virtual_address, index, open: true },
{ at: section.virtual_address + span, index, open: false },
);
});
events.sort((left, right) => left.at - right.at);
const segments: SectionSegment[] = [];
const active = new Set<number>();
for (let cursor = 0; cursor < events.length;) {
const at = events[cursor]!.at;
for (; cursor < events.length && events[cursor]!.at === at; cursor++) {
const event = events[cursor]!;
if (event.open) active.add(event.index);
else active.delete(event.index);
}
const [only] = active;
segments.push({
start: at,
owners: active.size,
name: active.size === 1 ? (sections[only!]?.name ?? "") : "",
});
}
return (rva) => {
let low = 0;
let high = segments.length - 1;
let found: SectionSegment | undefined;
while (low <= high) {
const middle = (low + high) >>> 1;
const segment = segments[middle]!;
if (segment.start <= rva) {
found = segment;
low = middle + 1;
} else high = middle - 1;
}
if (found === undefined || found.owners === 0) return null;
if (found.owners > 1)
throw new BinaryMappingFailure(
"ambiguous",
`PE RVA ${hex(rva)} belongs to multiple sections`,
);
return found.name.length === 0 ? null : found.name;
};
};
/**
* Read one PE TLS directory and load configuration without executing the image.
* Yields to the event loop and honors cancellation every
* `ENTRIES_PER_YIELD` callback slots.
*/
export const parsePeLoadConfig = async (
bytes: Buffer,
artifact: PeLoadConfig["artifact"],
input: InspectPeLoadConfigInput,
signal?: AbortSignal,
): Promise<PeLoadConfig> => {
assertArtifactReadActive(signal);
const image = readPeImage(bytes);
const bits = image.bits;
const base = image.image_base;
const lookup = createFileMappingIndex(
imageFileMappings(image),
image.zero_fills,
);
const pointerWidth = bits === 64 ? 8 : 4;
const readPointer = (offset: number): bigint =>
bits === 64
? bytes.readBigUInt64LE(offset)
: BigInt(bytes.readUInt32LE(offset));
const dataDirectory = (index: number): DirectorySpan | null => {
const entry = image.pe_data_directories[index];
if (entry === undefined) return null;
const { virtual_address: rva, size } = entry;
if (rva === 0 && size === 0) return null;
if (rva === 0 || size === 0)
throw new BinaryFormatFailure(
"malformed",
`PE data directory ${String(index)} declares a zero RVA or size while the other field is nonzero`,
);
if (!fitsRva(rva, size))
throw new BinaryFormatFailure(
"malformed",
`PE data directory ${String(index)} RVA ${hex(rva)} span of ${String(size)} bytes extends beyond the 32-bit RVA space`,
);
return {
rva,
size,
file_offset: lookup.offset(base + BigInt(rva), size),
};
};
const rvaOf = (va: bigint): number | null => {
if (va < base) return null;
const delta = va - base;
return delta <= 0xffffffffn ? Number(delta) : null;
};
const sectionName = sectionNameIndex(image.sections);
const locate = (va: bigint): PeLoadConfigLocated => {
if (va === 0n)
return { va: "0x0", rva: null, file_offset: null, section: null };
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;
}
}
return {
va: hex(va),
rva,
file_offset: fileOffset,
section: rva === null ? null : sectionName(rva),
};
};
const tls = await readTls({
bytes,
bits,
directory: dataDirectory(PE_TLS_DIRECTORY),
pointerWidth,
readPointer,
locate,
rvaOf,
sectionName,
lookup,
maxCallbacks: input.max_callbacks,
signal,
});
const loadConfig = readLoadConfig({
bytes,
bits,
directory: dataDirectory(PE_LOAD_CONFIG_DIRECTORY),
readPointer,
locate,
structOffset: (rva, size) => {
if (!fitsRva(rva, size))
throw new BinaryFormatFailure(
"malformed",
`Load configuration Size ${String(size)} extends beyond the 32-bit RVA space`,
);
try {
return lookup.offset(base + BigInt(rva), size);
} catch (cause) {
if (cause instanceof BinaryMappingFailure)
throw new BinaryFormatFailure(
"malformed",
`Load configuration Size ${String(size)} extends beyond its file-backed mapping`,
);
throw cause;
}
},
});
return {
artifact,
format: bits === 64 ? "pe32-plus" : "pe32",
machine: image.pe_machine,
bits,
architecture: image.architecture,
tls,
load_config: loadConfig,
examined_callbacks: tls?.callbacks.length ?? 0,
limitations: [...PE_LOAD_CONFIG_LIMITATIONS],
};
};
const readTls = async ({
bytes,
bits,
directory,
pointerWidth,
readPointer,
locate,
rvaOf,
sectionName,
lookup,
maxCallbacks,
signal,
}: {
readonly bytes: Buffer;
readonly bits: 32 | 64;
readonly directory: DirectorySpan | null;
readonly pointerWidth: number;
readonly readPointer: (offset: number) => bigint;
readonly locate: (va: bigint) => PeLoadConfigLocated;
readonly rvaOf: (va: bigint) => number | null;
readonly sectionName: (rva: number) => string | null;
readonly lookup: { offset(address: bigint, size: number): number };
readonly maxCallbacks: number;
readonly signal: AbortSignal | undefined;
}): Promise<PeLoadConfigTls | null> => {
if (directory === null) return null;
const structBytes = TLS_STRUCT_BYTES[bits];
if (directory.size < structBytes)
throw new BinaryFormatFailure(
"malformed",
`TLS directory is ${String(directory.size)} bytes; PE${String(bits)} requires ${String(structBytes)}`,
);
const start = readPointer(directory.file_offset);
const end = readPointer(directory.file_offset + pointerWidth);
const index = readPointer(directory.file_offset + pointerWidth * 2);
const callbacksVa = readPointer(directory.file_offset + pointerWidth * 3);
const sizeOfZeroFill = bytes.readUInt32LE(
directory.file_offset + pointerWidth * 4,
);
const characteristics = bytes.readUInt32LE(
directory.file_offset + pointerWidth * 4 + 4,
);
if (end < start)
throw new BinaryFormatFailure(
"malformed",
"TLS raw data end precedes its start",
);
const rawSizeBig = end - start;
if (rawSizeBig > 0xffffffffn)
throw new BinaryFormatFailure(
"malformed",
"TLS raw data size exceeds the 32-bit range",
);
const rawSize = Number(rawSizeBig);
let rawFile: number | null = null;
let rawSection: string | null = null;
if (rawSize === 0) {
const rva = rvaOf(start);
rawSection = rva === null ? null : sectionName(rva);
} else {
rawFile = lookup.offset(start, rawSize);
const startRva = rvaOf(start);
const last = startRva === null ? null : startRva + rawSize - 1;
if (startRva !== null && last !== null && last <= 0xffffffff) {
const startName = sectionName(startRva);
const endName = sectionName(last);
rawSection =
startName !== null && startName === endName ? startName : null;
}
}
const callbacks: PeLoadConfigLocated[] = [];
const callbackArrayLocations: { offset: number; bytes: number }[] = [];
if (callbacksVa !== 0n) {
let slot = callbacksVa;
let entries = 0;
for (;;) {
entries += 1;
if (entries % ENTRIES_PER_YIELD === 0) {
await setImmediate();
assertArtifactReadActive(signal);
}
const slotOffset = lookup.offset(slot, pointerWidth);
const value = readPointer(slotOffset);
consume(callbackArrayLocations, slotOffset, pointerWidth);
if (value === 0n) break;
if (callbacks.length >= maxCallbacks)
throw new PeLoadConfigLimitFailure(
"callbacks",
maxCallbacks,
`TLS callback array exceeds the ${String(maxCallbacks)}-entry callback allowance`,
);
callbacks.push(locate(value));
slot += BigInt(pointerWidth);
}
}
return {
directory,
start_address_of_raw_data: hex(start),
end_address_of_raw_data: hex(end),
raw_data_size: rawSize,
raw_data_file_offset: rawFile,
raw_data_section: rawSection,
address_of_index: locate(index),
address_of_callbacks: hex(callbacksVa),
size_of_zero_fill: sizeOfZeroFill,
characteristics,
alignment: tlsAlignment(characteristics),
callbacks,
callback_array_locations: callbackArrayLocations,
};
};
const readLoadConfig = ({
bytes,
bits,
directory,
readPointer,
locate,
structOffset,
}: {
readonly bytes: Buffer;
readonly bits: 32 | 64;
readonly directory: DirectorySpan | null;
readonly readPointer: (offset: number) => bigint;
readonly locate: (va: bigint) => PeLoadConfigLocated;
readonly structOffset: (rva: number, size: number) => number;
}): PeLoadConfigDirectory | null => {
if (directory === null) return null;
if (directory.size < 4)
throw new BinaryFormatFailure(
"malformed",
"Load configuration directory is shorter than its Size field",
);
const size = bytes.readUInt32LE(directory.file_offset);
if (size < 4)
throw new BinaryFormatFailure(
"malformed",
`Load configuration Size ${String(size)} is shorter than its Size field`,
);
// x86 images declare a 64-byte data directory for compatibility while the
// structure's own Size gives its real length; the loader reads Size bytes.
// Accept the larger Size only when those bytes are file-backed in one run.
if (
size > directory.size &&
structOffset(directory.rva, size) !== directory.file_offset
)
throw new BinaryFormatFailure(
"malformed",
`Load configuration Size ${String(size)} is not contiguously file-backed`,
);
const readField = (name: PeLoadConfigField): bigint | null => {
if (!loadConfigFieldCovered(bits, size, name)) return null;
const field = loadConfigFieldLayout(bits, name);
return readPointerWidth(
bytes,
directory.file_offset + field.offset,
field.width,
readPointer,
);
};
const located = (
name: PeLoadConfigLocatedField,
): PeLoadConfigLocated | null => {
const value = readField(name);
return value === null ? null : locate(value);
};
const count = (name: PeLoadConfigCountField): number | null => {
const value = readField(name);
if (value === null) return null;
if (value > BigInt(Number.MAX_SAFE_INTEGER))
throw new BinaryFormatFailure(
"malformed",
`Load configuration ${name} exceeds the safe integer range`,
);
return Number(value);
};
const raw = (name: PeLoadConfigRawField): string | null => {
const value = readField(name);
return value === null ? null : hex(value);
};
const flags = readField("guard_flags");
const decoded = flags === null ? null : guardFlagRecord(Number(flags));
return {
directory,
size,
structure_location: { offset: directory.file_offset, bytes: size },
security_cookie: located("security_cookie"),
se_handler_table: located("se_handler_table"),
se_handler_count: count("se_handler_count"),
guard_cf_check_function_pointer: located("guard_cf_check_function_pointer"),
guard_cf_dispatch_function_pointer: located(
"guard_cf_dispatch_function_pointer",
),
guard_cf_function_table: located("guard_cf_function_table"),
guard_cf_function_count: count("guard_cf_function_count"),
guard_flags:
decoded === null
? null
: {
raw: Number(flags),
flags: decoded.flags,
unrecognized: decoded.unrecognized,
cf_function_table_stride: decoded.cf_function_table_stride,
},
guard_address_taken_iat_entry_table: located(
"guard_address_taken_iat_entry_table",
),
guard_address_taken_iat_entry_count: count(
"guard_address_taken_iat_entry_count",
),
guard_long_jump_target_table: located("guard_long_jump_target_table"),
guard_long_jump_target_count: count("guard_long_jump_target_count"),
dynamic_value_reloc_table: raw("dynamic_value_reloc_table"),
chpe_metadata_pointer: raw("chpe_metadata_pointer"),
guard_eh_continuation_table: located("guard_eh_continuation_table"),
guard_eh_continuation_count: count("guard_eh_continuation_count"),
guard_xfg_check_function_pointer: located(
"guard_xfg_check_function_pointer",
),
inferences: decoded?.inferences ?? [],
};
};
const readPointerWidth = (
bytes: Buffer,
offset: number,
width: 4 | 8,
readPointer: (offset: number) => bigint,
): bigint =>
width === 8 ? readPointer(offset) : BigInt(bytes.readUInt32LE(offset));
+253
View File
@@ -0,0 +1,253 @@
import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
readStableArtifact,
completedStableArtifact,
} from "../../artifacts/readStableArtifact.js";
import {
artifactFilePrimaryFacts,
type ArtifactFilePrimaryFacts,
assertArtifactReadActive,
bindArtifactFileCleanup,
artifactFileCancellationCause,
} from "../../artifacts/ArtifactFileOperation.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import type { PeLoadConfigPort } from "../../application/binaryDiagnostics/PeLoadConfigPort.js";
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
AnalysisUnsupportedTargetError,
type ResourceConstraintRecovery,
} from "../../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
import {
MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES,
MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS,
type InspectPeLoadConfigInput,
} from "../../domain/native/peLoadConfig.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err, ok } from "../../domain/result.js";
import { PE_LOAD_CONFIG_PROVIDER } from "../../domain/investigationProviders.js";
import {
BinaryFormatFailure,
BinaryResourceFailure,
} from "../metadata/BinaryContainer.js";
import {
parsePeLoadConfig,
PeLoadConfigLimitFailure,
type PeLoadConfigLimit,
} from "./PeLoadConfigParser.js";
const OPERATION = "inspect_pe_load_config";
/** Portable TLS/load-config reader over one bounded, identity-checked regular file. */
export class PeLoadConfigProvider implements PeLoadConfigPort {
readonly identity = PE_LOAD_CONFIG_PROVIDER;
readonly #resources = new ArtifactResourceScope();
async close(): Promise<void> {
try {
await this.#resources.close();
} catch (cause: unknown) {
if (
!(cause instanceof ArtifactReaderFailure) ||
cause.cleanup === undefined
)
throw cause;
const error = new ProviderCleanupError(
PE_LOAD_CONFIG_PROVIDER.id,
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ cause, operation: OPERATION },
);
bindArtifactFileCleanup(cause, error);
throw error;
}
}
async inspect(input: InspectPeLoadConfigInput, options?: ExecutionOptions) {
try {
return await this.#resources.run(async () => {
const snapshot = await readStableArtifact(
input.path,
input.max_file_bytes,
options?.signal,
undefined,
this.#resources,
);
assertArtifactReadActive(options?.signal);
const report = await parsePeLoadConfig(
snapshot.bytes,
{
path: input.path,
sha256: snapshot.sha256,
bytes: snapshot.bytes.length,
},
input,
options?.signal,
);
assertArtifactReadActive(options?.signal);
return ok(report);
});
} catch (cause) {
return err(peLoadConfigFailure(cause, input));
}
}
}
const fileSizeRemediation = (input: InspectPeLoadConfigInput): string =>
input.max_file_bytes >= MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES
? `The image exceeds the ${String(MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.`
: `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`;
const limitRecovery = (
limit: PeLoadConfigLimit | null,
input: InspectPeLoadConfigInput,
): ResourceConstraintRecovery =>
limit === "callbacks"
? {
retryable: false,
remediationAction:
input.max_callbacks >= MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS
? `The image exceeds the ${String(MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS)}-callback ceiling; select a smaller artifact.`
: `Raise max_callbacks above ${String(input.max_callbacks)} or select a smaller artifact.`,
}
: fixedAllowanceOptions("The PE structural decoding allowance", "parser");
const peLoadConfigFailurePrimary = (
facts: ArtifactFilePrimaryFacts,
input: InspectPeLoadConfigInput,
): AnalysisError => {
const { cause, origin } = facts;
if (origin === "owned-or-classified" && cause instanceof AnalysisError)
return cause;
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "integrity"
)
return new AnalysisArtifactChangedError(
OPERATION,
input.path,
cause.message,
{ cause },
);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure &&
cause.reason === "cancelled"
)
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
if (cause instanceof BinaryResourceFailure) {
const limit =
cause instanceof PeLoadConfigLimitFailure ? cause.limit : null;
return new AnalysisResourceConstraintError(
OPERATION,
"capacity",
`Selected artifact ${input.path}: ${cause.message}`,
{ boundary: cause.boundary, maximum: cause.maximum_bytes, limit },
{ cause, ...limitRecovery(limit, input) },
);
}
if (cause instanceof BinaryFormatFailure)
return cause.kind === "unsupported"
? new AnalysisUnsupportedTargetError(
OPERATION,
input.path,
cause.message,
{ cause },
)
: new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: `Selected artifact ${input.path}: ${cause.message}`,
},
]);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure
) {
if (cause.reason === "limit")
return new AnalysisResourceConstraintError(
OPERATION,
"file-size",
cause.message,
{ max_file_bytes: input.max_file_bytes },
{
cause,
retryable: false,
remediationAction: fileSizeRemediation(input),
},
);
if (cause.reason === "path" || cause.reason === "format")
return new AnalysisInputError(OPERATION, { cause }, [
{ path: ["path"], reason: "invalid_format", message: cause.message },
]);
}
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EACCES" || cause.code === "EPERM")
return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, {
cause,
});
if (isSelectedRegularFileError(cause))
return new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`,
},
]);
}
return new ProviderAdapterError(PE_LOAD_CONFIG_PROVIDER.id, OPERATION, {
cause,
diagnostics: {
path: input.path,
reason: cause instanceof Error ? cause.message : String(cause),
},
});
};
const peLoadConfigFailure = (
cause: unknown,
input: InspectPeLoadConfigInput,
): AnalysisError => {
const primary = peLoadConfigFailurePrimary(
artifactFilePrimaryFacts(cause),
input,
);
const snapshot = completedStableArtifact(cause);
if (snapshot !== undefined) {
// This inspect boundary acquires exactly one StableArtifactSnapshot before
// parsing. Completion describes that read, not a decoded report.
primary.retainPartialObservation(
fileSnapshotPartialObservationSchema.parse({
kind: "file-snapshot-hash",
path: input.path,
sha256: snapshot.sha256,
bytes: snapshot.bytes.length,
}),
);
}
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
return primary;
const cleanup = new ProviderCleanupError(
PE_LOAD_CONFIG_PROVIDER.id,
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ operation: OPERATION, cause },
);
const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION);
bindArtifactFileCleanup(cause, error);
return error;
};
+15
View File
@@ -25,6 +25,7 @@ import { captureProviderEnvironment } from "./providerEnvironment.js";
import {
createElfNotesService,
createAppleIpsService,
createPeLoadConfigService,
createPeCertificatesService,
createPeImportsService,
createNativeExportsService,
@@ -32,12 +33,14 @@ import {
} from "../composition/binaryDiagnostics.js";
import type { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js";
import type { AppleIpsService } from "../application/binaryDiagnostics/AppleIpsService.js";
import type { PeLoadConfigService } from "../application/binaryDiagnostics/PeLoadConfigService.js";
import type { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js";
import type { PeImportsService } from "../application/binaryDiagnostics/PeImportsService.js";
import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js";
import type { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js";
import { registerElfNotesTool } from "./registerElfNotesTool.js";
import { registerAppleIpsTool } from "./registerAppleIpsTool.js";
import { registerPeLoadConfigTool } from "./registerPeLoadConfigTool.js";
import { registerPeCertificatesTool } from "./registerPeCertificatesTool.js";
import { registerPeImportsTool } from "./registerPeImportsTool.js";
import { registerPeResourcesTool } from "./registerPeResourcesTool.js";
@@ -474,6 +477,7 @@ export const createServer = (
const nativeExports = createNativeExportsService();
const elfNotes = createElfNotesService();
const appleIps = createAppleIpsService();
const peLoadConfig = createPeLoadConfigService();
const peCertificates = createPeCertificatesService();
const peImports = createPeImportsService();
const electronFuses = options.electronFuses ?? createElectronFuseService();
@@ -587,6 +591,7 @@ export const createServer = (
nativeExports.close(),
elfNotes.close(),
appleIps.close(),
peLoadConfig.close(),
peCertificates.close(),
peImports.close(),
electronFuses.close(),
@@ -657,6 +662,7 @@ export const createServer = (
nativeExports,
elfNotes,
appleIps,
peLoadConfig,
peCertificates,
peImports,
electronFuses,
@@ -896,6 +902,7 @@ const registerConfiguredAnalysisTools = (
nativeExports,
elfNotes,
appleIps,
peLoadConfig,
peCertificates,
peImports,
pythonPyc,
@@ -971,6 +978,13 @@ const registerConfiguredAnalysisTools = (
recordEvidence,
withAdmittedAnalysis,
);
registerPeLoadConfigTool(
server,
peLoadConfig,
toolLogger,
recordEvidence,
withAdmittedAnalysis,
);
registerPeCertificatesTool(
server,
peCertificates,
@@ -1142,6 +1156,7 @@ interface ServerToolContext extends ReturnType<typeof createSessionRecorders> {
readonly nativeExports: NativeExportsService;
readonly elfNotes: ElfNotesService;
readonly appleIps: AppleIpsService;
readonly peLoadConfig: PeLoadConfigService;
readonly peCertificates: PeCertificatesService;
readonly peImports: PeImportsService;
readonly electronFuses: ElectronFuseService;
+31
View File
@@ -0,0 +1,31 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { PeLoadConfigService } from "../application/binaryDiagnostics/PeLoadConfigService.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { Logger } from "pino";
import { createEvidenceToolHandler } from "./contractToolHandler.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
/** Register the explicit-file PE TLS/load-config workflow and retain its complete Evidence. */
export const registerPeLoadConfigTool = (
server: EvidenceMcpServer,
service: PeLoadConfigService,
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
withAdmittedAnalysis?: WithAdmittedAnalysis,
): void => {
const contract = toolContract("inspect_pe_load_config");
const handler = createEvidenceToolHandler(
server,
(input, options) => service.inspect(input, options),
logger,
recordEvidence,
withAdmittedAnalysis,
);
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
handler(contract),
);
};
+41
View File
@@ -0,0 +1,41 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import { peLoadConfigImage } from "../../src/native/pe/PeLoadConfig.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
for (const bits of [32, 64] as const) {
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 }));
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.bits).toBe(bits);
expect(evidence.normalized_result.tls?.callbacks).toHaveLength(3);
expect(evidence.subject?.local_path).toBe(join(root, "app.exe"));
});
}
cliTest("fails closed at the callback limit", async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-config-budget-");
const path = join(root, "app.exe");
await writeFile(path, peLoadConfigImage());
const result = await cli.run({
arguments: [
"inspect-pe-load-config",
path,
"--max-callbacks",
"1",
"--json",
],
});
expect(result.exitCode).toBe(1);
expect(JSON.stringify(result.json)).toContain("Raise max_callbacks above 1");
});
@@ -223,6 +223,7 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
["inspect_native_exports", { path: nul }],
["inspect_elf_notes", { path: nul }],
["inspect_apple_ips", { path: nul }],
["inspect_pe_load_config", { path: nul }],
["inspect_python_pyc", { path: nul }],
["inspect_erlang_beam", { path: nul }],
["inspect_binary_layout", { path: nul }],
@@ -0,0 +1,147 @@
import { createHash } from "node:crypto";
import { symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createPeLoadConfigService } from "../../../src/composition/binaryDiagnostics.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { peLoadConfigImage } from "../../../src/native/pe/PeLoadConfig.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("PE import stable filesystem boundary", () => {
it("reports non-file and malformed PE selections as caller input errors", async () => {
const root = await createTestTempDirectory("rea-pe-load-config-files-");
const path = join(root, "app.exe");
const alias = join(root, "alias.exe");
const malformed = join(root, "malformed.exe");
await writeFile(path, peLoadConfigImage());
await writeFile(malformed, Buffer.from("not a pe"));
await symlink(path, alias);
const service = createPeLoadConfigService();
for (const [candidate, reason] of [
[root, "invalid_format"],
[alias, "invalid_format"],
[malformed, "invalid_format"],
[join(root, "missing.exe"), "invalid_value"],
[join(path, "child.exe"), "invalid_value"],
] as const) {
const result = await service.inspect({ path: candidate });
if (result.ok) throw new Error("Expected file acquisition failure");
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
operation: "inspect_pe_load_config",
issues: [
{
path: ["path"],
reason,
message: expect.any(String),
},
],
});
}
});
it("returns typed resource and cancellation failures", async () => {
const root = await createTestTempDirectory("rea-pe-load-config-budget-");
const path = join(root, "app.exe");
await writeFile(path, peLoadConfigImage());
const service = createPeLoadConfigService();
const tooSmall = await service.inspect({ path, max_file_bytes: 64 });
if (tooSmall.ok) throw new Error("Expected resource budget failure");
expect(tooSmall.error._tag).toBe("AnalysisResourceConstraintError");
const cancelled = await service.inspect(
{ path },
{ signal: AbortSignal.abort() },
);
if (cancelled.ok) throw new Error("Expected cancellation");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
});
it("names the exhausted callback budget", async () => {
const root = await createTestTempDirectory("rea-pe-config-limit-");
const path = join(root, "app.exe");
await writeFile(path, peLoadConfigImage());
const service = createPeLoadConfigService();
try {
const result = await service.inspect({ path, max_callbacks: 1 });
if (result.ok) throw new Error("Expected callback budget failure");
expect(result.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
remediationAction: expect.stringContaining(
"Raise max_callbacks above 1",
),
});
} finally {
await service.close();
}
});
it.each(["control", "close-failure", "close-cancel", "close-both"] as const)(
"retains completed PE acquisition through %s",
async (mode) => {
const root = await createTestTempDirectory("rea-pe-config-finalize-");
const path = join(root, "app.exe");
const bytes = peLoadConfigImage();
await writeFile(path, bytes);
const controller = new AbortController();
const reason = { request: "cancel during PE close" };
const closeFailure = Object.assign(new Error("controlled PE close EIO"), {
code: "EIO",
});
const original = OwnedFileHandle.prototype.close;
let owner: OwnedFileHandle | undefined;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async function (this: OwnedFileHandle) {
owner = this;
if (mode === "close-cancel" || mode === "close-both")
controller.abort(reason);
if (mode === "close-failure" || mode === "close-both")
throw closeFailure;
await original.call(this);
});
const service = createPeLoadConfigService();
try {
const result = await service.inspect(
{ path },
{ signal: controller.signal },
);
if (mode === "control") {
if (!result.ok) throw result.error;
expect(result.value.normalized_result).toMatchObject({
load_config: { guard_flags: { raw: 0x500 } },
});
} else {
if (result.ok) throw new Error("Expected finalization failure");
const acquired = {
kind: "file-snapshot-hash",
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
};
expect(result.error.partialObservation).toEqual(acquired);
expect(
projectAnalysisError(result.error).details?.partial_observation,
).toEqual(acquired);
const failedClose = mode === "close-failure" || mode === "close-both";
expect(result.error.cleanupIncomplete).toBe(failedClose);
if (failedClose) expect(owner?.handle.fd).toBeGreaterThanOrEqual(0);
if (mode !== "close-failure") {
const primary = result.error.cleanupPrimary ?? result.error;
expect(primary).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
}
close.mockRestore();
await service.close();
expect(result.error.cleanupIncomplete).toBe(false);
}
expect(owner?.handle.fd).toBe(-1);
} finally {
close.mockRestore();
await service.close();
}
},
);
});
@@ -0,0 +1,65 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { createServer } from "../../../src/server/createServer.js";
import { peLoadConfigImage } from "../../../src/native/pe/PeLoadConfig.fixture.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest } from "../../support/mcp/mcpFixture.js";
mcpTest(
"advertises and reads PE TLS/load config without executing or changing the image",
async ({ mcp }) => {
const session = createTestBinarySession(() => {
throw new Error("Must not start a deep provider");
});
const client = await mcp.connect(
createServer({ kind: "session", session }),
);
try {
const definition = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_pe_load_config",
);
if (definition === undefined)
throw new Error("Missing advertised contract");
expect(definition.annotations).toMatchObject({
readOnlyHint: false,
idempotentHint: true,
});
const root = await createTestTempDirectory("rea-pe-config-mcp-");
const path = join(root, "app.exe");
const bytes = peLoadConfigImage();
await writeFile(path, bytes);
const response = await client.callTool(
{
name: "inspect_pe_load_config",
arguments: { path },
},
{ toolDefinition: definition },
);
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
const evidence = toolContract(
"inspect_pe_load_config",
).outputSchema.parse(response.structuredContent);
expect(evidence.normalized_result.tls?.callbacks[0]?.file_offset).toBe(
0x1600,
);
expect(
evidence.normalized_result.load_config?.guard_flags?.flags,
).toContain("IMAGE_GUARD_CF_INSTRUMENTED");
expect(await readFile(path)).toEqual(bytes);
const bad = await client.callTool(
{
name: "inspect_pe_load_config",
arguments: { path: "relative.exe" },
},
{ toolDefinition: definition },
);
expect(bad.isError).toBe(true);
} finally {
await session.close();
}
},
60_000,
);
@@ -1,3 +1,5 @@
import { PeLoadConfigProvider } from "../../src/native/pe/PeLoadConfigProvider.js";
import { inspectPeLoadConfigInputSchema } from "../../src/domain/native/peLoadConfig.js";
import { open, writeFile, type FileHandle } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
@@ -860,6 +862,7 @@ const stableArtifactProviders = [
["ELF notes", () => new ElfNotesProvider()],
["Apple IPS", () => new AppleIpsProvider()],
["PE certificates", () => new PeCertificatesProvider()],
["PE load configuration", () => new PeLoadConfigProvider()],
["PE imports", () => new PeImportsProvider()],
["native exports", () => new NativeExportsProvider()],
["Python pyc", () => new PythonPycProvider()],
@@ -896,6 +899,11 @@ const inspectFileFailure = (
inspectPeCertificatesInputSchema.parse({ path }),
options,
);
if (provider instanceof PeLoadConfigProvider)
return provider.inspect(
inspectPeLoadConfigInputSchema.parse({ path }),
options,
);
if (provider instanceof PeImportsProvider)
return provider.inspect(
inspectPeImportsInputSchema.parse({ path }),