TL;DR
Ransomware encrypts your files by opening hundreds of them per second. You can catch this at the kernel level with eBPF — no modules, no signatures, low overhead. This tutorial walks through a working detector: hook execve and openat, stream events to userspace via per-CPU perf buffers, count opens in a 1-second sliding window, and kill the process when the rate exceeds a threshold.
All code is from talus-process-monitor (MIT).
The idea
A ransomware process has one observable trait: it opens files at an extreme rate. A legitimate process opens a few files per second. Ransomware opens hundreds or thousands.
We do not need a model or signature database. We need to watch one number: how many openat calls does a single PID make in 1 second.
eBPF lets us watch this in the kernel, before any data is encrypted. The overhead is near-zero — we only count events, we do not copy file contents.
Kernel side: tracepoints and a PerfEventArray
In Rust, the eBPF kernel runtime is aya-ebpf. We define a shared event struct:
#[repr(C)]
pub struct ProcessEvent {
pub event_type: u8,
pub pid: u32,
pub uid: u32,
pub comm: [u8; 16],
pub filename: [u8; 64],
}
#[map]
pub static EVENTS: PerfEventArray<ProcessEvent> = PerfEventArray::new(0);
PerfEventArray gives us one buffer per CPU — concurrent processes do not block each other.
Two tracepoints fire on execve and openat:
#[tracepoint(name = "sys_enter_execve", category = "syscalls")]
pub fn sys_enter_execve(ctx: TracePointContext) -> u32 {
emit_event(&ctx, EVENT_EXECVE, 0)
}
#[tracepoint(name = "sys_enter_openat", category = "syscalls")]
pub fn sys_enter_openat(ctx: TracePointContext) -> u32 {
emit_event(&ctx, EVENT_OPENAT, 1)
}
emit_event reads PID and UID of the current process, then reads the filename from the tracepoint args:
let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
let uid = bpf_get_current_uid_gid() as u32;
let ptr_size = core::mem::size_of::<*const c_char>();
let filename_offset = 16 + filename_arg as usize * ptr_size;
if let Ok(filename) = unsafe { ctx.read_at::<*const c_char>(filename_offset) } {
if !filename.is_null() {
if let Ok(bytes) = unsafe {
bpf_probe_read_user_str_bytes(filename.cast::<u8>(), dst)
} { /* copy into event.filename */ }
}
}
Then push the event into the buffer:
EVENTS.output(ctx, &event, 0);
One eBPF quirk: no std functions. No memcpy, no format!. We write a byte-by-byte copy loop to avoid LLVM builtins:
unsafe fn raw_copy(dst: *mut u8, src: *const u8, len: usize) {
let mut i = 0;
while i < len {
*dst.add(i) = *src.add(i);
i += 1;
}
}
Userspace: sliding window and response
On the userspace side (Rust, aya library), we read events off the perf buffer and maintain a 1-second sliding window per PID:
let window = self.windows.entry(ev.pid).or_default(); // VecDeque<Instant>
let cutoff = now - Duration::from_secs(WINDOW_SECS); // 1s
while window.front().is_some_and(|t| *t < cutoff) {
window.pop_front();
}
window.push_back(now);
let opens_now = window.len();
When opens_now hits the threshold (default: 50), we fire an alert and — optionally — kill the process:
if self.threshold > 0 && stats.window_opens == self.threshold {
stats.alerts += 1;
outputs.push(Output::Alert(Alert { /* pid, comm, opens */ }));
if self.auto_kill {
let rc = unsafe { libc::kill(pid as i32, libc::SIGKILL) };
// SIGKILL cannot be caught — process stops immediately
}
}
That is the full loop: trace → count → alert → kill.
Run it
Requirements: Linux kernel 5.8+, root, Rust nightly, clang.
# Build
./build.sh
# Monitor only (no killing)
sudo target/release/process-monitor --alert-threshold 50
# EDR mode: detect and kill
sudo target/release/process-monitor --alert-threshold 50 --auto-kill
# Test it
sudo process-monitor --alert-threshold 3 --auto-kill
# another terminal:
for i in $(seq 1 100); do touch /tmp/f$i; done
# → the loop gets SIGKILL after 3 opens in 1s
Limits and where to go next
This heuristic has false positives — a backup tool also opens many files fast. Real improvements from the same project:
- Shannon entropy scoring on filenames: encrypted/randomized names have high entropy.
- File-extension tracking: mass
.enc/.lockedwrites are a strong signal. - Network egress tracing (
connect,sendto): catch data exfiltration alongside encryption.
The project also ships an online-trained MLP model that turns 10 behavioural features into a benign / suspicious / ransomware score — no external ML dependencies, just a few KB of Rust.
Summary
- eBPF gives kernel-level tracing without kernel modules.
- Rust + aya keeps the whole pipeline safe and maintainable.
- A 1-second sliding window on
openatrate is a cheap, effective ransomware signal. - Responding with SIGKILL turns a monitor into an EDR-style agent in ~30 lines of Rust.
Full source: github.com/BartoszOsiej/talus-process-monitor