diff --git a/bridge/pwndbg/core_context.py b/bridge/pwndbg/core_context.py index 337ff5f57..498c89b3b 100644 --- a/bridge/pwndbg/core_context.py +++ b/bridge/pwndbg/core_context.py @@ -20,7 +20,7 @@ def inspect(request): root = Path(os.environ["REA_CORE_CONTEXT_REQUEST"]).parent empty = root / "no-libraries" empty.mkdir(mode=0o700) - for command in ["set auto-load off", "set debuginfod enabled off", "set auto-solib-add off", "set history save off", "set pagination off", + for command in ["set auto-load off", "set debuginfod enabled off", "set auto-solib-add off", "set history save off", "set pagination off", "set target-file-system-kind unix", "set sysroot ./no-libraries", "set solib-search-path ./no-libraries", "set debug-file-directory ./no-libraries"]: gdb.execute(command, to_string=True) diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md index bd508d051..836aa8267 100644 --- a/docs/recorded-crashes.md +++ b/docs/recorded-crashes.md @@ -61,9 +61,21 @@ The outer provider owns both processes and one stable core snapshot. An owned Python launcher lowers inherited soft limits before replacing itself with GDB. GDB starts without initfiles, auto-load, debuginfod or automatic shared-library loading. Owned empty library search paths and caches are used, with plugin -updates disabled. Fixed package code verifies a core connection and no paired -executable before querying mapping candidates. No caller GDB command language is -exposed. The exact observed GDB/plugin version strings are retained. +updates disabled. Debug-file lookup is also restricted to the owned empty +search directory. GDB 16 can otherwise locate the recorded executable independently +of these settings. While opening the core, fixed package code temporarily selects +`/proc/self/exe` (the already running debugger) with `exec-file`, without loading +symbols or running an inferior. This occupies GDB's executable slot and prevents +its automatic executable discovery. The validated decoder profile selects the +core architecture explicitly, so the placeholder cannot select it instead. The +bridge discards that selection with `file` before querying mapping candidates, +and verifies the core connection and absence of a main symbol file. Before launching GDB, the provider also checks +source-bound `NT_FILE` descriptors: mapping names must be absolute Unix paths +without dot segments. Relative names bypass GDB's sysroot, and traversal can +escape it. Unresolved or unsafe mapping coverage refuses debugger enrichment +while retaining the basic decoded observation and original bytes. No caller GDB +command language is exposed. The exact observed GDB/plugin version strings are +retained. Mapping names are producer display metadata, with unknown current file identity. Map permissions are derived from reported flags; zero/unfamiliar flags leave @@ -93,4 +105,6 @@ GDB/Python/pwndbg/strace. Fixture generation explicitly launches its owned test program; subsequent inspection only reads the resulting core. Real acceptance checks recorded threads, high registers, missing/malformed notes, opaque owners, CLI/MCP parity, optional debugger context and historical PID collision with an -owned live sentinel. Original inputs and host configuration remain unchanged. +owned live sentinel. The syscall trace also rejects access to the still-present +source executable. The CI workflow defines Ubuntu 24.04 and Debian 13 (GDB 16) +container lanes. Original inputs and host configuration remain unchanged. diff --git a/src/native/pwndbg/CoreFileMappingAuthority.test.ts b/src/native/pwndbg/CoreFileMappingAuthority.test.ts new file mode 100644 index 000000000..576563264 --- /dev/null +++ b/src/native/pwndbg/CoreFileMappingAuthority.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { validateCoreFileMappingAuthority } from "./CoreFileMappingAuthority.js"; +import { + recordedCrashFixture, + recordedCrashFileMappingFixture, +} from "../../../tests/fixtures/binaryDiagnostics/recordedCrash.js"; + +function mapping(paths: readonly Buffer[]) { + const report = recordedCrashFileMappingFixture(paths); + const note = report.notes[0]; + if (note === undefined) throw new Error("Expected a mapping note"); + const bytes = Buffer.from(note.descriptor_bytes_base64, "base64"); + note.descriptor_location.offset = "0x0"; + return { report, bytes }; +} + +describe("core-only NT_FILE authority", () => { + it.each([ + "/usr/bin/app", + "//usr/lib/libc.so", + "/a/.../b", + "/a/../b", + "../../app", + "relative/app", + "/a/./b", + "", + "target:/app", + ])( + "retains original pathname bytes and admits only confined Unix paths: %s", + (path) => { + const { report, bytes } = mapping([Buffer.from(path)]); + const original = Buffer.from(bytes); + const result = validateCoreFileMappingAuthority(report, bytes); + expect(result.ok).toBe( + ["/usr/bin/app", "//usr/lib/libc.so", "/a/.../b"].includes(path), + ); + expect(bytes).toEqual(original); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + capturedOutput: report.decoder_diagnostics, + }); + }, + ); + + it("does not decode invalid UTF-8 to decide pathname authority", () => { + const { report, bytes } = mapping([Buffer.from([47, 255, 47, 46, 46, 46])]); + expect(validateCoreFileMappingAuthority(report, bytes).ok).toBe(true); + }); + + it.each([ + "short-header", + "huge-count", + "unterminated", + "extra-path", + "unsafe-second", + ])("refuses unresolved or unsafe mapping coverage: %s", (scenario) => { + const { report, bytes } = mapping([ + Buffer.from("/safe"), + Buffer.from(scenario === "unsafe-second" ? "../unsafe" : "/also-safe"), + ]); + let snapshot = bytes; + if (scenario === "short-header") snapshot = bytes.subarray(0, 8); + if (scenario === "huge-count") bytes.writeBigUInt64LE(0xffffffffffffffffn); + if (scenario === "unterminated") + snapshot = bytes.subarray(0, bytes.length - 1); + if (scenario === "extra-path") + snapshot = Buffer.concat([bytes, Buffer.from("extra\0")]); + const note = report.notes[0]; + if (note === undefined) throw new Error("Expected a mapping note"); + note.descriptor_location.bytes = `0x${snapshot.length.toString(16)}`; + expect(validateCoreFileMappingAuthority(report, snapshot).ok).toBe(false); + }); + + it("permits cores without file mappings and empty mapping tables", () => { + const report = recordedCrashFixture(); + expect(validateCoreFileMappingAuthority(report, Buffer.alloc(0)).ok).toBe( + true, + ); + const empty = mapping([]); + expect(validateCoreFileMappingAuthority(empty.report, empty.bytes).ok).toBe( + true, + ); + }); +}); diff --git a/src/native/pwndbg/CoreFileMappingAuthority.ts b/src/native/pwndbg/CoreFileMappingAuthority.ts new file mode 100644 index 000000000..6a9d298b9 --- /dev/null +++ b/src/native/pwndbg/CoreFileMappingAuthority.ts @@ -0,0 +1,63 @@ +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import type { RecordedCrash } from "../../domain/native/recordedCrash.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +/** Interpret source-bound ELF64 NT_FILE paths once, before GDB can open them. */ +export function validateCoreFileMappingAuthority( + report: RecordedCrash, + snapshot: Buffer, +): Result { + for (const note of report.notes) { + if (note.type !== "NT_FILE") continue; + const refuse = (reason: string) => { + const message = `Core mapping note ${note.index} cannot be confined to the owned debugger sysroot: ${reason}.`; + return err( + new AnalysisCapabilityUnavailableError( + "pwndbg-core", + "inspect_recorded_crash", + message, + { + userMessage: `${message} Omit include_debugger_context for basic recorded evidence.`, + capturedOutput: report.decoder_diagnostics, + }, + ), + ); + }; + // The provider has already bound this range and note kind to the snapshot. + const start = Number(BigInt(note.descriptor_location.offset)); + const bytes = snapshot.subarray( + start, + start + Number(BigInt(note.descriptor_location.bytes)), + ); + if (bytes.length < 16) return refuse("missing mapping table header"); + const count = bytes.readBigUInt64LE(0); + if (count > BigInt(Math.floor((bytes.length - 16) / 24))) + return refuse("mapping count exceeds its descriptor"); + let position = 16 + Number(count) * 24; + for (let index = 0; index < Number(count); index++) { + const end = bytes.indexOf(0, position); + if (end < 0) return refuse("unterminated mapping pathname"); + const path = bytes.subarray(position, end); + // Inspect Unix path bytes without lossy UTF-8 decoding or host resolution. + // Relative paths bypass GDB's sysroot; dot segments can escape its prefix. + if (path[0] !== 0x2f) return refuse("mapping pathname is not absolute"); + let component = 0; + for (let cursor = 0; cursor <= path.length; cursor++) { + if (cursor !== path.length && path[cursor] !== 0x2f) continue; + const length = cursor - component; + if ( + (length === 1 && path[component] === 0x2e) || + (length === 2 && + path[component] === 0x2e && + path[component + 1] === 0x2e) + ) + return refuse("mapping pathname contains a dot segment"); + component = cursor + 1; + } + position = end + 1; + } + if (bytes.subarray(position).some((byte) => byte !== 0)) + return refuse("mapping pathname coverage is unresolved"); + } + return ok(undefined); +} diff --git a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts index 17ac4aa80..38794a6bd 100644 --- a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts +++ b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts @@ -1,4 +1,7 @@ -import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { recordedCrashPayloadSchema, @@ -10,6 +13,7 @@ import type { RecordedCrashPort } from "../../application/binaryDiagnostics/Reco import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; import { PwntoolsDecoder, type PwntoolsLauncher } from "./PwntoolsDecoder.js"; import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsRelease.js"; +import { validateCoreFileMappingAuthority } from "../pwndbg/CoreFileMappingAuthority.js"; import { validateRecordedCrashSources } from "./RecordedCrashSourceBindings.js"; import { validateRecordedCrashStructure } from "./RecordedCrashStructureBindings.js"; import { @@ -118,6 +122,16 @@ export class PwntoolsRecordedCrashProvider } if (!input.include_debugger_context) return value; try { + if (options?.signal?.aborted) + throw new AnalysisCancelledError("inspect_recorded_crash", { + cause: options.signal.reason, + capturedOutput: diagnostics, + }); + const authority = validateCoreFileMappingAuthority( + value, + snapshot.bytes, + ); + if (!authority.ok) throw authority.error; const context = await inspectPwndbgCore({ environment, architecture: value.architecture, diff --git a/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts b/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts index 970f57f5d..9edfa061c 100644 --- a/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts +++ b/tests/boundary/binaryDiagnostics/RecordedCrashStructureBindings.test.ts @@ -2,9 +2,15 @@ import { expect, it } from "vitest"; import { recordedCrashFixture, recordedCrashFixtureBytes, + recordedCrashFileMappingFixture, } from "../../fixtures/binaryDiagnostics/recordedCrash.js"; import { validateRecordedCrashStructure } from "../../../src/native/pwntools/RecordedCrashStructureBindings.js"; -import { RECORDED_LINUX_CORE_PROFILES } from "../../../src/domain/native/recordedCrash.js"; +import { validateCoreFileMappingAuthority } from "../../../src/native/pwndbg/CoreFileMappingAuthority.js"; +import { validateRecordedCrashSources } from "../../../src/native/pwntools/RecordedCrashSourceBindings.js"; +import { + recordedCrashSchema, + RECORDED_LINUX_CORE_PROFILES, +} from "../../../src/domain/native/recordedCrash.js"; it.each(["x86_64-little-endian", "aarch64-little-endian"] as const)( "admits a %s core whose header carries that profile's e_machine", @@ -60,3 +66,23 @@ it.each([ ).toThrow(/Reported core structure differs/); }, ); + +it.each(["/usr/bin/fixture", "../../fixture", "/a/../fixture"])( + "separates valid source evidence from debugger mapping authority: %s", + (path) => { + const report = recordedCrashFileMappingFixture([Buffer.from(path)]); + const snapshot = recordedCrashFixtureBytes(report); + expect(() => recordedCrashSchema.parse(report)).not.toThrow(); + validateRecordedCrashStructure( + report, + snapshot, + report.decoder_diagnostics, + ); + validateRecordedCrashSources(report, snapshot, report.decoder_diagnostics); + const original = Buffer.from(snapshot); + expect(validateCoreFileMappingAuthority(report, snapshot).ok).toBe( + path === "/usr/bin/fixture", + ); + expect(snapshot).toEqual(original); + }, +); diff --git a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts index d80277226..c8c90d480 100644 --- a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts @@ -1,8 +1,5 @@ import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; -import type { - RecordedCrash, - RecordedCrashArchitecture, -} from "../../../src/domain/native/recordedCrash.js"; +import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join } from "node:path"; @@ -26,6 +23,7 @@ import { } from "../../fixtures/providerProcess.js"; import { recordedCrashFixture, + recordedCrashFileMappingFixture, recordedCrashDebuggerFixture, recordedCrashFixtureBytes, recordedCrashSignalFixture, @@ -132,11 +130,22 @@ it const architecture = scenario.endsWith("aarch64") ? "aarch64-little-endian" : "x86_64-little-endian"; - const { path, value, bytes } = await fixture( - true, - scenario === "wrong-owner-display", - architecture, - ); + const { path, value, bytes } = await fixture((path) => { + const value = recordedCrashSignalFixture(path, architecture); + if (scenario === "wrong-owner-display") { + value.notes = value.notes.map((note) => + note.index === 0 + ? { + ...note, + owner_bytes_base64: Buffer.from("EVIL\0").toString("base64"), + owner_display: "EVIL", + } + : note, + ); + value.threads = []; + } + return value; + }); if (scenario.startsWith("wrong-ei-version-")) bytes[6] = Number(scenario.at(-1)); if (scenario.startsWith("wrong-e-version-")) @@ -201,6 +210,7 @@ it .runIf(host) .each([ "unconfigured", + "unsafe-mapping", "success", "process-failure", "invalid-context", @@ -212,7 +222,15 @@ it ] as const)( "owns optional context from the same snapshot: %s", async (scenario) => { - const { path, value, bytes, root } = await fixture(); + const { path, value, bytes, root } = await fixture( + scenario === "unsafe-mapping" + ? (path) => + recordedCrashFileMappingFixture( + [Buffer.from("../../outside-owned-root")], + path, + ) + : recordedCrashFixture, + ); const controller = new AbortController(); let owned = ""; let snapshotPath = ""; @@ -311,17 +329,18 @@ it } else { if (result.ok) throw new Error("Expected optional debugger failure"); expectOptionalContextFailure(scenario, result.error, value); + if (scenario === "unsafe-mapping") { + expect(launches).toBe(1); + await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" }); + snapshotPath = ""; + await expectBasicCoreFallback(provider, path, value.notes); + expect(launches).toBe(2); + } if (scenario === "cancelled-exec-foreign") await expectForeignDebuggerPreserved(debuggerProcess); } - if (scenario === "cancelled" || scenario === "cancelled-exec") { - if (debuggerProcess === undefined) - throw new Error("Expected an owned debugger process"); - expect( - debuggerProcess.exitCode !== null || - debuggerProcess.signalCode !== null, - ).toBe(true); - } + if (scenario === "cancelled" || scenario === "cancelled-exec") + expectDebuggerStopped(debuggerProcess); expect(await readFile(path)).toEqual(bytes); if ( scenario === "cleanup-failure" || @@ -354,15 +373,45 @@ it }, ); +const expectDebuggerStopped = ( + child: SpawnedOwnedProviderProcess["process"] | undefined, +): void => { + if (child === undefined) + throw new Error("Expected an owned debugger process"); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); +}; + +const expectBasicCoreFallback = async ( + provider: PwntoolsRecordedCrashProvider, + path: string, + notes: RecordedCrash["notes"], +) => { + const basic = await provider.inspect({ + path, + include_debugger_context: false, + }); + if (!basic.ok) throw basic.error; + expect(basic.value.notes).toEqual(notes); + expect(basic.value.debugger).toEqual({ status: "not-requested" }); +}; + const expectOptionalContextFailure = ( scenario: string, error: AnalysisError, value: RecordedCrash, ) => { const projected = projectAnalysisError(error); + if (scenario === "unsafe-mapping") + expect(error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + partialObservation: { + notes: value.notes, + debugger: { status: "unavailable", requested: true }, + }, + }); if (scenario === "cancelled-exec-foreign") expect(projected.code).toBe("cleanup_incomplete"); - if (scenario === "unconfigured") + if (scenario === "unconfigured" || scenario === "unsafe-mapping") expect(projected).toMatchObject({ details: { captured_output: { stdout: "basic stage diagnostic" } }, }); @@ -425,28 +474,12 @@ const expectOptionalContextFailure = ( }; const fixture = async ( - includeSignal = false, - opaqueOwner = false, - architecture: RecordedCrashArchitecture = "x86_64-little-endian", + createReport: (path: string) => RecordedCrash = recordedCrashFixture, ) => { const workspace = await createTestWorkspace("rea-recorded-core-seam-"); onTestFinished(() => removeTestWorkspace(workspace.root)); const path = join(workspace.root, "recorded.core"); - const value = includeSignal - ? recordedCrashSignalFixture(path, architecture) - : recordedCrashFixture(path, architecture); - if (opaqueOwner) { - value.notes = value.notes.map((note) => - note.index === 0 - ? { - ...note, - owner_bytes_base64: Buffer.from("EVIL\0").toString("base64"), - owner_display: "EVIL", - } - : note, - ); - value.threads = []; - } + const value = createReport(path); const bytes = recordedCrashFixtureBytes(value); await writeFile(path, bytes); return { path, value, bytes, root: workspace.root }; diff --git a/tests/fixtures/binaryDiagnostics/recordedCrash.ts b/tests/fixtures/binaryDiagnostics/recordedCrash.ts index 55698aac7..523b0f634 100644 --- a/tests/fixtures/binaryDiagnostics/recordedCrash.ts +++ b/tests/fixtures/binaryDiagnostics/recordedCrash.ts @@ -149,6 +149,44 @@ export const recordedCrashSignalFixture = ( return value; }; +/** Source-owned ELF64 NT_FILE descriptor with exact pathname bytes. */ +export const recordedCrashFileMappingFixture = ( + paths: readonly Buffer[], + path?: string, +): RecordedCrash => { + const value = recordedCrashFixture(path); + const header = Buffer.alloc(16); + header.writeBigUInt64LE(BigInt(paths.length)); + header.writeBigUInt64LE(4096n, 8); + const descriptor = Buffer.concat([ + header, + Buffer.alloc(paths.length * 24), + ...paths.flatMap((name) => [name, Buffer.from([0])]), + ]); + const note = value.notes[0]; + if (note === undefined) throw new Error("Expected a source-owned note"); + const bytes = NOTE_HEADER_BYTES + Math.ceil(descriptor.length / 4) * 4; + value.notes = [ + { + ...note, + type: "NT_FILE", + location: { offset: hex(NOTE_START), bytes: hex(bytes) }, + descriptor_location: { + offset: hex(DESCRIPTOR_START), + bytes: hex(descriptor.length), + }, + descriptor_bytes_base64: descriptor.toString("base64"), + }, + ]; + value.segments = value.segments.map((segment) => ({ + ...segment, + file_size: hex(bytes), + })); + value.artifact.bytes = Math.max(512, NOTE_START + bytes); + value.threads = []; + return value; +}; + /** Construct only the original byte ranges consumed by the provider protocol tests. */ export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => { const bytes = Buffer.alloc(value.artifact.bytes); @@ -184,7 +222,11 @@ export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => { start + 4, ); bytes.writeUInt32LE( - note.type === "NT_PRSTATUS" ? 1 : 0x53494749, + note.type === "NT_PRSTATUS" + ? 1 + : note.type === "NT_FILE" + ? 0x46494c45 + : 0x53494749, start + 8, ); Buffer.from(note.owner_bytes_base64, "base64").copy(