Skip to content

Commit e79e604

Browse files
ZhiXiao-LinRoyLin
andauthored
feat: SecurityAction — privesc / injection / open-port (v0.11.0) (#3)
Unified rare-and-loud, in-kernel-filtered security event on one ring: - setuid-root: setuid/setresuid/setreuid -> (e)uid 0 from non-root (privesc, incl. EPERM-bound attempt). Thread-group-leader gated so glibc NPTL setxid broadcast doesn't fan out one escalation into N events. - ptrace: ptrace(ATTACH|SEIZE) of another process (detail = target pid). - bind: bind() to a fixed non-loopback port (detail = port). Live-validated on Linux 6.8 (all three fire with correct detail, verifier loads clean, 16-thread setuid deduped to one event). Adversarial verify fan-out (4 lenses) before ship; its two medium findings (setreuid gap, per-thread dup) fixed here. Co-authored-by: RoyLin <roylin@RoyLindeMacBook-Pro.local>
1 parent 73d6642 commit e79e604

12 files changed

Lines changed: 224 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
All notable changes to a3s-observer will be documented in this file.
44

5+
## [0.11.0] — SecurityAction: privesc / injection / open-port
6+
7+
### Added
8+
9+
- **`AgentEvent::SecurityAction { pid, kind, detail }`** — one rare-and-loud, in-kernel-filtered
10+
event for the security-sensitive syscalls an agent rarely makes but that matter when it does.
11+
Three kinds, all on a single `SEC_EVENTS` ring:
12+
- `setuid-root``setuid`/`setresuid`/`setreuid` setting (e)uid 0 from a non-root caller
13+
(privilege escalation, including the EPERM-bound *attempt*). Gated to the thread-group leader,
14+
so glibc's NPTL setxid broadcast doesn't fan out one escalation into N duplicate events.
15+
- `ptrace``ptrace(ATTACH|SEIZE)` of another process (`detail` = target pid): process injection.
16+
- `bind``bind()` to a fixed **non-loopback** port (`detail` = port): an off-host-reachable
17+
listener. Loopback (127.0.0.0/8) binds are filtered as common local-helper noise.
18+
19+
Group escalation (`setgid`) and loopback-only binds are intentionally out of scope. Validated
20+
live on Linux 6.8 (all three fire with correct `detail`, verifier loads clean, multithreaded
21+
`setuid` deduped to one event), and adversarially reviewed before release.
22+
523
## [0.10.0] — richer signals: exit-signal, LLM model, dest-port, uid
624

725
### Added

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer"
3-
version = "0.10.0"
3+
version = "0.11.0"
44
edition = "2021"
55
license = "MIT"
66
description = "General-purpose, language-agnostic eBPF observability for AI agents (LLM calls, tools, files, network egress)."

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ latency / TTFT, or plaintext) / **where** (peer IP / hostname).
5353
| `unlink`\* | `sys_enter_unlinkat` | `FileDelete` — files deleted (`A3S_OBSERVER_FILES=1`) |
5454
| `ssl`\* | OpenSSL `SSL_write` / `SSL_read` uprobes | `SslContent` — request/response plaintext (`A3S_OBSERVER_SSL=1`) |
5555
| `llm-api`\* | parsed from `SslContent` | `LlmApi`**model** + token usage (`A3S_OBSERVER_SSL=1`) |
56+
| `security` | `setuid` / `ptrace` / `bind` syscalls | `SecurityAction` — privilege escalation (→root) / process injection / opened a listening port (rare + in-kernel-filtered) |
5657

5758
Userspace enriches each event with **identity** (k8s cgroup→pod, `/proc` comm+ppid, or an
5859
in-kernel `comm` fallback for short-lived processes), a `(pid,fd)→peer` **correlation**, and

a3s-observer-collector/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-collector"
3-
version = "0.10.0"
3+
version = "0.11.0"
44
edition = "2021"
55
license = "MIT"
66
description = "a3s-observer collector: loads the eBPF probes and exports enriched events."

