fix(providers): validate ELF versions and preserve probe cancellation

This commit is contained in:
morluto
2026-10-09 00:49:30 +08:00
parent 915855e75c
commit 8b01e0cc89
8 changed files with 140 additions and 9 deletions
+4
View File
@@ -43,6 +43,10 @@ def inspect_core(path, cache):
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires ELF64 little-endian.")
if len(content) < 64:
raise CoreFailure("format", "Selected ELF64 core header is truncated.")
if content[6] != 1:
raise CoreFailure("format", "ELF identification version is not EV_CURRENT.")
if int.from_bytes(content[20:24], "little") != 1:
raise CoreFailure("format", "ELF header version is not EV_CURRENT.")
# Linux commonly emits ELFOSABI_NONE (0), also accepted as GNU/Linux (3).
# Other declared OSABIs must never select Linux note ctypes.
if content[7] not in (0, 3) or content[8] != 0:
+11 -2
View File
@@ -127,7 +127,6 @@ const environment = {
process.env.ComSpec ??
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe")
).replaceAll("\\", "/"),
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "error",
GHIDRA_HEADLESS_MAXMEM: "512m",
NODE_OPTIONS: "--max-old-space-size=512",
@@ -291,7 +290,17 @@ try {
expected.every((name) => report.mcpOperations.includes(name)),
"Packaged verification omitted an admitted operation.",
);
await call("binary_session", {});
const session = await call("binary_session", {});
assert.equal(
session.analysis_provider_binding?.selection_source,
"auto-single-candidate",
"Packaged MCP verification must exercise automatic provider selection.",
);
assert.equal(
session.analysis_provider_binding?.provider?.id,
"ghidra",
"The sole available Windows analysis provider must be Ghidra.",
);
await call("close_binary", {});
await assertCleanup();
} finally {
+30 -7
View File
@@ -49,12 +49,18 @@ export const inspectPwndbgCore = async ({
diagnostics,
options,
launcher,
probePath,
}: {
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly rootPath: string;
readonly diagnostics: AnalysisCapturedOutput;
readonly options: ExecutionOptions | undefined;
readonly launcher?: PwntoolsLauncher;
readonly probePath?: (
path: string,
kind: "file" | "directory",
mode: number,
) => Promise<void>;
}): Promise<RecordedCrash["debugger"]> => {
const gdb = environment.REA_PWNDBG_GDB ?? "/usr/bin/gdb";
const entry = environment.REA_PWNDBG_GDBINIT ?? "";
@@ -65,28 +71,45 @@ export const inspectPwndbgCore = async ({
userMessage: message,
capturedOutput: diagnostics,
});
const checkCancelled = () => {
if (options?.signal?.aborted)
throw new AnalysisCancelledError(OPERATION, {
capturedOutput: diagnostics,
});
};
checkCancelled();
for (const [key, path, kind] of [
["REA_PWNDBG_GDB", gdb, "file"],
["REA_PWNDBG_GDBINIT", entry, "file"],
["REA_PWNDBG_VENV_PATH", venv, "directory"],
] as const) {
try {
checkCancelled();
if (!isAbsolute(path))
throw new Error("Expected an absolute caller-supplied path.");
await access(
path,
key === "REA_PWNDBG_GDB" ? constants.X_OK : constants.R_OK,
);
const information = await stat(path);
if (kind === "file" ? !information.isFile() : !information.isDirectory())
throw new Error(`Expected a ${kind}.`);
const mode = key === "REA_PWNDBG_GDB" ? constants.X_OK : constants.R_OK;
if (probePath !== undefined) {
await probePath(path, kind, mode);
} else {
await access(path, mode);
checkCancelled();
const information = await stat(path);
checkCancelled();
if (
kind === "file" ? !information.isFile() : !information.isDirectory()
)
throw new Error(`Expected a ${kind}.`);
}
checkCancelled();
} catch (cause: unknown) {
if (options?.signal?.aborted) checkCancelled();
throw unavailable(
`${key} is unavailable at ${path}: ${cause instanceof Error ? cause.message : String(cause)}. Configure the requested core-only context, or omit include_debugger_context for basic recorded evidence.`,
cause,
);
}
}
checkCancelled();
const stage = join(rootPath, "debugger");
await mkdir(stage, { mode: 0o700 });
const requestPath = join(stage, "request.json");
@@ -85,6 +85,8 @@ export function validateRecordedCrashStructure(
!header
.subarray(0, 6)
.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1])) ||
header[6] !== 1 ||
header.readUInt32LE(20) !== 1 ||
![0, 3].includes(header[7] ?? -1) ||
header[8] !== 0 ||
header.readUInt16LE(16) !== 4 ||
@@ -0,0 +1,28 @@
import { expect, it } from "vitest";
import {
recordedCrashFixture,
recordedCrashFixtureBytes,
} from "../../fixtures/binaryDiagnostics/recordedCrash.js";
import { validateRecordedCrashStructure } from "../../../src/native/pwntools/RecordedCrashStructureBindings.js";
it.each([
[6, 0],
[6, 2],
[20, 0],
[20, 2],
] as const)(
"rejects non-current ELF version at byte %i: %i",
(offset, version) => {
const report = recordedCrashFixture();
const snapshot = recordedCrashFixtureBytes(report);
if (offset === 6) snapshot[offset] = version;
else snapshot.writeUInt32LE(version, offset);
expect(() =>
validateRecordedCrashStructure(
report,
snapshot,
report.decoder_diagnostics,
),
).toThrow(/Reported core structure differs/);
},
);
@@ -43,6 +43,10 @@ it
"omitted-physical-note",
"hidden-segment",
"wrong-header-range",
"wrong-ei-version-zero",
"wrong-ei-version-two",
"wrong-e-version-zero",
"wrong-e-version-two",
"wrong-note-kind",
"wrong-owner-display",
"wrong-pid",
@@ -60,6 +64,11 @@ it
true,
scenario === "wrong-owner-display",
);
if (scenario.startsWith("wrong-ei-version-"))
bytes[6] = Number(scenario.at(-1));
if (scenario.startsWith("wrong-e-version-"))
bytes.writeUInt32LE(Number(scenario.at(-1)), 20);
if (scenario.startsWith("wrong-e")) await writeFile(path, bytes);
let owned = "";
const provider = new PwntoolsRecordedCrashProvider(
{ REA_PWNTOOLS_PYTHON: process.execPath },
@@ -0,0 +1,55 @@
import { access } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../../../tests/support/workspace/workspaceFixture.js";
import { inspectPwndbgCore } from "../../../src/native/pwndbg/PwndbgCoreContext.js";
const diagnostics = { stdout: "", stderr: "", truncated: false };
it.each(["pre-aborted", "aborted-during-probe"] as const)(
"cancellation wins over debugger path failures: %s",
async (scenario) => {
const workspace = await createTestWorkspace("rea-pwndbg-cancel-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const controller = new AbortController();
const gdb = join(workspace.root, "missing-gdb");
let probes = 0;
const probePath = async () => {
probes++;
if (scenario === "aborted-during-probe") {
await new Promise<void>((resolve) => {
setImmediate(() => {
controller.abort();
resolve();
});
});
throw new Error("path became unavailable");
}
throw new Error("path is unavailable");
};
if (scenario === "pre-aborted") controller.abort();
await expect(
inspectPwndbgCore({
environment: {
REA_PWNDBG_GDB: gdb,
REA_PWNDBG_GDBINIT: join(workspace.root, "missing-init"),
REA_PWNDBG_VENV_PATH: join(workspace.root, "missing-venv"),
},
rootPath: workspace.root,
diagnostics,
options: { signal: controller.signal },
probePath,
}),
).rejects.toBeInstanceOf(AnalysisCancelledError);
expect(probes).toBe(scenario === "pre-aborted" ? 0 : 1);
await expect(
access(join(workspace.root, "debugger")),
).rejects.toMatchObject({
code: "ENOENT",
});
},
);
+1
View File
@@ -139,6 +139,7 @@ export const recordedCrashSignalFixture = (path?: string): RecordedCrash => {
export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => {
const bytes = Buffer.alloc(value.artifact.bytes);
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
bytes.writeUInt32LE(1, 20);
bytes.writeUInt16LE(4, 16);
bytes.writeUInt16LE(62, 18);
bytes.writeBigUInt64LE(64n, 32);