Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions agent/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ pub const KIND_PRIV_CHANGE: u32 = 5;
/// config tampering (ADR-0014). Reuses [`FileEvent`]
/// (the `kind` discriminates it from the read/exec/library file events).
pub const KIND_FILE_WRITE: u32 = 6;
/// A ptrace ATTACH access check (fentry on `security_ptrace_access_check`, filtered
/// in-kernel to `mode & PTRACE_MODE_ATTACH` — JEF-318, Retire-Falco G2). The classic
/// process-injection primitive Falco fires critical on. Carries NO body beyond the shared
/// [`EventHeader`]: the occurrence, attributed by the header's pid/cgroup, IS the fact — the
/// target `task_struct`'s pid is deliberately NOT read (see the eBPF probe's doc comment for
/// why). Userspace emits a `Behavior::PtraceAttach`.
pub const KIND_PTRACE_ATTACH: u32 = 7;
/// A kernel module load (fentry on `security_kernel_load_data`, filtered in-kernel to
/// `id == LOADING_MODULE` — JEF-318, Retire-Falco G2). Falco fires critical on
/// `init_module`/`finit_module`; `load_module()` calls this hook on BOTH syscalls before any
/// parsing, so one probe covers both. Carries NO body beyond [`EventHeader`], same shape as
/// [`KIND_PTRACE_ATTACH`] — the occurrence is the fact. Userspace emits a
/// `Behavior::ModuleLoad`.
pub const KIND_MODULE_LOAD: u32 = 8;

/// Max path bytes carried per [`FileEvent`]. Secret-mount paths are well under this; a
/// longer path is truncated (the secret name still lands). Sized to keep the eBPF stack
Expand Down
139 changes: 138 additions & 1 deletion agent/protector-agent-ebpf/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ use aya_ebpf::{
use protector_agent_common::{
should_coalesce, ConnEvent, ConnKey, EventHeader, ExecEvent, FileEvent, PrivEvent, ReadKey,
WriteKey, DEDUP_MAP_CAP, DEDUP_WINDOW_NS, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN,
KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_PRIV_CHANGE, PATH_CAP,
KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_MODULE_LOAD, KIND_PRIV_CHANGE, KIND_PTRACE_ATTACH,
PATH_CAP,
};

/// Ring buffer of behavioral events (all kinds) drained by userspace.
Expand Down Expand Up @@ -197,6 +198,40 @@ fn allow_credential_read(key: &ReadKey) -> bool {
true
}

/// In-kernel dedup map for the ptrace-attach probe (JEF-318): `pid` → last-emit time (ns).
/// `security_ptrace_access_check` fires on every PTRACE_MODE_ATTACH check — not just a
/// `ptrace(PTRACE_ATTACH/PTRACE_SEIZE)` syscall, but also `process_vm_readv`/
/// `process_vm_writev` (a debugger or monitoring tool reading another process's memory),
/// which a legitimate chatty caller can invoke in a tight loop. The dedup key is JUST the
/// attacking `pid` — no target (see [`try_ptrace_access_check`]'s doc for why the target
/// `task_struct` is never read): a repeat attach check from the SAME attacker inside the
/// window is the same "this pid is ptrace-attaching things" fact refreshed, not a new one.
/// Mirrors [`CREDENTIAL_READ_SEEN`]'s JEF-320 ring-DoS lesson — an unbounded fentry on a hook
/// with a legitimate high-frequency caller is exactly the shape that flooded the ring there.
#[map]
static PTRACE_SEEN: LruHashMap<u32, u64> = LruHashMap::with_max_entries(DEDUP_MAP_CAP, 0);

/// The ptrace-attach dedup gate (JEF-318), mirroring [`allow_credential_read`]. Returns
/// `true` if an attach check from `pid` should be emitted, `false` if it's a repeat inside
/// [`DEDUP_WINDOW_NS`] and was coalesced (the shared [`COALESCED`] counter is bumped here).
/// Fail open: an insert that never fails falls through to emit, so a bookkeeping error never
/// silently loses a real signal. The first sighting of a pid (or one LRU-evicted) always emits.
fn allow_ptrace(pid: u32) -> bool {
let now = unsafe { bpf_ktime_get_ns() };
if let Some(last) = PTRACE_SEEN.get_ptr_mut(&pid) {
// SAFETY: `last` points at this key's live slot; we read then overwrite it.
let last_ns = unsafe { *last };
if should_coalesce(last_ns, now, DEDUP_WINDOW_NS) {
record_coalesced();
return false;
}
unsafe { *last = now };
return true;
}
let _ = PTRACE_SEEN.insert(&pid, &now, 0);
true
}

// Minimal kernel sockaddr layout for the IPv4 case. We only touch the family and the
// `sockaddr_in` address/port; reads are bounds-checked by `bpf_probe_read_kernel`.
const AF_INET: u16 = 2;
Expand Down Expand Up @@ -632,6 +667,108 @@ fn exe_is_anon_inode(bprm: *const vmlinux::linux_binprm) -> bool {
}
}

