mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
fix(ci): distinguish process inventory from historical PID lookups
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
Reference in New Issue
Block a user