Skip to content

M3: verify RENAME_EXCHANGE, gate inode_mkdir - #3

Merged
nikicat merged 2 commits into
mainfrom
m3/rename-exchange
Jul 27, 2026
Merged

M3: verify RENAME_EXCHANGE, gate inode_mkdir#3
nikicat merged 2 commits into
mainfrom
m3/rename-exchange

Conversation

@nikicat

@nikicat nikicat commented Jul 27, 2026

Copy link
Copy Markdown
Owner

M3, continued. Answers the open question the plan recorded as believed, not verified.

The question

An exchange re-parents both inodes and destroys neither, so the destroy check's write floor does not cover it. The protected side leaving is an escape — but only if the hook is asked about that side at all.

The answer

It is: security_inode_rename calls inode_rename a second time with the pair swapped when RENAME_EXCHANGE is set, so both directions reach the escape check. Measured in the VM rather than read off the source.

Why the scenario looks the way it does

The obvious version of this test would pass while testing nothing. On a denying object, an exchange is refused by the destroy or clobber check on the first hook call — which proves nothing about the second.

So the object here deliberately allows write, and the unprotected name is passed first:

call sees verdict
first foreign inode entering a writable object — an ordinary clobber allow
swapped the vault's own inode taking a name in unprotected $TMP — escape deny

The swapped call is the sole thing between the secret and a name outside its object. The scenario also checks both inodes stayed put, covers the mirror argument order (refused by the first call), and pins that an exchange wholly inside one object is still allowed as an internal shuffle.

The helper preflights renameat2 against two unprotected files before any policy is live. An unavailable syscall exits non-zero exactly like a refusal, so without the preflight the deny assertions would pass for the wrong reason.

Also here

  • Retires the /tmp self-test probe note from the plan and STATUS now that fix(daemon): move the self-test probe out of the shared /tmp namespace #2 shipped — the analysis lives in the code's doc comment, and git log has the rest. Item 1 of the queue is marked done.
  • Records the twice-fired-hook finding under Durable kernel facts, with the warning not to "simplify" the scenario to a denying object.

Verification

Full VM suite green on the pinned v6.12 — exit 0, zero FAILs, new scenario included. bash -n clean.

Remaining in M3: inode_mkdir, bprm_check_security (the execute action), and the listdir coarseness note.

🤖 Generated with Claude Code

nikicat and others added 2 commits July 28, 2026 01:22
An exchange re-parents both inodes and destroys neither, so the destroy
check's `write` floor does not cover it. The protected side leaving is an
escape, but only if the hook is asked about that side at all — which the
plan recorded as believed, not verified.

It is now verified: security_inode_rename calls inode_rename a second time
with the pair swapped when RENAME_EXCHANGE is set, so both directions reach
the escape check. Measured in the VM rather than read off the source.

The scenario is built around the one case that actually tests this. On a
denying object an exchange is refused by the destroy or clobber check on the
*first* call, which proves nothing about the second; so the object here
deliberately ALLOWS write, and the unprotected name is passed first. The
first call then sees an ordinary permitted clobber and the swapped call is
the sole thing keeping the secret inside its object. Both inodes are checked
to have stayed put, the mirror argument order is covered, and an exchange
wholly inside one object is pinned as still allowed.

The helper preflights renameat2 against two unprotected files before any
policy is live: an unavailable syscall exits non-zero exactly like a refusal,
so without the preflight the deny assertions would pass for the wrong reason.

Also retires the /tmp self-test probe note from the plan and STATUS now that
the fix has shipped — the analysis lives in the code's doc comment — and
records the twice-fired-hook finding under Durable kernel facts, including
the warning not to "simplify" the scenario to a denying object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…moving one

`inode_rmdir` was gated and `inode_mkdir` was not, so a subject that could
not remove a directory inside a protected object could still add one. Adding
a name inside an object is an ordinary create/write on it; the asymmetry was
the only reason it went unmediated.

The new dentry is *negative* — no inode exists yet — so `find_object` on it
misses on the first step of the walk and the verdict has to come from the
parent, the same shape as the clobber arm of `decide_reparent`. That makes
this the first hook classified off a to-be-created name rather than an
existing one, which is why the daemon's self-test grows a probe for it: every
other probe starts from a positive dentry, so none of them would catch a
wrong argument index here.

It deliberately does not cover `rm -rf ~/.ssh && mkdir ~/.ssh`. That case is
closed by `inode_rmdir` refusing the removal — once the object's own directory
is gone there is no protected ancestor left for this hook to find. Recorded in
DESIGN §15.5 and the plan, since the hook name invites the opposite reading.

The smoke scenario checks both polarities in one policy: mkdir inside an
object that allows create/write still works (this must not break legitimate
structure), and mkdir inside a nested denying object is refused with no
directory left behind.

HOOKS drops its hardcoded length — `&[&str]` has nothing to keep in sync, and
`HOOKS.len()` still feeds the startup log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nikicat nikicat changed the title test(bpf): verify RENAME_EXCHANGE reaches the escape check M3: verify RENAME_EXCHANGE, gate inode_mkdir Jul 27, 2026
@nikicat

nikicat commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Added a second commit: inode_mkdir.

rmdir was gated and mkdir was not, so a subject that could not remove a directory inside a protected object could still add one.

The new dentry is negative — no inode yet — so find_object on it misses on the first step and the verdict comes from the parent, the same shape as the clobber arm of decide_reparent. That makes it the first hook classified off a to-be-created name, which is why the daemon self-test grows a probe for it: every other probe starts from a positive dentry, so none would catch a wrong argument index here.

It deliberately does not cover rm -rf ~/.ssh && mkdir ~/.ssh — that is closed by inode_rmdir refusing the removal, since once the object's own directory is gone there is no protected ancestor left to find. Recorded in DESIGN §15.5 and the plan, because the hook name invites the opposite reading.

Smoke scenario covers both polarities in one policy: mkdir inside an object allowing create/write still works, and mkdir inside a nested denying object is refused with nothing left behind.

HOOKS also drops its hardcoded length.

Remaining in M3 after this: bprm_check_security (the execute action) — the first hook needing a new BTF offset (linux_binprm -> file) — and the listdir coarseness note.

@nikicat
nikicat marked this pull request as ready for review July 27, 2026 22:58
@nikicat
nikicat merged commit 942407e 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