/// `PTRACE_MODE_ATTACH` (include/linux/ptrace.h) — set when the caller is asking to ATTACH
/// (`PTRACE_ATTACH`/`PTRACE_SEIZE`, or a `process_vm_readv`/`process_vm_writev` cross-process
/// memory access), as opposed to a `PTRACE_MODE_READ`-only check (e.g. every `/proc/<pid>/…`
/// stat, which fires constantly and carries no injection signal). Filtering to this bit
/// in-kernel is the FIRST volume cut on this hook — see [`try_ptrace_access_check`].
const PTRACE_MODE_ATTACH: u32 = 0x02;

/// fentry on `security_ptrace_access_check(struct task_struct *child, unsigned int mode)` —
/// the ptrace-attach probe (JEF-318, Retire-Falco G2). Falco fires critical on a ptrace
/// ATTACH: the classic process-injection primitive (debugger-attach, code injection via
/// `PTRACE_POKETEXT`, credential/memory scraping via `process_vm_readv`). This hook fires on
/// EVERY ptrace access check, including the read-only `PTRACE_MODE_READ` checks
/// `/proc/<pid>/…` triggers constantly, so [`try_ptrace_access_check`] filters in-kernel to
/// `mode & PTRACE_MODE_ATTACH` before touching anything else — an ATTACH request
/// specifically, not a read-only check — then further dedups per attacking pid
/// ([`allow_ptrace`]) so a legitimate chatty caller (a debugger single-stepping via repeated
/// `process_vm_readv`) can't flood the ring (the JEF-320 ring-DoS lesson).
///
/// No vmlinux struct read at all: `mode` is passed BY VALUE (a plain `unsigned int`
/// register), and the attacking workload is already fully identified by [`make_header`]'s
/// pid/cgroup. **DECISION (JEF-318):** the target `task_struct`'s pid is deliberately NOT
/// read — `struct task_struct` is enormous and its layout shifts heavily across kernel
/// configs/versions (far more volatile than the already-ON-NODE-PENDING `linux_binprm`/
/// `inode` offsets from JEF-317), so adding that offset here would be a materially bigger
/// verifier-rejection risk for a field the corroboration predicate below doesn't need — the
/// attacking pid alone is enough to scope the Falco-parity signal to the foothold entry.
#[fentry(function = "security_ptrace_access_check")]
pub fn ptrace_access_check(ctx: FEntryContext) -> u32 {
let _ = try_ptrace_access_check(&ctx);
0
}

fn try_ptrace_access_check(ctx: &FEntryContext) -> Result<(), i64> {
// security_ptrace_access_check's 2nd argument is `unsigned int mode`.
let mode: u32 = unsafe { ctx.arg(1) };
if mode & PTRACE_MODE_ATTACH == 0 {
return Ok(()); // a read-only access check — not the attach signal
}
let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32;
if !allow_ptrace(pid) {
return Ok(());
}
emit_fact(KIND_PTRACE_ATTACH);
Ok(())
}

/// `enum kernel_load_data_id`'s `LOADING_MODULE` value (`include/linux/kernel_read_file.h`):
/// the enum is a stable, list-ordered generator macro — `LOADING_UNKNOWN`(0),
/// `LOADING_FIRMWARE`(1), `LOADING_MODULE`(2), `LOADING_KEXEC_IMAGE`(3),
/// `LOADING_KEXEC_INITRAMFS`(4), `LOADING_POLICY`(5), `LOADING_X509_CERTIFICATE`(6),
/// `LOADING_MAX_ID`(7). **ON-NODE BTF VERIFICATION PENDING (JEF-318):** confirm against
/// `bpftool btf dump … format c | grep -A8 'enum kernel_load_data_id'` on BOTH fleet arches
/// before this ships past a spike deploy (docs/ebpf-testing-on-nodes.md). Unlike a struct
/// offset, a wrong value here is NOT verifier-checked — it's a plain integer compare, so a
/// reorder (unlikely; this list has been stable since its 5.x introduction, but unconfirmed
/// on THIS fleet kernel) would misclassify silently rather than fail loud.
const LOADING_MODULE: u32 = 2;

