Батьківський Контроль з BPF LSM

Вступ

У цьому дописі поділюся результатами своїх спроб із блокуванням процесів для окремих користувачів за допомогою eBPF, зокрема LSM хуків. Хоча для таких завдань вже існують AppArmor та SELinux, такий проект добре показує можливості LSM в контексті кібербезпеки. Вступ до eBPF ви можете прочитати в попередньому дописі, а цей матеріал присвячений саме батьківському контролю.

parent-meme

Про BPF LSM

BPF LSM це відносно новий фреймворк, що дозволяє eBPF програмам під’єднуватись до Linux Security Module (LSM) хуків. На відміну від інших, LSM хуки створені саме для задач безпеки та дають можливість не лише читати, а й блокувати події безпосередньо в ядрі. Наш батьківський контроль працюватиме саме на LSM хуках, а саме:

Userspace на Go

Для початку нам потрібна програма (main.go), яка завантажить eBPF код у ядро та взаємодіятиме з ним. У ній ми визначимо список заборонених процесів у форматі uid,path, проте список також можна зчитувати з файлу.

var entries = []struct {
	uid  uint32
	path string
}{
	{1000, "/usr/bin/wget"}, // Блокуємо wget для UID 1000 (ubuntu)
	{1000, "/usr/bin/curl"}, // Блокуємо curl для UID 1000 (ubuntu)
	{0, "/usr/bin/nc"},      // Блокуємо netcat для UID 0 (root)
}

Повний код програми ви знайдете в GitHub репозиторії, проте ключові завдання main.go - це створити дві мапи (maps) з блоклістом та PID програми, а далі передати їх в eBPF програму у ядрі.

// Завантажуємо заборонені процеси в мапу Blocklist
for i, entry := range entries {
    var be blockedEntry
    be.Uid = entry.uid
    copy(be.Path[:], entry.path)
    key := uint32(i)
    objs.Blocklist.Put(&key, &be)
}
// Завантажуємо PID програми в мапу ProtectedPid
pid := uint32(os.Getpid())
key := uint32(0)
objs.ProtectedPid.Put(&key, &pid)

Kernelspace на C

Програма на Go прочитає eBPF-інструкції мовою C, parent_control.c, і перетворить їх на байт-код. Цей байт-код працюватиме в ядрі, під’єднається до LSM хука lsm/bprm_check_security і звірятиме кожний створений процес із блоклістом. Нижче наведено спрощений код для контролю процесів:

SEC("lsm/bprm_check_security") // Хук, яким можна заборонити запуск процесу
int BPF_PROG(restrict_bprm_check, struct linux_binprm *bprm, int ret)
{
    // Дістаємо дані з події...
    // Записуємо їх в змінну
    struct event_ctx current_ctx = {
        .current_path = filename,
        .current_uid = uid,
        .should_block = 0
    };

    // В циклі перевіряємо чи подія підпадає під наш блокліст
    bpf_for_each_map_elem(&blocklist, check_blocked_callback, &current_ctx, 0);
    // Якщо шлях до процесу в блоклісті, блокуємо його (-EPERM)
    if (current_ctx.should_block) {
        bpf_printk("Program blocked: UID=%d, PATH=%s\n", uid, filename);
        return -EPERM;
    }
    return 0;
}

Згідно блоклісту, /usr/bin/nc блокується для UID=0 (root):

nc-blocklist

Захист Програми

Аби батьківський контроль не можна було зупинити командою kill, додамо функцію на інший хук lsm/task_kill. Зверніть увагу, що eBPF програму можна зробити майже безсмертною: заборонити kill, ptrace та взаємодію через /proc, навіть для root користувача. Але поки обмежимось простим захистом:

SEC("lsm/task_kill") // Хук, яким можна заборонити зупинку процесу ззовні
int BPF_PROG(protect_task_kill, struct task_struct *p, struct kernel_siginfo *info,
             int sig, const struct cred *cred, int ret)
{
    __u32 key = 0;
    // Читаємо який процес хочуть зупинити
    __u32 target_pid = BPF_CORE_READ(p, pid);
    // Читаємо PID нашої Go програми з eBPF мапи
    __u32 *protected = bpf_map_lookup_elem(&protected_pid, &key);
    // Звіряємо чи хочуть зупинити саме наш процес
    if (protected && *protected == target_pid) {
        __u64 uid_gid = bpf_get_current_uid_gid();
        __u32 uid = uid_gid & 0xFFFFFFFF;
        __u64 pid_tgid = bpf_get_current_pid_tgid();
        __u32 source_pid = pid_tgid >> 32;
        // Якщо так - логуємо та забороняємо спробу
        bpf_printk("Tamper attempt: UID=%d, PID=%d, SIG=%d\n", uid, source_pid, sig);
        return -EPERM;
    }
    return 0;
}

Навіть root користувачу тепер не вийде зупинити батьківський контроль:

tamper-protect

Переваги LSM

Наведену версію батьківського контролю легко обійти, просто скопіювавши заборонений бінарник у місце поза блоклістом (cp /usr/bin/wget /tmp/notwget). Також BPF LSM доступний тільки в ядрах версії 5.7+ і вимкнений в деяких дистрибутивах за замовчуванням. Однак, завдяки LSM хукам у нашої програми є переваги навіть над топовими EDR рішеннями:

Блокування процесів у просторі ядра

Захист програми (Tamper Protection)

У Підсумку

BPF LSM є відносно новим явищем серед рішень безпеки, проте вже можна знайти практичні приклади його застосування: Cloudflare через LSM патчить вразливості в ядрі на ходу, Elastic та Tetragon моніторять та блокують ним події в Kubernetes кластерах, ну і не забудьте про батьківський контроль з цього допису.

Коли всі ключові дистрибутиви (як от Ubuntu) почнуть підтримувати BPF LSM за замовчуванням, а також коли з часом компанії перейдуть на Linux 5.7+, цей чудовий фреймворк точно дасть про себе знати не тільки в модних контейнеризованих системах, а й на всіх класичних Linux серверах - як серед EDR рішень, так і серед різного роду руткітів.