fix(process): exonerate kernel threads from ownership token checks

An unrelated kernel thread that appeared during a provider run has no
readable /proc/<pid>/environ, so the ownership sweep recorded it as a live
candidate with an unreadable token and failed cleanup with
cleanup_incomplete. Kernel threads exit quickly, so the read usually
reports ESRCH and is not attributable to environment withholding, which
meant a cleanly finished provider run discarded its result.

Kernel threads are forked by the kernel, never by a provider, so they can
never carry that run's token. Read PF_KTHREAD from the same /proc/<pid>/stat
identity observation and treat a kernel thread with no readable token as
unverified evidence, like an unrelated process whose environment the OS
withholds. The candidate still has to be otherwise unrelated and holds no
signal authority.
This commit is contained in:
2SinceJuly authored and Ysopwnable committed 2026-11-14 05:36:23 +01:00
1 parent 7db1b61790
commit 5bb6591d58
5 files changed
+190 -5

No files matched your search

+19
View File
@@ -37,6 +37,7 @@ it("parses command names containing parentheses and retains native relation fact
parentPid: 17,
processGroupId: 18,
sessionId: 19,
kernelThread: false,
});
});
@@ -69,6 +70,7 @@ it("retains identity when Linux teardown reports unavailable group and session I
parentPid: 0,
processGroupId: -1,
sessionId: -1,
kernelThread: false,
});
expect(
parseLinuxProcessStat(statRecord({ processGroupId: "-2" })),
@@ -122,6 +124,23 @@ it.each(["invalid", "-1", "4.1", "9007199254740992"])(
parentPid: 17,
processGroupId: 18,
sessionId: 19,
kernelThread: undefined,
});
},
);
it("distinguishes a kernel thread from a process sharing its flags", () => {
// Field 9 task_flags: PF_KTHREAD is 0x00200000; PF_EXITING stays orthogonal.
expect(parseLinuxProcessStat(statRecord({ flags: "2097408" }))).toMatchObject({
kernelThread: true,
lifecycle: "non-zombie",
});
expect(parseLinuxProcessStat(statRecord({ flags: "2097156" }))).toMatchObject({
kernelThread: true,
lifecycle: "exiting",
});
expect(parseLinuxProcessStat(statRecord({ flags: "4" }))).toMatchObject({
kernelThread: false,
lifecycle: "exiting",
});
});
+10
View File
@@ -10,6 +10,12 @@ export interface LinuxProcessStat {
readonly parentPid: number;
readonly processGroupId: number;
readonly sessionId: number;
/**
* CF_KTHREAD|PF_KTHREAD (Linux `fs/proc/array.c` task_flags). Kernel threads
* are never forked by a provider, never inherit its environment, and cannot
* be token-owned; undefined when field 9 could not be parsed.
*/
readonly kernelThread: boolean | undefined;
}
/**
@@ -70,6 +76,9 @@ export const parseLinuxProcessStat = (
flagsText !== undefined && /^\d+$/u.test(flagsText)
? Number(flagsText)
: Number.NaN;
const kernelThread = Number.isSafeInteger(flags)
? (flags & 0x00200000) !== 0
: undefined;
const lifecycle: ProcessLifecycle | undefined =
state === "Z"
? "zombie"
@@ -89,5 +98,6 @@ export const parseLinuxProcessStat = (
parentPid,
processGroupId,
sessionId,
kernelThread,
};
};
@@ -0,0 +1,141 @@
import { expect, it } from "vitest";
import {
verifyNoTokenOwnedProcesses,
type ProcessIdentityObservation,
type ProcessOwnershipHost,
type ProcessRunTokenObservation,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
/**
* A kernel thread that appeared while the capture ran. The kernel reports it
* with ppid 2 and pgid 0; the capture never forked it and it never inherits
* the provider's environment.
*/
const kernelThread: ProcessTableEntry = {
pid: 200,
parentPid: 2,
processGroupId: 0,
state: "R",
command: "psimon",
};
/**
* Rows present while the capture runs (and so in its snapshot), but released
* before the final verification below.
*/
const captureRows: readonly ProcessTableEntry[] = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture-root",
},
{
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "capture-child",
},
];
const makeHost = (options: {
readonly kernelThread: boolean;
}): ProcessOwnershipHost => ({
platform: "linux",
// Cleanup already released the capture tree; only the kernel thread remains.
listProcesses: async () => [kernelThread],
environment: async () => ({}),
runTokens: async (entries) =>
new Map<number, ProcessRunTokenObservation>(
entries.map(({ pid }) => [
pid,
pid === kernelThread.pid
? // The thread exited between the snapshot and its environment read.
{
state: "unavailable",
code: "ESRCH",
reason: "ESRCH: no such process",
}
: { state: "readable", runId: "sweep-run" },
]),
),
processIdentities: async (entries) =>
new Map<number, ProcessIdentityObservation>(
entries.map((entry) => {
if (entry.pid !== kernelThread.pid)
return [
entry.pid,
{
state: "absent" as const,
reason: "not-found-in-caller-pid-namespace" as const,
},
] as const;
return [
entry.pid,
{
state: "readable" as const,
identity: `start-${String(entry.pid)}`,
lifecycle: {
state: "observed" as const,
lifecycle: "non-zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: kernelThread.parentPid,
processGroupId: kernelThread.processGroupId,
},
...(options.kernelThread ? { kernelThread: true as const } : {}),
},
] as const;
}),
),
captureBaseline: async () =>
captureRows.map(({ pid }) => ({
pid,
identity: `start-${String(pid)}`,
})),
signalGroup: () => undefined,
});
const relation = {
leaderPid: 100,
processGroupId: 100,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
};
it("exonerates an unrelated kernel thread whose token read raced its exit", async () => {
await expect(
verifyNoTokenOwnedProcesses(
"sweep-run",
makeHost({ kernelThread: true }),
undefined,
relation,
),
).resolves.toEqual({
cleaned: true,
signaled: false,
unverified: [{ pid: kernelThread.pid, diagnostic: "ESRCH: no such process" }],
});
});
it("keeps an unrelated user process with an unreadable token fail-closed", async () => {
// The same ESRCH diagnostic without the native PF_KTHREAD fact stays a
// cleanup failure: only the kernel-thread observation exonerates it.
await expect(
verifyNoTokenOwnedProcesses(
"sweep-run",
makeHost({ kernelThread: false }),
undefined,
relation,
),
).resolves.toMatchObject({
cleaned: false,
reason: expect.stringContaining(
"process ownership token could not be read for 1 live process(es)",
),
});
});
+17 -5
View File
@@ -110,6 +110,12 @@ export type ProcessIdentityObservation =
* truncated. It names a process in diagnostics; never identity or lookup.
*/
readonly name?: string;
/**
* Linux PF_KTHREAD from the same `/proc/<pid>/stat` record. A kernel
* thread is never forked by a provider and cannot carry its run token;
* optional because only a Linux stat read can establish it.
*/
readonly kernelThread?: true;
}
| {
readonly state: "absent";
@@ -1911,13 +1917,19 @@ const scanWithOwnershipEvidence = async (
fact.identityAfter.state === "readable"
? fact.identityAfter
: undefined;
// An unrelated kernel thread is forked by the kernel, never by a provider,
// so it cannot carry this run's token and cleanup must not act on it. Its
// token read typically fails while the thread exits (ESRCH) rather than
// because the environment was withheld, which is why environment
// withholding alone cannot exonerate it.
if (
knownUnrelated &&
environmentWithheldFromInspection(
fact.token.reason,
observed?.effectiveUid,
observed?.environmentRestriction,
)
(observed?.kernelThread === true ||
environmentWithheldFromInspection(
fact.token.reason,
observed?.effectiveUid,
observed?.environmentRestriction,
))
) {
unverified.push({
pid: fact.process.pid,
@@ -1304,6 +1304,9 @@ export const createSystemProcessOwnershipHost = (
state: "readable" as const,
identity: `linux-starttime:${facts.startTime}`,
...(facts.name === "" ? {} : { name: facts.name }),
...(facts.kernelThread === true
? { kernelThread: true as const }
: {}),
...(effectiveUid === undefined ? {} : { effectiveUid }),
...(environmentRestriction === undefined
? {}