Книга: BPF для мониторинга Linux
Назад: 7. Express Data Path
Дальше: 9. Реальные способы применения

8. Безопасность ядра Linux, его возможности и Seccomp

BPF предоставляет мощный способ расширения ядра без ущерба для стабильности, безопасности и скорости. По этой причине разработчики ядра подумали, что было бы неплохо использовать его универсальность для улучшения изоляции процессов в Seccomp путем реализации фильтров Seccomp, поддерживаемых программами BPF, известными также как Seccomp BPF. В этой главе мы расскажем, что такое Seccomp и как он применяется. Затем вы узнаете, как писать фильтры Seccomp с помощью программ BPF. После этого рассмотрим встроенные ловушки BPF, которые есть в ядре для модулей безопасности Linux.

Модули безопасности Linux (LSM) — это платформа, предоставляющая набор функций, которые можно применять для стандартизированной реализации различных моделей безопасности. LSM может использоваться непосредственно в дереве исходного кода ядра, например Apparmor, SELinux и Tomoyo.

Начнем с обсуждения возможностей Linux.

Возможности

Суть возможностей Linux заключается в том, что вам нужно предоставить непривилегированному процессу разрешение на выполнение определенной задачи, но без использования suid для этой цели, или иным образом сделать процесс привилегированным, уменьшая возможность атак и обеспечивая процессу возможность выполнения определенных задач. Например, если вашему приложению нужно открыть привилегированный порт, допустим, 80, вместо запуска процесса от имени root можете просто предоставить ему возможность CAP_NET_BIND_SERVICE.

Рассмотрим программу Go с именем main.go:

package main

 

import (

         "net/http"

         "log"

)

 

func main() {

    log.Fatalf("%v", http.ListenAndServe(":80", nil))

}

Эта программа обслуживает HTTP-сервер на порте 80 (это привилегированный порт). Обычно мы запускаем ее сразу после компиляции:

$ go build -o capabilities main.go

$ ./capabilities

Однако, поскольку мы не предоставляем привилегии root, этот код выдаст ошибку при привязке порта:

2019/04/25 23:17:06 listen tcp :80: bind: permission denied

exit status 1

7253.png

capsh (средство управления оболочкой) — это инструмент, который запускает оболочку с определенным набором возможностей.

В этом случае, как уже говорилось, вместо предоставления полных прав root можно разрешить привязку привилегированных портов, предоставив возможность cap_net_bind_service наряду со всеми остальными, которые уже есть в программе. Для этого можем заключить нашу программу в capsh:

# capsh --caps='cap_net_bind_service+eip cap_setpcap,cap_setuid,cap_setgid+ep' \

    --keep=1 --user="nobody" \

    --addamb=cap_net_bind_service -- -c "./capabilities"

Немного разберемся в этой команде.

capsh — используем capsh в качестве оболочки.

• --caps='cap_net_bind_service+eipcap_setpcap,cap_setuid,cap_setgid+ep' — поскольку нам нужно сменить пользователя (мы не хотим запускаться с правами root), укажем cap_net_bind_service и возможность фактически изменить идентификатор пользователя с root на nobody, а именно cap_setuid и cap_setgid.

• --keep=1 — хотим сохранить установленные возможности, когда выполнено переключение с аккаунта root.

• --user="nobody" — конечным пользователем, запускающим программу, будет nobody.

• --addamb=cap_net_bind_service — задаем очистку связанных возможностей после переключения из режима root.

• ---c"./capabilities" — просто запускаем программу.

7268.png

Связанные возможности — это особый вид возможностей, которые наследуются дочерними программами, когда текущая программа выполняет их с помощью execve(). Наследоваться могут только возможности, разрешенные как связанные, или, другими словами, как возможности окружения.

Вероятно, вам интересно, что значит +eip после указания возможности в опции --caps. Эти флаги используются для определения того, что возможность:

должна быть активирована (p);

• доступна для применения (е);

• может быть унаследована дочерними процессами (i).

