mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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: |
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user