|
2 | 2 | #![no_main] |
3 | 3 |
|
4 | 4 | use a3s_observer_common::{ |
5 | | - ConnectEvent, DnsEvent, ExecEvent, ExitEvent, FileEvent, LlmEvent, SslEvent, TlsEvent, |
6 | | - ARGV_SLOTS, ARG_LEN, DNS_SNAP_LEN, FILE_DELETE_FLAG, PATH_SNAP_LEN, SSL_SNAP_LEN, TLS_SNAP_LEN, |
| 5 | + ConnectEvent, DnsEvent, ExecEvent, ExitEvent, FileEvent, LlmEvent, SecEvent, SslEvent, |
| 6 | + TlsEvent, ARGV_SLOTS, ARG_LEN, DNS_SNAP_LEN, FILE_DELETE_FLAG, PATH_SNAP_LEN, SEC_BIND, |
| 7 | + SEC_PTRACE, SEC_SETUID, SSL_SNAP_LEN, TLS_SNAP_LEN, |
7 | 8 | }; |
8 | 9 | use aya_ebpf::{ |
9 | 10 | helpers::gen::bpf_probe_read_user, |
@@ -39,6 +40,11 @@ static FILE_EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0); |
39 | 40 | #[map] |
40 | 41 | static LLM_EVENTS: RingBuf = RingBuf::with_byte_size(64 * 1024, 0); |
41 | 42 |
|
| 43 | +// Security-sensitive actions (privesc / injection / open-port). In-kernel-filtered to the loud |
| 44 | +// cases, so this stays near-empty — a small ring is plenty. |
| 45 | +#[map] |
| 46 | +static SEC_EVENTS: RingBuf = RingBuf::with_byte_size(64 * 1024, 0); |
| 47 | + |
42 | 48 | // Count of events dropped because a ring was full — data-loss visibility under extreme load. |
43 | 49 | #[map] |
44 | 50 | static DROPS: PerCpuArray<u64> = PerCpuArray::with_max_entries(1, 0); |
@@ -376,6 +382,131 @@ fn try_connect(ctx: &TracePointContext) -> Result<u32, i64> { |
376 | 382 | Ok(0) |
377 | 383 | } |
378 | 384 |
|
| 385 | +// ---- security-sensitive actions: privesc (setuid) / injection (ptrace) / open-port (bind) ---- |
| 386 | +// |
| 387 | +// One ring, in-kernel-filtered to the loud cases. These syscalls are rare for a normal agent, so |
| 388 | +// when one fires it's worth a look — that's the whole point of a separate "rare and loud" tier. |
| 389 | + |
| 390 | +fn emit_sec(kind: u32, detail: u64) { |
| 391 | + let Some(mut entry) = reserve_or_drop::<SecEvent>(&SEC_EVENTS) else { |
| 392 | + return; |
| 393 | + }; |
| 394 | + let ev = entry.as_mut_ptr(); |
| 395 | + unsafe { |
| 396 | + (*ev).pid = (bpf_get_current_pid_tgid() >> 32) as u32; |
| 397 | + (*ev).kind = kind; |
| 398 | + (*ev).detail = detail; |
| 399 | + (*ev).comm = bpf_get_current_comm().unwrap_or_default(); |
| 400 | + } |
| 401 | + entry.submit(0); |
| 402 | +} |
| 403 | + |
| 404 | +// Escalation TO root from a non-root caller — the loud case. Dropping privs (root → nobody, which |
| 405 | +// every daemon does at boot) is noise and is filtered out. NOTE: legitimate setuid-root tools |
| 406 | +// (sudo/su/passwd) also fire here — it's a genuine privilege transition, expected to pair with a |
| 407 | +// ToolExec of the setuid binary, not inherently malicious. |
| 408 | +fn try_setuid_to(target: u32) { |
| 409 | + // glibc broadcasts setuid/setresuid/setreuid to EVERY thread (NPTL setxid), so one logical |
| 410 | + // escalation fires this per-thread — the same fanout do_exit has. Emit once, from the |
| 411 | + // thread-group leader (tgid == tid), matching the proc_exit convention. (A raw setuid syscall |
| 412 | + // from a non-leader thread is thus missed — vanishingly rare vs the glibc/single-threaded paths.) |
| 413 | + let id = bpf_get_current_pid_tgid(); |
| 414 | + if (id >> 32) as u32 != id as u32 { |
| 415 | + return; |
| 416 | + } |
| 417 | + if target == 0 && (bpf_get_current_uid_gid() as u32) != 0 { |
| 418 | + emit_sec(SEC_SETUID, 0); |
| 419 | + } |
| 420 | +} |
| 421 | + |
| 422 | +#[tracepoint] |
| 423 | +pub fn sec_setuid(ctx: TracePointContext) -> u32 { |
| 424 | + try_sec_setuid(&ctx).unwrap_or(0) |
| 425 | +} |
| 426 | +fn try_sec_setuid(ctx: &TracePointContext) -> Result<u32, i64> { |
| 427 | + let uid: u64 = unsafe { ctx.read_at(16)? }; // sys_enter_setuid: uid_t uid @16 |
| 428 | + try_setuid_to(uid as u32); |
| 429 | + Ok(0) |
| 430 | +} |
| 431 | + |
| 432 | +#[tracepoint] |
| 433 | +pub fn sec_setresuid(ctx: TracePointContext) -> u32 { |
| 434 | + try_sec_setresuid(&ctx).unwrap_or(0) |
| 435 | +} |
| 436 | +fn try_sec_setresuid(ctx: &TracePointContext) -> Result<u32, i64> { |
| 437 | + // sys_enter_setresuid: ruid @16, euid @24, suid @32 — the euid grants effective privilege. |
| 438 | + let euid: u64 = unsafe { ctx.read_at(24)? }; |
| 439 | + try_setuid_to(euid as u32); |
| 440 | + Ok(0) |
| 441 | +} |
| 442 | + |
| 443 | +#[tracepoint] |
| 444 | +pub fn sec_setreuid(ctx: TracePointContext) -> u32 { |
| 445 | + try_sec_setreuid(&ctx).unwrap_or(0) |
| 446 | +} |
| 447 | +fn try_sec_setreuid(ctx: &TracePointContext) -> Result<u32, i64> { |
| 448 | + // sys_enter_setreuid: ruid @16, euid @24 — euid is the effective uid being set (the privesc |
| 449 | + // path os.setreuid / seteuid take, which neither setuid nor setresuid catches). |
| 450 | + let euid: u64 = unsafe { ctx.read_at(24)? }; |
| 451 | + try_setuid_to(euid as u32); |
| 452 | + Ok(0) |
| 453 | +} |
| 454 | + |
| 455 | +#[tracepoint] |
| 456 | +pub fn sec_ptrace(ctx: TracePointContext) -> u32 { |
| 457 | + try_sec_ptrace(&ctx).unwrap_or(0) |
| 458 | +} |
| 459 | +fn try_sec_ptrace(ctx: &TracePointContext) -> Result<u32, i64> { |
| 460 | + // sys_enter_ptrace: long request @16, long pid @24. |
| 461 | + let request: u64 = unsafe { ctx.read_at(16)? }; |
| 462 | + let target: u64 = unsafe { ctx.read_at(24)? }; |
| 463 | + // PTRACE_ATTACH = 16, PTRACE_SEIZE = 0x4206 — the gateway to memory/register injection |
| 464 | + // (you must attach before POKE*). TRACEME = 0 is benign self-trace. Skip self-targeting. |
| 465 | + let self_pid = (bpf_get_current_pid_tgid() >> 32) as u32; |
| 466 | + if (request == 16 || request == 0x4206) && target as u32 != self_pid { |
| 467 | + emit_sec(SEC_PTRACE, target); |
| 468 | + } |
| 469 | + Ok(0) |
| 470 | +} |
| 471 | + |
| 472 | +#[tracepoint] |
| 473 | +pub fn sec_bind(ctx: TracePointContext) -> u32 { |
| 474 | + try_sec_bind(&ctx).unwrap_or(0) |
| 475 | +} |
| 476 | +fn try_sec_bind(ctx: &TracePointContext) -> Result<u32, i64> { |
| 477 | + // sys_enter_bind: int fd @16, struct sockaddr *umyaddr @24, int addrlen @32 — same shape as connect. |
| 478 | + let addr_ptr: *const u8 = unsafe { ctx.read_at(24)? }; |
| 479 | + let addrlen: u64 = unsafe { ctx.read_at(32)? }; |
| 480 | + if addrlen < 8 { |
| 481 | + return Ok(0); |
| 482 | + } |
| 483 | + let mut fam = [0u8; 2]; |
| 484 | + if unsafe { bpf_probe_read_user_buf(addr_ptr, &mut fam) }.is_err() { |
| 485 | + return Ok(0); |
| 486 | + } |
| 487 | + let family = u16::from_ne_bytes(fam); |
| 488 | + if family != 2 && family != 10 { |
| 489 | + return Ok(0); // AF_INET / AF_INET6 only |
| 490 | + } |
| 491 | + // Skip loopback (127.0.0.0/8) binds — local-only helper sockets (runtime debug/metrics servers) |
| 492 | + // are common noise; an off-host-reachable listener is the loud case. (IPv6 ::1 not filtered.) |
| 493 | + if family == 2 { |
| 494 | + let mut oct = [0u8; 1]; |
| 495 | + let _ = unsafe { bpf_probe_read_user_buf(addr_ptr.add(4), &mut oct) }; // first octet of sin_addr |
| 496 | + if oct[0] == 127 { |
| 497 | + return Ok(0); |
| 498 | + } |
| 499 | + } |
| 500 | + let mut port = [0u8; 2]; |
| 501 | + let _ = unsafe { bpf_probe_read_user_buf(addr_ptr.add(2), &mut port) }; // sin_port (network order) |
| 502 | + let port = u16::from_be_bytes(port); |
| 503 | + // port 0 = kernel picks (a client's ephemeral source port); a fixed port = a server listening. |
| 504 | + if port != 0 { |
| 505 | + emit_sec(SEC_BIND, port as u64); |
| 506 | + } |
| 507 | + Ok(0) |
| 508 | +} |
| 509 | + |
379 | 510 | // ---- DNS query (sys_enter_sendto to :53) ---- |
380 | 511 | // Detects a UDP DNS query by the dest port (sockaddr @ offset 48) and copies the packet; |
381 | 512 | // userspace parses the question name. Connected-UDP sends (NULL dest addr) aren't covered. |
|
0 commit comments