Поскольку мы хотим использовать cap_net_bind_service, нужно сделать это с флагом e. Затем запустим оболочку в команде. В результате запустится двоичный файл capabilities, и нам нужно пометить его флагом i. Наконец, мы хотим, чтобы возможность была активирована (мы это сделали, не меняя UID) с помощью p. Это выглядит как cap_net_bind_service+eip.

Можете проверить результат с помощью ss. Немного сократим вывод, чтобы он поместился на странице, но он покажет связанный порт и идентификатор пользователя, отличные от 0, в данном случае 65 534:

# ss -tulpn -e -H | cut -d' ' -f17-

128    *:80    *:*

users:(("capabilities",pid=30040,fd=3)) uid:65534 ino:11311579 sk:2c v6only:0

В этом примере мы использовали capsh, но вы можете написать оболочку с помощью libcap. За дополнительной информацией обращайтесь к man3libcap.

При написании программ разработчик довольно часто не знает заранее всех возможностей, необходимых программе во время выполнения; более того, в новых версиях эти возможности могут измениться.

Чтобы лучше понять возможности нашей программы, можем взять инструмент BCC capable, который устанавливает kprobe для функции ядра cap_capable:

/usr/share/bcc/tools/capable

TIME      UID    PID    TID    COMM           CAP  NAME              AUDIT

10:12:53  0      424    424    systemd-udevd  12   CAP_NET_ADMIN        1

10:12:57  0      1103   1101   timesync       25   CAP_SYS_TIME         1

10:12:57  0      19545  19545  capabilities   10   CAP_NET_BIND_SERVICE 1

Добиться того же самого мы можем, используя bpftrace с однострочным kprobe в функции ядра cap_capable:

bpftrace -e \

    'kprobe:cap_capable {

        time("%H:%M:%S ");

        printf("%-6d %-6d %-16s %-4d %d\n", uid, pid, comm, arg2, arg3);

    }' \

    | grep -i capabilities

Это выведет что-то вроде следующего, если возможности нашей программы будут активированы после kprobe:

12:01:56  1000  13524  capabilities  21  0

12:01:56  1000  13524  capabilities  21  0

12:01:56  1000  13524  capabilities  21  0

12:01:56  1000  13524  capabilities  12  0

12:01:56  1000  13524  capabilities  12  0

12:01:56  1000  13524  capabilities  12  0

12:01:56  1000  13524  capabilities  12  0

12:01:56  1000  13524  capabilities  10  1

Пятый столбец — это возможности, в которых нуждается процесс, и, поскольку эти выходные данные включают в том числе неаудитные события, мы видим все неаудитные проверки и, наконец, требуемую возможность с флагом аудита (последний в выводе), установленным в 1. Возможность, которая нас интересует, — CAP_NET_BIND_SERVICE, она определяется как константа в исходном коде ядра в файле include/uapi/linux/ability.h с идентификатором 10:

/* Allows binding to TCP/UDP sockets below 1024 */

/* Allows binding to ATM VCIs below 32 */

 

#define CAP_NET_BIND_SERVICE 10

Возможности часто задействуются во время выполнения контейнеров, таких как runC или Docker, чтобы они работали в непривилегированном режиме, но им были разрешены только те возможности, которые необходимы для запуска большинства приложений. Когда приложению требуются определенные возможности, в Docker можно обеспечить их с помощью --cap-add:

docker run -it --rm --cap-add=NET_ADMIN ubuntu ip link add dummy0 type dummy

Эта команда предоставит контейнеру возможность CAP_NET_ADMIN, что позволит ему настроить сетевую ссылку для добавления интерфейса dummy0.

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

Seccomp

Seccomp означает Secure Computing, это уровень безопасности, реализованный в ядре Linux, который позволяет разработчикам фильтровать определенные системные вызовы. Хотя Seccomp сопоставим с возможностями Linux, его способность управлять определенными системными вызовами делает его намного более гибким по сравнению с ними.

