feat(agent,engine): fileless-exec (anon-inode) corroboration — Route A rework (JEF-317)#277
Conversation
… (JEF-317)
Falco fires critical on memfd_create + execve of the resulting anonymous fd
("fileless" malware that never touches disk); neither the exec probe's path
artifact nor F2's FileWrite (JEF-306) has anything to correlate against, since
such an fd never touches security_file_open. This was the one shipped-critical
Falco class without a clean agent map (JEF-316 F0 parity audit).
The signal already rides the wire: the exec probe (security_bprm_check,
ADR-0014/JEF-53) already emits the kernel's own bprm->filename as
Behavior::ProcessExec::path verbatim. The kernel's own execveat(fd, "",
AT_EMPTY_PATH) synthesis (what fexecve()/every memfd-backed loader uses)
resolves that string to an fd-only form ("/dev/fd/<n>", "/proc/<pid>/fd/<n>")
with no on-disk path — no new probe, offset, or wire field needed (JEF-113:
agent stays pure data). engine::observe::exec_class now classifies that shape
as a fileless exec, feeding the existing notable_exec/is_alarming_now blanket
corroboration path (JEF-55/JEF-117/JEF-309) the same way a shell or
package-manager exec does. Doc-only touch to the bprm probe and the
ProcessExec wire doc explain why no agent-side code changed.
Shadow-gated throughout (ADR-0014): only sets `corroborated`, never actuates.
eBPF change — ON-NODE LOAD VALIDATION PENDING is not applicable here in the
usual sense: no probe, offset, or wire byte changed, so there is no new
attach/verifier risk to validate. The bprm_check probe this relies on is
already deployed and verified on-node (JEF-53). Recommended follow-up is a
functional smoke test (memfd_create + execveat on a real pod) to confirm the
classifier fires end-to-end, not an eBPF reload.
Closes JEF-317
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
|
HELD — not merging. Confirmed security finding (Medium severity, High impact in this threat model). The verification and tests are good work, but the engine-side-only detection forges corroboration at a high base rate and must be reworked before merge.
So the classifier fires on routine runtime behavior at a high base rate, and is trivially attacker-triggerable by any in-container Fix direction for the rework: do not route this into blanket corroboration on path shape alone. Either require an independent signal / foothold scope, or detect the actual anonymous/memfd inode — which needs the eBPF inode signal ( The characterizing intent (close the Falco memfd-parity gap, ADR-0014) is right; the discrimination has to move where the specificity is. Leaving open for rework. |
… rework (JEF-317) REWORK of the withdrawn v1 approach: v1 classified ProcessExec.path SHAPE (/dev/fd/<n>, /proc/<pid>/fd/<n>) as "fileless" and fed it into the blanket corroborates-ANY-objective arm. A security review caught the flaw — the kernel synthesizes the identical path for a benign fexecve() of an on-disk file, and runc copies itself into a memfd and re-execs via that exact shape on ~every container start (the CVE-2019-5736 mitigation) — so path shape can't distinguish malware from routine behavior, and it forged corroboration on every objective. v1 is fully removed (is_anon_fd_path/is_fileless_exec + its notable_exec/blanket wiring). Route A replaces it with the real signal — the exec'd binary's backing INODE, not its path: - agent/protector-agent-ebpf: the existing bprm_check probe (fentry on security_bprm_check — NOT lsm/*, since `bpf` is not in the fleet's active LSM list) now also reads bprm->file->f_inode and determines whether it's anonymous: shmem/tmpfs-backed (inode->i_sb->s_magic) and/or unlinked (inode->i_nlink == 0, which also catches "delete the binary while it's still executing"). Emitted as a new pure-data ExecEvent.exe_anon_inode field — a kernel-observed FACT, not a verdict (JEF-113: classification policy stays engine-side). - agent/protector-agent-ebpf/src/vmlinux.rs: adds linux_binprm.file (+64) and inode.i_nlink (+72), each derived from kernel source layout (NOT dumped from live BTF) and offset_of!-guarded per the JEF-324 pattern. linux_binprm.file's derivation independently lands on the SAME offset as the already-verified filename field (+96), a strong self-consistency signal, but both fields are ON-NODE BTF VERIFICATION PENDING — see the PR body for exactly what needs bpftool btf dump confirmation. - behavior::Behavior::ProcessExec gains exe_anon_inode: bool (defaulted, omitted from the wire when false — old sensors' JSON is untouched). - engine::reason::proof::corroborate adds anon_inode_exec_on_foothold: conservatively scoped to BOTH a proven foothold entry AND an Execution-tactic objective — never the blanket "any objective" gate a shell/package-manager exec gets. The flat corroborates() relation explicitly does NOT route exe_anon_inode into its blanket arm. CAVEAT (documented, not solved): the runc-memfd-reexec false-positive base rate — specifically whether it attributes to the workload cgroup or the host runtime — is UNKNOWN until measured on a live node. The foothold+ Execution-tactic scoping is the conservative mitigation while that measurement is pending; the code and PR both flag this loudly. Also splits behavior/src/lib.rs's test module into src/tests.rs (repo CLAUDE.md: tests count toward the 1,000-line file cap; the new field's tests pushed it over). Closes JEF-317 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
Summary — REWORK (Route A)
Closes JEF-317 — [Retire-Falco G1] Agent coverage for fileless exec (
memfd_create/ anonymous-fdexecve).HELD by a prior security review on the v1 approach in this same PR. This push replaces v1 entirely with Route A. Still HELD pending on-node BTF verification + a load-test — see the checklist at the bottom.
Why v1 was withdrawn
v1 classified
Behavior::ProcessExec.pathshaped/dev/fd/<n>or/proc/<pid>/fd/<n>as "fileless" and routed it into the blanketcorroborates-ANY-objective arm (JEF-117), reusing the shell/package-manager wiring. The review caught the flaw:/dev/fd/<n>bprm->filenamefor a perfectly benignfexecve()of an on-disk file (any caller execing an already-open fd) — path shape can't tell the two apart.So the classifier fired on routine runtime behavior at a high base rate, and being blanket corroboration, a single such exec forged "active intrusion" corroboration for every objective on the graph. v1 is fully removed:
is_anon_fd_path/is_fileless_execand theirnotable_exec/blanket wiring are gone (see the diff inengine/src/engine/observe/exec_class.rsandengine/src/engine/reason/proof/corroborate.rs).Route A — detect the actual anon/memfd inode
The real signal is the exec'd binary's backing inode, not its path — a memfd/anonymous-fd exec's backing file lives on a shmem/tmpfs superblock and/or is unlinked; a normal on-disk, directory-linked executable is neither.
1. eBPF (
agent/protector-agent-ebpf) — the existing exec probe (fentry onsecurity_bprm_check, ADR-0014/JEF-53 — already deployed and verified on-node) now also readsbprm->file->f_inode(the ALREADY-OPENED executable —bprm->fileis set bybprm_execve()in fs/exec.c beforesecurity_bprm_checkfires, so this is not a TOCTOU-able separate lookup) and determines whether the backing inode is anonymous:inode->i_sb->s_magic == TMPFS_MAGIC(memfd_create is shmem-backed under the hood), and/orinode->i_nlink == 0(a memfd is never linked into any directory; this also catches the separate "unlink the binary while it's still executing" technique on a normal filesystem).Attached via fentry, not
lsm/*— confirmed on-node over SSH on both fleet arches thatbpfis NOT in the active LSM list (CONFIG_LSMomits it, nolsm=on the kernel cmdline), so anlsm/program would never attach here; every probe in this file already uses fentry for exactly this reason.Emitted as a new pure-data
ExecEvent.exe_anon_inode: u8field (a dedicated struct, not tacked onto the sharedFileEvent, since file-open/library-load/file-write have nobprmto read it from). A kernel-observed FACT, not a verdict — classification stays engine-side (JEF-113).2. vmlinux bindings (
agent/protector-agent-ebpf/src/vmlinux.rs) — adds two fields, eachoffset_of!-guarded per the JEF-324 pattern (NOT a full vmlinux dump):struct file.f_inode— already present (reused as-is, no change).struct linux_binprm.fileat +64 — derived from kernel source layout (struct linux_binprminlinux/binfmts.h):vma+vma_pages+mm+p+argmin+the 4-bit flag bitfield (padded to 8) +executable+interpreter+file(+64)+cred+unsafe+per_clear+argc+envclandsfilenameat +96 — which matches the independently, already on-node-verifiedfilenameoffset exactly. A strong self-consistency signal, not proof.struct inode.i_nlinkat +72 — the anonymous-unioni_nlinkfield immediately afteri_ino(+64, 8 bytes), no padding needed.struct inode.i_sb/struct super_block.s_magic— already present (reused as-is).ON-NODE BTF VERIFICATION PENDING — exactly these two fields need
bpftool btf dump … format cconfirmation on BOTH fleet arches (7.0.0 amd64-generic + arm64-raspi) before this can be trusted at face value:linux_binprm.fileoffset (currently+64)inode.i_nlinkoffset (currently+72)Everything else this probe reads (
file.f_inode,inode.i_sb,super_block.s_magic,linux_binprm.filename) is already verified on-node from prior work (JEF-53/JEF-324) and unchanged here.3. Engine (
engine/src/engine/reason/proof/corroborate.rs) — a newanon_inode_exec_on_footholdpredicate, structured exactly like the existing JEF-314 (privilege_escalation_on_foothold) / JEF-321 (drop_then_execute) entry-scoped shapes: gated on both a proven internet-facing foothold entry and an Execution-tactic objective (T1610 Deploy Container / T1609 Container Administration Command). The flatcorroborates()relation explicitly does not routeexe_anon_inodeinto its blanket arm — a bare anon-inode exec is model evidence only unless both gates hold. Shadow-gated throughout (ADR-0014): only setscorroborated, never actuates.🔴 Caveat that is NOT solved here — must be measured on-node before this scoping is finalized
Even the inode signal has a known benign source: runc's own memfd re-exec on ~every container start. Whether that attributes to the workload's cgroup (this entry) or to the host container runtime depends on
setnstiming relative tosecurity_bprm_checkfiring — this is unknown until measured on a live node. The foothold+Execution-tactic scoping is the conservative mitigation while that measurement is pending, not a claim the false-positive problem is solved. If runc's re-exec does attribute to arbitrary workload cgroups, an additional discriminator (e.g. requiring a second corroborating signal) will be needed — flagged in the code (corroborate.rsdoc comment onanon_inode_exec_on_foothold) and here, not silently assumed away.Files touched
eBPF/agent (kernel-facing, needs on-node validation):
agent/protector-agent-ebpf/src/main.rs— the inode read (exe_is_anon_inode), refactored to share asuperblock_magic/inode_ofhelper with the existing tmpfs/file-write probes.agent/protector-agent-ebpf/src/vmlinux.rs— the two new struct-field bindings above.agent/common/src/lib.rs— newExecEventwire struct (KIND_EXEC's own body, notFileEvent).agent/protector-agent/src/observer.rs(+observer_ebpf_tests.rs) — userspace decode of the new field, plumbed through intoBehavior::ProcessExec.Engine (pure Rust, no eBPF/offset risk):
behavior/src/lib.rs—Behavior::ProcessExec.exe_anon_inode: bool(defaulted, omitted from the wire whenfalse, so older sensors' JSON is byte-identical to before). Split its test module intobehavior/src/tests.rs(the new field's tests pushedlib.rsover the repo's 1,000-line cap).engine/src/engine/observe/exec_class.rs— v1'sis_fileless_exec/is_anon_fd_pathfully removed; module doc explains why JEF-317 deliberately does NOT live in this module's blanket "notable exec" gate.engine/src/engine/reason/proof/corroborate.rs— the newanon_inode_exec_on_footholdpredicate.engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs— new, mirrorscorroborate_privesc_tests.rs's structure.alarm_class.rs,peer_class.rs,dashboard/view_model/alerts.rs, adjudicate test fixtures) needed a mechanicalexe_anon_inodefield addition/pattern update — no logic change.Test plan
cargo fmt --all -- --check(root/engine+behavior workspace, agent workspace, ebpf crate) — cleancargo clippy --workspace --all-targets -- -D warnings(root workspace, agent workspace) — cleancargo clippy --release -- -D warnings(protector-agent-ebpf;--all-targetsdoesn't apply to thisno_stdcrate — no test harness) — cleancargo test(root workspace: engine + behavior) — 926 passed, 0 failed, 2 ignored (network-gated)cargo test(agent workspace: protector-agent + protector-agent-common) — 51 passed, 0 failedcargo check --releaseforprotector-agent-ebpf(Rust-level compile-only; the finalbpf-linkerlink step needs a proper LLVM/BPF toolchain not available in this sandbox — same limitation docs/ebpf-testing-on-nodes.md describes)engine::reason::proof::corroborate_anon_inode_exec_tests(new file, 4 tests): foothold-scoped positive, non-foothold negative,exe_anon_inode: falsenegative, wrong-tactic negativeengine::reason::proof::corroborate_objective_tests::anon_inode_exec_does_not_blanket_corroborate— regression guard reproducing the exact shape the security review flaggedengine::observe::exec_class::anon_inode_exec_alone_is_not_notable_here— regression guard that Route A didn't accidentally get folded back into the blanket gatebehavior::tests::exe_anon_inode_*(3 tests) — raw-fact summary/fingerprint distinction, serde omit-when-false + legacy-deserialize-to-falseagent::protector-agent::observer::ebpf::decode_exec_carries_the_anon_inode_flag_through— newExecEventdecode test (feature-gatedebpf; could not be feature-compiled in this sandbox — see ON-NODE-PENDING below)/soundcheck:pr-review-equivalent pass (single-pass, this session): no Critical/High findings — all new kernel reads reuse the existing null/error-checkedread_kernelpattern; no new TOCTOU (the read is against the already-openedbprm->file); the new wire field funnels through the same auto-escaped render path the existingpathfield already uses/simplifypass (single-pass, this session): extracted a sharedsuperblock_magichelper to cut a redundant kernel read per exec event in the eBPF hot path; restyled the newsummary()branch to match the existingImageLinkageif/else idiom already in that fileON-NODE-PENDING (blocks final merge sign-off)
bpftool btf dump … format con both fleet arches (7.0.0 amd64-generic, 7.0.0 arm64-raspi), confirming:struct linux_binprm.fileat offset +64struct inode.i_nlinkat offset +72bprm_checkstill attaches (fentry, unaffected by this change) and the new inode reads don't trip the verifier (bpf_probe_read_kernelon these two new fields, same allowed pattern as the existingi_sb/s_magicreads — expected to be fine, but unconfirmed).anon_inode_exec_on_foothold's scoping will need an additional discriminator before this can be trusted beyond "model evidence."This PR remains HELD pending items 1–3. Architect: no merge without at least item 1 (offset verification) — a wrong offset here degrades from "verifier rejection, loud in the heartbeat" (survivable) to, in the worst case, a silently wrong bool if the misread pointer happens to still verify (the risk flagged in
vmlinux.rs's module doc).🤖 Generated with Claude Code
https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP