Skip to content

fix(sec): a hook that cannot evaluate an operation refuses it - #10

Merged
jlgore merged 1 commit into
014-terminal-safetyfrom
015-hooks-fail-closed
Jul 26, 2026
Merged

fix(sec): a hook that cannot evaluate an operation refuses it#10
jlgore merged 1 commit into
014-terminal-safetyfrom
015-hooks-fail-closed

Conversation

@jlgore

@jlgore jlgore commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Closes triage f007 and f034, and marks f010 stale. Stacked on #9 (which is stacked on #8) — bases retarget as each merges.

The hole

Four branches in the LSM programs returned 0 — allow — when the hook couldn't reach a decision. Three of the four are provokable from inside a scope, which makes them escapes rather than edge cases.

Branch Reachable how
bpf_d_path fails in bprm_check_security fails -ENAMETOOLONG past its 4KB buffer. execve never has to pass a >PATH_MAX argument: chdir down a deep chain, exec a short relative name, and the kernel resolves it to something the hook can't render. The exec allowlist simply stops applying. (f007)
bpf_d_path fails in file_open same provocation, against the deny list
non-IP address family in socket_connect _ => return 0 — an AF_UNIX connect to a local agent socket left an egress-enforced scope unmediated (f034)
null bprm/file/sockaddr, unavailable PATHBUF a bee bug rather than an attack, but it failed open silently

The fix

deny_unevaluated returns the denial errno and emits an audit record in all four. It runs only after the scope has been shown to enforce that dimension (network flag set, deny list present, exec allowlist present), so an unenforced scope is untouched and observe mode still records without blocking.

For non-IP families this is a policy statement as much as a code change: the authoring language spells destinations host:port, so no rule can ever name an AF_UNIX or AF_NETLINK peer — and "no rule matches" in an enforcing scope means deny.

The cost is real and accepted, and it's documented in policy.schema.md: a network-enforced scope refuses local socket IPC outright, and no enforced scope can open or execute a path longer than the kernel's path buffer. The better long-term answer for exec is an inode identity fallback, which needs the inode-pinning backend EnforcementPlan currently refuses (UnsupportedInodePin); deny-on-unresolvable is correct in the meantime and stays correct after. Recorded as research R16 with both alternatives and why they lost.

Verification

Three new VM matrix cases — exec-unresolvable-denied, file-unresolvable-denied, net-unix-denied — and 35/35 on the BPF-LSM VM, which also confirms the programs still verify on a real kernel.

All three fail against the pre-fix binary. Worth flagging: exec-unresolvable-denied initially passed pre-fix, which was a false PASS — seq isn't on the test's exec allowlist, so the descent loop never ran and the exec failed for the wrong reason. Rewritten to use shell builtins only, plus a DESCENT_FAILED guard so the case can never silently prove nothing again. Pre-fix run after that correction:

net-unix-denied           FAIL  AF_UNIX reachable from an enforced scope (CONNECTED)
exec-unresolvable-denied  FAIL  exec of an unresolvable image ran (DEEP_EXEC_OK EXEC_RC=0 ...)
file-unresolvable-denied  FAIL  read of an unresolvable path succeeded
SUMMARY  pass=32  fail=3

Host side: cargo clippy --workspace --all-targets -- -D warnings clean, cargo fmt applied, workspace tests green except the pre-existing repl_command::no_provider_at_all_reports_what_is_missing (build without --features enforce).

f010 is stale, not open

The report claims tool children keep launcher privileges with no no_new_privs and no capability drop. hardening.rs::drop_privileges does both, and the priv-drop VM case asserts NoNewPrivs=1 CapBnd=0x6. It was closed by 8e2cdbb/3225d44, which the triage run predates. Marked not_applicable in TRIAGE rather than left looking like open work.

🤖 Generated with Claude Code

Four branches in the LSM programs returned 0 — allow — when the hook could not
reach a decision: a `bpf_d_path` failure in `file_open` and `bprm_check_security`,
a null `bprm`/`file`/sockaddr argument, an unavailable `PATHBUF` slot, and any
address family `socket_connect` does not decode. Three are provokable from inside
a scope, which makes them escapes rather than edge cases.

`bpf_d_path` fails with -ENAMETOOLONG once the resolved path passes its 4KB
buffer, and `execve` never has to pass a path that long: chdir down a deep chain
and exec a short *relative* name, and the kernel resolves it to something the
hook cannot render. That was a general way out of an exec-enforced scope — the
allowlist simply stopped applying (f007). The family default was the same shape
in the network hook: an AF_UNIX connect to a local agent socket left an
egress-enforced scope unmediated (f034).

`deny_unevaluated` now returns the denial errno and emits an audit record in all
four. It runs only after the scope has been shown to enforce that dimension, so
an unenforced scope is untouched and observe mode still records without blocking.

For non-IP families this is a policy statement as much as a code change: the
authoring language spells destinations `host:port`, so no rule can ever name an
AF_UNIX or AF_NETLINK peer — and "no rule matches" in an enforcing scope means
deny. The cost is real and accepted: a network-enforced scope refuses local
socket IPC outright, and no enforced scope can open or exec a path longer than
the kernel's path buffer. Recorded as research R16, with the inode-identity
fallback named as the better long-term answer once the backend can pin inodes.

VM matrix: three new cases (`exec-unresolvable-denied`, `file-unresolvable-denied`,
`net-unix-denied`), 35/35 on the BPF-LSM VM. All three fail against the pre-fix
binary — the exec one only after its descent loop was rewritten to use shell
builtins, since a denied `seq` left it in the shallow directory and turned it
into a false PASS.

Also marks triage f010 as stale rather than open: `hardening.rs::drop_privileges`
already sets PR_SET_NO_NEW_PRIVS and empties the capability bounding set except
the DAC pair, and the `priv-drop` case asserts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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