Seccomp и возможности Linux не исключают друг друга, их часто используют вместе, чтобы получить пользу от обоих подходов. Например, вы можете захотеть предоставить процессу возможность CAP_NET_ADMIN, но не разрешить ему принимать соединения через сокет, блокируя системные вызовы accept и accept4.

Способ фильтрации Seccomp основан на фильтрах BPF, работающих в режиме SECCOMP_MODE_FILTER, и фильтрация системных вызовов выполняется так же, как и для пакетов.

Фильтры Seccomp загружаются с использованием prctl через операцию PR_SET_SECCOMP. Эти фильтры имеют форму программы BPF, которая выполняется для каждого пакета Seccomp, представленного с помощью структуры seccomp_data. Эта структура содержит эталонную архитектуру, указатель инструкций процессора во время системного вызова и максимум шесть аргументов системного вызова, выраженных как uint64.

Вот как выглядит структура seccomp_data из исходного кода ядра в файле linux/seccomp.h:

struct seccomp_data {

        int nr;

        __u32 arch;

        __u64 instruction_pointer;

        __u64 args[6];

};

Как видно из этой структуры, мы можем фильтровать по системному вызову, его аргументам или их комбинации.

После получения каждого пакета Seccomp фильтр обязан выполнить обработку, чтобы принять окончательное решение, и сообщить ядру, что делать дальше. Окончательное решение выражается одним из возвращаемых значений (кодов состояния).

SECCOMP_RET_KILL_PROCESS — завершение всего процесса сразу же после фильтрации системного вызова, который из-за этого не выполняется.

• SECCOMP_RET_KILL_THREAD — завершение текущего потока сразу же после фильтрации системного вызова, который из-за этого не выполняется.

• SECCOMP_RET_KILL — алиас для SECCOMP_RET_KILL_THREAD, оставлен для обратной совместимости.

• SECCOMP_RET_TRAP — системный вызов запрещен, и сигнал SIGSYS (Bad System Call) отправляется вызывающей его задаче.

• SECCOMP_RET_ERRNO — системный вызов не выполняется, и часть возвращаемого значения фильтра SECCOMP_RET_DATA передается в пространство пользователя как значение errno. В зависимости от причины ошибки возвращаются разные значения errno. Список номеров ошибок приведен в следующем разделе.

• SECCOMP_RET_TRACE — используется для уведомления трассировщика ptrace с помощью PTRACE_O_TRACESECCOMP для перехвата, когда выполняется системный вызов, чтобы видеть и контролировать этот процесс. Если трассировщик не подключен, возвращается ошибка, errno устанавливается в -ENOSYS, а системный вызов не выполняется.

• SECCOMP_RET_LOG — системный вызов разрешен и зарегистрирован в журнале.

• SECCOMP_RET_ALLOW — системный вызов просто разрешен.

7290.png

ptrace — это системный вызов для реализации механизмов трассировки в процессе, называемом tracee, с возможностью наблюдения и контроля за выполнением процесса. Программа трассировки может эффективно влиять на выполнение и изменять регистры памяти tracee. В контексте Seccomp ptrace используется, когда запускается кодом состояния SECCOMP_RET_TRACE, следовательно, трассировщик может предотвратить выполнение системного вызова и реализовать собственную логику.

Ошибки Seccomp

Время от времени, работая с Seccomp, вы будете сталкиваться с различными ошибками, которые идентифицируются возвращаемым значением типа SECCOMP_RET_ERRNO. Чтобы сообщить об ошибке, системный вызов seccomp вернет -1 вместо 0.

Возможны следующие ошибки:

EACCESS — вызывающей стороне не разрешено делать системный вызов. Обычно это происходит потому, что у нее нет привилегий CAP_SYS_ADMIN или же не установлен no_new_privs с помощью prctl (об этом поговорим позже);

• EFAULT — переданные аргументы (args в структуре seccomp_data) не имеют действительного адреса;

• EINVAL — здесь может быть четыре причины:

• запрошенная операция неизвестна или не поддерживается ядром в текущей конфигурации;

