============================================================== Detecting ransomware with eBPF in Rust ============================================================== PUBLISHED : 2026-09-16 (UTC) AUTHOR : bartoszosiej SOURCE : https://bartoszosiej.github.io/content/ebpf-ransomware-everything PLAINTEXT : https://bartoszosiej.github.io/blog/ebpf-ransomware-everything.txt REPO : https://github.com/BartoszOsiej/BartoszOsiej.github.io/blob/main/content/ebpf-ransomware-everything.md TL;DR -------------------------------------------------------------- A working-code tutorial on building a ransomware detector in Rust with eBPF: kernel tracepoints, perf buffers, sliding-window detection, and automated SIGKILL response. -------------------------------------------------------------- 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 = 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::(), 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 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 / .locked writes 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 openat rate 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 // EOF · bartoszosiej · 736 words · #ebpf #rust #linux #security #ransomware #tutorial · read the blog like software: curl | less