/// fentry on `security_kernel_load_data(enum kernel_load_data_id id, bool contents)` — the
/// kernel-module-load probe (JEF-318, Retire-Falco G2). Falco fires critical on
/// `init_module`/`finit_module`. `load_module()` (kernel/module/main.c) calls this hook
/// EARLY — before any parsing — on BOTH syscalls: `init_module`'s in-memory buffer AND
/// `finit_module`'s fd (which first reaches `security_kernel_read_file(id=READING_MODULE)`
/// to read the fd into that same buffer, then falls through to the same `load_module()` call
/// this probe hooks). One probe on `security_kernel_load_data` therefore covers both
/// syscalls, with no `struct file`/path chase at all: `id` and `contents` are passed BY
/// VALUE (plain scalars), so — like the ptrace probe above — this touches no vmlinux struct
/// offset whatsoever. Filters in-kernel to `id == LOADING_MODULE`: the SAME hook also fires
/// for firmware/kexec/policy/x509 loads, which are not the Falco-parity signal this closes.
/// No dedup gate (unlike ptrace/credential-read above): a real module load is RARE in a
/// normal container workload (no `modprobe`/`insmod` in the entrypoint) — high signal, low
/// volume by construction.
#[fentry(function = "security_kernel_load_data")]
pub fn kernel_load_data(ctx: FEntryContext) -> u32 {
let _ = try_kernel_load_data(&ctx);
0
}

fn try_kernel_load_data(ctx: &FEntryContext) -> Result<(), i64> {
// security_kernel_load_data's 1st argument is `enum kernel_load_data_id id`.
let id: u32 = unsafe { ctx.arg(0) };
if id != LOADING_MODULE {
return Ok(());
}
emit_fact(KIND_MODULE_LOAD);
Ok(())
}

/// Emit a bare [`EventHeader`]-only fact of `kind` — shared by the ptrace-attach and
/// module-load probes (JEF-318), whose entire signal IS the occurrence, attributed by
/// [`make_header`]'s pid/cgroup, with no further payload. Unlike every other emitter in this
/// file there is no body struct: the ring event for these two kinds IS the header, so
/// userspace's `decode` needs no kind-specific byte parse beyond the header it already reads.
fn emit_fact(kind: u32) {
if let Some(mut slot) = EVENTS.reserve::<EventHeader>(0) {
slot.write(make_header(kind));
slot.submit(0);
} else {
record_drop(); // ring full — count the loss instead of silently skipping
}
}

/// bpf_d_path the file's path into a [`FileEvent`] of `kind` and submit it. Shared by the
/// secret-read (file_open) probe — it needs the full path so the engine can match it to a
/// Secret mount. (Library-load uses [`emit_lib_name`]: bpf_d_path is disallowed in its hook.)
Expand Down
31 changes: 29 additions & 2 deletions agent/protector-agent/src/observer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ mod ebpf {
// kernel↔userspace byte contract can't drift (ADR-0014).
use protector_agent_common::{
ConnEvent, EventHeader, ExecEvent, FileEvent, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN,
KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_PRIV_CHANGE, PATH_CAP, PrivEvent,
KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_MODULE_LOAD, KIND_PRIV_CHANGE, KIND_PTRACE_ATTACH,
PATH_CAP, PrivEvent,
};
use protector_behavior::{Attribution, Behavior};

Expand Down Expand Up @@ -160,6 +161,18 @@ mod ebpf {
/// eBPF side already filtered to write-intent opens and deduped repeats to the same
/// `(pid, inode)`; this just carries the path through (JEF-306).
FileWrite { attr: EventAttr, path: String },
/// Ptrace ATTACH access check (JEF-318): a process attempted to PTRACE_ATTACH (or
/// PTRACE_SEIZE / a cross-process memory access) another process — the
/// process-injection primitive Falco fires critical on. No payload beyond
/// attribution: the attacking pid/cgroup IS the fact (the target pid is deliberately
/// not read — see the eBPF probe's doc comment).
PtraceAttach { attr: EventAttr },
/// Kernel module load (JEF-318): `init_module`/`finit_module` reached
/// `load_module()`'s `security_kernel_load_data(LOADING_MODULE, …)` call — a
/// container loading arbitrary code into the HOST kernel, the module-load parity
/// signal Falco fires critical on. No payload beyond attribution — the occurrence is
/// the fact.
ModuleLoad { attr: EventAttr },
}

/// The pair of identities every event carries for attribution (JEF-158): the in-kernel
Expand Down Expand Up @@ -190,7 +203,9 @@ mod ebpf {
| RawEvent::LibraryLoad { attr, .. }
| RawEvent::PrivChange { attr, .. }
| RawEvent::Exec { attr, .. }
| RawEvent::FileWrite { attr, .. } => *attr,
| RawEvent::FileWrite { attr, .. }
| RawEvent::PtraceAttach { attr, .. }
| RawEvent::ModuleLoad { attr, .. } => *attr,
}
}

