diff --git a/bridge/pwntools/recorded_crash_impl.py b/bridge/pwntools/recorded_crash_impl.py index 84aa72ac5..208e59fe6 100644 --- a/bridge/pwntools/recorded_crash_impl.py +++ b/bridge/pwntools/recorded_crash_impl.py @@ -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: diff --git a/scripts/verify-packaged-ghidra-windows.mjs b/scripts/verify-packaged-ghidra-windows.mjs index fa17c856c..ac2a09579 100644 --- a/scripts/verify-packaged-ghidra-windows.mjs +++ b/scripts/verify-packaged-ghidra-windows.mjs @@ -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 { diff --git a/src/native/pwndbg/PwndbgCoreContext.ts b/src/native/pwndbg/PwndbgCoreContext.ts index 48f940549..9c5f339e0 100644 --- a/src/native/pwndbg/PwndbgCoreContext.ts +++ b/src/native/pwndbg/PwndbgCoreContext.ts @@ -49,12 +49,18 @@ export const inspectPwndbgCore = async ({ diagnostics, options, launcher, + probePath, }: { readonly environment: Readonly; readonly rootPath: string; readonly diagnostics: AnalysisCapturedOutput; readonly options: ExecutionOptions | undefined; readonly launcher?: PwntoolsLauncher; + readonly probePath?: ( + path: string, + kind: "file" | "directory", + mode: number, + ) => Promise; }): Promise => { 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"); diff --git a/src/native/pwntools/RecordedCrashStructureBindings.ts b/src/native/pwntools/RecordedCrashStructureBindings.ts index 4f80ef60d..3d1cacce6 100644 --- a/src/native/pwntools/RecordedCrashStructureBindings.ts +++ b/src/native/pwntools/RecordedCrashStructureBindings.ts @@ -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 || diff --git a/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts b/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts new file mode 100644 index 000000000..5b98e4241 --- /dev/null +++ b/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts @@ -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/); + }, +); diff --git a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts index 5b3ca3b7b..e10297ad3 100644 --- a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts @@ -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 }, diff --git a/tests/boundary/native/PwndbgCoreContext.test.ts b/tests/boundary/native/PwndbgCoreContext.test.ts new file mode 100644 index 000000000..4eb042b6b --- /dev/null +++ b/tests/boundary/native/PwndbgCoreContext.test.ts @@ -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((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", + }); + }, +); diff --git a/tests/fixtures/binaryDiagnostics/recordedCrash.ts b/tests/fixtures/binaryDiagnostics/recordedCrash.ts index 636a20c30..518b2d330 100644 --- a/tests/fixtures/binaryDiagnostics/recordedCrash.ts +++ b/tests/fixtures/binaryDiagnostics/recordedCrash.ts @@ -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);