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
144 changes: 144 additions & 0 deletions agent/protector-agent/src/linkage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//! Per-workload ELF static/dynamic **linkage** classification (JEF-407).
//!
//! The engine has no in-cluster access to a workload's entrypoint bytes, so JEF-404's
//! static-linkage reachability sat dormant — `Image::static_binary` was always `None` in
//! prod and a Go / musl-static CVE rendered `not-observed` forever. The node-local agent DOES
//! see the running binary (`/proc/<pid>/exe`), so it is the natural byte source: on an exec it
//! reads only the leading ELF header bytes, classifies linkage with the SAME shared parser the
//! engine uses ([`protector_behavior::elf::elf_static_linkage`] — no `PT_INTERP` ⇒ static), and
//! emits a [`Behavior::ImageLinkage`] over the EXISTING behavioral wire. No new egress (the
//! zero-egress invariant holds), no heavy dependency (the parser is std-only).
//!
//! Split into a pure classifier (injectable read, unit-testable without a real `/proc`) and a
//! thin `/proc` reader (compiled only where it's used), keeping the byte source a separate,
//! testable concern from the ELF logic — the same shape the rest of the agent uses.

use protector_behavior::{Behavior, elf::elf_static_linkage};

/// How many leading bytes of the entrypoint binary to read. The classifier inspects only the
/// ELF header and the program-header table; a `PT_INTERP` (the dynamic-loader marker) is
/// among the very first program headers a linker emits, so 4 KiB comfortably covers the
/// header + the whole program-header table of any real executable while staying a single
/// cheap page-sized read. A binary whose table somehow extends past this simply classifies
/// `None` (unknown) — never a wrong answer (the classifier is conservative by construction).
pub const ELF_HEAD_CAP: usize = 4096;

/// Classify a pid's entrypoint linkage from its ELF header (JEF-407).
///
/// Returns `Some(true)` for a statically linked binary (no `PT_INTERP`), `Some(false)` for a
/// dynamically linked one, and `None` when the linkage is unknown — the exe couldn't be read
/// (process gone / denied) or the bytes don't parse as an ELF we recognize. `None` is dropped
/// by the caller rather than guessed, so the engine keeps its prior `static_binary == None`
/// behavior for that workload — never a false "static".
///
/// `read_head` is injected (a `Fn(u32) -> Option<Vec<u8>>` yielding the leading bytes of
/// `/proc/<pid>/exe`) so this is unit-testable with synthetic ELF fixtures and no real `/proc`.
pub fn classify_linkage(read_head: impl Fn(u32) -> Option<Vec<u8>>, pid: u32) -> Option<bool> {
elf_static_linkage(&read_head(pid)?)
}

/// The wire signal for a classified linkage: a [`Behavior::ImageLinkage`] carrying the
/// static/dynamic bit. Trivial, but named so the observer's emit path reads clearly and the
/// mapping lives in one place.
pub fn linkage_behavior(static_linkage: bool) -> Behavior {
Behavior::ImageLinkage { static_linkage }
}

/// Read up to [`ELF_HEAD_CAP`] leading bytes of a pid's entrypoint binary via
/// `/proc/<pid>/exe`. `None` if the process is gone or the exe is unreadable (a kernel thread,
/// a denied read) — the linkage is then unknown and the caller drops it, never fatal. Reads
/// only the header prefix, not the whole (possibly huge) binary.
pub fn read_exe_head(pid: u32) -> Option<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(format!("/proc/{pid}/exe")).ok()?;
let mut buf = Vec::with_capacity(ELF_HEAD_CAP);
// `take` bounds the read to the header prefix regardless of the binary's real size.
file.take(ELF_HEAD_CAP as u64).read_to_end(&mut buf).ok()?;
Some(buf)
}

