Rust에서 eBPF로 랜섬웨어 탐지하기

작성자

카테고리:

← 피드로
DEV Community · Bartosz Osiej · 2026-09-17 개발(SW)

Bartosz Osiej

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);

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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 */ }
    }
}

Enter fullscreen mode Exit fullscreen mode

Then push the event into the buffer:

EVENTS.output(ctx, &event, 0);

Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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
    }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

원문에서 계속 ↗