Skip to content

feat(agent,engine): ptrace-attach + kernel-module-load probes for code-injection/kernel-tamper (JEF-318) - #282

Merged
thejefflarson merged 4 commits into
mainfrom
thejefflarson/jef-318-retire-falco-g2-agent-probe-for-code-injection-kernel-tamper
Aug 1, 2026
Merged

feat(agent,engine): ptrace-attach + kernel-module-load probes for code-injection/kernel-tamper (JEF-318)#282
thejefflarson merged 4 commits into
mainfrom
thejefflarson/jef-318-retire-falco-g2-agent-probe-for-code-injection-kernel-tamper

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Summary

Falco fires critical on ptrace attach (process injection) and kernel-module load
(init_module/finit_module); no agent hook covered security_ptrace_access_check
or module load before this. Adds two fentry probes (Retire-Falco G2 parity) plus
engine-side "tamper-now" corroboration, entry-scoped and foothold-gated (never
blanket) — feeds F3's classifier (JEF-309). Detection is on by default — no
PROTECTOR_*_ENABLE flag (per the just-codified repo convention); shadow already
makes it inert of action. Shadow-gated throughout (ADR-0014): only sets
corroborated, never actuates.

The fentry mechanism (both probes, zero vmlinux struct offsets)

  • ptrace-attach: fentry on security_ptrace_access_check(child, mode), filtered
    in-kernel to mode & PTRACE_MODE_ATTACH so the constant PTRACE_MODE_READ checks
    every /proc/<pid>/… access triggers never reach the ring. Further deduped per
    attacking pid (PTRACE_SEEN, mirroring CREDENTIAL_READ_SEEN's JEF-320 ring-DoS
    lesson) — process_vm_readv/writev also hit this hook, and a legitimate chatty
    caller (a debugger) can invoke it in a tight loop.
  • kernel-module-load: fentry on security_kernel_load_data(id, contents),
    filtered to id == LOADING_MODULE. load_module() calls this hook on both
    init_module (in-memory buffer) and finit_module (fd — which first reaches
    security_kernel_read_file) before any parsing, so one probe covers both
    syscalls. Module loads are rare in normal container workloads — no dedup gate
    needed.
  • Both are fentry, not lsm/*bpf is not in the fleet's active LSM list
    (confirmed on-node this session), so lsm/ programs would never attach; fentry on
    the security_* function works regardless, same as every other probe in this file.
  • DECISION: neither probe reads a single vmlinux struct field. Both hooks'
    arguments (mode, id, contents) are plain scalars, not pointers — and the
    ptrace target task_struct's pid is deliberately not read: struct task_struct is enormous and its layout is far more volatile across kernel
    configs/versions than the already-ON-NODE-PENDING JEF-317 linux_binprm/inode
    offsets, so it was judged too fragile a read for a field the corroboration
    predicate below doesn't need — the attacking pid alone (already on every event's
    EventHeader) is enough to scope the signal to the foothold entry. This keeps
    this ticket's on-node risk profile materially lower than JEF-317's — no new
    struct-offset verifier risk at all.
  • Both new KIND_PTRACE_ATTACH/KIND_MODULE_LOAD ring events reuse the shared
    EventHeader verbatim as their entire wire body (no new struct) — the
    occurrence, attributed by the header's pid/cgroup, is the whole fact.

Wire (pure data, JEF-113)

agent/common: two new KIND_* constants (7, 8). behavior: two new fieldless
Behavior variants — PtraceAttach, ModuleLoad. No classification lives on the
wire type; the agent emits only the raw kernel occurrence.

Engine (engine/src/engine/reason/proof/corroborate.rs)

ptrace_attach_on_foothold and module_load_on_foothold, mirroring
privilege_escalation_on_foothold's exact shape: corroborate a
PrivilegeEscalation-tactic objective only on a proven internet-facing foothold
entry. Neither behavior is ever routed into the flat corroborates() blanket arm
(both stay false there, like PrivilegeChange) — a debugger, strace, a
supervisor ptrace-attaching its own child, or a legitimate driver-loading DaemonSet
(a CNI/CSI plugin, a kernel-module operator) are all ordinary on non-foothold pods.

DECISION NEEDED (flagged for the architect, not blocking): ATT&CK tags
ptrace/process-injection (T1055) Defense Evasion + Privilege Escalation, but
this repo's Tactic enum has no DefenseEvasion variant. Both new shapes land on
PrivilegeEscalation, mirroring privilege_escalation_on_foothold's own
T1611/T1098.006 precedent (kernel-module load is at least as defensible a fit —
full kernel-mode code execution from a container is genuinely a host-escape/
privilege-escalation event). A dedicated DefenseEvasion tactic variant is a
reasonable follow-up if a future shape needs the distinction — not resolving it
here to stay in scope.

ON-NODE-PENDING (loud, per docs/ebpf-testing-on-nodes.md — done post-merge via the merge-behind-shadow roll + SSH)

  • Confirm fentry attaches to security_ptrace_access_check and
    security_kernel_load_data on both fleet arches (7.0.0 raspi arm64 +
    generic amd64) — expected yes (every other probe in this file already attaches
    this way), but unconfirmed off-node.
  • Confirm enum kernel_load_data_id's LOADING_MODULE == 2 via bpftool btf dump … format c | grep -A8 'enum kernel_load_data_id' on both arches. This is a
    plain integer compare, not verifier-checked (unlike a struct offset) — a
    wrong value would misclassify silently rather than reject loud, so this is the
    one item here that fails quietly if wrong.
  • Confirm PTRACE_MODE_ATTACH == 0x02 matches the running kernel's
    include/linux/ptrace.h (stable across kernel versions in upstream Linux, but
    unconfirmed on this fleet).
  • Functional smoke test: ptrace(PTRACE_ATTACH) (or gdb -p) + insmod/modprobe
    on a real pod, confirm both classifiers fire end-to-end (probe attach → decode →
    Behavior::PtraceAttach/ModuleLoad → engine corroboration on a foothold entry).
  • Measure the PTRACE_SEEN dedup gate's real-world hit rate — is
    process_vm_readv/writev actually chatty enough on the fleet to need it, or
    was this conservative-by-default?

This PR is merge-behind-shadow: shadow-gated (ADR-0014), and since these are
brand-new probes attaching to previously-unhooked LSM functions, on-node load/attach
validation is a genuine open question the way JEF-317's inode-offset fields were —
unlike JEF-317's Route A follow-up, though, neither new probe here touches a
vmlinux struct at all, so the verifier-rejection risk specifically is lower.

Test plan

  • Engine unit tests for both entry-scoped predicates: positive (foothold +
    matching tactic corroborates), negative (non-foothold entry does not),
    tactic-gate regression (an unrelated objective does not corroborate even on
    the foothold), and cross-shape non-interference (the other new behavior /
    an ordinary exec does not falsely trigger either shape) —
    corroborate_ptrace_tests.rs, corroborate_module_load_tests.rs.
  • Behavior wire tests: serde round-trip to the bare {"kind": "..."} tagged
    shape, summary()/fingerprint_key()/variant_label(), is_alert() == false, full RuntimeObservation round-trip — behavior/src/tests.rs.
  • Agent decode tests: the header-only wire shape decodes and maps to the right
    Behavior for both new kinds, plus a truncated-event regression —
    observer_ebpf_tests.rs.
  • cargo fmt --all clean (engine/behavior workspace + agent workspace + the
    protector-agent-ebpf crate).
  • cargo clippy --workspace --all-targets -- -D warnings clean on the
    engine/behavior workspace and the agent (userspace) workspace; cargo clippy -- -D warnings clean on protector-agent-ebpf.
  • cargo test green: 960 engine/behavior tests, 45 + 7 agent-userspace tests
    (all pre-existing plus new).
  • protector-agent-ebpf (the kernel-side crate carrying all the new probe
    code) compiles cleanly via cargo check/cargo rustc — rustc succeeds; the
    bpf-linker LINK step fails locally with "unable to find LLVM shared lib",
    the documented local-toolchain gap (docs/ebpf-testing-on-nodes.md), not a
    code issue. CI's agent.yml ebpf job (self-hosted Linux, baked-in
    bpf-linker) is the real gate for the link step.
  • Not run anywhere (pre-existing gap, not introduced by this PR): cargo test --features ebpf for the agent crate has no CI runner today — agent.yml
    only runs cargo build --features ebpf (no test), and rust.yml doesn't
    touch the agent at all. This means observer_ebpf_tests.rs — including
    tests that predate this PR (exec/file-write decode, etc.) — has never
    actually executed in CI. Flagging as a follow-up gap, not fixing here
    (out of this ticket's scope).

Closes JEF-318

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

…e-injection/kernel-tamper (JEF-318)

Falco fires critical on ptrace attach (process injection) and kernel-module load
(init_module/finit_module); no agent hook covered security_ptrace_access_check or
module load before this. Adds two fentry probes (Retire-Falco G2) + engine-side
"tamper-now" corroboration, entry-scoped and foothold-gated (never blanket), and
default-on (no PROTECTOR_*_ENABLE flag — detection is on by default; shadow already
makes it inert of action).

AGENT (agent/protector-agent-ebpf/src/main.rs):
- fentry on security_ptrace_access_check(child, mode), filtered in-kernel to
  mode & PTRACE_MODE_ATTACH so the constant PTRACE_MODE_READ checks /proc/<pid>/...
  triggers never reach the ring. Further deduped per attacking pid (PTRACE_SEEN,
  mirroring CREDENTIAL_READ_SEEN's JEF-320 ring-DoS lesson) since process_vm_readv/
  writev also hits this hook and a legitimate chatty caller (a debugger) can invoke
  it in a loop.
- fentry on security_kernel_load_data(id, contents), filtered to id == LOADING_MODULE.
  load_module() calls this hook on BOTH init_module (in-memory buffer) and
  finit_module (fd, which first reaches security_kernel_read_file) before any
  parsing, so one probe covers both syscalls. Rare in normal workloads — no dedup
  needed.
- DECISION: neither probe touches a single vmlinux struct offset. ptrace's `mode`
  and module-load's `id`/`contents` are plain scalar args (not pointers), and the
  ptrace target task_struct's pid is deliberately NOT read — struct task_struct is
  enormous and its layout is far more volatile across kernel configs/versions than
  the already-ON-NODE-PENDING JEF-317 linux_binprm/inode offsets, so it was judged
  too fragile a read for a field the corroboration predicate doesn't need (the
  attacking pid alone, from the existing EventHeader, is enough). This keeps this
  probe's on-node risk profile lower than JEF-317's.
- Both new KIND_PTRACE_ATTACH/KIND_MODULE_LOAD events reuse the shared EventHeader
  verbatim as their wire body (no new struct) — the occurrence, attributed by
  pid/cgroup, is the whole fact.

WIRE (agent/common, behavior): two new KIND_* constants; two new fieldless
Behavior variants (PtraceAttach, ModuleLoad) — pure kernel facts, no classification
on the wire type (JEF-113).

ENGINE (engine/src/engine/reason/proof/corroborate.rs): ptrace_attach_on_foothold
and module_load_on_foothold, mirroring privilege_escalation_on_foothold's shape —
corroborate a PrivilegeEscalation-tactic objective ONLY on a proven internet-facing
foothold entry. Neither behavior is ever routed into the flat corroborates() blanket
arm (both stay `false` there, like PrivilegeChange) — a debugger, strace, a
supervisor ptrace-attaching its own child, or a legitimate driver-loading DaemonSet
are all ordinary on non-foothold pods.

DECISION NEEDED (flagged, not blocking): ATT&CK tags ptrace/process-injection
(T1055) Defense Evasion + Privilege Escalation, but this repo's Tactic enum has no
DefenseEvasion variant. Both new shapes land on PrivilegeEscalation, mirroring
privilege_escalation_on_foothold's own T1611/T1098.006 precedent. A dedicated
DefenseEvasion tactic is a reasonable follow-up if a future shape needs the
distinction.

ON-NODE-PENDING (docs/ebpf-testing-on-nodes.md loop): confirm fentry attaches to
both new hooks on both fleet arches; confirm enum kernel_load_data_id's
LOADING_MODULE == 2 via `bpftool btf dump ... format c | grep -A8 'enum
kernel_load_data_id'` (a plain integer compare, not verifier-checked, so a wrong
value would misclassify silently rather than reject loud); functional smoke test
(ptrace-attach + insmod on a real pod) to confirm both classifiers fire end-to-end.

Tests: engine unit tests for both entry-scoped predicates (positive/negative/
foothold-gate/tactic-gate/cross-shape-non-interference), behavior wire round-trip +
fingerprint/summary/variant-label tests, agent decode tests for the header-only
wire shape. cargo fmt clean, clippy -D warnings clean (engine/behavior workspace,
agent workspace, and the protector-agent-ebpf crate via `cargo check`/`cargo
clippy` — direct rustc compiles clean; the bpf-linker LINK step fails locally with
"unable to find LLVM shared lib", the known local-toolchain gap, not a code issue).

Closes JEF-318

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson enabled auto-merge (squash) July 28, 2026 02:05
@thejefflarson
thejefflarson merged commit 65fd2b1 into main Aug 1, 2026
7 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-318-retire-falco-g2-agent-probe-for-code-injection-kernel-tamper branch August 1, 2026 19:31
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