mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
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:
5 files changed
+255
-14
No files matched your search
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user