• указанные флаги недействительны для запрошенной операции;

• операция включает BPF_ABS, но есть проблемы с указанным смещением, которое может превышать размер структуры seccomp_data;

• количество инструкций, переданных в фильтр, превышает максимальное;

• ENOMEM — недостаточно памяти для выполнения программы;

• EOPNOTSUPP — операция указала, что с SECCOMP_GET_ACTION_AVAIL действие было доступно, однако ядро не поддерживает возврат в аргументах;

• ESRCH — возникла проблема при синхронизации другого потока;

• ENOSYS — нет трассировщика, прикрепленного к действию SECCOMP_RET_TRACE.

7303.png

prctl — это системный вызов, который позволяет программе пользовательского пространства управлять (устанавливать и получать) конкретными аспектами процесса, такими как порядковый номер байтов, имена потоков, режим защищенных вычислений (Seccomp), привилегии, события Perf и т.д.

Seccomp может показаться вам технологией песочницы, но это не так. Seccomp — утилита, которая позволяет пользователям разрабатывать механизм песочницы. Теперь рассмотрим, как создаются программы пользовательских взаимодействий с использованием фильтра, вызываемого непосредственно системным вызовом Seccomp.

Пример фильтра BPF Seccomp

Здесь мы покажем, как соединить два рассмотренных ранее действия, а именно:

напишем программу Seccomp BPF, которая будет применяться в качестве фильтра с различными кодами возврата в зависимости от принимаемых решений;

• загрузим фильтр, используя prctl.

Для начала нужны заголовки из стандартной библиотеки и ядра Linux:

#include <errno.h>

#include <linux/audit.h>

#include <linux/bpf.h>

#include <linux/filter.h>

#include <linux/seccomp.h>

#include <linux/unistd.h>

#include <stddef.h>

#include <stdio.h>

#include <stdlib.h>

#include <sys/prctl.h>

#include <unistd.h>

Прежде чем пытаться выполнить этот пример, мы должны убедиться, что ядро скомпилировано с CONFIG_SECCOMP и CONFIG_SECCOMP_FILTER, установленными в y. На рабочей машине это можно проверить так:

cat /proc/config.gz| zcat   | grep -i CONFIG_SECCOMP

Остальная часть кода представляет собой функцию install_filter, состоящую из двух частей. Первая часть содержит наш список инструкций по фильтрации BPF:

static int install_filter(int nr, int arch, int error) {

  struct sock_filter filter[] = {

    BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, arch))),

    BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, arch, 0, 3),

    BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, nr))),

    BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, nr, 0, 1),

    BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (error & SECCOMP_RET_DATA)),

    BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW),

  };

Инструкции устанавливаются с помощью макросов BPF_STMT и BPF_JUMP, определенных в файле linux/filter.h.

Пройдемся по инструкциям.

BPF_STMT(BPF_LD+BPF_W+BPF_ABS(offsetof(structseccomp_data,arch))) — система загружает и накапливает с BPF_LD в форме слова BPF_W, пакетные данные располагаются с фиксированным смещением BPF_ABS.

• BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K,arch,0,3) — проверяет с применением BPF_JEQ, является ли значение архитектуры в константе аккумулятора BPF_K равным arch. Если это так, переходит со смещением 0 к следующей инструкции, в противном случае, чтобы выдать ошибку, перепрыгнет со смещением 3 (в данном случае), потому что arch не совпадает.

• BPF_STMT(BPF_LD+BPF_W+BPF_ABS(offsetof(structseccomp_data,nr))) — загружает и накапливает с BPF_LD в форме слова BPF_W, которое является номером системного вызова, содержащимся в фиксированном смещении BPF_ABS.

• BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K,nr,0,1) — сравнивает номер системного вызова со значением переменной nr. Если они равны, переходит к следующей инструкции и запрещает системный вызов, в противном случае разрешает системный вызов с SECCOMP_RET_ALLOW.

