Skip to content

feat(agent,engine): fileless-exec (anon-inode) corroboration — Route A rework (JEF-317)#277

Open
thejefflarson wants to merge 2 commits into
mainfrom
thejefflarson/jef-317-retire-falco-g1-agent-coverage-for-fileless-exec
Open

feat(agent,engine): fileless-exec (anon-inode) corroboration — Route A rework (JEF-317)#277
thejefflarson wants to merge 2 commits into
mainfrom
thejefflarson/jef-317-retire-falco-g1-agent-coverage-for-fileless-exec

Conversation

@thejefflarson

@thejefflarson thejefflarson commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Summary — REWORK (Route A)

Closes JEF-317 — [Retire-Falco G1] Agent coverage for fileless exec (memfd_create / anonymous-fd execve).

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.path shaped /dev/fd/<n> or /proc/<pid>/fd/<n> as "fileless" and routed it into the blanket corroborates-ANY-objective arm (JEF-117), reusing the shell/package-manager wiring. The review caught the flaw:

  • The kernel synthesizes the identical /dev/fd/<n> bprm->filename for a perfectly benign fexecve() of an on-disk file (any caller execing an already-open fd) — path shape can't tell the two apart.
  • runc copies itself into a memfd and re-execs via that exact shape on ~every container start (the CVE-2019-5736 mitigation), not an attack.

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_exec and their notable_exec/blanket wiring are gone (see the diff in engine/src/engine/observe/exec_class.rs and engine/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 on security_bprm_check, ADR-0014/JEF-53 — already deployed and verified on-node) now also reads bprm->file->f_inode (the ALREADY-OPENED executable — bprm->file is set by bprm_execve() in fs/exec.c before security_bprm_check fires, 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/or
  • inode->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 that bpf is NOT in the active LSM list (CONFIG_LSM omits it, no lsm= on the kernel cmdline), so an lsm/ 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: u8 field (a dedicated struct, not tacked onto the shared FileEvent, since file-open/library-load/file-write have no bprm to 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, each offset_of!-guarded per the JEF-324 pattern (NOT a full vmlinux dump):

  • struct file.f_inodealready present (reused as-is, no change).
  • struct linux_binprm.file at +64 — derived from kernel source layout (struct linux_binprm in linux/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+envc lands filename at +96 — which matches the independently, already on-node-verified filename offset exactly. A strong self-consistency signal, not proof.
  • struct inode.i_nlink at +72 — the anonymous-union i_nlink field immediately after i_ino (+64, 8 bytes), no padding needed.
  • struct inode.i_sb / struct super_block.s_magicalready present (reused as-is).

ON-NODE BTF VERIFICATION PENDING — exactly these two fields need bpftool btf dump … format c confirmation on BOTH fleet arches (7.0.0 amd64-generic + arm64-raspi) before this can be trusted at face value:

  • linux_binprm.file offset (currently +64)
  • inode.i_nlink offset (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 new anon_inode_exec_on_foothold predicate, 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 flat corroborates() relation explicitly does not route exe_anon_inode into its blanket arm — a bare anon-inode exec is model evidence only unless both gates hold. Shadow-gated throughout (ADR-0014): only sets corroborated, 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 setns timing relative to security_bprm_check firing — 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.rs doc comment on anon_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 a superblock_magic/inode_of helper 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 — new ExecEvent wire struct (KIND_EXEC's own body, not FileEvent).
  • agent/protector-agent/src/observer.rs (+ observer_ebpf_tests.rs) — userspace decode of the new field, plumbed through into Behavior::ProcessExec.

Engine (pure Rust, no eBPF/offset risk):

  • behavior/src/lib.rsBehavior::ProcessExec.exe_anon_inode: bool (defaulted, omitted from the wire when false, so older sensors' JSON is byte-identical to before). Split its test module into behavior/src/tests.rs (the new field's tests pushed lib.rs over the repo's 1,000-line cap).
  • engine/src/engine/observe/exec_class.rs — v1's is_fileless_exec/is_anon_fd_path fully 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 new anon_inode_exec_on_foothold predicate.
  • engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs — new, mirrors corroborate_privesc_tests.rs's structure.
  • A handful of other files (alarm_class.rs, peer_class.rs, dashboard/view_model/alerts.rs, adjudicate test fixtures) needed a mechanical exe_anon_inode field addition/pattern update — no logic change.

Test plan

  • cargo fmt --all -- --check (root/engine+behavior workspace, agent workspace, ebpf crate) — clean
  • cargo clippy --workspace --all-targets -- -D warnings (root workspace, agent workspace) — clean
  • cargo clippy --release -- -D warnings (protector-agent-ebpf; --all-targets doesn't apply to this no_std crate — no test harness) — clean
  • cargo 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 failed
  • cargo check --release for protector-agent-ebpf (Rust-level compile-only; the final bpf-linker link step needs a proper LLVM/BPF toolchain not available in this sandbox — same limitation docs/ebpf-testing-on-nodes.md describes)
  • New/updated tests:
    • engine::reason::proof::corroborate_anon_inode_exec_tests (new file, 4 tests): foothold-scoped positive, non-foothold negative, exe_anon_inode: false negative, wrong-tactic negative
    • engine::reason::proof::corroborate_objective_tests::anon_inode_exec_does_not_blanket_corroborate — regression guard reproducing the exact shape the security review flagged
    • engine::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 gate
    • behavior::tests::exe_anon_inode_* (3 tests) — raw-fact summary/fingerprint distinction, serde omit-when-false + legacy-deserialize-to-false
    • agent::protector-agent::observer::ebpf::decode_exec_carries_the_anon_inode_flag_through — new ExecEvent decode test (feature-gated ebpf; 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-checked read_kernel pattern; no new TOCTOU (the read is against the already-opened bprm->file); the new wire field funnels through the same auto-escaped render path the existing path field already uses
  • /simplify pass (single-pass, this session): extracted a shared superblock_magic helper to cut a redundant kernel read per exec event in the eBPF hot path; restyled the new summary() branch to match the existing ImageLinkage if/else idiom already in that file

ON-NODE-PENDING (blocks final merge sign-off)

  1. bpftool btf dump … format c on both fleet arches (7.0.0 amd64-generic, 7.0.0 arm64-raspi), confirming:
    • struct linux_binprm.file at offset +64
    • struct inode.i_nlink at offset +72
  2. Functional load-test: deploy behind the spike-gate pattern (docs/ebpf-testing-on-nodes.md), confirm bprm_check still attaches (fentry, unaffected by this change) and the new inode reads don't trip the verifier (bpf_probe_read_kernel on these two new fields, same allowed pattern as the existing i_sb/s_magic reads — expected to be fine, but unconfirmed).
  3. The runc-memfd-reexec false-positive base rate — measure whether runc's own memfd re-exec attributes to workload cgroups or the host runtime. If it pollutes workload attribution, 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

… (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
@thejefflarson

Copy link
Copy Markdown
Owner Author

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.

engine/src/engine/observe/exec_class.rs is_anon_fd_path (~L43) → corroborate.rs blanket arm (~L108) — path-shape alone cannot distinguish fileless malware from routine runtime behavior.

is_fileless_exec matches /dev/fd/<n> and /proc/<pid>/fd/<n>, which routes into notable_execcorroborates() returning true for any objective (the blanket tamper-now gate, JEF-117). The problem is that this path shape is not specific to memfd-backed fileless malware:

  • The kernel synthesizes the identical /dev/fd/<n> bprm->filename for a perfectly benign fexecve() of an on-disk file (any caller that execs an already-open fd).
  • runc and its clones copy themselves into a memfd and re-exec via an fd on essentially every container start — this is the standard CVE-2019-5736 mitigation, not an attack.

So the classifier fires on routine runtime behavior at a high base rate, and is trivially attacker-triggerable by any in-container fexecve. Because the arm is blanket corroboration, a single such exec forges "active intrusion" corroboration for every objective on the graph. Root cause: engine-side path matching loses the specificity real memfd detection needs — /dev/fd/<n> is the resolved name for both memfd and on-disk fds.

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 (memfd/anon-inode tell) surfaced from the agent plus on-node validation, not a string match on the exec path.

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
@thejefflarson thejefflarson changed the title feat(engine): fileless-exec (memfd_create/anonymous-fd) corroboration (JEF-317) feat(agent,engine): fileless-exec (anon-inode) corroboration — Route A rework (JEF-317) Jul 27, 2026
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