O seccomp
Мы научимся формировать фильтр seccomp для произвольного приложения, формируя таким образом песочницу, процессы в которой могут использовать только заранее определенный перечень системных вызовов.
Если злоумышленник получит возможность выполнить произвольный код, seccomp не даст ему использовать системные вызовы, которые не были заранее объявлены и предусмотрены фильтром.
Изначально seccomp — это разработка компании Google. Сейчас фильтр является штатным механизмом ядра Linux.
Например, в Docker используется фильтр системных вызовов на основе seccomp, который по умолчанию блокирует более 40 системных вызовов из около 500 существующих в ядре Linux на текущий момент.
Seccomp позволяет указать, что делать с процессом-нарушителем: например, принудительно завершить работу процесса или просто вернуть ошибку в запрещенном системном вызове.
Почему фильтруем системные вызовы
Системные вызовы (syscall) — это обращение к ядру операционной системы от процесса, выполняющегося в пространстве пользователя.
Без использования системных вызовов приложение не может ни считать откуда-либо информацию, ни записать ее или вывести на экран, так как любые операции ввода-вывода, такие как дисковые или сетевые, выполняются с помощью запросов к ядру.
Ограничив для приложения список используемых системных вызовов, мы блокируем возможность кода злоумышленника реализовать те или иные угрозы.
В ядре Linux сейчас около 500 различных системных вызовов. Информацию о них можно получить из кода ядра. Для более практичного представления списка системных вызовов, их идентификаторов и назначения удобно воспользоваться одним из существующих генераторов табличного представления системных вызовов для архитектуры x86_64.
Пример приложения
Рассмотрим в качестве примера простенькую программу на языке C (Си), выводящую версию ядра операционной системы. В ней используется системный вызов uname(2). Для получения того же результата можно воспользоваться стандартной командой:
$ uname -r
Но нам нужен пример маленького приложения, чтобы выяснить, как определить необходимые ему системные вызовы, а дальше посмотреть, как используемые системные вызовы можно ограничить. Итак, исходный код приложения release.c:
#include <stdio.h>
#include <sys/utsname.h>
int main(void)
{
struct utsname name;
if (uname(&name)) return 1;
printf("Версия ядра: %sn", name.release);
return 0;
}
Собираем и запускаем:
$ make release
cc release.c -o release
$ ./release
Версия ядра: 6.12.68-6.12-alt1
Какие системные вызовы нужны процессу
Как выяснить, какие системные вызовы использует наше приложение?
Очевидно, что это явно используемый в коде системный вызов uname(2), возвращающий информацию о системе. Кроме того, очевидно, что это системные вызовы read(2) и write(2), используемые для организации ввода-вывода, вызовы openat(2) и close(2) — для работы со стандартными дескрипторами ввода-вывода.
Полный ли это перечень? Нет, неполный, и ниже мы увидим, как собрать информацию обо всех системных вызовах, необходимых процессу.
Seccomp-фильтрация в systemd
Для работы с фильтрами seccomp командными средствами удобнее всего использовать инструменты SystemD.
Выполним запуск приложения средствами systemd-run, использовав явное перечисление фильтруемых системных вызовов в параметре property=“SystemCallFilter=…”:
$ systemd-run --user --pty --same-dir --wait --collect
--service-type=exec
--property="SystemCallFilter=uname read write openat close"
./release
Running as unit: run-p123231-i123531.service; invocation ID: 07573b418714492eb7046cf6cb4dc8eb
Press ^] three times within 1s to disconnect TTY.
Finished with result: core-dump
Main processes terminated with: code=dumped, status=31/SYS
Service runtime: 143ms
CPU time consumed: 40ms
Memory peak: 952K (swap: 0B)
Видим, что процесс был завершен принудительно сигналом SIGSYS(31):
Finished with result: core-dump
Main processes terminated with: code=dumped, status=31/SYS
Это означает, что был использован неправильный (в данном случае неразрешенный seccomp) системный вызов. Определим, какой именно?
Проверка по журналу аудита причин завершения приложения
Ищем в журнале аудита (сервис аудита должен работать) сообщение о завершении процесса 31-м сигналом:
# journalctl -ekf -g 'sig=31'
фев 27 13:00:22 y520 kernel: audit: type=1326 audit(1772186422.360:2):
auid=500 uid=500 gid=500 ses=2 pid=123232 comm="release"
exe=2F686F6D652F65676F722F626F6F6B732FD0B2D090D099D0A2D0982F736563636F6D702F72656C65617365 sig=31
arch=c000003e syscall=21 compat=0 ip=0x7f1d746b5bd7 code=0x80000000
Видим, что процесс был завершен при выполнении системного вызова 21 (syscall=21).
Ищем 21-й системный вызов в ранее приведенной таблице и видим, что это системный вызов access(2). Добавляем его в наш фильтр и заново запускаем приложение, но это может оказаться не самым коротким путем к цели.
Адаптация списка системных вызовов при использовании strace
Утилита strace(1) — это инструмент перехвата системных вызовов и сигналов, используемых приложением в процессе работы.
Получим список системных вызовов с помощью strace:
$ $ strace -qqqc -U name ./release
Версия ядра: 6.12.68-6.12-alt1
syscall
----------------
execve
mmap
openat
mprotect
brk
munmap
newfstatat
write
read
close
pread64
access
set_robust_list
uname
arch_prctl
set_tid_address
prlimit64
getrandom
rseq
----------------
total
Теперь мы имеем достаточно точный список системных вызовов процесса, используем его для определения фильтра.
Результат
$ systemd-run --user --pty --same-dir --wait --collect
--service-type=exec
--property="SystemCallFilter=execve mmap openat mprotect brk munmap
newfstatat write read close pread64 access set_robust_list uname arch_prctl set_tid_address
prlimit64 getrandom rseq" ./release
Running as unit: run-p126302-i126602.service; invocation ID:
9333d0df14f94e45834542f725182b61
Press ^] three times within 1s to disconnect TTY.
Версия ядра: 6.12.68-6.12-alt1
Finished with result: success
Main processes terminated with: code=exited, status=0/SUCCESS
Service runtime: 40ms
CPU time consumed: 28ms
Memory peak: 620K (swap: 0B)
Как мы видим, процесс успешно выполнился, наш фильтр на этот раз оказался достаточным.
Версия ядра: 6.12.68-6.12-alt1
Finished with result: success
Main processes terminated with: code=exited, status=0/SUCCESS
Если strace(1) не увидит что-либо из используемых системных вызовов, финальную адаптацию можно провести с применением сообщений в журнале аудита, как было показано выше.
Итак, мы научились формировать фильтр seccomp для произвольного приложения, предотвращая использование в нашей песочнице заранее непредусмотренных системных вызовов.