#[cfg(test)]
mod tests {
use super::*;

/// Build a minimal 64-bit little-endian ELF header + program-header table over `p_types`
/// (mirrors the shared classifier's own fixtures — the smallest bytes that classify).
fn elf64_le(p_types: &[u32]) -> Vec<u8> {
const EHDR64: usize = 0x40;
const PHENT64: usize = 0x38;
let phnum = p_types.len();
let phoff = EHDR64;
let mut buf = vec![0u8; EHDR64 + phnum * PHENT64];
buf[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
buf[4] = 2; // ELFCLASS64
buf[5] = 1; // ELFDATA2LSB
buf[0x20..0x28].copy_from_slice(&(phoff as u64).to_le_bytes());
buf[0x36..0x38].copy_from_slice(&(PHENT64 as u16).to_le_bytes());
buf[0x38..0x3A].copy_from_slice(&(phnum as u16).to_le_bytes());
for (i, &p_type) in p_types.iter().enumerate() {
let at = phoff + i * PHENT64;
buf[at..at + 4].copy_from_slice(&p_type.to_le_bytes());
}
buf
}

const PT_LOAD: u32 = 1;
const PT_INTERP: u32 = 3;

#[test]
fn static_entrypoint_classifies_static() {
// A Go / musl-static entrypoint: LOAD segments, no PT_INTERP → Some(true).
let bytes = elf64_le(&[PT_LOAD, PT_LOAD]);
assert_eq!(classify_linkage(|_| Some(bytes.clone()), 42), Some(true));
}

#[test]
fn dynamic_entrypoint_classifies_dynamic() {
// A glibc dynamically linked entrypoint: a PT_INTERP among the LOADs → Some(false).
let bytes = elf64_le(&[PT_LOAD, PT_INTERP]);
assert_eq!(classify_linkage(|_| Some(bytes.clone()), 42), Some(false));
}

#[test]
fn unreadable_exe_is_unknown() {
// The process is gone / the exe is unreadable → None (dropped, never guessed).
assert_eq!(classify_linkage(|_| None, 42), None);
}

#[test]
fn non_elf_bytes_are_unknown() {
// A script interpreter or garbage prefix → None, not a wrong classification.
assert_eq!(
classify_linkage(|_| Some(b"#!/bin/sh\n".to_vec()), 42),
None
);
}

#[test]
fn read_exe_head_reads_at_most_the_cap_and_never_panics() {
// `/proc/self/exe` exists on Linux (where the agent runs); reading it must return the
// leading header bytes bounded by the cap, and must never read the whole binary. On a
// non-Linux dev box `/proc` is absent → None (the same honest "unknown" the classifier
// drops). Either way the read must not panic.
if let Some(head) = read_exe_head(std::process::id()) {
assert!(head.len() <= ELF_HEAD_CAP, "read is bounded by the cap");
assert!(!head.is_empty(), "a real binary has a header");
}
}

#[test]
fn linkage_behavior_carries_the_bit() {
assert_eq!(
linkage_behavior(true),
Behavior::ImageLinkage {
static_linkage: true
}
);
assert_eq!(
linkage_behavior(false),
Behavior::ImageLinkage {
static_linkage: false
}
);
}
}
5 changes: 5 additions & 0 deletions agent/protector-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
//! reversible network cut.

mod coalesce;
// The linkage classifier (JEF-407) is called only from the ebpf observer's exec path (plus
// its own unit tests), so gate it like `pod` — the default no-eBPF build doesn't carry it as
// dead code (the repo treats warnings as errors).
#[cfg(any(feature = "ebpf", test))]
mod linkage;
mod observer;
#[cfg(any(feature = "ebpf", test))]
mod pod;
Expand Down
42 changes: 41 additions & 1 deletion agent/protector-agent/src/observer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,13 @@ mod ebpf {
// Per-pid cache for the FALLBACK `/proc` read only — a table miss from a chatty
// host pid shouldn't re-read `/proc` per event. Bounded; cleared wholesale at cap.
let mut fallback_cache: HashMap<u32, PodAttribution> = HashMap::new();
// Pod UIDs we've already reported entrypoint linkage for (JEF-407). Linkage is a
// stable per-image fact, so we classify `/proc/<pid>/exe` once per pod on its first
// exec and never again — one ELF read per pod, not per exec. Bounded like the pid
// cache; cleared wholesale at the cap (a re-report on a churned pod is harmless —
// the engine's `record()` dedupes an identical linkage signal).
let mut linkage_reported: std::collections::HashSet<String> =
std::collections::HashSet::new();
loop {
let raw = tokio::select! {
recv = raw_rx.recv() => match recv {
Expand Down Expand Up @@ -469,8 +476,13 @@ mod ebpf {
continue;
}
};
// JEF-407: an exec is our chance to classify the workload's ENTRYPOINT linkage
// — `/proc/<pid>/exe` is the exec'd binary. Capture the pid before `raw` is
// consumed; only an Exec triggers a linkage classification, and only the first
// time we see a given pod (linkage is a stable per-image fact).
let exec_pid = matches!(raw, RawEvent::Exec { .. }).then_some(attr.pid);
let obs = RuntimeObservation {
attribution: Attribution::by_pod_uid(uid),
attribution: Attribution::by_pod_uid(uid.clone()),
source: Some(SOURCE.into()),
observed_at_ms: now_ms(),
// The agent's node (JEF-308) is stamped by the flusher in `main` from `K8S_NODE`
Expand All @@ -483,6 +495,34 @@ mod ebpf {
}
// A signal successfully attributed and forwarded — the rate numerator.
signals.fetch_add(1, Ordering::Relaxed);

// Emit the entrypoint's static/dynamic linkage once per pod (JEF-407). Bounds
// the ELF read to one-per-pod, and drops an unknown classification (unreadable
// exe / non-ELF) rather than guessing — the engine then keeps its prior
// `static_binary == None` behavior for that workload.
if let Some(pid) = exec_pid
&& !linkage_reported.contains(&uid)
{
if linkage_reported.len() >= PID_CACHE_CAP {
linkage_reported.clear();
}
linkage_reported.insert(uid.clone());
if let Some(static_linkage) =
crate::linkage::classify_linkage(crate::linkage::read_exe_head, pid)
{
let linkage = RuntimeObservation {
attribution: Attribution::by_pod_uid(uid),
source: Some(SOURCE.into()),
observed_at_ms: now_ms(),
node: None,
behavior: crate::linkage::linkage_behavior(static_linkage),
};
if tx.send(linkage).await.is_err() {
return; // report receiver gone — shut down
}
signals.fetch_add(1, Ordering::Relaxed);
}
}
}
}

Expand Down
149 changes: 149 additions & 0 deletions behavior/src/elf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! Minimal ELF static-linkage classification (JEF-404), shared by the engine and the
//! first-party agent (JEF-407).
//!
//! Reachability is proven by correlating a CVE's package against runtime
//! [`Behavior::LibraryLoaded`](crate::Behavior::LibraryLoaded) events: a `.so` the kernel
//! loads names the vulnerable package. **Statically linked binaries have no per-library
//! `.so` loads** — Go compiles everything into one executable, a CGO-disabled / musl-static
//! build does the same — so `LibraryLoaded` never names the package and every CVE renders
//! `not-observed` even when the vulnerable code is genuinely on a hot path. That absence
//! must not read as evidence-of-absence.
//!
//! The cheapest reliable signal that a binary is static is its ELF header: a dynamically
//! linked executable carries a `PT_INTERP` program header naming its dynamic loader
//! (`/lib64/ld-linux-x86-64.so.2`, …); a fully static one does **not**. This module reads
//! ONLY the ELF header and the program-header table — no sections, no symbols, no dynamic
//! table — so it is a tiny, well-scoped parser rather than a heavyweight ELF dependency
//! (zero new crate; std-only, so it can live in this shared wire crate and be reused by
//! the node-local agent that reads `/proc/<pid>/exe` without pulling in any engine deps).
//!
//! It is byte-only and pure: give it the leading bytes of a binary, get back whether it is
//! statically linked. That keeps it fully unit-testable with tiny synthetic fixtures and
//! keeps *where the bytes come from* a separate plumbing concern (the engine had none in
//! prod until JEF-407 wired the agent as the byte source).

/// The four-byte ELF magic (`0x7f 'E' 'L' 'F'`) every ELF file starts with.
const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F'];

/// `e_ident[EI_CLASS]` values: 32-bit vs 64-bit. Their layouts differ only in field widths.
const ELFCLASS32: u8 = 1;
const ELFCLASS64: u8 = 2;

/// `e_ident[EI_DATA]` values: little- vs big-endian. We honor the file's own byte order so
/// a cross-compiled (e.g. big-endian) binary classifies correctly.
const ELFDATA2LSB: u8 = 1;
const ELFDATA2MSB: u8 = 2;

/// Program-header type `PT_INTERP` — names the dynamic loader. Its presence is the
/// definitive "this binary is dynamically linked" signal; its absence (in an otherwise
/// valid executable) means static linkage.
const PT_INTERP: u32 = 3;

/// Classify a binary's ELF header as statically vs dynamically linked (JEF-404).
///
/// Returns:
/// - `Some(true)` — a valid ELF with **no** `PT_INTERP` program header: statically linked.
/// - `Some(false)` — a valid ELF that carries a `PT_INTERP` header: dynamically linked.
/// - `None` — not an ELF, truncated, or a header we can't parse: linkage unknown, so
/// the caller must fall back to the pre-existing behavior (never guess "static").
///
/// Deliberately conservative: any malformed or unrecognized field yields `None` rather than
/// a wrong classification — a false "static" would mislabel a `not-observed` CVE as
/// indeterminate, and a false "dynamic" would do the reverse, so an honest "unknown" is the
/// only safe answer when the bytes don't parse cleanly.
///
/// Only the ELF header (`e_phoff`, `e_phentsize`, `e_phnum`) and the program-header table
/// (each entry's `p_type`) are read — bounded, allocation-free, and it stops at the first
/// `PT_INTERP` it finds. Every byte offset used to slice `bytes` is formed with
/// `checked_add`/`checked_mul`, so a crafted `e_phoff`/`e_phnum` near `usize::MAX` can never
/// overflow-panic (harmless in release, but a debug-build panic without the checks) — it
/// simply returns `None`.
pub fn elf_static_linkage(bytes: &[u8]) -> Option<bool> {
// e_ident is 16 bytes; the smallest header we parse (32-bit) is 52 bytes. Reject
// anything too short to hold even the fields we read.
if bytes.len() < 20 || bytes[..4] != ELF_MAGIC {
return None;
}
let class = bytes[4];
let data = bytes[5];
let le = match data {
ELFDATA2LSB => true,
ELFDATA2MSB => false,
_ => return None,
};

// Field offsets/sizes differ between the 32- and 64-bit ELF header layouts. We need
// e_phoff (program-header table file offset), e_phentsize (per-entry size), and e_phnum
// (entry count) — plus, per entry, the p_type at the entry's start.
let (phoff_off, phentsize_off, phnum_off) = match class {
ELFCLASS64 => (0x20usize, 0x36usize, 0x38usize),
ELFCLASS32 => (0x1Cusize, 0x2Ausize, 0x2Cusize),
_ => return None,
};

let phoff = match class {
ELFCLASS64 => read_u64(bytes, phoff_off, le)? as usize,
_ => read_u32(bytes, phoff_off, le)? as usize,
};
let phentsize = read_u16(bytes, phentsize_off, le)? as usize;
let phnum = read_u16(bytes, phnum_off, le)? as usize;

// No program headers at all (e.g. a relocatable object, or e_phoff == 0): we cannot see
// an interpreter, so we cannot assert static linkage — report unknown.
if phoff == 0 || phnum == 0 || phentsize < 4 {
return None;
}

// p_type is the FIRST 4 bytes of every program-header entry in both ELF classes, so we
// only ever read those 4 bytes per entry regardless of 32/64-bit width.
for i in 0..phnum {
let entry = phoff.checked_add(i.checked_mul(phentsize)?)?;
let p_type = read_u32(bytes, entry, le)?;
if p_type == PT_INTERP {
// A dynamic loader is named → dynamically linked.
return Some(false);
}
}
// A valid ELF whose program headers name no interpreter → statically linked.
Some(true)
}

/// Read a little/big-endian `u16` at `off`, or `None` if `off + 2` overflows or runs past
/// the end. `checked_add` keeps a crafted near-`usize::MAX` offset from overflow-panicking
/// in a debug build (JEF-407 hardening) — the `bytes.get` bound then handles the truncation.
fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
let end = off.checked_add(2)?;
let b: [u8; 2] = bytes.get(off..end)?.try_into().ok()?;
Some(if le {
u16::from_le_bytes(b)
} else {
u16::from_be_bytes(b)
})
}

/// Read a little/big-endian `u32` at `off`, or `None` if `off + 4` overflows or runs past
/// the end (see [`read_u16`] for the `checked_add` rationale).
fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
let end = off.checked_add(4)?;
let b: [u8; 4] = bytes.get(off..end)?.try_into().ok()?;
Some(if le {
u32::from_le_bytes(b)
} else {
u32::from_be_bytes(b)
})
}

/// Read a little/big-endian `u64` at `off`, or `None` if `off + 8` overflows or runs past
/// the end (see [`read_u16`] for the `checked_add` rationale).
fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
let end = off.checked_add(8)?;
let b: [u8; 8] = bytes.get(off..end)?.try_into().ok()?;
Some(if le {
u64::from_le_bytes(b)
} else {
u64::from_be_bytes(b)
})
}

