Skip to content

fix(enforce): verify register_session cgroup ownership against reality (#119)#126

Draft
gnanirahulnutakki wants to merge 1 commit into
devfrom
fix/epicA-119-cgroup-ownership
Draft

fix(enforce): verify register_session cgroup ownership against reality (#119)#126
gnanirahulnutakki wants to merge 1 commit into
devfrom
fix/epicA-119-cgroup-ownership

Conversation

@gnanirahulnutakki

Copy link
Copy Markdown
Member

Summary

Closes #119 (HIGH). Part of Epic A (#63). Residual of #108, gap in #115's verifyRegisterSessionCgroup.

verifyRegisterSessionCgroup checked only that root_pid was a process the peer had spawned (PID ancestry) — it never inspected reg.CgroupID. A non-root allowed peer could register_session{root_pid: <its own child>, cgroup_id: <victim's cgroup>}: ancestry passed trivially (the child really was theirs), the mismatched cgroup_id was never checked, and the peer could then apply_policy on its own session and reach the enforcement write path for a cgroup — and workload — it does not own.

#115's code comment argued this couldn't be reliably checked because cgroup namespaces make /proc/<pid>/cgroup namespace-relative. That's correct, but misapplied: the daemon never needs the socket peer's own cgroup (which can be namespace-relative if the peer is containerized) — it needs root_pid's cgroup, resolved by the daemon itself. The daemon runs host-side, in the init cgroup namespace, so /proc/<root_pid>/cgroup read from there reports the real, non-namespace-relative path — exactly what #119's "Recommended fix" section describes.

Fix

Two independent, both-required checks in verifyRegisterSessionCgroup (daemon_cgroup_verify_linux.go):

  1. Ancestry (existing, fix(enforce): close enforcement-surface authz + stale-policy findings (#108/#109/#110) #115) — root_pid must be a process the peer spawned.
  2. Ownership (new, security(enforce): register_session cgroup-ownership check ignores cgroup_id — non-root peer can bind+govern a cgroup it doesn't own (residual of #108) #119) — resolveCgroupID(root_pid) reads /proc/<root_pid>/cgroup, resolves the cgroup v2 unified-hierarchy entry to the cgroup directory's inode (the same value bpf_get_current_cgroup_id() returns, and what the Python run bridge computes via os.stat().st_ino when it creates a session's cgroup), and requires it equal reg.CgroupID. Applied even to the root_pid == peerPID self-registration case, which the old ancestry check exempted entirely — the same vulnerability in an even simpler form (no child needed at all).

Plus the collision guard #119 calls out as a cheap interim mitigation, kept as permanent defense-in-depth rather than dropped once the real fix landed: checkCgroupCollision (main.go, platform-neutral) rejects registering a cgroup_id already bound to another live session, wired into handleAuthorizedRequest alongside the ownership check.

PoC evidence (before → after)

Ran the exact #119 PoC shape — register_session{root_pid: <spawned child>, cgroup_id: <mismatched>} — against both commits, in a --privileged Docker container on a real Linux kernel (not cross-compiled type-checking):

Before (commit cfddf89, #115, unmodified):

=== RUN   TestIssue119Baseline_PocWasAcceptedBeforeFix
    #119 CONFIRMED: pre-fix verifyRegisterSessionCgroup ACCEPTED
    register_session{root_pid=3271 (attacker's own child), cgroup_id=999999999 (victim, unrelated)}
    — OK=true, err=nil. Session "attacker-session" would have been bound to a cgroup the peer does not own.
--- PASS: TestIssue119Baseline_PocWasAcceptedBeforeFix (0.00s)

(Throwaway harness, not part of this PR — confirms the regression test below isn't vacuous.)

After (this branch):

=== RUN   TestVerifyRegisterSessionCgroup_Issue119PocRejected
    #119 PoC correctly rejected: root_pid 3348 is in cgroup 624, not the claimed cgroup_id 625;
    a peer may only register a cgroup_id its root_pid actually occupies
--- PASS: TestVerifyRegisterSessionCgroup_Issue119PocRejected (0.00s)

Test plan

All run for real on Linux (--privileged Docker container, golang:1.26-bookworm, real cgroup v2), not just cross-compiled:

  • TestVerifyRegisterSessionCgroup_Issue119PocRejected — the exact PoC shape (spawned child, mismatched cgroup_id), now rejected.
  • TestVerifyRegisterSessionCgroup_Issue119PocAcceptedWithCorrectCgroup — same ancestry, TRUE cgroup_id → accepted (proves this is a real comparison, not a blanket rejection).
  • TestVerifyRegisterSessionCgroup_SelfWithWrongCgroupRejected — the simpler self-registration variant of the same gap.
  • TestVerifyRegisterSessionCgroup_LegitAdoptFlowAccepted — reproduces the real ardur run sequence end to end: creates a real cgroup v2 subtree, spawns a child, moves it via cgroup.procs (mirrors CgroupHandle.adopt_pid), registers with the resulting real inode → accepted. Skips gracefully (not a failure) if the environment doesn't grant cgroup v2 write access; it did in this container and passed for real.
  • TestVerifyRegisterSessionCgroup_SelfIsOwned, _NonDescendantRejected, _BogusRootRejected, _PeerNotVisibleSkips, _RootPeerSkips — existing fix(enforce): close enforcement-surface authz + stale-policy findings (#108/#109/#110) #115 coverage, updated where the new check changes expected behavior (SelfIsOwned now needs the real cgroup_id, since the old test's arbitrary value only passed because nothing checked it).
  • TestCheckCgroupCollision_* (5 tests) + TestHandleAuthorizedRequest_RejectsCgroupCollisionEvenWithNoOpVerifier — collision guard, both unit-level and wired through the real request-handling path.
  • Full repo go build/go vet/go test ./... green: real Linux (Docker, all packages, not just the changed one), cross-compiled linux/{amd64,arm64}, and darwin native.
  • gofmt -l: clean on every changed file.
  • Confirmed by code inspection that kernel-smoke (ardur-guard-smoke) never calls register_session at all, and seccomp-smoke's register_session call runs entirely under sudo (UID 0) — both untouched by this change, since the root-peer skip this fix preserves is exactly what exempts them.

#119)

verifyRegisterSessionCgroup only checked that root_pid was a process the
peer had spawned (PID ancestry) — it never checked whether root_pid was
actually IN the claimed cgroup_id. A non-root allowed peer could
register{root_pid: <its own child>, cgroup_id: <victim's cgroup>};
ancestry passed trivially, and the mismatched cgroup_id was never
validated, so the peer could then apply_policy against a cgroup — and
workload — it does not own. Demonstrated live: on the pre-fix commit
this exact call returns OK=true.

#115's code comment argued cgroup ownership couldn't be reliably checked
because cgroup namespaces make /proc/<pid>/cgroup namespace-relative.
That reasoning is correct, but was applied to the wrong process: the
daemon never needs the socket PEER's own cgroup (which can be
namespace-relative if the peer runs containerized) — it needs root_pid's
cgroup, resolved by the daemon itself, which runs host-side in the init
cgroup namespace. /proc/<root_pid>/cgroup read from there reports the
real, non-namespace-relative path.

Fix: resolveCgroupID reads /proc/<root_pid>/cgroup, resolves the cgroup
v2 unified-hierarchy entry to the cgroup directory's inode (the same
value bpf_get_current_cgroup_id() returns, and what the Python run
bridge computes via os.stat().st_ino), and requires it equal the
claimed cgroup_id. Applied even to the root_pid==peerPID self-
registration case, which the old ancestry check exempted entirely and
was the same vulnerability in an even simpler form.

Also adds checkCgroupCollision: a claim that independently passes
ownership verification must still not be allowed to bind a cgroup_id
another live session already holds. Platform-neutral (no /proc
dependency), wired into handleAuthorizedRequest alongside the ownership
check.

Verified on real Linux (Docker, --privileged), not just cross-compiled:
- The exact #119 PoC (spawned child, mismatched cgroup_id) is REJECTED.
- The identical scenario against the pre-fix commit (cfddf89) is
  confirmed to return err=nil (accepted) — proving the regression test
  is non-vacuous, not just a change that happens to pass.
- The legitimate ardur-run adoption flow — create a cgroup, move a real
  child into it via cgroup.procs, register with the real inode — is
  accepted end to end.
- Collision guard rejects a second session claiming an already-bound
  cgroup_id, including through the real handleAuthorizedRequest path.
- Full repo go build/vet/test green on real Linux and cross-compiled
  linux/{amd64,arm64}; darwin build/vet/test green natively.
- kernel-smoke (ardur-guard-smoke) and seccomp-smoke's register_session
  call (root/UID-0, exempted by design) are unaffected — confirmed by
  code inspection, neither path changed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant