Skip to content

feat(bpf): gate exec with bprm_check_security, completing M3's hooks - #4

Merged
nikicat merged 2 commits into
mainfrom
m3/execute-hook
Jul 27, 2026
Merged

feat(bpf): gate exec with bprm_check_security, completing M3's hooks#4
nikicat merged 2 commits into
mainfrom
m3/execute-hook

Conversation

@nikicat

@nikicat nikicat commented Jul 27, 2026

Copy link
Copy Markdown
Owner

The last hook in M3. execute compiled but never fired; it does now.

What's new mechanically

bprm_check_security, whose linux_binprm -> file is the first offset any hook has needed beyond the dentry chain the others already reach through.

That offset is applied to the hook's trusted context pointer, so it joins file_f_* and dentry_d_* as one that must stay real under maybe_poison_offsets — the verifier range-checks trusted-pointer math against .rodata at load, so poisoning it would abort the load and turn the failure-path test into a load failure rather than the self-test abort it exists to exercise.

execute is not implied by read

An exec opens the file (FMODE_EXEC | FMODE_READ), so file_open already refuses to run the contents of an object that denies reads. What this hook adds is the converse: an object may be readable and not runnable. The subject is the process performing the exec, since at this hook the caller is still its old image.

Interaction with copy-down, worth knowing before writing policy: expressing "readable but not runnable" needs an explicit deny, not a narrower allow. Copy-down puts the parent's rules in the child's bucket too, so a child rule that doesn't match the requested action falls through to them — an allow read on a child does not withdraw an execute the parent granted. The smoke scenario is built on that shape, and also pins that the denied binary stays readable, which is the point of the hook.

The self-test needed a second object

Under the deny-all probe rule the exec open is refused by file_open and the verdict never reaches the bprm hook — so the probe would pass with the hook detached, which is exactly the silent no-op the self-test exists to catch. The exec probe therefore gets its own object that allows read and denies the rest, and its file carries the x bit, without which may_open(MAY_EXEC) fails EACCES first.

Verification

VM smoke green, exit 0, zero FAILs. The execute scenario passes all three assertions (granted exec runs, denied exec refused, denied binary still readable). The startup self-test is fail-closed, so the daemon booting at all in every scenario is itself proof the hook mediates.

cargo test 131 passed / 0 failed.

M3 after this

All hooks ship. What remains is the listdir coarseness note — documentation, not a hook; a finer one would need the hot file_permission.

🤖 Generated with Claude Code

nikicat and others added 2 commits July 28, 2026 01:59
The `execute` action compiled but never fired. It does now, off
`bprm_check_security`, whose `linux_binprm -> file` is the first offset any
hook has needed beyond the dentry chain the others already reach through.

That offset is applied to the hook's *trusted* context pointer, so it joins
`file_f_*` and `dentry_d_*` as one that must stay real under
`maybe_poison_offsets`: the verifier range-checks trusted-pointer math
against `.rodata` at load time, so poisoning it would abort the load and turn
the failure-path test into a load failure instead of the self-test abort it
is meant to exercise.

`execute` is not implied by `read`. An exec opens the file
(FMODE_EXEC|FMODE_READ), so `file_open` already refuses to run the contents
of an object that denies reads; what this hook adds is the converse — an
object may be readable and not runnable. The subject is the process
performing the exec, since at this hook the caller is still its old image.

Expressing "readable but not runnable" needs an explicit deny rather than a
narrower allow. Copy-down puts the parent's rules in the child's bucket too,
so a child rule that does not match the requested action falls through to
them; an `allow read` on a child does not withdraw an `execute` the parent
granted. The smoke scenario is built on that shape and also pins that the
denied binary stays readable, which is the whole point of the hook.

The self-test needs a second object to prove this one. Under the deny-all
probe rule the exec *open* is refused by `file_open` and the verdict never
reaches the bprm hook, so the probe would pass with the hook detached — the
exact silent-no-op the self-test exists to catch. The exec probe therefore
gets its own object that allows `read` and denies the rest, and its file
carries the x bit, without which `may_open(MAY_EXEC)` fails EACCES first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan asked for a note that `listdir` is coarse. The real situation is
sharper than coarse, and worth stating as a trap.

There is no per-getdents hook, so listing a directory *is* `file_open` on it,
and `requested_action` derives only read/write/create from f_mode/f_flags.
Nothing ever requests `listdir`. A rule naming it alone therefore matches no
request, and the open falls through to default-deny: `allow @tools listdir
@secrets` denies the listing it looks like it grants.

It fails closed, so this is a usability trap rather than a hole. Policy must
spell directory listing as `read` today. Both shipped example policies pair
`listdir` with `read` already and now say why, so the keyword cannot be
copied out on its own.

Making it fire would mean tagging the request when the opened inode is a
directory (i_mode & S_IFMT) — one more BTF offset. Deliberately not taken:
`read` covers the case correctly, and an unused offset is a maintenance cost
on every kernel.

Recorded as a candidate follow-up: a `cordon policy check` warning on an
action set naming `listdir` without `read` would turn the silent surprise
into a lint. Userspace-only and cheap, but a CLI behaviour change rather
than an M3 hook, so not bundled here.

With this, M3 is complete — every hook in the action-to-hook map fires
except `listdir`, which is specified as having no hook by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nikicat
nikicat marked this pull request as ready for review July 27, 2026 23:42
@nikicat
nikicat merged commit 4b120f2 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