From b33c0df02e93fabca00a44f2de625b1a63637ee9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:27:37 +0800 Subject: [PATCH] fix(pwndbg): reject core mapping paths that escape sysroot Interpret source-bound ELF64 NT_FILE descriptors once before debugger launch. Bound counts and pathname coverage by the descriptor, retain exact path bytes, and refuse relative names or dot segments rather than letting GDB resolve them against the host. Pin GDB's pathname semantics to Unix. Preserve the decoded partial observation on refusal, basic-only recovery, and pre-admission cancellation. Share an exact mapping fixture and replace the old fixture boolean modes with explicit report producers. Validation: final series has 33 passing Vitest cases plus five Python producer controls; 38 Linux x64 provider cases are explicitly skipped on macOS. Typecheck/lint, docs checks and test discovery pass. Real GDB16.3 and strace reproduce the relative-path escape using only an owned fixture; compiled admission admits a real absolute-path core and refuses that relative-path core. Full native pwndbg / Linux x64 remain unverified here. Task token, CPU and peak-family-memory costs are unmeasured; no speed claim. Fixes #4942 --- bridge/pwndbg/core_context.py | 2 +- docs/recorded-crashes.md | 22 +++- .../pwndbg/CoreFileMappingAuthority.test.ts | 85 ++++++++++++++ src/native/pwndbg/CoreFileMappingAuthority.ts | 63 +++++++++++ .../pwntools/PwntoolsRecordedCrashProvider.ts | 16 ++- .../RecordedCrashStructureBindings.test.ts | 28 ++++- .../recordedCrashProvider.test.ts | 107 ++++++++++++------ .../binaryDiagnostics/recordedCrash.ts | 44 ++++++- 8 files changed, 322 insertions(+), 45 deletions(-) create mode 100644 src/native/pwndbg/CoreFileMappingAuthority.test.ts create mode 100644 src/native/pwndbg/CoreFileMappingAuthority.ts 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(