fix(process): identify inaccessible Linux namespace peers (#7147)

* fix(process): recognize permission-denied user namespace peers

* fix(process): fall back to UID maps when namespace links are denied

---------

Co-authored-by: dhruvac29 <dhruvac29@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-11 03:36:38 +08:00
1 parent 9dfec80e93
commit 3345019fe1
5 files changed
+255 -14

No files matched your search

+14
View File
@@ -2,6 +2,7 @@ import { expect, it } from "vitest";
import {
linuxEnvironmentRestriction,
linuxUidMapsDiffer,
parseLinuxProcessIds,
} from "./LinuxProcessStatus.js";
@@ -70,3 +71,16 @@ it("does not derive a credential restriction without caller credentials", () =>
}),
).toBeUndefined();
});
it.each([
["0 1000 1\n", "0 0 1\n", true],
[" 0\t0 1\n", "0 0 1\n", false],
["", "0 0 1\n", undefined],
["0 0 1\n", undefined, undefined],
["0 0 1 extra\n", "0 0 1\n", undefined],
] as const)(
"compares readable UID maps conservatively",
(target, caller, expected) => {
expect(linuxUidMapsDiffer(target, caller)).toBe(expected);
},
);
+44 -7
View File
@@ -32,10 +32,39 @@ export const parseLinuxProcessIds = (
/**
* Why Linux withholds `/proc/<pid>/environ` from a caller without
* CAP_SYS_PTRACE (PTRACE_MODE_READ_FSCREDS): the target's real, effective or
* saved UID/GID differs from the caller's, or the target is non-dumpable, which
* procfs reports as root ownership of `/proc/<pid>/environ`.
* saved UID/GID differs from the caller's, the target is non-dumpable (which
* procfs reports as root ownership of `/proc/<pid>/environ`), or the target
* occupies another user namespace.
*/
export type LinuxEnvironmentRestriction = "credentials" | "non-dumpable";
export type LinuxEnvironmentRestriction =
| "credentials"
| "non-dumpable"
| "user-namespace";
/** Different readable UID maps prove distinct namespaces; equal maps do not prove sameness. */
export const linuxUidMapsDiffer = (
target: string | undefined,
caller: string | undefined,
): boolean | undefined => {
const normalized = (value: string | undefined): string | undefined => {
if (value === undefined || value.trim() === "") return undefined;
const rows: string[] = [];
for (const line of value.trim().split("\n")) {
const match = /^\s*([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*$/u.exec(line);
if (match === null) return undefined;
const numbers = match.slice(1).map(Number);
if (numbers.some((number) => !Number.isSafeInteger(number)))
return undefined;
rows.push(numbers.join(" "));
}
return rows.join("\n");
};
const targetMap = normalized(target);
const callerMap = normalized(caller);
return targetMap === undefined || callerMap === undefined
? undefined
: targetMap !== callerMap;
};
/** Derive the kernel's environment restriction from one generation's procfs facts. */
export const linuxEnvironmentRestriction = (facts: {
@@ -44,8 +73,16 @@ export const linuxEnvironmentRestriction = (facts: {
readonly procfsOwnerUid: number | undefined;
readonly callerUid: number | undefined;
readonly callerGid: number | undefined;
readonly userNamespaceDiffers?: boolean | undefined;
}): LinuxEnvironmentRestriction | undefined => {
const { uids, gids, procfsOwnerUid, callerUid, callerGid } = facts;
const {
uids,
gids,
procfsOwnerUid,
callerUid,
callerGid,
userNamespaceDiffers,
} = facts;
if (
(callerUid !== undefined &&
uids.slice(0, 3).some((uid) => uid !== callerUid)) ||
@@ -54,7 +91,7 @@ export const linuxEnvironmentRestriction = (facts: {
gids.slice(0, 3).some((gid) => gid !== callerGid))
)
return "credentials";
return procfsOwnerUid !== undefined && procfsOwnerUid !== uids[1]
? "non-dumpable"
: undefined;
if (procfsOwnerUid !== undefined && procfsOwnerUid !== uids[1])
return "non-dumpable";
return userNamespaceDiffers === true ? "user-namespace" : undefined;
};
@@ -4,6 +4,7 @@ import { expect, it, vi } from "vitest";
const filesystem = vi.hoisted(() => ({
readFile: vi.fn(),
readlink: vi.fn(async (_path: string) => "user:[1]"),
// Host procfs ownership must never leak into fixture PIDs.
stat: vi.fn(async () => {
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
@@ -12,13 +13,20 @@ const filesystem = vi.hoisted(() => ({
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return { ...original, readFile: filesystem.readFile, stat: filesystem.stat };
return {
...original,
readFile: filesystem.readFile,
readlink: filesystem.readlink,
stat: filesystem.stat,
};
});
import {
createSystemProcessOwnershipHost,
readProcessOwnershipFacts,
} from "./ProcessOwnershipObservation.js";
import { verifyNoTokenOwnedProcesses } from "./ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
import type {
ProcessOwnershipHost,
ProcessTableEntry,
@@ -161,6 +169,131 @@ it.each([
},
);
it.each([
[
"different namespace",
"user:[2]",
"0 0 1\n",
42,
"environment_errno_EACCES",
true,
],
[
"permission denied",
"user:[2]",
"0 0 1\n",
42,
"environment_errno_EPERM",
true,
],
[
"same namespace",
"user:[1]",
"0 0 1\n",
42,
"environment_errno_EACCES",
false,
],
[
"denied namespace lookup",
null,
"0 1000 1\n",
42,
"environment_errno_EACCES",
true,
],
[
"equal maps after denied lookup",
null,
"0 0 1\n",
42,
"environment_errno_EACCES",
false,
],
[
"unreadable namespace and map",
null,
null,
42,
"environment_errno_EACCES",
false,
],
[
"owned group",
"user:[2]",
"0 0 1\n",
100,
"environment_errno_EACCES",
false,
],
[
"other read failure",
"user:[2]",
"0 0 1\n",
42,
"environment_errno_EIO",
false,
],
] as const)(
"classifies an unreadable process with %s and %s",
async (
_case,
targetNamespace,
targetUidMap,
processGroupId,
diagnostic,
cleaned,
) => {
const callerUid = process.getuid?.() ?? 1000;
const candidate = { ...entry, processGroupId };
filesystem.readFile.mockImplementation(async (path) => {
if (String(path) === "/proc/42/stat")
return procStat(100, 1, processGroupId);
if (String(path) === "/proc/42/status")
return `Uid:\t${callerUid}\t${callerUid}\t${callerUid}\t${callerUid}\nGid:\t1000\t1000\t1000\t1000\n`;
if (String(path) === "/proc/self/uid_map") return "0 0 1\n";
if (String(path) === "/proc/42/uid_map") {
if (targetUidMap === null) throw filesystemError("EACCES");
return targetUidMap;
}
throw new Error(`unexpected path: ${String(path)}`);
});
filesystem.stat.mockResolvedValue({ uid: callerUid } as never);
filesystem.readlink.mockImplementation(async (path) => {
if (String(path) === "/proc/self/ns/user") return "user:[1]";
if (targetNamespace === null) throw filesystemError("EACCES");
return targetNamespace;
});
const systemHost = createSystemProcessOwnershipHost("linux", {});
const host: ProcessOwnershipHost = {
...systemHost,
listProcesses: async () => [candidate],
runTokens: async () =>
new Map([[42, { state: "unavailable", reason: diagnostic }]]),
};
const result = await withCaller(callerUid, 1000, () =>
verifyNoTokenOwnedProcesses("owned-run", host, undefined, {
leaderPid: 100,
processGroupId: 100,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
}),
);
if (cleaned)
expect(result).toMatchObject({
cleaned: true,
unverified: [{ pid: 42, diagnostic }],
});
else
expect(result).toMatchObject({
cleaned: false,
failures: [{ pid: 42, reason: "environment-unreadable" }],
});
expect(filesystem.readlink).toHaveBeenCalledWith("/proc/42/ns/user");
},
);
it("keeps native identity when status UID is unavailable", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
filesystem.readFile
+5 -2
View File
@@ -1564,8 +1564,9 @@ const currentProcessUid = (): number | undefined =>
* CS_RESTRICT processes from KERN_PROCARGS2, and fails with EINVAL for
* processes owned by another account. Linux denies `/proc/<pid>/environ` for
* another account, mismatched real/saved IDs (setgid and setuid programs) and
* non-dumpable processes. These classes cannot be token-verified by this
* caller. An unrelated candidate is recorded as unverified instead of failing
* non-dumpable processes, and processes in other user namespaces when the
* environment read is permission-denied. These classes cannot be token-verified
* by this caller. An unrelated candidate is recorded as unverified instead of failing
* an otherwise clean capture; every related or otherwise unreadable candidate
* stays fail-closed.
*/
@@ -1574,6 +1575,8 @@ const environmentWithheldFromInspection = (
observedUid: number | undefined,
restriction?: LinuxEnvironmentRestriction,
): boolean => {
if (restriction === "user-namespace")
return /^(?:environment_errno_)?(?:EACCES|EPERM)(?::|\b)/u.test(diagnostic);
if (restriction !== undefined) return true;
const uid = currentProcessUid();
if (uid !== undefined && observedUid !== undefined && observedUid !== uid)
+58 -4
View File
@@ -1,5 +1,5 @@
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
import { readFile, stat as statPath } from "node:fs/promises";
import { readFile, readlink, stat as statPath } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import {
@@ -12,6 +12,7 @@ import {
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
import {
linuxEnvironmentRestriction,
linuxUidMapsDiffer,
parseLinuxProcessIds,
type LinuxEnvironmentRestriction,
} from "./LinuxProcessStatus.js";
@@ -1254,7 +1255,23 @@ export const createSystemProcessOwnershipHost = (
ProcessOwnershipHost["processIdentities"]
> = async (processes, signal, credentialPids) => {
if (processes.length === 0) return new Map();
if (platform === "linux")
if (platform === "linux") {
const callerUserNamespace =
credentialPids !== undefined && credentialPids.size > 0
? readlink("/proc/self/ns/user").catch(() => undefined)
: undefined;
let callerUidMap: Promise<string | undefined> | undefined;
const readUidMap = async (path: string): Promise<string | undefined> => {
try {
return await readFile(path, {
encoding: "utf8",
...(signal === undefined ? {} : { signal }),
});
} catch (cause: unknown) {
if (isExpectedAbort(cause, signal)) throw cause;
return undefined;
}
};
return new Map<number, ProcessIdentityObservation>(
await Promise.all(
processes.map(async ({ pid }) => {
@@ -1340,13 +1357,49 @@ export const createSystemProcessOwnershipHost = (
void cause;
}
signal?.throwIfAborted();
const environmentRestriction = linuxEnvironmentRestriction({
const restrictionFacts = {
uids,
gids: parseLinuxProcessIds(status, "Gid"),
procfsOwnerUid,
callerUid: process.geteuid?.(),
callerGid: process.getegid?.(),
});
};
let environmentRestriction =
linuxEnvironmentRestriction(restrictionFacts);
if (environmentRestriction === undefined) {
let userNamespaceDiffers: boolean | undefined;
try {
const targetUserNamespace = await readlink(
`/proc/${String(pid)}/ns/user`,
);
const callerNamespace = await callerUserNamespace;
if (callerNamespace !== undefined)
userNamespaceDiffers =
callerNamespace !== targetUserNamespace;
} catch (cause: unknown) {
void cause;
}
signal?.throwIfAborted();
if (userNamespaceDiffers === undefined) {
// ns/user readlink shares environ's ptrace check. A differing
// readable UID map can still establish distinct namespaces.
const targetUidMap = await readUidMap(
`/proc/${String(pid)}/uid_map`,
);
if (targetUidMap !== undefined) {
callerUidMap ??= readUidMap("/proc/self/uid_map");
userNamespaceDiffers = linuxUidMapsDiffer(
targetUidMap,
await callerUidMap,
);
}
}
environmentRestriction = linuxEnvironmentRestriction({
...restrictionFacts,
userNamespaceDiffers,
});
}
signal?.throwIfAborted();
let afterStatus: string;
try {
@@ -1428,6 +1481,7 @@ export const createSystemProcessOwnershipHost = (
}),
),
);
}
if (darwinTokens === undefined) return new Map();
try {
return await darwinTokens.identities(processes, signal);