diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md index 836aa82677..4f04639ef2 100644 --- a/docs/recorded-crashes.md +++ b/docs/recorded-crashes.md @@ -105,6 +105,11 @@ 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. The syscall trace also rejects access to the still-present +owned live sentinel. Observed Node processes and their traced threads may read +that live process's `stat` for the shared ownership inventory; debugger and +decoder processes cannot use this exception. A second recording uses a valid +historical PID above the host's assignable PID range and rejects every lookup, +including Node `stat` reads, to detect historical-PID-driven access separately +from live inventory. 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/scripts/lib/recorded-core-fixtures.py b/scripts/lib/recorded-core-fixtures.py index cc8d654439..ba65bb3cab 100644 --- a/scripts/lib/recorded-core-fixtures.py +++ b/scripts/lib/recorded-core-fixtures.py @@ -8,7 +8,7 @@ from elftools.elf.elffile import ELFFile from elftools.construct import Container from pwnlib.elf.datatypes import elf_prstatus_amd64 -source, directory, sentinel = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3]) +source, directory, sentinel, unlisted = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]) original = source.read_bytes() image = ELFFile(io.BytesIO(original)) segments = list(image.iter_segments()) @@ -81,14 +81,15 @@ content = bytearray(original) start = descriptor_start(status) + elf_prstatus_amd64.pr_pid.offset content[start:start + ctypes.sizeof(ctypes.c_uint32)] = (0xfffffffe).to_bytes(4, "little") save("negative-pid", content) -content = bytearray(original) -content[start:start + ctypes.sizeof(ctypes.c_uint32)] = sentinel.to_bytes(4, "little") -for note in notes: - if note.n_name == "CORE" and note.n_type == "NT_PRPSINFO": - # Build with unchanged pyelftools; no hard-coded PID offset or host file resolution. - raw = dict(note.n_desc, pr_pid=sentinel) - encoded = image.structs.Elf_Prpsinfo.build(Container(**raw)) - offset = descriptor_start(note) - content[offset:offset + len(encoded)] = encoded -save("historical-pid-collision", content) +for name, pid in [("historical-pid-collision", sentinel), ("historical-pid-unlisted", unlisted)]: + content = bytearray(original) + content[start:start + ctypes.sizeof(ctypes.c_uint32)] = pid.to_bytes(4, "little") + for note in notes: + if note.n_name == "CORE" and note.n_type == "NT_PRPSINFO": + # Build with unchanged pyelftools; no hard-coded PID offset or host file resolution. + raw = dict(note.n_desc, pr_pid=pid) + encoded = image.structs.Elf_Prpsinfo.build(Container(**raw)) + offset = descriptor_start(note) + content[offset:offset + len(encoded)] = encoded + save(name, content) print(json.dumps(files)) diff --git a/scripts/verify-recorded-crash.mjs b/scripts/verify-recorded-crash.mjs index eb59749c73..3fa099801d 100644 --- a/scripts/verify-recorded-crash.mjs +++ b/scripts/verify-recorded-crash.mjs @@ -61,6 +61,10 @@ try { const core = await createRecordedCore(options); sentinel = await startCoreSentinel(options); sentinel.assertAlive(); + // Valid signed ELF PID, outside this Linux host's assignable PID range. + const unlistedPid = 2 ** 31 - 1; + const pidMaximum = Number(await readFile("/proc/sys/kernel/pid_max", "utf8")); + assert.ok(Number.isSafeInteger(pidMaximum) && pidMaximum < unlistedPid); const fixtures = JSON.parse( await fixtureCommand( environment.REA_PWNTOOLS_PYTHON, @@ -72,6 +76,7 @@ try { core, root.path, String(sentinel.pid), + String(unlistedPid), ], options, ), @@ -276,6 +281,15 @@ try { core: fixtures["historical-pid-collision"], sentinel, }); + trace.push( + ...(await verifyCoreInspectionTrace({ + ...options, + entrypoint, + core: fixtures["historical-pid-unlisted"], + historicalPid: unlistedPid, + sentinel, + })), + ); cases += trace.length; assert.ok((await readFile(core)).length > 0); } catch (cause) { diff --git a/scripts/verify/recorded-crash/trace-policy.d.mts b/scripts/verify/recorded-crash/trace-policy.d.mts new file mode 100644 index 0000000000..ad73ad47ce --- /dev/null +++ b/scripts/verify/recorded-crash/trace-policy.d.mts @@ -0,0 +1,15 @@ +export interface SyscallTrace { + readonly pid: number; + readonly text: string; +} +export function nodeInventoryThreads( + traces: readonly SyscallTrace[], + executable: string, +): ReadonlySet; +export function assertHistoricalPidReads(input: { + readonly trace: SyscallTrace; + readonly historicalPid: number; + readonly nodeThreads: ReadonlySet; + readonly liveCollision: boolean; + readonly psInventory: boolean; +}): readonly string[]; diff --git a/scripts/verify/recorded-crash/trace-policy.mjs b/scripts/verify/recorded-crash/trace-policy.mjs new file mode 100644 index 0000000000..9a5e2f32fa --- /dev/null +++ b/scripts/verify/recorded-crash/trace-policy.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; + +/** Identify REA's Node processes and their observed CLONE_THREAD children. */ +export function nodeInventoryThreads(traces, executable) { + const executesForeign = (text) => + text + .split("\n") + .some( + (line) => + /^execve\(/u.test(line) && + line.endsWith(" = 0") && + !line.startsWith(`execve(${JSON.stringify(executable)},`), + ); + const byPid = new Map(traces.map((trace) => [trace.pid, trace])); + const nodes = new Set( + traces + .filter( + ({ text }) => + !executesForeign(text) && + text + .split("\n") + .some( + (line) => + line.startsWith(`execve(${JSON.stringify(executable)},`) && + line.endsWith(" = 0"), + ), + ) + .map(({ pid }) => pid), + ); + for (const pid of nodes) { + let pendingThreadClone = false; + for (const line of byPid.get(pid)?.text.split("\n") ?? []) { + if (!/^(?:clone3?\(|<\.\.\. clone3? resumed>)/u.test(line)) continue; + if (/^clone3?\(/u.test(line)) + pendingThreadClone = /\bCLONE_THREAD\b/u.test(line); + const child = / = (\d+)$/u.exec(line); + if (pendingThreadClone && child !== null) { + const childPid = Number(child[1]); + const trace = byPid.get(childPid); + if (trace !== undefined && !executesForeign(trace.text)) + nodes.add(childPid); + } + if (!line.endsWith("")) pendingThreadClone = false; + } + } + return nodes; +} + +/** Permit only observed inventory metadata; an unlisted historical PID permits no lookup. */ +export function assertHistoricalPidReads({ + trace, + historicalPid, + nodeThreads, + liveCollision, + psInventory, +}) { + const reads = trace.text + .split("\n") + .filter((line) => line.includes(`/proc/${historicalPid}/`)); + for (const line of reads) { + const nodeStat = + nodeThreads.has(trace.pid) && + new RegExp( + `^openat\\([^,]+, "/proc/${historicalPid}/stat", O_RDONLY(?:\\|O_CLOEXEC)?\\)`, + "u", + ).test(line); + const psMetadata = + psInventory && + new RegExp( + `/proc/${historicalPid}/(?:stat|status|cmdline|wchan|environ)"`, + "u", + ).test(line); + assert.ok( + liveCollision && (nodeStat || psMetadata), + `Historical PID caused a non-inventory lookup in trace ${trace.pid}: ${line}`, + ); + } + return reads; +} diff --git a/scripts/verify/recorded-crash/trace.mjs b/scripts/verify/recorded-crash/trace.mjs index 7cf5835087..87324546d1 100644 --- a/scripts/verify/recorded-crash/trace.mjs +++ b/scripts/verify/recorded-crash/trace.mjs @@ -3,6 +3,10 @@ import { readdir, readFile, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { isAbsolute, join, resolve } from "node:path"; import { runOwnedCommand } from "../../../dist/process/OwnedCommand.js"; +import { + assertHistoricalPidReads, + nodeInventoryThreads, +} from "./trace-policy.mjs"; /** Check attempted syscalls, including failed attempts, on a recorded PID that collides with an owned live sentinel. */ export async function verifyCoreInspectionTrace({ @@ -12,6 +16,7 @@ export async function verifyCoreInspectionTrace({ core, sentinel, runId, + historicalPid = sentinel.pid, }) { const gdb = await realpath(environment.REA_PWNDBG_GDB); const python = await realpath(environment.REA_PWNTOOLS_PYTHON); @@ -30,7 +35,8 @@ export async function verifyCoreInspectionTrace({ const observations = []; for (const mode of ["cli", "mcp"]) { sentinel.assertAlive(); - const prefix = join(root, `inspection-${mode}.trace`); + const traceName = `inspection-${historicalPid}-${mode}.trace.`; + const prefix = join(root, traceName.slice(0, -1)); const invocation = mode === "cli" ? [ @@ -70,8 +76,22 @@ export async function verifyCoreInspectionTrace({ const executions = []; const ownershipReads = []; let gdbObserved = false; + const traces = []; for (const file of await readdir(root)) { - if (!file.startsWith(`inspection-${mode}.trace.`)) continue; + if (!file.startsWith(traceName)) continue; + const pid = Number(file.slice(traceName.length)); + assert.ok(Number.isSafeInteger(pid) && pid > 0); + const text = await readFile(join(root, file), "utf8"); + // Retain only execution/thread evidence across files, not all raw traces. + const inventory = text + .split("\n") + .filter((line) => /^(?:execve|clone|<\.\.\. clone)/u.test(line)) + .join("\n"); + traces.push({ pid, text: inventory, file }); + } + const nodeThreads = nodeInventoryThreads(traces, process.execPath); + for (const trace of traces) { + const { file } = trace; 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. @@ -90,26 +110,15 @@ export async function verifyCoreInspectionTrace({ ) && line.endsWith(" = 0"), ); - const pidReads = text - .split("\n") - .filter((line) => line.includes(`/proc/${sentinel.pid}/`)); - if (ownershipTrace) { - for (const line of pidReads) { - assert.ok( - new RegExp( - `/proc/${sentinel.pid}/(?:stat|status|cmdline|wchan|environ)"`, - "u", - ).test(line), - `Unexpected ownership observation of the sentinel: ${line}`, - ); - ownershipReads.push(line); - } - } else - assert.deepEqual( - pidReads, - [], - `Historical PID caused a provider lookup in ${file}: ${pidReads.join("\n")}`, - ); + ownershipReads.push( + ...assertHistoricalPidReads({ + trace: { pid: trace.pid, text }, + historicalPid, + nodeThreads, + liveCollision: historicalPid === sentinel.pid, + psInventory: ownershipTrace, + }), + ); assert.ok( !/\bptrace\(/u.test(text), `Inspection attempted ptrace: ${file}`, @@ -173,7 +182,7 @@ export async function verifyCoreInspectionTrace({ observations.push({ mode, executions, - historical_pid: sentinel.pid, + historical_pid: historicalPid, sentinel: "still-alive", ownership_process_table_reads: ownershipReads, }); diff --git a/tests/boundary/process/recordedCrashTracePolicy.test.ts b/tests/boundary/process/recordedCrashTracePolicy.test.ts new file mode 100644 index 0000000000..fbf9c78159 --- /dev/null +++ b/tests/boundary/process/recordedCrashTracePolicy.test.ts @@ -0,0 +1,65 @@ +import { expect, it } from "vitest"; +import { + assertHistoricalPidReads, + nodeInventoryThreads, +} from "../../../scripts/verify/recorded-crash/trace-policy.mjs"; + +const node = "/fixture/node"; +const stat = 'openat(AT_FDCWD, "/proc/900/stat", O_RDONLY|O_CLOEXEC) = 22'; +const traces = [ + { + pid: 100, + text: [ + 'execve("/fixture/node", ["node", "rea.mjs"], 0x1 /* 3 vars */) = 0', + "clone3({flags=CLONE_VM|CLONE_THREAD|CLONE_SIGHAND}, 88) = 101", + "clone(child_stack=NULL, flags=SIGCHLD) = 102", + ].join("\n"), + }, + { pid: 101, text: stat }, + { pid: 102, text: stat }, +]; + +const check = (pid: number, text: string, liveCollision = true) => + assertHistoricalPidReads({ + trace: { pid, text }, + historicalPid: 900, + nodeThreads: nodeInventoryThreads(traces, node), + liveCollision, + psInventory: false, + }); + +it("allows a live collision's stat read only in an observed Node process or thread", () => { + expect([...nodeInventoryThreads(traces, node)]).toEqual([100, 101]); + expect(check(100, stat)).toEqual([stat]); + expect(check(101, stat)).toEqual([stat]); + expect(() => check(102, stat)).toThrow("non-inventory lookup"); +}); + +it.each(["mem", "maps", "environ", "exe", "root"])( + "rejects Node access to the live historical PID's %s", + (name) => { + expect(() => check(101, stat.replace("/stat", `/${name}`))).toThrow( + "non-inventory lookup", + ); + }, +); + +it("rejects an unlisted historical PID lookup even from an observed inventory thread", () => { + expect(() => check(101, stat, false)).toThrow("non-inventory lookup"); +}); + +it("keeps exec-replaced threads foreign and follows acknowledged unfinished thread clones", () => { + const observed = [ + { + pid: 100, + text: [ + traces[0]!.text, + "clone3({flags=CLONE_VM|CLONE_THREAD|CLONE_SIGHAND}, 88 ", + "<... clone3 resumed>) = 103", + ].join("\n"), + }, + { pid: 101, text: 'execve("/fixture/gdb", ["gdb"], 0x1) = 0\n' + stat }, + { pid: 103, text: stat }, + ]; + expect([...nodeInventoryThreads(observed, node)]).toEqual([100, 103]); +});