Введение в Seccomp
Когда мы запускаем у себя на машине какую-то программу, она работает с теми же правами, что и пользователь, который её запустил. И вот тут есть проблема: если в программе есть баг, и кто-то сможет его использовать, то у злоумышленника появляется шанс скомпрометировать вообще всю систему. Поэтому так важно запускать программы с минимально возможными привилегиями.
Один из способов сделать это — использовать песочницу. Песочница — это программа, которая запускает другую программу, но уже с ограниченным набором прав. Отличный способ запускать недоверенный код: даже если он сможет что-то атаковать через уязвимость, урон будет ограничен. Кроме того, песочница может ограничить ресурсы, которые такой код может использовать — например, сколько памяти ему позволено съесть или сколько процессорного времени он может занять.
Что такое Seccomp?
В Linux, как и вообще в других операционных системах, программа вызывает системные вызовы, когда ей нужно попросить у ОС каких-то услуг. Например, если программе нужно прочитать файл — она делает системный вызов. Нужно открыть сокет — снова системный вызов. Хотите что-то вывести на экран — да, всё через системные вызовы.
Если в программе есть баг, который позволяет атакующему увести поток выполнения в место, контролируемое злоумышленником, — это называют уязвимостью «code injection». Уязвимость серьёзнейшая: атакующий получает возможность выполнить на машине произвольный код. Именно поэтому так важно запускать программы с минимальными привилегиями. Имея такую уязвимость, злоумышленник может вызвать системные вызовы, которые сама программа вообще не должна была уметь вызывать, и заставить её делать то, для чего она изначально не предназначалась.
Цитируя man-страницы seccomp в Linux:
Вызов seccomp() управляет состоянием Secure Computing (seccomp) текущего процесса.
Seccomp — это функция ядра Linux, которая позволяет программе ограничить набор системных вызовов, доступных ей для использования. Представьте, что у нас есть программа, которой нужно только читать файл, но мы совсем не хотим, чтобы она могла открывать сетевые сокеты. С помощью Seccomp можно сказать ядру: «разреши этому процессу вызывать read, но не socket». Если программа всё же попробует вызвать socket, ядро вернёт ей ошибку и завершит её выполнение.
Как работает Seccomp?
Seccomp использует BPF (Berkeley Packet Filter), чтобы фильтровать системные вызовы. BPF — это байткодовая виртуальная машина, которая изначально применялась для фильтрации сетевых пакетов в ядре Linux. В случае Seccomp та же самая механика используется для фильтрации системных вызовов. Каждый раз, когда программа делает системный вызов, ядро запускает BPF-программу. Та уже решает — разрешить вызов или нет.
Обратите внимание: если включён CONFIG_BPF_JIT, ядро применяет JIT-компиляцию к BPF-программе. То есть байткод переводится в машинные инструкции, и в итоге всё работает очень быстро.
Как использовать Seccomp?
Вот сигнатура системного вызова seccomp:
int seccomp(unsigned int operation, unsigned int flags, void *args);
Аргумент operation определяет, какое действие нужно выполнить. flags задаёт флаги. args — это указатель на дополнительные аргументы.
Нас интересует операция SECCOMP_SET_MODE_FILTER. Она устанавливает seccomp-фильтр для текущего процесса. Флаги пока опустим и сосредоточимся на аргументе args.
args — это указатель на структуру sock_fprog. В ней находится указатель на BPF-программу и её длина. BPF-программа — это массив структур sock_filter, каждая из которых содержит одну BPF-инструкцию.
Когда наша программа делает системный вызов, ядро передаёт BPF-программе указатель на структуру seccomp_data. В этой структуре описана информация о вызываемом системном вызове. BPF-программа может использовать эти данные, чтобы решить, разрешать вызов или нет.
Структура seccomp_data выглядит так:
struct seccomp_data {
int nr; /* System call number */
__u32 arch; /* AUDIT_ARCH_* value */
__u64 instruction_pointer; /* Instruction pointer */
__u64 args[6]; /* Arguments to the system call */
};
nr — номер системного вызова.
arch — архитектура, которой соответствует этот системный вызов.
instruction_pointer — адрес инструкции, вызвавшей системный вызов.
args — аргументы системного вызова.
В руководстве по seccomp советуют проверять архитектуру перед тем, как разрешать вызов. Для этого смотрят на поле arch в seccomp_data. Если архитектура не соответствует ожидаемой, процесс можно завершить, вернув SECCOMP_RET_KILL из BPF-программы. Причина проста: на разных архитектурах номера системных вызовов отличаются. Например, read на x86 имеет номер 3, а на x86_64 — номер 0. Если не проверять архитектуру, можно случайно разрешить read там, где вовсе не собирались.
Имейте в виду: фильтров может быть несколько. Они выполняются в обратном порядке относительно установки — последний добавленный фильтр срабатывает первым.
Пример 1 — Разрешаем только системный вызов read
В этом примере мы разрешим выполнять только системный вызов read. Для этого будем возвращать SECCOMP_RET_ALLOW, когда вызов нам подходит, и SECCOMP_RET_KILL, если он запрещён — тогда процесс сразу завершится. В первом примере используется «сырой» системный вызов seccomp. Позже станет видно, что с libseccomp всё можно упростить.
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <stddef.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
int main() {
// This is a buffer that we will read into.
char buf[32];
struct sock_filter filter[] = {
/* Load architecture into accumulator register. */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, arch))),
/* Check if the architecture is x86_64. */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
/* Kill the process if architecture does not match*/
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
/* Load the system call number. */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, nr))),
/* Check if the system call is `read`. */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 0, 1),
/* Allow the system call. */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* Kill the process. */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
perror("prctl(PR_SET_NO_NEW_PRIVS) failed");
return 1;
}
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) < 0) {
perror("prctl(PR_SET_SECCOMP) failed");
return 1;
}
read(0, buf, 4);
// after read this program will fail...
return 0;
}
Прежде чем запускать код, давайте разберёмся, что он делает. Сначала мы создаём BPF-программу. BPF-программа — это массив структур struct sock_filter. Каждая такая структура содержит одну BPF-инструкцию.
Чтобы писать программу в более «высокоуровневом» виде, мы используем макросы из заголовка linux/filter.h. Макрос BPF_STMT используется для создания структуры struct sock_filter из BPF-инструкции, которая представляет собой оператор (в нашем случае — операцию загрузки). Макрос BPF_JUMP используется для создания struct sock_filter из BPF-инструкции, которая представляет собой переход.
Например:
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, arch))),
Эта инструкция загружает архитектуру системного вызова в аккумулятор. Макрос BPF_LD говорит, что это операция загрузки. BPF_W указывает, что это 32-битная загрузка. BPF_ABS — что это абсолютная загрузка. Макрос offsetof(struct seccomp_data, arch) задаёт смещение поля arch в структуре seccomp_data.
Структура struct sock_filter содержит следующие поля:
struct sock_filter {
__u16 code; /* Actual filter code */
__u8 jt; /* Jump true */
__u8 jf; /* Jump false */
__u32 k; /* Generic multiuse field */
};
Если переписать массив sock_filter[] выше в виде BPF-инструкций, это будет выглядеть так:
ld [4] # Load the architecture of the system call into the accumulator register. jeq #0xc000003e, 1, 0 # Check if the architecture is x86_64. ret #0x00000000 # Kill the process if architecture does not match. ld [0] # Load the system call number. jeq #0x00000000, 0, 1 # Check if the system call is `read`. ret #0x7fff0000 # Allow the system call. ret #0x00000000 # Kill the process.
После того как мы создали массив sock_filter[], мы создаём структуру struct sock_fprog. В ней следующие поля:
struct sock_fprog {
unsigned short len;
struct sock_filter *filter;
};
Поле len содержит количество BPF-инструкций в массиве filter. Поле filter — это указатель на сами BPF-инструкции.
Дальше мы вызываем системный вызов prctl(2), чтобы выставить флаг NO_NEW_PRIVS. Этот флаг запрещает процессу получать новые привилегии. Потом ещё одним вызовом prctl(2) мы устанавливаем seccomp-фильтр. Флаг SECCOMP_MODE_FILTER говорит, что мы хотим включить режим фильтра. Аргумент &prog указывает на BPF-программу, которую мы хотим использовать.
Раз уж мы разрешили системный вызов read(2), сразу после установки seccomp-фильтра пробуем его вызвать. Если внимательно посмотреть на код выше, там есть комментарий о том, что программа не сможет корректно вернуть 0 после вызова read.
Посмотрим, что произойдёт. Сначала компилируем программу:
$ gcc -o example_1 example_1.c
Потом запускаем:
./example_1 aaaa fish: Job 1, './example_1' terminated by signal SIGSYS (Bad system call)
Как видно, программа упала с сигналом SIGSYS. Хотя нам и удалось прочитать 4 байта из стандартного ввода, программа всё равно завершилась с ошибкой. Давайте прогоняем её через strace:
...snip...
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) = 0
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, {len=7, filter=0x7fffd3c68b40}) = 0
read(0, aaaa
"aaa", 3) = 3
exit_group(0) = 231
+++ killed by SIGSYS (core dumped) +++
fish: Job 1, 'strace ./example_1' terminated by signal SIGSYS (Bad system call)
Похоже, программа падает сразу после системного вызова read(2). Это потому, что наша BPF-программа разрешает только read(2), но любая программа, чтобы корректно завершиться, должна ещё вызвать системный вызов exit_group(2). Значит, нужно разрешить и exit_group(2). Давайте поправим наш BPF-фильтр:
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <stddef.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(void) {
char buf[4];
struct sock_filter filter[] = {
/* Load architecture. */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, arch))),
/* Check if architecture is x86_64. */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
/* Kill the process if architecture does not match. */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
/* Load system call number. */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, nr))),
/* Check if system call is read. */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 0, 1),
/* Allow the system call. */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* Check if system call is exit_group. */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group, 0, 1),
/* Allow the system call. */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* Kill the process. */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
perror("prctl(PR_SET_NO_NEW_PRIVS) failed");
return 1;
}
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) < 0) {
perror("prctl(PR_SET_SECCOMP) failed");
return 1;
}
read(0, buf, 4);
return 0;
}
Теперь, если снова прогнать нашу программу через strace, видно, что она успевает прочитать 4 байта из стандартного ввода и корректно завершается:
...snip...
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) = 0
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, {len=9, filter=0x7ffe48c7a070}) = 0
read(0, aaaa
"aaaa", 4) = 4
exit_group(0) = ?
+++ exited with 0 +++
Небольшой лайфхак: в man-страницах seccomp(2) рекомендуют определять именно белый список системных вызовов, а не чёрный. Причина в том, что чёрный список очень легко что-то упустить, и это уже потенциальная дыра в безопасности. Так что если вы хотите использовать seccomp-фильтр, лучше опираться именно на белый список. Понятно, что для белого списка нужно заранее знать все системные вызовы, которые надо разрешить. Это не всегда просто, но если вы пишете программу, которая должна использовать seccomp-фильтр, вы можете воспользоваться strace, чтобы выяснить, какие системные вызовы ей реально нужны.
Например, следующая команда strace показывает, какие системные вызовы использует команда ls:
strace -c ls ...snip... % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 22.82 0.000034 1 22 mmap 13.42 0.000020 2 8 mprotect 10.07 0.000015 2 7 openat 6.71 0.000010 5 2 getdents64 6.04 0.000009 1 9 close 6.04 0.000009 1 8 newfstatat 4.70 0.000007 7 1 munmap 4.70 0.000007 3 2 statfs 4.70 0.000007 1 6 4 prctl 2.68 0.000004 4 1 write 2.68 0.000004 1 3 brk 2.68 0.000004 2 2 ioctl 2.01 0.000003 1 2 pread64 2.01 0.000003 1 2 1 access 1.34 0.000002 0 4 read 1.34 0.000002 1 2 1 arch_prctl 1.34 0.000002 2 1 set_tid_address 1.34 0.000002 2 1 set_robust_list 1.34 0.000002 2 1 prlimit64 1.34 0.000002 2 1 rseq 0.67 0.000001 1 1 getrandom 0.00 0.000000 0 1 execve ------ ----------- ----------- --------- --------- ---------------- 100.00 0.000149 1 87 6 total
Разумеется, когда вы разбираете вывод strace, нужно помнить, что часть системных вызовов делает динамический загрузчик. Поскольку в какой-то момент загрузчик передаёт управление в main вашей программы, в белый список стоит добавлять только те системные вызовы, которые реально используются после того, как seccomp-фильтр уже включён.
Использование libseccomp
Вы, наверное, заметили, что код в предыдущем примере довольно многословный. Чтобы сделать его более читаемым и снизить шанс ошибок, можно использовать библиотеку libseccomp. Она предоставляет высокоуровневый API для настройки seccomp-фильтра. Следующий пример делает то же самое, что и предыдущий, но уже с использованием libseccomp:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <seccomp.h>
#include <sys/prctl.h>
int main(int argc, char *argv[])
{
scmp_filter_ctx ctx;
char buf[4];
// set no_new_privs
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
perror("prctl failed");
return 1;
}
// Create a new seccomp filter context.
ctx = seccomp_init(SCMP_ACT_KILL);
if (ctx == NULL) {
perror("seccomp_init failed");
return 1;
}
// Add the syscalls that we want to allow to the seccomp filter context.
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0) < 0) {
perror("seccomp_rule_add failed");
return 1;
}
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0) < 0) {
perror("seccomp_rule_add failed");
return 1;
}
// Load the seccomp filter context.
if (seccomp_load(ctx) < 0) {
perror("seccomp_load failed");
return 1;
}
// Read 4 bytes from the standard input.
read(0, buf, 4);
// Release the seccomp filter context.
seccomp_release(ctx);
return 0;
}
Код выше использует libseccomp для установки seccomp-фильтра. Он выглядит гораздо понятнее и менее подвержен ошибкам. Мы просто создаём новый контекст seccomp-фильтра, добавляем в него системные вызовы, которые хотим разрешить, а затем загружаем этот контекст. Функция seccomp_load установит seccomp-фильтр, а заодно освободит ресурсы, связанные с контекстом фильтра.
Обратите внимание на использование макроса SCMP_SYS для указания системного вызова, который мы хотим разрешить. Этот макрос позволяет не опираться на номер системного вызова. Номера меняются от архитектуры к архитектуре и не являются переносимыми. Вместо этого с SCMP_SYS вы указываете имя системного вызова, а библиотека сама переведёт его в нужный номер.
Чтобы скомпилировать пример выше, нужно слинковать программу с библиотекой libseccomp. Это можно сделать так:
gcc -o example_3 example_3.c -lseccomp
Чтобы лучше понять, что делает наш фильтр, libseccomp предоставляет вспомогательные функции, которые позволяют вывести фильтр в человекочитаемом виде. Например, если добавить следующую строку в пример выше сразу после того, как мы добавили правила, фильтр будет напечатан в удобном формате:
seccomp_export_pfc(ctx, 1);
Вывод для нашего примера будет таким:
$ ./example_3
#
# pseudo filter code start
#
# filter for arch x86_64 (3221225534)
if ($arch == 3221225534)
# filter for syscall "exit_group" (231) [priority: 65535]
if ($syscall == 231)
action ALLOW;
# filter for syscall "read" (0) [priority: 65535]
if ($syscall == 0)
action ALLOW;
# default action
action KILL;
# invalid architecture action
action KILL;
#
# pseudo filter code end
#
Как вы видите, эта возможность очень полезна, чтобы понять, что именно делает фильтр, и помогает отлаживать его, когда он ведёт себя не так, как вы ожидаете.
Использование libseccomp с Go
libseccomp предоставляет удобную обёртку для Go. Сначала мы напишем простой код на Go, который вообще не использует libseccomp. Потом попробуем понять, какие системные вызовы делает эта программа. И, наконец, напишем новую версию программы, которая уже использует библиотеку libseccomp, чтобы запретить все системные вызовы, которые программе не нужны.
package main
import (
"log"
"os"
)
func main() {
// create a new file
f, err := os.Create("test.txt")
if err != nil {
log.Fatal("failed to create file: ", err)
}
// write to the file
if _, err := f.Write([]byte("hello world")); err != nil {
log.Fatal("failed to write to file: ", err)
}
// close the file
if err := f.Close(); err != nil {
log.Fatal("failed to close file: ", err)
}
}
Здесь у нас простая программа, которая создаёт файл.
# build the program $ go build -o simple main.go # now we run the program attached to strace $ strace -f -c ./simple strace: Process 424724 attached strace: Process 424725 attached strace: Process 424726 attached strace: Process 424727 attached % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ------------------ 41.70 0.000460 76 6 nanosleep 28.38 0.000313 31 10 2 futex 7.71 0.000085 21 4 clone 7.71 0.000085 42 2 openat 3.26 0.000036 2 14 rt_sigprocmask 3.26 0.000036 4 9 gettid 2.27 0.000025 1 22 mmap 2.18 0.000024 2 10 sigaltstack 1.18 0.000013 13 1 write 0.54 0.000006 2 3 fcntl 0.54 0.000006 3 2 1 epoll_ctl 0.45 0.000005 5 1 pipe2 0.27 0.000003 1 2 close 0.27 0.000003 3 1 epoll_create1 0.18 0.000002 2 1 getrlimit 0.09 0.000001 1 1 setrlimit 0.00 0.000000 0 1 read 0.00 0.000000 0 114 rt_sigaction 0.00 0.000000 0 1 madvise 0.00 0.000000 0 1 execve 0.00 0.000000 0 1 arch_prctl 0.00 0.000000 0 1 sched_getaffinity ------ ----------- ----------- --------- --------- ------------------ 100.00 0.001103 5 208 3 total
Как видно, программа использует довольно много системных вызовов.
Теперь попробуем написать новую версию программы, которая использует библиотеку libseccomp-golang и по умолчанию реализует политику «запрещено всё».
package main
import (
"log"
"os"
seccomp "github.com/seccomp/libseccomp-golang"
)
func main() {
// create a new filter which kills all the threads in the process
filter, err := seccomp.NewFilter(seccomp.ActKillProcess)
if err != nil {
log.Fatal("failed to create filter: ", err)
}
defer filter.Release()
// set the no new privs bit
if filter.SetNoNewPrivsBit(true) != nil {
log.Fatal("failed to set no new privs bit: ", err)
}
if filter.Load() != nil {
log.Fatal("failed to load filter: ", err)
}
// create a new file
f, err := os.Create("test.txt")
if err != nil {
log.Fatal("failed to create file: ", err)
}
// write to the file
if _, err := f.Write([]byte("hello world")); err != nil {
log.Fatal("failed to write to file: ", err)
}
// close the file
if err := f.Close(); err != nil {
log.Fatal("failed to close file: ", err)
}
}
Собираем и запускаем программу:
$ ./simple fish: Job 1, './simple' terminated by signal SIGSYS (Bad system call)
Ай! Ядро убило программу, потому что она попыталась вызвать системный вызов, который не разрешён фильтром. Теперь попробуем понять, какой именно вызов запрещён.
$ strace -f ./simple
...snip...
[pid 426136] openat(AT_FDCWD, "test.txt", O_RDWR|O_CREAT|O_TRUNC|O_CLOEXEC, 0666 <unfinished ...>
[pid 426137] <... nanosleep resumed>NULL) = 0
[pid 426137] nanosleep({tv_sec=0, tv_nsec=20000}, <unfinished ...>
[pid 426136] <... openat resumed>) = 257
[pid 426137] <... nanosleep resumed>NULL) = 35
[pid 426140] <... futex resumed>) = ?
[pid 426138] <... futex resumed>) = ?
[pid 426141] <... futex resumed>) = ?
[pid 426140] +++ killed by SIGSYS (core dumped) +++
[pid 426138] +++ killed by SIGSYS (core dumped) +++
[pid 426141] +++ killed by SIGSYS (core dumped) +++
[pid 426137] +++ killed by SIGSYS (core dumped) +++
[pid 426139] <... futex resumed>) = ?
[pid 426139] +++ killed by SIGSYS (core dumped) +++
+++ killed by SIGSYS (core dumped) +++
fish: Job 1, 'strace -f ./simple' terminated by signal SIGSYS (Bad system call)
Как видно, есть довольно много системных вызовов, которые фильтр не разрешает. Теперь попробуем добавить в фильтр нужные вызовы, взяв их из первого вывода strace, где программа запускалась без seccomp.
package main
import (
"log"
"os"
seccomp "github.com/seccomp/libseccomp-golang"
)
var (
// string slice of syscall names
syscalls = []string{
"nanosleep",
"futex",
"clone",
"openat",
"rt_sigprocmask",
"gettid",
"mmap",
"sigaltstack",
"write",
"fcntl",
"epoll_ctl",
"pipe2",
"close",
"epoll_create1",
"getrlimit",
"setrlimit",
"read",
"rt_sigaction",
"madvise",
"execve",
"arch_prctl",
"sched_getaffinity",
}
)
func main() {
// create a new filter which kills all the threads in the process
filter, err := seccomp.NewFilter(seccomp.ActKillProcess)
if err != nil {
log.Fatal("failed to create filter: ", err)
}
defer filter.Release()
// set the no new privs bit
if filter.SetNoNewPrivsBit(true) != nil {
log.Fatal("failed to set no new privs bit: ", err)
}
// iterate over the syscalls slice and add them to the filter
for _, syscall := range syscalls {
// resolve syscall number
seccompSys, err := seccomp.GetSyscallFromName(syscall)
if err != nil {
log.Fatal("failed to get syscall: ", err)
}
// add the syscall to the filter
if filter.AddRule(seccompSys, seccomp.ActAllow) != nil {
log.Fatal("failed to add rule: ", err)
}
}
if filter.Load() != nil {
log.Fatal("failed to load filter: ", err)
}
// create a new file
f, err := os.Create("test.txt")
if err != nil {
log.Fatal("failed to create file: ", err)
}
// write to the file
if _, err := f.Write([]byte("hello world")); err != nil {
log.Fatal("failed to write to file: ", err)
}
// close the file
if err := f.Close(); err != nil {
log.Fatal("failed to close file: ", err)
}
}
Как видно, логика очень похожа на наш первый пример на C: мы просто итерируемся по слайсу syscalls и добавляем каждый системный вызов в фильтр.
Теперь собираем и запускаем программу:
$ ./simple fish: Job 1, './simple' terminated by signal SIGSYS (Bad system call)
…и снова та же ошибка. Попробуем понять, какой системный вызов не разрешён.
$ strace -f ./simple [pid 427733] exit_group(0) = 231
Ну да, нашей программе в конце концов нужно корректно завершиться, так что давайте добавим exit_group в фильтр.
...snip...
var (
// string slice of syscall names
syscalls = []string{
"nanosleep",
"futex",
"clone",
"openat",
"rt_sigprocmask",
"gettid",
"mmap",
"sigaltstack",
"write",
"fcntl",
"epoll_ctl",
"pipe2",
"close",
"epoll_create1",
"getrlimit",
"setrlimit",
"read",
"rt_sigaction",
"madvise",
"execve",
"arch_prctl",
"sched_getaffinity",
}
)
...snip...
Теперь собираем и запускаем программу:
$ ./simple $ cat test.txt hello world⏎
Вот и всё, у нас есть рабочая программа, в которой включён seccomp. Есть ещё несколько системных вызовов, которые можно было бы убрать из фильтра, но это уже остаётся на откуп читателю. Подсказка: прогоните программу через strace и попробуйте понять, какие вызовы не нужны. Например, arch_prctl не нужен, потому что он используется загрузчиком, а execve не нужен, потому что мы не запускаем другие программы — его использует сам загрузчик, чтобы запустить наше приложение.
Seccomp в Rust
Вот тот же пример на Rust, с использованием крейта libseccomp.
use std::{fs::File, io::Write};
use libseccomp::{ScmpFilterContext, ScmpAction, ScmpSyscall};
fn main() {
// create a new filter
let mut filter = ScmpFilterContext::new_filter(ScmpAction::KillProcess).unwrap();
// add architecture to filter
let rule_openat_sys = ScmpSyscall::from_name("openat").unwrap();
let rule_write_sys = ScmpSyscall::from_name("write").unwrap();
let rule_fsync_sys = ScmpSyscall::from_name("fsync").unwrap();
let rule_close_sys = ScmpSyscall::from_name("close").unwrap();
let rule_sigaltstack_sys = ScmpSyscall::from_name("sigaltstack").unwrap();
let rule_munmap_sys = ScmpSyscall::from_name("munmap").unwrap();
let rule_exit_group_sys = ScmpSyscall::from_name("exit_group").unwrap();
filter.add_rule(ScmpAction::Allow, rule_openat_sys).unwrap();
filter.add_rule(ScmpAction::Allow, rule_write_sys).unwrap();
filter.add_rule(ScmpAction::Allow, rule_fsync_sys).unwrap();
filter.add_rule(ScmpAction::Allow, rule_close_sys).unwrap();
filter.add_rule(ScmpAction::Allow, rule_sigaltstack_sys).unwrap();
filter.add_rule(ScmpAction::Allow, rule_munmap_sys).unwrap();
filter.add_rule(ScmpAction::Allow, rule_exit_group_sys).unwrap();
filter.load().unwrap();
// create a file
let mut file = File::create("foo.txt").unwrap();
// write some content
file.write_all(b"Hello, world!").unwrap();
// sync content
file.sync_all().unwrap();
}
Предыдущий пример довольно прямолинейный и очень похож на варианты на C и Go. Конечно, набор используемых системных вызовов меняется в зависимости от языка, потому что рантайм Go использует некоторые syscalls, которые не используются в Rust. Также помните, что при написании кода нам часто приходится использовать больше одной библиотеки, и каждая библиотека может использовать свои системные вызовы, так что вам, возможно, придётся добавить ещё несколько syscalls в фильтр.
Бонус: где живёт мой фильтр?
В этом примере мы посмотрим на фильтр, который только что повесили на процесс, с помощью drgn.
drgn — это отладчик на Python, который позволяет исследовать и ядро, и userspace.
# сначала запускаем наш пример с libseccomp под gdb
# и ставим брейкпоинт сразу после загрузки seccomp-фильтра
# (обратите внимание: компилируем с отладочными символами)
$ gcc -o example_3 example_3.c -lseccomp -ggdb2
$ gdb -q ./example_3
pwndbg> b example_3.c:48
Breakpoint 1 at 0x4012be: file example_3.c, line 48.
pwndbg> r
...snip...
In file: .../seccomp/example_3.c
43 perror("seccomp_load failed");
44 return 1;
45 }
46
47 // Read 4 bytes from the standard input.
► 48 read(0, buf, 4);
49
50 // Release the seccomp filter context.
51 seccomp_release(ctx);
52
53 return 0;
...snip...
pwndbg> procinfo
exe '.../seccomp/example_3'
pid 445243
tid 445243
selinux unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
ppid 445170
uid [1000, 1000, 1000, 1000]
gid [1000, 1000, 1000, 1000]
groups [10, 973, 1000]
fd[0] /dev/pts/1
fd[1] /dev/pts/1
fd[2] /dev/pts/1
В другом терминале запускаем drgn и прикрепляемся к процессу:
$ sudo drgn
# здесь используем функцию find_task, чтобы получить task_struct
# в ядре Linux task_struct — это структура, описывающая процесс
>>> t = find_task(prog, 445243)
>>> t.type_
struct task_struct *
...
# как видно, в task_struct есть поле seccomp
>>> prog.type("struct task_struct")
struct task_struct {
struct thread_info thread_info;
unsigned int __state;
void *stack;
...snipped...
kuid_t loginuid;
unsigned int sessionid;
struct seccomp seccomp;
struct syscall_user_dispatch syscall_dispatch;
u64 parent_exec_id;
...snipped...
}
# нас интересует filter — это указатель на структуру seccomp_filter
>>> t.seccomp
(struct seccomp){
.mode = (int)2,
.filter_count = (atomic_t){
.counter = (int)1,
},
.filter = (struct seccomp_filter *)0xffff922e3529c300,
}
# уже близко... здесь указатель на структуру bpf_prog
>>> t.seccomp.filter
*(struct seccomp_filter *)0xffff922e3529c300 = {
...snipped...
.prev = (struct seccomp_filter *)0x0,
.prog = (struct bpf_prog *)0xffffb3e5089c5000,
...snipped...
}
# отлично, вот она!
>>> t.seccomp.filter.prog
*(struct bpf_prog *)0xffffb3e5089c5000 = {
.pages = (u16)1,
.jited = (u16)1, // <-- это значит, что фильтр JIT-компилирован
.jit_requested = (u16)1,
...snipped...
.len = (u32)16,
.jited_len = (u32)84, // <-- размер JIT-кода фильтра в байтах
.tag = (u8 [8]){},
.stats = (struct bpf_prog_stats *)0x41b40fc0f150,
.active = (int *)0x41b40fc03218,
.bpf_func = (unsigned int (*)(const void *, const struct bpf_insn *))0xffffffffc030d8e0, // <-- адрес JIT-кода фильтра
...snipped...
}
# что дальше? можно использовать адрес bpf_func, чтобы дампнуть код фильтра
# и продизассемблировать его.
# мы знаем, что фильтр занимает 84 байта, читаем 84 байта по этому адресу
>>> buf = prog.read(0xffffffffc030d8e0, 84)
# используем библиотеку capstone, чтобы дизассемблировать код
>>> from capstone import *
>>> md = Cs(CS_ARCH_X86, CS_MODE_64)
>>> for i in md.disasm(buf, 0x0):
... print("0x%x:\t%s\t%s" %(i.address, i.mnemonic, i.op_str))
...
0x0: nop dword ptr [rax + rax] <-- первая инструкция фильтра — NOOP
0x5: push rbp <-- сохраняем rbp, он будет использоваться как база для доступа к стеку
0x6: mov rbp, rsp <-- кладём указатель стека в rbp
0x9: push rbx <-- сохраняем rbx
0xa: push r13 <-- сохраняем r13
0xc: xor eax, eax <-- выставляем eax в 0
0xe: xor r13d, r13d <-- выставляем r13 в 0
0x11: mov rbx, rdi <-- rdi указывает на структуру seccomp_data с данными о текущем syscall
0x14: mov eax, dword ptr [rbx + 4] <-- eax = поле arch из seccomp_data
0x17: mov esi, 0xc000003e <-- rsi = 0xc000003e (x86_64)
0x1c: cmp rax, rsi <-- сравниваем eax и rsi
0x1f: jne 0x50 <-- если не равно — прыгаем на 0x50 и возвращаем 0, запрещая syscall
0x21: mov eax, dword ptr [rbx] <-- eax = поле nr (номер syscall) из seccomp_data
0x24: cmp rax, 0x40000000 <-- сравниваем с 0x40000000 (__X32_SYSCALL_BIT): выше — 32-битный syscall, ниже — 64-битный
0x2b: jb 0x37 <-- если eax < 0x40000000, прыгаем на 0x37
0x2d: mov esi, 0xffffffff <-- rsi = 0xffffffff (-1)
0x32: cmp rax, rsi <-- сравниваем rax и rsi
0x35: jne 0x50 <-- если не равно — прыгаем на 0x50 и запрещаем syscall
0x37: test rax, rax <-- эквивалент cmp rax, 0 — проверяем номер syscall
0x3a: je 0x45 <-- если rax == 0 — прыгаем на 0x45 и разрешаем read
0x3c: cmp rax, 0xe7 <-- сравниваем с 0xe7 — это exit_group
0x43: jne 0x50 <-- если не равно — прыгаем на 0x50 и запрещаем syscall
0x45: mov eax, 0x7fff0000 <-- eax = 0x7fff0000
0x4a: pop r13 <-- восстанавливаем r13
0x4c: pop rbx <-- восстанавливаем rbx
0x4d: leave <-- восстанавливаем rbp
0x4e: ret <-- возвращаем 0x7fff0000, разрешая syscall
0x4f: int3 <-- непонятно, зачем здесь int3, вероятно, чтобы ловить процесс
0x50: xor eax, eax <-- eax = 0
0x52: jmp 0x4a <-- прыгаем на 0x4a и возвращаем 0, запрещая syscall
Заключение
Это был всего лишь вводный пост о seccomp и о том, как его использовать. Надеюсь, это кому-нибудь пригодится. Мы начали с основ seccomp, потом посмотрели, как работать с ним в разных языках программирования, и в конце заглянули на то, как всё устроено со стороны ядра. Надеюсь, вам было интересно и вы узнали что-нибудь новое.
Читателю предлагается изучить документацию и ссылки в конце поста и попробовать написать свой seccomp-фильтр для какого-нибудь системного вызова и посмотреть, как он себя ведёт.
Ссылки