a3s-observer-collector/src/main.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use a3s_observer::{
1111
KubeResolver, LogExporter, Provider, ServiceClassifier, SniClassifier,
1212
};
1313
use a3s_observer_common::{
14-
ConnectEvent, DnsEvent, ExecEvent, ExitEvent, FileEvent, LlmEvent, SslEvent, TlsEvent,
15-
FILE_DELETE_FLAG,
14+
ConnectEvent, DnsEvent, ExecEvent, ExitEvent, FileEvent, LlmEvent, SecEvent, SslEvent,
15+
TlsEvent, FILE_DELETE_FLAG, SEC_BIND, SEC_PTRACE, SEC_SETUID,
1616
};
1717
use anyhow::Context as _;
1818
use aya::{
@@ -74,6 +74,11 @@ async fn main() -> anyhow::Result<()> {
7474
("read_exit", "sys_exit_read"),
7575
("recv_exit", "sys_exit_recvfrom"),
7676
("sock_close", "sys_enter_close"),
77+
("sec_setuid", "sys_enter_setuid"),
78+
("sec_setresuid", "sys_enter_setresuid"),
79+
("sec_setreuid", "sys_enter_setreuid"),
80+
("sec_ptrace", "sys_enter_ptrace"),
81+
("sec_bind", "sys_enter_bind"),
7782
];
7883
if files {
7984
probes.push(("file_open", "sys_enter_openat"));
@@ -173,6 +178,10 @@ async fn main() -> anyhow::Result<()> {
173178
ebpf.take_map("SSL_EVENTS")
174179
.context("`SSL_EVENTS` missing")?,
175180
)?;
181+
let mut sec_ring = RingBuf::try_from(
182+
ebpf.take_map("SEC_EVENTS")
183+
.context("`SEC_EVENTS` missing")?,
184+
)?;
176185
// Cumulative count of events dropped because a ring was full (data-loss visibility).
177186
let drops: PerCpuArray<_, u64> =
178187
PerCpuArray::try_from(ebpf.take_map("DROPS").context("`DROPS` missing")?)?;
@@ -220,6 +229,7 @@ async fn main() -> anyhow::Result<()> {
220229
file = stats.file,
221230
llm = stats.llm,
222231
ssl = stats.ssl,
232+
sec = stats.sec,
223233
dropped,
224234
output_dropped,
225235
"a3s-observer: events in the last 60s (dropped = cumulative ring-full, \
@@ -259,6 +269,25 @@ async fn main() -> anyhow::Result<()> {
259269
});
260270
}
261271
}
272+
while let Some(item) = sec_ring.next() {
273+
if let Some(ev) = read_pod::<SecEvent>(&item) {
274+
let kind = match ev.kind {
275+
SEC_SETUID => "setuid-root",
276+
SEC_PTRACE => "ptrace",
277+
SEC_BIND => "bind",
278+
_ => continue,
279+
};
280+
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
281+
identity: identity_for(&resolver, ev.pid, &ev.comm),
282+
provider: None,
283+
event: AgentEvent::SecurityAction {
284+
pid: ev.pid,
285+
kind,
286+
detail: ev.detail,
287+
},
288+
});
289+
}
290+
}
262291
// Drain connect BEFORE tls so a same-poll ClientHello finds its peer.
263292
while let Some(item) = connect_ring.next() {
264293
if let Some(ev) = read_pod::<ConnectEvent>(&item) {
@@ -415,6 +444,7 @@ async fn main() -> anyhow::Result<()> {
415444
file = stats.file,
416445
llm = stats.llm,
417446
ssl = stats.ssl,
447+
sec = stats.sec,
418448
"a3s-observer-collector: stopped (final window)"
419449
);
420450
Ok(())
@@ -512,6 +542,7 @@ struct Stats {
512542
file: u64,
513543
llm: u64,
514544
ssl: u64,
545+
sec: u64,
515546
}
516547

517548
/// Export an event and count it by kind for the throughput report.
@@ -526,6 +557,7 @@ fn emit(exporter: &dyn Exporter, stats: &mut Stats, ev: EnrichedEvent) {
526557
AgentEvent::LlmCall { .. } => stats.llm += 1,
527558
AgentEvent::SslContent { .. } => stats.ssl += 1,
528559
AgentEvent::LlmApi { .. } => stats.llm += 1,
560+
AgentEvent::SecurityAction { .. } => stats.sec += 1,
529561
}
530562
exporter.export(&ev);
531563
}

a3s-observer-common/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-common"
3-
version = "0.10.0"
3+
version = "0.11.0"
44
edition = "2021"
55
license = "MIT"
66
description = "Shared no_std types crossing the eBPF <-> userspace boundary for a3s-observer."

a3s-observer-common/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,19 @@ pub struct SslEvent {
133133
pub comm: [u8; 16],
134134
pub data: [u8; SSL_SNAP_LEN],
135135
}
136+
137+
/// A security-sensitive action — rare and high-signal, filtered in-kernel so volume stays near
138+
/// zero. One event/ring covers several syscalls (privilege escalation, process injection, opening
139+
/// a listening port) instead of a probe-per-syscall sprawl — keeps the model + ring count bounded.
140+
#[repr(C)]
141+
#[derive(Clone, Copy)]
142+
pub struct SecEvent {
143+
pub pid: u32,
144+
pub kind: u32, // SEC_* below
145+
pub detail: u64, // SEC_SETUID: 0 (escalated-to uid) · SEC_PTRACE: target pid · SEC_BIND: port
146+
pub comm: [u8; 16],
147+
}
148+
149+
pub const SEC_SETUID: u32 = 1; // setuid/setresuid → euid 0 from a non-root caller (privesc)
150+
pub const SEC_PTRACE: u32 = 2; // ptrace(ATTACH|SEIZE) of another process (injection)
151+
pub const SEC_BIND: u32 = 3; // bind() to a fixed (non-ephemeral) port (opened a listener)

a3s-observer-ebpf/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-ebpf"
3-
version = "0.10.0"
3+
version = "0.11.0"
44
edition = "2021"
55
license = "MIT"
66
publish = false

a3s-observer-ebpf/src/main.rs

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
#![no_main]
33

44
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,
78
};
89
use aya_ebpf::{
910
helpers::gen::bpf_probe_read_user,
@@ -39,6 +40,11 @@ static FILE_EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);
3940
#[map]
4041
static LLM_EVENTS: RingBuf = RingBuf::with_byte_size(64 * 1024, 0);
4142

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+
4248
// Count of events dropped because a ring was full — data-loss visibility under extreme load.
4349
#[map]
4450
static DROPS: PerCpuArray<u64> = PerCpuArray::with_max_entries(1, 0);
@@ -376,6 +382,131 @@ fn try_connect(ctx: &TracePointContext) -> Result<u32, i64> {
376382
Ok(0)
377383
}
378384

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+
379510
// ---- DNS query (sys_enter_sendto to :53) ----
380511
// Detects a UDP DNS query by the dest port (sockaddr @ offset 48) and copies the packet;
381512
// userspace parses the question name. Connected-UDP sends (NULL dest addr) aren't covered.

0 commit comments

Comments
 (0)