#[cfg(test)]
mod tests;
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,25 @@ fn out_of_range_program_header_table_is_unknown() {
bytes[0x20..0x28].copy_from_slice(&(0xFFFF_FFFFu64).to_le_bytes());
assert_eq!(elf_static_linkage(&bytes), None);
}

#[test]
fn crafted_max_offset_returns_none_without_overflow_panicking() {
// JEF-407 hardening: a crafted `e_phoff` near u64::MAX must NOT overflow-panic when the
// parser forms `phoff + i*phentsize` and the per-read `off + N` slice — it must return an
// honest `None`. Without the `checked_add` in the read helpers this panics on a debug
// build (`attempt to add with overflow`); with it, it's a clean unknown.
let mut bytes = elf64_le(&[PT_LOAD, PT_LOAD]);
// e_phoff = u64::MAX: `phoff + i*phentsize` and every `off + N` inside the read overflow.
bytes[0x20..0x28].copy_from_slice(&u64::MAX.to_le_bytes());
assert_eq!(
elf_static_linkage(&bytes),
None,
"a u64::MAX e_phoff is unknown, never a panic"
);

// A large-but-not-MAX e_phoff whose per-entry stride would overflow on a later entry is
// likewise a clean None (the second entry's offset wraps past usize::MAX).
let mut bytes = elf64_le(&[PT_LOAD, PT_LOAD]);
bytes[0x20..0x28].copy_from_slice(&(usize::MAX as u64 - 4).to_le_bytes());
assert_eq!(elf_static_linkage(&bytes), None);
}
Loading
Loading