Skip to content

fix(daemon): move the self-test probe out of the shared /tmp namespace - #2

Merged
nikicat merged 1 commit into
mainfrom
fix/selftest-probe-path
Jul 27, 2026
Merged

fix(daemon): move the self-test probe out of the shared /tmp namespace#2
nikicat merged 1 commit into
mainfrom
fix/selftest-probe-path

Conversation

@nikicat

@nikicat nikicat commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Item 1 of the resequenced queue (see #1): the one carried-over note that is a live escalation rather than a rough edge.

The bug

cordond is uid 0 and the self-test runs before seed_policy, so cordon is not yet enforcing on itself. The probe was $TMPDIR/cordon-selftest.<pid>/, built with create_dir_all — which accepts a pre-existing directory of any owner — and seeded with fs::write, which is O_WRONLY|O_CREAT|O_TRUNC with no O_NOFOLLOW.

A local user pre-creates that directory holding src -> <any root-owned file> and root truncates it on the next start. Pids are sequential, so guessing is cheap. Aimed at /etc/cordon/policy.toml it is a boundary kill: the daemon then fails to compile the policy and enforces nothing.

fs.protected_symlinks does not cover this — may_follow_link() keys on the symlink's immediate parent being sticky and world-writable, and there that parent is the attacker's own plain directory, so the check returns before comparing uids.

The fix

The probe moves to /run/cordon, root-only because /run is root-owned 0755. That removes the shared namespace outright rather than hardening each open against it — no O_NOFOLLOW/O_EXCL dance needed, because nothing unprivileged can plant any component of the path.

Secondarily, the probe is removed and recreated with create_dir instead of being reused wherever found, so a found directory and any symlink in it are gone before the seed writes. That guard races on its own and is not the fix — it keeps the failure visible if the location ever moves back.

probe_dir() became make_probe(base), absorbing the seed loop, so the step that actually follows the symlink is reachable from an unprivileged test.

Tests

Both verified to fail without their half of the fix, not merely asserted to:

  • the_probe_never_reuses_a_directory_it_found — plants selftest.<pid>/src -> policy.toml, asserts the victim survives. Reverting to create_dir_all reproduces the truncation: left: "x", right: "the live policy".
  • the_probe_lives_where_only_root_can_write — pins PROBE_DIR under /run/. Weaker, but it covers the half an unprivileged test cannot reach; flipping the constant to /tmp/cordon fails it.

Verification

cargo test 131 passed / 0 failed. run-bpf-lsm-vm.sh green on every scenario on the pinned v6.12 — load-bearing here, since the self-test is fail-closed and the daemon refuses to start if the probe path breaks.

Not done here: retiring the "still live" note for this bug in PLAN-rule-model.md, because #1 edits the same bullet. Follow-up once that merges.

🤖 Generated with Claude Code

cordond is uid 0 and the self-test runs *before* seed_policy, so cordon is
not yet enforcing on itself. The probe was $TMPDIR/cordon-selftest.<pid>/,
built with create_dir_all (which accepts a pre-existing directory of any
owner) and seeded with fs::write (O_WRONLY|O_CREAT|O_TRUNC, no O_NOFOLLOW).
A local user who pre-creates that directory holding `src -> <any root-owned
file>` gets root to truncate it on the next start; pids are sequential, so
guessing is cheap. Aimed at /etc/cordon/policy.toml it is a boundary kill:
the daemon then fails to compile the policy and enforces nothing.

fs.protected_symlinks does not cover this. may_follow_link() keys on the
symlink's immediate parent being sticky and world-writable, and there that
parent is the attacker's own plain directory, so the check returns before
comparing uids.

The probe moves to /run/cordon, which is root-only because /run is
root-owned 0755 — that removes the shared namespace outright instead of
hardening each open against it. Secondarily the probe is now removed and
recreated with create_dir rather than reused wherever it is found, so a
found directory (and any symlink in it) is gone before the seed writes.
That guard races on its own and is not the fix; it keeps the failure
visible if the location ever moves back.

Regression tests, both verified to fail without their half of the fix: the
planted-directory test reproduces the truncation (victim reads "x"), and a
constant check pins the probe under /run, which an unprivileged test cannot
exercise directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nikicat
nikicat marked this pull request as ready for review July 27, 2026 22:15
@nikicat
nikicat merged commit 3f66630 into main Jul 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant