Мониторинг системных вызовов execve с помощью eBPF и Go

Пошаговое руководство по перехвату запуска процессов на уровне ядра и обработке событий в пользовательском пространстве

Обложка статьи

Что такое BPF

В Linux есть встроенный функционал для безопасного запуска программ в пространстве ядра через виртуальную машину. Эта виртуальная машина и называется BPF. По сути, BPF открывает двери в ядро Linux нашим программам на высокоуровневых языках (Rust, Go, Python), что в свою очередь, предоставляет альтернативу написанию модулей ядра на C и с недавних пор Rust.

Сущности BPF

Полный список сущностей с которыми можно работать при написании программ можно посмотреть в документации или в специализированной литературе. Выделю несколько основных типов:

  • tracepoints — точки трассировки. Позволяют перехватывать события в системе. Список tracepoints можно найти в /sys/kernel/debug/tracing/events (например, tracepoints для отлавливания syscall'ов можно найти в /sys/kernel/debug/tracing/events/syscalls);
  • kprobes, kretprobes — зонды уровня ядра. Аналогично позволяют перехватывать некоторые события в системе. Отличием от tracepoints является то, что tracepoints зачастую обладают меньшим функционалом, однако, существуют на большинстве версий ядра, в отличии от kprobes, которые могут отличаться от версии ядер. kprobes — это зонды которые выполняют код до выполнения перехваченной команды, kretprobes в свою очередь наоборот выполняются после;
  • uprobes, uretprobes — зонды уровня пользователя. Аналогично kprobes и kretprobes, только работают в пользовательском пространстве;
  • map — объект для обмена программы bpf и программы в пользовательском пространстве данными.

Пишем простой перехватчик execve на Go

Подготовка окружения

Первым делом создадим директорию под наш проект (далее execve-tracer):

mkdir execve-tracer && cd execve-tracer

Далее инициализируем go module:

go mod init execve-tracer

Устанавливаем необходимые пакеты:

sudo apt install clang llvm libbpf-dev
sudo apt install linux-tools-common linux-tools-$(uname -r)
go get -t github.com/cilium/ebpf/cmd/bpf2go

И линкуем библиотеку (иначе будет ошибка отсутствия типов):

sudo ln -s /usr/include/x86_64-linux-gnu/asm /usr/include/asm

На этом наше окружение настроено и можно приступать к написанию программы.

Написание программы перехвата execve

Сначала создадим поддиректорию ebpf и в ней создадим файл execve.bpf.c:

mkdir ebpf && touch ebpf/execve.bpf.c

После определим параметры которые будет передавать tracepoint в нашу функцию, чтобы после этого прописать соответствующий объект в коде:

bpftrace -lv 'tracepoint:syscalls:sys_enter_execve'

В данный файл пишем следующий код (приведены подробные комментарии):

#include <linux/bpf.h>
#include <linux/ptrace.h>
#include <linux/sched.h>
#include <bpf/bpf_helpers.h>

#define TASK_COMM_LEN 16

// Это структура которую мы отправляем в user space
struct event {
    __u32 pid;
    char comm[TASK_COMM_LEN];
    char filename[256];
};

// структура syscall
// можно посмотреть через bpftrace -lv 'tracepoint:syscalls:sys_enter_execve'
// перед написанием кода
struct syscalls_enter_execve_args {
    unsigned short common_type;
    unsigned char common_flags;
    unsigned char common_preempt_count;
    int common_pid;
    int __syscall_nr;
    const char *filename;
    const char *const *argv;
    const char *const *envp;
};

// структура нашего канала в userspace
struct {
    // Тип - массив
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    // Максимальный размер массива
    __uint(max_entries, 128);
    // Объяснить генератору что это - map
} events SEC(".maps");

// Прикрепление к tracepoint
SEC("tp/syscalls/sys_enter_execve")
int monitor_execve(struct syscalls_enter_execve_args* ctx) {
    // пустая структура
    struct event evt = {};
    
    // Получение pid из tgid
    evt.pid = bpf_get_current_pid_tgid() >> 32;
    
    // Получение текущей команды
    bpf_get_current_comm(evt.comm, sizeof(evt.comm));
    
    // Парсинг названия файла в str
    bpf_probe_read_user_str(evt.filename, sizeof(evt.filename), ctx->filename);
    
    // Отправка в userspace
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &evt, sizeof(evt));
    
    return 0;
}

// Лицензия
char _license[] SEC("license") = "GPL";

Создание Go-приложения

Далее создаём файл main.go:

touch main.go

И пишем следующий код (приведены комментарии):

package main

// генерация кода по написанной выше программе
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -target bpf -go-package=main EbpfMonitoring ebpf/execve.bpf.c -- -I. -O2 -Wall -g

import (
    "bytes"
    "encoding/binary"
    "fmt"
    "log"
    "os"
    "os/signal"
    "strings"
    "syscall"
    
    "github.com/cilium/ebpf/link"
    "github.com/cilium/ebpf/perf"
    "github.com/cilium/ebpf/rlimit"
)

// структура аналогичная структуре в BPF-программе 
// (ВАЖНО чтобы совпадал даже порядок полей)
type Event struct {
    Pid      uint32
    Comm     [16]byte
    Filename [256]byte
}

func main() {
    // убираем memlock
    if err := rlimit.RemoveMemlock(); err != nil {
        log.Fatalf("Failed to remove memlock: %v", err)
    }
    
    // загружаем объекты
    objs := EbpfMonitoringObjects{}
    if err := LoadEbpfMonitoringObjects(&objs, nil); err != nil {
        log.Fatalf("Failed to load eBPF objects: %v", err)
    }
    defer objs.Close()
    
    // подключаемся к tracepoint
    tp, err := link.Tracepoint("syscalls", "sys_enter_execve", objs.MonitorExecve, nil)
    if err != nil {
        log.Fatalf("Failed to attach tracepoint: %v", err)
    }
    defer tp.Close()
    
    // подключаем reader к map в который BPF-программа передаёт события
    rd, err := perf.NewReader(objs.Events, os.Getpagesize())
    if err != nil {
        log.Fatalf("Failed to open perf buffer: %v", err)
    }
    defer rd.Close()

    fmt.Println("eBPF program running...")

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

    // простая функция обработки событий
    go func() {
        var e Event
        for {
            record, err := rd.Read()
            if err != nil {
                log.Printf("Failed to read from perf buffer: %v", err)
                continue
            }
            if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &e); err != nil {
                log.Printf("Failed to decode event: %v", err)
                continue
            }
            fmt.Printf("Execve: PID=%d, Process=%s, File=%s\n",
                e.Pid,
                strings.TrimRight(string(e.Comm[:]), "\x00"),
                strings.TrimRight(string(e.Filename[:]), "\x00"),
            )
        }
    }()

    <-sigChan
}

Сборка и запуск

Далее собираем проект и наслаждаемся:

sudo go generate
sudo go build -o ebpf_tracer main.go ebpfmonitoring_bpf.go
sudo ./ebpf_tracer

Вывод

В рамках данной статьи было:

  • кратко разобрано что такое eBPF;
  • подготовлено окружение для написания программы для работы с eBPF;
  • подробно разобрано на примере кода как писать eBPF на execve.

Надеюсь, что данный материал окажется кому-нибудь полезным. Всем спасибо за прочтение.