• BPF_STMT(BPF_RET+BPF_K,SECCOMP_RET_ERRNO|(error&SECCOMP_RET_DATA)) — завершает программу с BPF_RET и в результате выдает ошибку SECCOMP_RET_ERRNO с номером из переменной err.

• BPF_STMT(BPF_RET+BPF_K,SECCOMP_RET_ALLOW) — завершает программу с BPF_RET и разрешает выполнение системного вызова с помощью SECCOMP_RET_ALLOW.

Seccomp — это cBPF

Возможно, вам интересно, почему вместо скомпилированного объекта ELF или программы на C, скомпилированной с JIT, используется список инструкций.

На это есть две причины.

Во-первых, Seccomp применяет cBPF (классический BPF), а не eBPF, что означает: у него нет реестров, а есть только аккумулятор для хранения последнего результата вычислений, как можно увидеть в примере.

Во-вторых, Seccomp принимает указатель на массив инструкций BPF напрямую и ничего больше. Макросы, которые мы использовали, всего лишь помогают указать эти инструкции в удобной для программистов форме.

Если вам нужна дополнительная помощь, чтобы понять эту сборку, рассмотрите псевдокод, который делает то же самое:

if (arch != AUDIT_ARCH_X86_64) {

    return SECCOMP_RET_ALLOW;

}

 

if (nr == __NR_write) {

    return SECCOMP_RET_ERRNO;

}

return SECCOMP_RET_ALLOW;

После определения кода фильтра в структуре socket_filter нужно определить sock_fprog, содержащий код и вычисленную длину фильтра. Эта структура данных необходима в качестве аргумента для объявления работы процесса в дальнейшем:

struct sock_fprog prog = {

  .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),

  .filter = filter,

};

Осталось только одно, что нужно сделать в функции install_filter, — загрузить саму программу! Для этого используем prctl, взяв PR_SET_SECCOMP в качестве опции, чтобы войти в режим защищенных вычислений. Затем укажем режиму загружать фильтр с помощью SECCOMP_MODE_FILTER, который содержится в переменной prog типа sock_fprog:

  if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {

    perror("prctl(PR_SET_SECCOMP)");

    return 1;

  }

  return 0;

}

Наконец, можем воспользоваться нашей функцией install_filter, но перед этим нужно задействовать prctl, чтобы установить PR_SET_NO_NEW_PRIVS для текущего выполнения и тем самым избежать ситуации, когда дочерние процессы получают более широкие привилегии, чем родительские. При этом мы можем делать следующие вызовы prctl в функции install_filter, не имея прав root.

Теперь мы можем вызвать функцию install_filter. Заблокируем все системные вызовы write, относящиеся к архитектуре X86-64, и просто дадим разрешение, блокирующее все попытки. После установки фильтра продолжаем выполнение, используя первый аргумент:

int main(int argc, char const *argv[]) {

  if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {

    perror("prctl(NO_NEW_PRIVS)");

    return 1;

  }

  install_filter(__NR_write, AUDIT_ARCH_X86_64, EPERM);

  return system(argv[1]);

}

Приступим. Для компиляции нашей программы мы можем использовать либо clang, либо gcc, в любом случае это просто компиляция файла main.c без специальных опций:

clang main.c -o filter-write

Как было отмечено, мы заблокировали все записи в программе. Чтобы проверить это, нужна программа, которая что-то выводит, — ls кажется хорошим кандидатом. Вот как она обычно себя ведет:

ls -la

total 36

drwxr-xr-x 2 fntlnz users  4096 Apr 28 21:09 .

drwxr-xr-x 4 fntlnz users  4096 Apr 26 13:01 ..

-rwxr-xr-x 1 fntlnz users 16800 Apr 28 21:09 filter-write

-rw-r--r-- 1 fntlnz users    19 Apr 28 21:09 .gitignore

-rw-r--r-- 1 fntlnz users  1282 Apr 28 21:08 main.c

Прекрасно! Вот как выглядит использование нашей программы-оболочки: мы просто передаем программу, которую хотим протестировать, в качестве первого аргумента:

./filter-write "ls -la"

