← BARTOSZ_OSIEJ // ROOT

[06] // BLOG // WRITEUP

Detecting ransomware with eBPF in Rust

2026-09-16 · #ebpf #rust #linux #security #ransomware #tutorial

TL;DR — SYNOPSISA working-code tutorial on building a ransomware detector in Rust with eBPF: kernel tracepoints, perf buffers, sliding-window detection, and automated SIGKILL response.
SYNCED_WITH ▸ /content/ebpf-ransomware-everything.md — this page renders from that file; the repo is the source of truth. PLAINTEXT ▸ /blog/ebpf-ransomware-everything.txt — read it like software: curl -s https://bartoszosiej.github.io/content/ebpf-ransomware-everything/blog/ebpf-ransomware-everything.txt | less LAST_COMMIT ▸ querying git…

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:

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

Full source: github.com/BartoszOsiej/talus-process-monitor