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
This commit is contained in:
morluto committed 2026-10-18 10:27:37 +08:00
1 parent 5bf4fffc1b
commit b33c0df02e
8 files changed
+322 -45

No files matched your search

+1 -1
View File
@@ -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)
+18 -4
View File
@@ -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.
@@ -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,
);
});
});
@@ -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<void, AnalysisCapabilityUnavailableError> {
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);
}
@@ -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,
@@ -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);
},
);
@@ -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 };
+43 -1
View File
@@ -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(