DEV Community

Bartosz Osiej
Bartosz Osiej

Posted on

Detecting ransomware with eBPF in Rust

Detecting ransomware with eBPF in Rust

Title: Detecting ransomware with eBPF in Rust
Target: ~1000 words, B1-safe English, working-code-first
Build: demo + article for DEV.to / Draft.dev share
Source repo: github.com/BartoszOsiej/talus-process-monitor (MIT)


1. The idea (hook)

Ransomware works in a simple way: it opens your files, encrypts them, and writes them back. Fast. One process can open hundreds or thousands of files within seconds.

You do not need a huge model to spot it. You need to watch one number: how many files a single process opens per second.

This article shows a small eBPF program in Rust that does exactly that. It hooks two syscalls (execve and openat), counts file-open rate per process, and — when the rate is too high — kills the process.

All code comes from a real open-source project: talus-process-monitor (MIT, github.com/BartoszOsiej/talus-process-monitor).

2. What is eBPF in one sentence

eBPF lets you attach small programs to kernel events (syscalls, network packets, timers) without writing or loading a kernel module. The programs run in a sandboxed VM in the kernel. You get kernel-level visibility with low overhead.

In Rust, the library is called aya. aya-ebpf is the runtime for the kernel side. aya manages loading from userspace.

3. Kernel side: a tiny eBPF program

We define one "event" struct. Both kernel and userspace must agree on the layout, so it uses #[repr(C)]:

#[repr(C)]
pub struct ProcessEvent {
    pub event_type: u8,
    pub pid: u32,
    pub uid: u32,
    pub comm: [u8; 16],     // process name
    pub filename: [u8; 64], // opened file path
}

#[map]
pub static EVENTS: PerfEventArray<ProcessEvent> = PerfEventArray::new(0);
Enter fullscreen mode Exit fullscreen mode

PerfEventArray is the channel between kernel and userspace. Each CPU has its own buffer, so concurrent processes do not block each other.

Next, two tracepoints. These run when a process starts (execve) or opens a file (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)
}
Enter fullscreen mode Exit fullscreen mode

The main work happens in emit_event. First we read PID and UID of the current process:

let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
let uid = bpf_get_current_uid_gid() as u32;
Enter fullscreen mode Exit fullscreen mode

Then we read the filename argument from the tracepoint args. The tracepoint layout is (common fields, filename, flags, mode), so the filename pointer sits at a fixed offset:

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() {
        // bpf_probe_read_user safely copies userspace memory
        if let Ok(bytes) = unsafe {
            bpf_probe_read_user_str_bytes(filename.cast::<u8>(), dst)
        } { /* copy into event.filename */ }
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we push the event into the buffer:

EVENTS.output(ctx, &event, 0);
Enter fullscreen mode Exit fullscreen mode

One note about eBPF: you cannot use normal Rust std function calls. No memcpy, no memset, no format!. The code uses hand-written byte loops (raw_copy) to avoid LLVM builtins. This is a common eBPF gotcha:

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Userspace: the detection engine

On the userspace side we read events off the perf buffer and keep a 1-second sliding window of open events per PID:

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();
Enter fullscreen mode Exit fullscreen mode

If opens_now reaches the threshold (default 50 opens in 1 second), we fire an alert:

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 result = kill_process(ev.pid);
        outputs.push(Output::Action(ResponseAction { /* ... */ }));
    }
}
Enter fullscreen mode Exit fullscreen mode

kill_process is a plain kill(2) with SIGKILL:

fn kill_process(pid: u32) -> bool {
    let rc = unsafe { libc::kill(pid as i32, libc::SIGKILL) };
    rc == 0
}
Enter fullscreen mode Exit fullscreen mode

SIGKILL cannot be caught. The offending process stops immediately. That is the full "detect and respond" loop: hook → count → alert → kill.

5. Run it

Requirements: Linux kernel 5.8+, root (or CAP_BPF + CAP_SYS_ADMIN), Rust nightly and 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
Enter fullscreen mode Exit fullscreen mode

Test it with a loop that opens many files fast:

sudo process-monitor --alert-threshold 3 --auto-kill
# in another terminal:
for i in $(seq 1 100); do touch /tmp/f$i; done
Enter fullscreen mode Exit fullscreen mode

The process that runs the loop violates the threshold and gets SIGKILL.

6. Limits and next steps

This heuristic has false positives (a backup tool also opens many files fast) — that is why a configurable threshold and a lower default matter. Real-world improvements from the same repo:

  • Shannon entropy scoring on filenames: encrypted/randomized names have high entropy.
  • Network egress tracing (connect, sendto): catch data exfiltration.
  • File-extension tracking: mass .enc / .locked writes.

The project also ships a small online-trained MLP model (no external deps) that turns raw event features into score: benign / suspicious / ransomware.

7. Summary

  • eBPF gives you kernel-level tracing without kernel modules.
  • Rust + aya makes the whole pipeline safe to build and maintain.
  • A 1-second sliding window on openat rate is a cheap, real ransomware signal.
  • Responding with SIGKILL turns a monitor into an EDR-style agent — 30 lines of Rust.

Full source code: github.com/BartoszOsiej/talus-process-monitor
Follow me on DEV.to for the next part: network egress detection with eBPF.


Try it on your machine

Talus is open source (MIT) — community edition includes the TUI, real-time eBPF tracing, and the ransomware heuristic.

If you want the full version — web dashboard, auto-kill response, SIEM exports (Kafka / ClickHouse / Memgraph) — Talus Enterprise is $50 one-time, perpetual: Get Talus Enterprise

Activation: talus license activate <KEY>

Top comments (0)