Expand Down Expand Up @@ -224,6 +239,8 @@ mod ebpf {
exe_anon_inode,
},
RawEvent::FileWrite { path, .. } => Behavior::FileWrite { path },
RawEvent::PtraceAttach { .. } => Behavior::PtraceAttach,
RawEvent::ModuleLoad { .. } => Behavior::ModuleLoad,
}
}
}
Expand Down Expand Up @@ -592,6 +609,8 @@ mod ebpf {
("mmap_file", "security_mmap_file"),
("fix_setuid", "security_task_fix_setuid"),
("bprm_check", "security_bprm_check"),
("ptrace_access_check", "security_ptrace_access_check"),
("kernel_load_data", "security_kernel_load_data"),
];
let attempted = FENTRY_PROBES.len() as u32;
let btf = match Btf::from_sys_fs() {
Expand Down Expand Up @@ -692,6 +711,14 @@ mod ebpf {
let ev = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::<FileEvent>()) };
Self::file_write(&ev)
}
// JEF-318: both bodies ARE the header — already parsed above, and its length
// already checked at the top of this function — so no further byte parse.
KIND_PTRACE_ATTACH => Some(RawEvent::PtraceAttach {
attr: EventAttr::from_header(&header),
}),
KIND_MODULE_LOAD => Some(RawEvent::ModuleLoad {
attr: EventAttr::from_header(&header),
}),
_ => None, // unknown kind (older/newer probe set) — skip
}
}
Expand Down
62 changes: 62 additions & 0 deletions agent/protector-agent/src/observer/ebpf/observer_ebpf_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,65 @@ fn decode_file_write_parses_path_and_maps_to_file_write() {
other => panic!("expected FileWrite, got {other:?}"),
}
}

#[test]
fn decode_ptrace_attach_parses_with_no_body_beyond_the_header() {
// JEF-318: a KIND_PTRACE_ATTACH event IS an EventHeader — no extra bytes, unlike every
// other kind's body. Decode must still succeed on exactly `size_of::<EventHeader>()`
// bytes and attribute + map it to Behavior::PtraceAttach.
let header = EventHeader {
kind: KIND_PTRACE_ATTACH,
pid: 4321,
cgroup_id: 999,
};
let bytes = unsafe {
std::slice::from_raw_parts(
(&header as *const EventHeader).cast::<u8>(),
std::mem::size_of::<EventHeader>(),
)
};
let raw = EbpfObserver::decode(bytes).expect("KIND_PTRACE_ATTACH should decode");
match &raw {
RawEvent::PtraceAttach { attr } => {
assert_eq!(attr.pid, 4321);
assert_eq!(attr.cgroup_id, 999);
}
_ => panic!("expected RawEvent::PtraceAttach"),
}
assert_eq!(raw.attr().pid, 4321);
assert_eq!(raw.into_behavior(), Behavior::PtraceAttach);
}

#[test]
fn decode_module_load_parses_with_no_body_beyond_the_header() {
// JEF-318: same header-only shape as KIND_PTRACE_ATTACH, distinct kind + behavior.
let header = EventHeader {
kind: KIND_MODULE_LOAD,
pid: 555,
cgroup_id: 4242,
};
let bytes = unsafe {
std::slice::from_raw_parts(
(&header as *const EventHeader).cast::<u8>(),
std::mem::size_of::<EventHeader>(),
)
};
let raw = EbpfObserver::decode(bytes).expect("KIND_MODULE_LOAD should decode");
match &raw {
RawEvent::ModuleLoad { attr } => {
assert_eq!(attr.pid, 555);
assert_eq!(attr.cgroup_id, 4242);
}
_ => panic!("expected RawEvent::ModuleLoad"),
}
assert_eq!(raw.attr().cgroup_id, 4242);
assert_eq!(raw.into_behavior(), Behavior::ModuleLoad);
}

#[test]
fn decode_drops_a_truncated_event_shorter_than_the_header() {
// A byte slice shorter than EventHeader itself must never be parsed — regardless of
// kind, since decode() reads the header before it can even dispatch.
let too_short = [0u8; 4];
assert!(EbpfObserver::decode(&too_short).is_none());
}
Loading
Loading