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
This commit is contained in:
morluto committed 2026-10-18 10:26:42 +08:00
1 parent 847472acf5
commit 5bf4fffc1b
7 files changed
+235 -26

No files matched your search

+12 -5
View File
@@ -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: |
+14 -1
View File
@@ -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.")
+117
View File
@@ -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()
+7
View File
@@ -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(
+4
View File
@@ -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<NodeJS.ProcessEnv>;
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,
}),
@@ -120,6 +120,7 @@ export class PwntoolsRecordedCrashProvider
try {
const context = await inspectPwndbgCore({
environment,
architecture: value.architecture,
rootPath,
resources,
diagnostics,
+80 -20
View File
@@ -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<void>((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);
},
);