После выполнения эта программа выдает совершенно пустой вывод. Тем не менее мы можем применить strace, чтобы увидеть, что происходит:

strace -f ./filter-write "ls -la"

Результат работы сильно укорочен, но соответствующая его часть показывает, что записи блокируются с ошибкой EPERM — той самой, которую мы настроили. Это означает, что программа ничего не выводит, потому что не может получить доступ к системному вызову write:

[pid 25099] write(2, "ls: ", 4)         = -1 EPERM (Operation not permitted)

[pid 25099] write(2, "write error", 11) = -1 EPERM (Operation not permitted)

[pid 25099] write(2, "\n", 1)           = -1 EPERM (Operation not permitted)

Теперь вы понимаете, как работает Seccomp BPF, и хорошо представляете, что можно сделать с его помощью. Но разве не хотелось бы добиться того же с помощью eBPF вместо cBPF, чтобы использовать всю его мощь?

Размышляя о программах eBPF, большинство людей думает, что они просто пишут их и загружают с привилегиями администратора. Хотя это утверждение в целом верно, ядро реализует набор механизмов для защиты объектов eBPF на различных уровнях. Эти механизмы называются ловушками BPF LSM.

Ловушки BPF LSM

Чтобы обеспечить независимый от архитектуры контроль системных событий, LSM реализует концепцию ловушек. Технически вызов ловушки похож на системный вызов, однако независим от системы и интегрирован с инфраструктурой. LSM предоставляет новую концепцию, в которой уровень абстракции способен помочь избежать проблем, возникающих в ходе работы с системными вызовами на разных архитектурах.

На момент написания книги ядро имело семь ловушек, связанных с BPF-программами, и SELinux — единственный встроенный LSM, реализующий их.

Исходный код ловушек размещен в дереве ядра в файле include/linux/security.h:

extern int security_bpf(int cmd, union bpf_attr *attr, unsigned int size);

extern int security_bpf_map(struct bpf_map *map, fmode_t fmode);

extern int security_bpf_prog(struct bpf_prog *prog);

extern int security_bpf_map_alloc(struct bpf_map *map);

extern void security_bpf_map_free(struct bpf_map *map);

extern int security_bpf_prog_alloc(struct bpf_prog_aux *aux);

extern void security_bpf_prog_free(struct bpf_prog_aux *aux);

Каждая из них будет вызываться на разных этапах выполнения:

security_bpf — проводит начальную проверку выполненных системных вызовов BPF;

• security_bpf_map — проверяет, когда ядро возвращает файловый дескриптор для карты;

• security_bpf_prog — проверяет, когда ядро возвращает дескриптор файла для программы eBPF;

• security_bpf_map_alloc — проверяет, инициализировано ли поле безопасности внутри карт BPF;

• security_bpf_map_free — проверяет, выполняется ли очистка поля безопасности внутри карт BPF;

• security_bpf_prog_alloc — проверяет, инициализируется ли поле безопасности внутри BPF-программ;

• security_bpf_prog_free — проверяет, очищается ли поле безопасности внутри BPF-программ.

Теперь, видя все это, мы понимаем: идея перехватчиков LSM BPF заключается в том, что они могут обеспечить защиту каждого объекта eBPF, гарантируя, что только те из них, которые имеют соответствующие привилегии, могут выполнять операции над картами и программами.

Резюме

Безопасность — это не то, что вы можете внедрить универсальным способом для всего, что хотите уберечь. Важно иметь возможность защищать системы на разных уровнях и разными способами. Хотите верьте, хотите нет, лучший способ обезопасить систему — организовывать разные уровни защиты с разных позиций, чтобы снижение безопасности одного уровня не позволило получить доступ ко всей системе. Разработчики ядра проделали большую работу, предоставив нам набор различных слоев и точек взаимодействия. Мы надеемся, что дали вам хорошее представление о том, что такое слои и как использовать программы BPF для работы с ними.

Назад: 7. Express Data Path
Дальше: 9. Реальные способы применения