fix(ci): distinguish process inventory from historical PID lookups

This commit is contained in:
gensokyo-dev
2026-10-19 13:28:25 +00:00
parent 5f94077b3c
commit b32ce58c3a
7 changed files with 223 additions and 35 deletions
+6 -1
View File
@@ -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.
+12 -11
View File
@@ -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))
+14
View File
@@ -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) {
@@ -0,0 +1,15 @@
export interface SyscallTrace {
readonly pid: number;
readonly text: string;
}
export function nodeInventoryThreads(
traces: readonly SyscallTrace[],
executable: string,
): ReadonlySet<number>;
export function assertHistoricalPidReads(input: {
readonly trace: SyscallTrace;
readonly historicalPid: number;
readonly nodeThreads: ReadonlySet<number>;
readonly liveCollision: boolean;
readonly psInventory: boolean;
}): readonly string[];
@@ -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("<unfinished ...>")) 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;
}
+32 -23
View File
@@ -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,
});
@@ -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 <unfinished ...>",
"<... 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]);
});