Signal programs: reacting with force
The other enforcement style: instead of denying a syscall at an LSM hook, react to one. A tracepoint on execve matches a forbidden binary and calls bpf_send_signal(SIGKILL) to kill the process before it runs — a 'signal program', lab-only offense that illuminates defense.
Chapter 37 prevented an operation by returning -EPERM from an LSM hook.
There’s a second enforcement style: let the event happen and react to
it. eBPF can send a signal to a process from inside the kernel with
bpf_send_signal(), so a program can watch for something it doesn’t like
and kill the offender on the spot. This chapter builds the canonical
example — kill any process that tries to execute a forbidden binary, before
it gets to run — which doubles as a lesson in why this technique is powerful
and why it belongs in a lab.
The code is in examples/38-signal-kill/. ./demo.sh there builds,
deploys, and runs it; its README.md covers what it does and how to drive
it.
Prevent vs. react
Two ways to stop something with eBPF:
- Prevent (Chapter 37): an LSM hook returns an error and the syscall never succeeds. Clean, but only at hooks the kernel offers, and only with an allow/deny answer.
- React: watch any event you can probe, and if it’s bad, do something
— emit an alert, or
bpf_send_signal()to kill the process. Broader reach (any tracepoint/kprobe), but it’s after-the-fact: you’re racing the action, and a signal is a blunt instrument.
bpf_send_signal(sig) sends a signal to the current task — the one whose
event you’re handling. Catch a process at sys_enter_execve and send
SIGKILL, and it dies before the new program image is running. That’s the
“kill on sight” pattern: a denylist enforced with lethal force.
A word on framing: this is lab-only. Killing processes from the kernel based on a string match is a great way to understand how EDR and runtime security tools work — and a great way to make a real system unusable. We build it to learn the mechanism and its hazards, not to deploy it.
How the code works
Matching and killing
const NEEDLE: &[u8] = b"/tmp/forbidden"; // kill anything exec'd from this prefix
const SIGKILL: u32 = 9;
#[tracepoint]
pub fn kill_on_exec(ctx: TracePointContext) -> u32 {
let _ = handle(&ctx);
0
}
fn handle(ctx: &TracePointContext) -> Result<(), ()> {
// sys_enter_execve: the filename pointer is at offset 16 (as in Ch 11)
let fname: *const u8 = unsafe { ctx.read_at(16) }.map_err(|_| ())?;
let mut buf = [0u8; 64];
let _ = unsafe { bpf_probe_read_user_str_bytes(fname, &mut buf) };
if starts_with(&buf, NEEDLE) {
let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
emit_kill(pid); // tell user space who we killed
unsafe { bpf_send_signal(SIGKILL); } // and kill it
}
Ok(())
}
#[inline(always)]
fn starts_with(buf: &[u8; 64], needle: &[u8]) -> bool {
let mut i = 0;
while i < needle.len() { // bounded by a const length → verifier-OK
if buf[i] != needle[i] { return false; }
i += 1;
}
true
}
The pieces, the way you’d write them:
- We hook
sys_enter_execveexactly asexecsnoopdid in Chapter 11, reading the filename pointer from the tracepoint record at offset 16 and copying the string withbpf_probe_read_user_str_bytes(it’s a user-space pointer, so the user reader). starts_withcompares the filename against a fixed prefix. The loop is bounded byneedle.len(), a compile-time constant, andbufis indexed within its fixed 64-byte size — both required for the verifier to accept it. String matching in eBPF lives or dies by these bounds.- On a match we
bpf_send_signal(SIGKILL). Because the program runs in the context of the process callingexecve, the signal targets that process; it’s killed before the new image takes over. We also emit a small record first so user space can report what was killed.
Reporting what died
A RingBuf carries one record per kill so the loader can show it:
#[map] static KILLS: RingBuf = RingBuf::with_byte_size(64 * 1024, 0);
fn emit_kill(pid: u32) {
if let Some(mut slot) = KILLS.reserve::<KillEvent>(0) {
let ev = KillEvent { pid, comm: bpf_get_current_comm().unwrap_or_default() };
unsafe { *slot.as_mut_ptr() = ev; }
slot.submit(0);
}
}
User space attaches the tracepoint, drains the ring, and for each record
prints killed <comm> (pid N) and bumps ebpf_signal_kills_total — the
same attach/drain pattern as every tracing chapter, with a lethal side
effect in the kernel.
Build, deploy, observe
cd examples/38-signal-kill && ./demo.sh
The demo loads the program on the target, then in a loop copies a harmless
binary to /tmp/forbidden-sleep and tries to run it — each attempt is
killed instantly — while also running a normal sleep that survives. You’ll
see killed forbidden-sleep (pid …) lines from the loader and
ebpf_signal_kills_total rising in Grafana, and on the target the forbidden
command exits immediately with “Killed”.
In Grafana (127.0.0.1:3000 → Explore), graph rate(ebpf_signal_kills_total[1m]) — forbidden executions killed.
Cross-check
On the target ([vm]$ — ssh fedora@$(scripts/lab/vm-ip.sh ebpf-target)):
[vm]$ cp /usr/bin/sleep /tmp/forbidden-sleep
[vm]$ /tmp/forbidden-sleep 60 ; echo "exit=$?" # killed: exit shows the signal
Killed
exit=137 # 128 + 9 (SIGKILL)
[vm]$ sleep 60 & # an allowed binary keeps running
Exit status 137 (128 + SIGKILL’s 9) on the forbidden binary while a normal
sleep runs untouched is the program working; dmesg/journalctl will also
show the process terminated by SIGKILL.
What you learned
bpf_send_signal()lets a kernel-side eBPF program kill (or signal) the current process — the react enforcement style, complementing Chapter 37’s prevent.- Safe string matching in eBPF: copy into a fixed buffer and compare against a constant-length needle so every access is bounded for the verifier.
- Why “kill on sight” is lab-only: it’s the mechanism behind runtime security tooling, and also a fast way to break a real system — learn it, don’t ship it as written.
Chapter 39 continues the Security part, building toward richer LSM policy and tamper detection.
Verification status: verified — Fedora 44, kernel 7.1.3. Built and run on the lab VM (Fedora 44, kernel 7.1.3-200.fc44): builds, loads, and attaches cleanly and runs without error. Confirmed on this kernel — attach targets and struct offsets can be version-specific.