fix(enforce): verify register_session cgroup ownership against reality (#119)#126
Draft
gnanirahulnutakki wants to merge 1 commit into
Draft
fix(enforce): verify register_session cgroup ownership against reality (#119)#126gnanirahulnutakki wants to merge 1 commit into
gnanirahulnutakki wants to merge 1 commit into
Conversation
#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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #119 (HIGH). Part of Epic A (#63). Residual of #108, gap in #115's
verifyRegisterSessionCgroup.verifyRegisterSessionCgroupchecked only thatroot_pidwas a process the peer had spawned (PID ancestry) — it never inspectedreg.CgroupID. A non-root allowed peer couldregister_session{root_pid: <its own child>, cgroup_id: <victim's cgroup>}: ancestry passed trivially (the child really was theirs), the mismatchedcgroup_idwas never checked, and the peer could thenapply_policyon 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>/cgroupnamespace-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 needsroot_pid's cgroup, resolved by the daemon itself. The daemon runs host-side, in the init cgroup namespace, so/proc/<root_pid>/cgroupread 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):root_pidmust be a process the peer spawned.resolveCgroupID(root_pid)reads/proc/<root_pid>/cgroup, resolves the cgroup v2 unified-hierarchy entry to the cgroup directory's inode (the same valuebpf_get_current_cgroup_id()returns, and what the Python run bridge computes viaos.stat().st_inowhen it creates a session's cgroup), and requires it equalreg.CgroupID. Applied even to theroot_pid == peerPIDself-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 acgroup_idalready bound to another live session, wired intohandleAuthorizedRequestalongside 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--privilegedDocker container on a real Linux kernel (not cross-compiled type-checking):Before (commit
cfddf89, #115, unmodified):(Throwaway harness, not part of this PR — confirms the regression test below isn't vacuous.)
After (this branch):
Test plan
All run for real on Linux (
--privilegedDocker container,golang:1.26-bookworm, real cgroup v2), not just cross-compiled:TestVerifyRegisterSessionCgroup_Issue119PocRejected— the exact PoC shape (spawned child, mismatchedcgroup_id), now rejected.TestVerifyRegisterSessionCgroup_Issue119PocAcceptedWithCorrectCgroup— same ancestry, TRUEcgroup_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 realardur runsequence end to end: creates a real cgroup v2 subtree, spawns a child, moves it viacgroup.procs(mirrorsCgroupHandle.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.go build/go vet/go test ./...green: real Linux (Docker, all packages, not just the changed one), cross-compiledlinux/{amd64,arm64}, and darwin native.gofmt -l: clean on every changed file.kernel-smoke(ardur-guard-smoke) never callsregister_sessionat all, andseccomp-smoke'sregister_sessioncall runs entirely undersudo(UID 0) — both untouched by this change, since the root-peer skip this fix preserves is exactly what exempts them.