From 5bf4fffc1b835130ddb16f96db9ce13b639591db Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:26:42 +0800 Subject: [PATCH] fix(pwndbg): prevent core-triggered executable discovery Occupy GDB's executable slot with its owned /proc/self/exe while loading a core, then discard it before mapping queries. Select the validated decoder architecture explicitly and restrict build-ID lookup to the owned empty path. Preserve cancellation reasons at debugger admission. Keep the original fixture executable present during syscall verification and add Debian 13 / GDB 16 coverage alongside Ubuntu 24.04 in the CI definition. Validation: controlled producer regressions and request-profile controls; real GDB 16.3 / strace on Linux arm64 shows the old bridge opening the recorded executable and the fix returning the same mapping without that access. A cross-architecture gdb-multiarch probe confirms profile selection. The plugin was controlled; full native pwndbg and Linux x64 remain unverified. No performance improvement claimed; task tokens/CPU/family memory unmeasured. Fixes #4914 --- .github/workflows/real-recorded-crash.yml | 17 ++- bridge/pwndbg/core_context.py | 15 ++- bridge/pwndbg/test_core_context.py | 117 ++++++++++++++++++ scripts/verify/recorded-crash/trace.mjs | 7 ++ src/native/pwndbg/PwndbgCoreContext.ts | 4 + .../pwntools/PwntoolsRecordedCrashProvider.ts | 1 + .../boundary/native/PwndbgCoreContext.test.ts | 100 ++++++++++++--- 7 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 bridge/pwndbg/test_core_context.py diff --git a/.github/workflows/real-recorded-crash.yml b/.github/workflows/real-recorded-crash.yml index 58cf90aed3..e5f16c30e7 100644 --- a/.github/workflows/real-recorded-crash.yml +++ b/.github/workflows/real-recorded-crash.yml @@ -24,7 +24,19 @@ jobs: real-recorded-crash: runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + image: ["ubuntu:24.04", "debian:trixie-slim"] + container: ${{ matrix.image }} steps: + - name: Install fixture and debugger prerequisites in the disposable container + shell: bash + run: | + apt-get update -qq + apt-get install --yes --no-install-recommends \ + ca-certificates git python3-venv gcc libc6-dev strace gdb + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: @@ -36,11 +48,6 @@ jobs: - uses: ./.github/actions/portable-build-cache with: build_cache_artifact: ${{ inputs.build_cache_artifact }} - - name: Install GDB on the disposable CI runner - shell: bash - run: | - sudo apt-get update -qq - sudo apt-get install --yes --no-install-recommends gdb - name: Acquire exact unchanged upstreams in isolated runner storage shell: bash run: | diff --git a/bridge/pwndbg/core_context.py b/bridge/pwndbg/core_context.py index 57f86f6882..337ff5f57c 100644 --- a/bridge/pwndbg/core_context.py +++ b/bridge/pwndbg/core_context.py @@ -21,7 +21,8 @@ def inspect(request): 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", - "set sysroot ./no-libraries", "set solib-search-path ./no-libraries"]: + "set sysroot ./no-libraries", "set solib-search-path ./no-libraries", + "set debug-file-directory ./no-libraries"]: gdb.execute(command, to_string=True) entry = request["gdbinit"] try: @@ -38,7 +39,19 @@ def inspect(request): # relative filename in the owned cwd so no selected path becomes GDB syntax. if Path(request["snapshot_path"]) != root.parent / "object.snapshot": raise ContextFailure("decoder", "Core context request did not select the shared owned snapshot.") + # GDB 16 searches AT_EXECFN/NT_FILE outside sysroot when no executable BFD + # is selected. Occupy that slot with the already running, owned debugger; + # exec-file loads no symbols and never runs it. Clear it before any query. + # /proc/self/exe identifies this process, not the core's historical PID. + architecture = {"x86_64-little-endian": "i386:x86-64", "aarch64-little-endian": "aarch64"}.get(request["architecture"]) + if architecture is None: + raise ContextFailure("unsupported", "Unsupported recorded core architecture.") + gdb.execute("exec-file /proc/self/exe", to_string=True) + # The placeholder must not select the core's architecture. Reuse the + # decoder's validated profile, including for cross-architecture GDB builds. + gdb.execute("set architecture " + architecture, to_string=True) gdb.execute("core-file ../object.snapshot", to_string=True) + gdb.execute("file", to_string=True) inferior = gdb.selected_inferior() if inferior.connection is None or inferior.connection.type != "core" or gdb.current_progspace().filename is not None: raise ContextFailure("decoder", "Requested core-only boundary was not established; no mapping query performed.") diff --git a/bridge/pwndbg/test_core_context.py b/bridge/pwndbg/test_core_context.py new file mode 100644 index 0000000000..95f5611733 --- /dev/null +++ b/bridge/pwndbg/test_core_context.py @@ -0,0 +1,117 @@ +"""Producer regressions with controlled GDB state, not native debugger qualification.""" +import json +import os +from pathlib import Path +import runpy +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + + +BRIDGE = Path(__file__).with_name("core_context.py") + + +class CoreContextTests(unittest.TestCase): + def inspect(self, failure=None, connection="core", symbols=None, architecture="x86_64-little-endian"): + commands, reads, queries = [], [], [] + state = {"executable": None, "symbols": None, "connection": None} + gdb = types.ModuleType("gdb") + gdb.VERSION = "16.3" + gdb.selected_inferior = lambda: types.SimpleNamespace( + connection=state["connection"], pid=123) + gdb.current_progspace = lambda: types.SimpleNamespace(filename=state["symbols"]) + + def execute(command, to_string): + self.assertTrue(to_string) + commands.append(command) + if command == failure: + raise RuntimeError("controlled command failure") + if command == "exec-file /proc/self/exe": + state["executable"] = "/proc/self/exe" + elif command == "core-file ../object.snapshot": + if state["executable"] is None: + reads.append("recorded-executable") + state["executable"] = state["symbols"] = "recorded-executable" + state["connection"] = types.SimpleNamespace(type=connection) + elif command == "file": + state["executable"] = None + state["symbols"] = symbols + + gdb.execute = execute + pwndbg = types.ModuleType("pwndbg") + pwndbg.aglib = types.ModuleType("pwndbg.aglib") + pwndbg.aglib.vmmap = types.ModuleType("pwndbg.aglib.vmmap") + pwndbg.lib = types.ModuleType("pwndbg.lib") + pwndbg.lib.version = types.ModuleType("pwndbg.lib.version") + pwndbg.lib.version.__version__ = "2026.09.15" + + def maps(): + self.assertIsNone(state["executable"], "Helper executable must be discarded before mapping queries") + queries.append("maps") + return [types.SimpleNamespace(start=4096, end=8192, offset=0, flags=5, objfile="recorded-executable")] + + pwndbg.aglib.vmmap.get = maps + modules = {module.__name__: module for module in [ + gdb, pwndbg, pwndbg.aglib, pwndbg.aglib.vmmap, pwndbg.lib, pwndbg.lib.version]} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + stage = root / "debugger" + stage.mkdir() + plugin = root / "gdbinit.py" + plugin.write_text("") + request = stage / "request.json" + reply = stage / "reply.json" + request.write_text(json.dumps({"gdbinit": str(plugin), "architecture": architecture, "snapshot_path": str(root / "object.snapshot"), "reply_path": str(reply)})) + with patch.dict(sys.modules, modules), patch.dict(os.environ, REA_CORE_CONTEXT_REQUEST=str(request)): + runpy.run_path(str(BRIDGE)) + result = json.loads(reply.read_text()) + return result, commands, reads, queries + + def test_core_load_never_opens_recorded_executable_and_queries_only_after_discard(self): + result, commands, reads, queries = self.inspect() + self.assertTrue(result["ok"], result) + self.assertEqual(reads, []) + self.assertEqual(queries, ["maps"]) + self.assertEqual(commands[-4:], ["exec-file /proc/self/exe", "set architecture i386:x86-64", "core-file ../object.snapshot", "file"]) + self.assertLess(commands.index("set debug-file-directory ./no-libraries"), commands.index("core-file ../object.snapshot")) + self.assertIsNone(result["value"]["executable"]) + self.assertEqual(result["value"]["maps"][0]["current_file_identity"], "unknown") + + def test_failed_guard_core_load_or_discard_never_queries_mappings(self): + for command in ["exec-file /proc/self/exe", "set architecture i386:x86-64", "core-file ../object.snapshot", "file"]: + with self.subTest(command=command): + result, commands, reads, queries = self.inspect(failure=command) + self.assertFalse(result["ok"]) + self.assertEqual(reads, []) + self.assertEqual(queries, []) + if command in ["exec-file /proc/self/exe", "set architecture i386:x86-64"]: + self.assertNotIn("core-file ../object.snapshot", commands) + + def test_cross_architecture_uses_the_validated_decoder_profile(self): + result, commands, reads, queries = self.inspect(architecture="aarch64-little-endian") + self.assertTrue(result["ok"], result) + self.assertEqual(reads, []) + self.assertEqual(queries, ["maps"]) + self.assertLess(commands.index("set architecture aarch64"), commands.index("core-file ../object.snapshot")) + + def test_unknown_architecture_refuses_before_loading_core(self): + result, commands, reads, queries = self.inspect(architecture="unknown") + self.assertFalse(result["ok"]) + self.assertEqual(result["reason"], "unsupported") + self.assertNotIn("core-file ../object.snapshot", commands) + self.assertEqual(reads, []) + self.assertEqual(queries, []) + + def test_wrong_connection_or_remaining_symbols_never_queries_mappings(self): + for options in [{"connection": "native"}, {"symbols": "unexpected-symbols"}]: + with self.subTest(options=options): + result, _, reads, queries = self.inspect(**options) + self.assertFalse(result["ok"]) + self.assertEqual(reads, []) + self.assertEqual(queries, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify/recorded-crash/trace.mjs b/scripts/verify/recorded-crash/trace.mjs index f5f31624ba..7cf5835087 100644 --- a/scripts/verify/recorded-crash/trace.mjs +++ b/scripts/verify/recorded-crash/trace.mjs @@ -73,6 +73,13 @@ export async function verifyCoreInspectionTrace({ for (const file of await readdir(root)) { if (!file.startsWith(`inspection-${mode}.trace.`)) continue; const text = await readFile(join(root, file), "utf8"); + // Keep the source executable present: GDB 16 otherwise silently hides a + // core-only violation when its automatic executable lookup finds nothing. + const recordedExecutable = JSON.stringify(join(root, "fixture-program")); + assert.ok( + !text.split("\n").some((line) => line.includes(recordedExecutable)), + `Inspection attempted access to the recorded executable: ${file}`, + ); const ownershipTrace = text .split("\n") .some( diff --git a/src/native/pwndbg/PwndbgCoreContext.ts b/src/native/pwndbg/PwndbgCoreContext.ts index 760ca842d3..962c60f3fa 100644 --- a/src/native/pwndbg/PwndbgCoreContext.ts +++ b/src/native/pwndbg/PwndbgCoreContext.ts @@ -52,6 +52,7 @@ const replySchema = z.discriminatedUnion("ok", [ /** Add mapping candidates from an owned core-only debugger, using the existing snapshot. */ export const inspectPwndbgCore = async ({ environment, + architecture, rootPath, resources, diagnostics, @@ -60,6 +61,7 @@ export const inspectPwndbgCore = async ({ probePath, }: { readonly environment: Readonly; + readonly architecture: RecordedCrash["architecture"]; readonly rootPath: string; readonly resources: ArtifactResourceScope; readonly diagnostics: AnalysisCapturedOutput; @@ -83,6 +85,7 @@ export const inspectPwndbgCore = async ({ const checkCancelled = () => { if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, capturedOutput: diagnostics, }); }; @@ -129,6 +132,7 @@ export const inspectPwndbgCore = async ({ snapshot_path: join(rootPath, "object.snapshot"), reply_path: replyPath, gdb, + architecture, gdbinit: entry, venv, }), diff --git a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts index 24cee73143..17ac4aa800 100644 --- a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts +++ b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts @@ -120,6 +120,7 @@ export class PwntoolsRecordedCrashProvider try { const context = await inspectPwndbgCore({ environment, + architecture: value.architecture, rootPath, resources, diagnostics, diff --git a/tests/boundary/native/PwndbgCoreContext.test.ts b/tests/boundary/native/PwndbgCoreContext.test.ts index d38b01ac3f..f4b72876fb 100644 --- a/tests/boundary/native/PwndbgCoreContext.test.ts +++ b/tests/boundary/native/PwndbgCoreContext.test.ts @@ -1,4 +1,6 @@ -import { access } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; @@ -17,6 +19,7 @@ it.each(["pre-aborted", "aborted-during-probe"] as const)( const workspace = await createTestWorkspace("rea-pwndbg-cancel-"); onTestFinished(() => removeTestWorkspace(workspace.root)); const controller = new AbortController(); + const reason = { cancelled: scenario }; const gdb = join(workspace.root, "missing-gdb"); let probes = 0; const probePath = async () => { @@ -24,7 +27,7 @@ it.each(["pre-aborted", "aborted-during-probe"] as const)( if (scenario === "aborted-during-probe") { await new Promise((resolve) => { setImmediate(() => { - controller.abort(); + controller.abort(reason); resolve(); }); }); @@ -32,24 +35,29 @@ it.each(["pre-aborted", "aborted-during-probe"] as const)( } 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, - resources: new ArtifactResourceScope(), - diagnostics, - options: { signal: controller.signal }, - probePath, - runCommand: () => { - throw new Error("No process should exist before cancellation"); - }, - }), - ).rejects.toBeInstanceOf(AnalysisCancelledError); + if (scenario === "pre-aborted") controller.abort(reason); + const inspection = inspectPwndbgCore({ + environment: { + REA_PWNDBG_GDB: gdb, + REA_PWNDBG_GDBINIT: join(workspace.root, "missing-init"), + REA_PWNDBG_VENV_PATH: join(workspace.root, "missing-venv"), + }, + architecture: "x86_64-little-endian", + rootPath: workspace.root, + resources: new ArtifactResourceScope(), + diagnostics, + options: { signal: controller.signal }, + probePath, + runCommand: () => { + throw new Error("No process should exist before cancellation"); + }, + }); + const failure = await inspection.catch((cause: unknown) => cause); + if (!(failure instanceof AnalysisCancelledError)) + throw new Error( + "Expected cancellation at the debugger admission boundary", + ); + expect(failure.cause).toBe(reason); expect(probes).toBe(scenario === "pre-aborted" ? 0 : 1); await expect( access(join(workspace.root, "debugger")), @@ -58,3 +66,55 @@ it.each(["pre-aborted", "aborted-during-probe"] as const)( }); }, ); + +const execute = promisify(execFile); + +it("establishes core-only debugger state before querying recorded mappings", async () => { + await execute( + "python3", + ["-m", "unittest", "bridge/pwndbg/test_core_context.py", "-v"], + { + timeout: 10_000, + maxBuffer: 1024 * 1024, + }, + ); +}); + +it.each(["x86_64-little-endian", "aarch64-little-endian"] as const)( + "passes the validated %s profile to the debugger without reinterpreting core bytes", + async (architecture) => { + const workspace = await createTestWorkspace("rea-pwndbg-profile-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + let requested = false; + await expect( + inspectPwndbgCore({ + architecture, + environment: { + REA_PWNDBG_GDB: join(workspace.root, "gdb"), + REA_PWNDBG_GDBINIT: join(workspace.root, "gdbinit.py"), + REA_PWNDBG_VENV_PATH: join(workspace.root, "venv"), + }, + rootPath: workspace.root, + resources: new ArtifactResourceScope(), + diagnostics, + options: undefined, + probePath: async () => {}, + runCommand: async () => { + const request: unknown = JSON.parse( + await readFile( + join(workspace.root, "debugger", "request.json"), + "utf8", + ), + ); + expect(request).toMatchObject({ + architecture, + snapshot_path: join(workspace.root, "object.snapshot"), + }); + requested = true; + throw new Error("Stop after observing the debugger request"); + }, + }), + ).rejects.toBeDefined(); + expect(requested).toBe(true); + }, +);