Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Проект: песочница как у раннера курса
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Проект: песочница как у раннера курса
В прошлом уроке
zboxнаучился принимать исходник по HTTP:zbox serveповерх TINY слушает порт,POST /runкладётmain.zigво временный каталог, собирает и запускает его под лимитами. Звучит как раннер курса, но это пока раннер, которому нельзя доверить ни одной чужой программы. Она видит все процессы машины и может послать им сигнал, читает/etc/passwdи твой~/.ssh, пишет куда разрешено тебе, открывает сокеты, монтирует, если zbox запущен от root, и вообще делает всё, что может твой пользователь. Раннер курса, который весь раздел гонял твои задачи, от этого защищён одной командойdocker runс десятком флагов. Сегодня мы разберём каждый флаг до системного вызова и соберём ту же изоляцию руками: шесть пространств имён однимunshare, свою корневую ФС черезpivot_root, свой/proc, корень только для чтения, пустые capabilities, запрет на новые привилегии и фильтр seccomp, который мы соберём сами из байтов. Это последний шагzboxв блоке, и после него у каждой строчкиrunner/bs-runner/src/sandbox.rsбудет знакомое тебе имя.
Цели урока
- Объяснить, из чего сделан контейнер: процесс плюс пространства имён (что он видит), cgroups (сколько он может взять) и capabilities,
no_new_privs, seccomp (что ему разрешено сделать). - Завести все шесть нужных пространств имён одним
unshareбез прав root через пространство пользователей и объяснить, зачем послеCLONE_NEWPIDнужен ещё одинfork. - Переключить процесс в распакованную корневую ФС через
pivot_rootи назвать, чем он лучшеchrootи какие условия ставит ядро. - Смонтировать свои
/proc,/tmp,/devи знать две ловушки: порядок монтирования procfs и пустой/devпослеdocker export. - Разобраться, почему capabilities не пропадают сами при
execve, и обнулить их через bounding set. - Написать фильтр seccomp на классическом BPF: раскладка
seccomp_data, формат инструкции, прыжки, вердикты и их старшинство, проверка архитектуры. - Собрать таблицу разрешённых сисколлов из профиля раннера и проверить её трассой
zt strace. - Подключить изоляцию к
zbox serveи пройти полный цикл:curlшлёт исходник, песочница собирает и запускает его и отвечает JSON-ом. - Сопоставить каждый шаг
zboxс флагомdocker run, которым то же самое включаетbs-runner, и честно сказать, где совпадение неполное.
Идея: контейнер это процесс с шорами
Контейнера как объекта в ядре Linux нет. Нет системного вызова create_container, нет таблицы контейнеров, нет поля «контейнер» в task_struct. Есть обычный процесс, которому ядро по-другому отвечает на три вопроса.
- Что он видит. Какие процессы, какую файловую систему, какую сеть, какое имя машины. Это пространства имён (namespaces).
- Сколько он может взять. Памяти, процессора, процессов. Это cgroups, и ты уже ставил лимиты
memory.maxв уроке про управление памятью. - Что ему можно сделать. Какие привилегированные действия, какие системные вызовы. Это capabilities, бит
no_new_privsи seccomp.
С точки зрения администратора ты это уже видел в уроке про эксплуатацию Linux, там это «лестница изоляции» от голого процесса до микро-виртуалки. Сегодня мы идём на ступеньку ниже: каждое слово этой лестницы окажется одним или двумя системными вызовами из урока про сисколлы, и мы вызовем их сами.
Вот все слои песочницы, от внешнего к внутреннему. Первые пять zbox уже умеет: процесс из урока 48, лимит времени через сигналы из урока 49, лимит памяти, захват вывода, выключенная сеть из урока про сокеты. Остальные четыре мы добавим сегодня. Нажми на слой, чтобы увидеть наш вызов и флаг раннера.
- программа студента
отдельный процесс
Чужая программа живёт в своём процессе: свой стек, своя память, свой код возврата. Родитель ждёт и читает rusage.
- в zbox
fork, execvp, wait4- урок
- 48 · процессы
- в bs-runner
docker run --rm --init
Контейнер это тоже процесс; --init подсаживает крошечный PID 1, который подбирает зомби за инструментами сборки.
лимит времени
Ребёнок в своей группе процессов, будильник тикает в родителе, по истечении вся группа получает SIGKILL, чтобы внуки не пережили ребёнка.
- в zbox
setpgid, setitimer, kill(-pgid, SIGKILL), sigsuspend- урок
- 49 · сигналы
- в bs-runner
docker kill -s KILL <name>
У docker run нет флага на wall-clock. bs-runner ждёт wall_ms из runner.toml через tokio timeout и убивает контейнер сам; --cpus 1.00 ограничивает только долю процессора.
лимит памяти
Группа cgroups v2 с потолком: при переполнении ядро убивает процесс по OOM, а не свопит машину. Второй способ, setrlimit(RLIMIT_AS), ограничивает только адреса.
- в zbox
mkdir в /sys/fs/cgroup, write memory.max и memory.swap.max, write cgroup.procs; setrlimit(RLIMIT_AS)- урок
- 54 · виртуальная память как менеджер
- в bs-runner
--memory 256m --memory-swap 256m--pids-limit 64--ulimit nproc=64:64--ulimit nofile=256:256
Docker пишет те же файлы memory.max и pids.max в cgroup контейнера. Лимит процессов закрывает fork-бомбу, лимит дескрипторов не даёт съесть таблицу файлов.
захват вывода
stdout и stderr ребёнка смотрят в два пайпа, родитель читает оба через poll, чтобы не зависнуть на полном буфере, и обрезает по лимиту.
- в zbox
pipe, dup2, poll, короткие счёты дочитываются циклом- урок
- 61 · буферизованный ввод и вывод
- в bs-runner
- без флага, делает сам раннер
Флага нет: bs-runner читает stdout и stderr самого docker run через такие же пайпы и режет по max_output_bytes.
сеть выключена
Новое сетевое пространство имён, в котором есть только lo: наружу дороги нет, connect к чужому адресу отвечает ENETUNREACH, DNS молчит. В zbox lo к тому же выключен, и даже 127.0.0.1 недоступен.
- в zbox
unshare(CLONE_NEWNET)- урок
- 64 · сокеты
- в bs-runner
--network none
Docker заводит такое же пространство, но поднимает в нём lo: сервер на 127.0.0.1 внутри контейнера работает. Скачать зависимости изнутри задачи нельзя, поэтому образы держат их заранее.
свои PID и файловая система
Ребёнок видит себя как PID 1 и не видит чужих процессов; корень переключён в распакованный образ, /proc смонтирован заново, всё лишнее только для чтения.
- в zbox
unshare(CLONE_NEWPID | CLONE_NEWNS) и второй fork: программа это внук, PID 1; pivot_root, mount("proc"), mount с MS_RDONLY- урок
- 67 · финал zbox
- в bs-runner
<image>--read-only--tmpfs /work:rw,exec,size=…,nosuid,nodev,uid=…--tmpfs /tmp:rw,noexec,nosuid,nodev-v <dir>:/work-init:ro
Образ и есть корневая ФС, --read-only запрещает писать в неё, а два tmpfs дают место под сборку: в /work можно запускать бинарники, в /tmp нельзя.
не root
Внутри своего пространства пользователей программа это uid 0, а снаружи тот же процесс это обычный uid 10001 без прав на чужое. Capabilities у неё обнулены.
- в zbox
unshare(CLONE_NEWUSER), write /proc/self/setgroups, uid_map и gid_map, prctl(PR_CAPBSET_DROP)- урок
- 67 · финал zbox
- в bs-runner
--user 10001:10001--cap-drop ALL
Без capabilities даже root в контейнере не может монтировать, менять сеть или посылать сигналы чужим. bs-runner берёт --user из конфига языка в runner.toml (runner, uid 10001) и пространство пользователей не заводит: программа не root даже внутри.
без новых привилегий
Бит, после которого execve не поднимает права даже через setuid-бинарник: sudo, su и ping внутри становятся обычными программами.
- в zbox
prctl(PR_SET_NO_NEW_PRIVS, 1)- урок
- 67 · финал zbox
- в bs-runner
--security-opt no-new-privileges
Ещё это обязательное условие для seccomp-фильтра от непривилегированного процесса: ядро иначе откажет.
seccomp
BPF-программа смотрит на номер каждого системного вызова и его аргументы: разрешённые пропускает, остальные получают EPERM или убивают процесс. Таблица это профиль раннера минус сокеты и вызовы, которые Docker даёт только с CAP_SYS_ADMIN; трасса zt strace проверяет, что её хватает.
- в zbox
seccomp(SECCOMP_SET_MODE_FILTER, 0, &prog)- урок
- 67 · финал zbox
- в bs-runner
--security-opt seccomp=runner/sandbox/seccomp.json--security-opt apparmor=bs-runner-task
Docker собирает такую же BPF-программу из json. Профиль курса запрещает clone с пространствами имён, поэтому шаги этого урока в песочнице курса не проверить.
Семь пространств, шесть нужных
Пространство имён заводится флагом CLONE_NEW*: либо в clone для нового процесса, либо в unshare для текущего. Процесс попадает в новое пространство, а все его будущие дети наследуют его.
| Пространство | Флаг | Что своё у процесса | Зачем раннеру |
|---|---|---|---|
| пользователей | CLONE_NEWUSER | uid и gid, capabilities | стать root внутри без root снаружи; права на остальные пять |
| PID | CLONE_NEWPID | номера процессов | программа не видит и не может убить чужие процессы |
| монтирования | CLONE_NEWNS | дерево точек монтирования | своя корневая ФС, свои /proc, /tmp, /dev |
| сетевое | CLONE_NEWNET | интерфейсы, маршруты, порты | сети нет совсем, как в уроке 64 |
| UTS | CLONE_NEWUTS | имя узла | hostname внутри не меняет имя машины |
| IPC | CLONE_NEWIPC | очереди сообщений, семафоры и память System V | программа не достучится до чужой разделяемой памяти |
| cgroup | CLONE_NEWCGROUP | вид на дерево cgroups | не нужно: лимиты ставит zbox снаружи |
Есть ещё восьмое, пространство времени (CLONE_NEWTIME), для сдвига монотонных часов при миграции контейнера; раннеру оно тоже ни к чему.
Флаг CLONE_NEWNS называется так по историческим причинам: пространство монтирования появилось первым (Linux 2.4.19, 2002 год), и никто не думал, что будут другие. Отсюда безликое «new namespace».
Попробуем без кода
В образе раннера есть unshare(1) из util-linux, обёртка над тем же системным вызовом. Запустим её в контейнере --privileged от пользователя runner (uid 10001), от которого песочница курса гоняет задачи:
$ id -u
10001
$ unshare --user --map-root-user --pid --fork --mount-proc --uts --net \
sh -c 'hostname zbox; echo "pid=$$ host=$(hostname) $(id -u)"; ls -d /proc/[0-9]*; cat /proc/net/dev | tail -n +3 | cut -c1-8'
pid=1 host=zbox 0
/proc/1
lo:
$ sh -c 'echo "pid=$$ host=$(hostname) $(id -u)"; ls -d /proc/[0-9]* | wc -l'
pid=364 host=37f56587d8b2 10001
4
Внутри оболочка это процесс номер 1, единственный в /proc, имя узла zbox (и поменяли его мы, без прав root), uid 0, из сетевых интерфейсов только lo. Снаружи тот же пользователь видит свой настоящий номер, имя контейнера, uid 10001 и чужие процессы. Каждый ключ unshare(1) это кусок нашего плана:
--user --map-root-user:CLONE_NEWUSERплюс запись0 10001 1в/proc/self/uid_map. Без этого ключа все остальные потребовали бы настоящего root.--pid --fork:CLONE_NEWPIDи обязательныйforkпосле него. Почему обязательный, разберём через минуту.--mount-proc:CLONE_NEWNSплюс свежая procfs поверх/proc. Без неёls /procпоказал бы процессы машины: файловая система/procрисует то PID-пространство, в котором её смонтировали.--uts,--net: имя узла и пустая сеть.
Наш код будет делать ровно это, плюс корневую ФС, capabilities и seccomp, которых у unshare(1) нет.
Пространство пользователей: root без root
Все пространства, кроме пользовательского, требуют capability CAP_SYS_ADMIN. Обычному пользователю её взять неоткуда. Кроме одного пути: CLONE_NEWUSER разрешён всем (если администратор не выключил его sysctl-ом), и в новом пространстве пользователей процесс получает полный набор capabilities. Но только над тем, что этому пространству принадлежит.
Это ключевая идея, и её легко понять неправильно. Процесс в своём пространстве пользователей это root, который владеет пустым миром. Он может завести в нём сетевое пространство и настраивать его как угодно: это его сеть. Может смонтировать tmpfs в своём пространстве монтирования. Но файл /etc/shadow принадлежит настоящему root из корневого пространства, и для ядра наш «root» по отношению к нему это uid 10001, то есть никто. Прав на чужое не прибавилось ни на бит.
Чтобы процесс внутри был именно uid 0, а не «никто» (65534), ядру нужно отображение: какой uid внутри какому снаружи соответствует. Его пишут в /proc/<pid>/uid_map строкой из трёх чисел:
0 10001 1
«Начиная с uid 0 внутри, отобразить на uid 10001 снаружи, диапазон длиной 1». Файл принимает ровно одну запись, один раз. Без привилегий можно отобразить только свой собственный uid и только в одну строку; на диапазон вроде 0 100000 65536, как у rootless Docker, нужны setuid-помощники newuidmap и newgidmap. С gid_map то же самое, но перед ним ядро требует записать deny в /proc/self/setgroups. Иначе процесс, ставший root внутри, мог бы вызвать setgroups и выбросить из своего списка группу, которая ему что-то запрещала (права вида rw----r--, где группе нельзя, а остальным можно).
Всё это ты уже писал в уроке про сокеты, в обходном пути --no-net без CAP_SYS_ADMIN. Сегодня мы вызовем тот же код с другим набором флагов.
PID-пространство и второй fork
unshare(CLONE_NEWPID) ведёт себя не так, как остальные флаги. Сетевое или UTS-пространство процесс меняет сразу: следующий же socket пойдёт в новую сеть. А номер процесса поменять нельзя: его знают родитель, wait4, группа процессов, сессия. Поэтому ядро оставляет вызвавший процесс в старом PID-пространстве, а в новое кладёт его следующего ребёнка, и тот становится там процессом номер 1.
Отсюда форма нашего дерева процессов:
zbox (родитель) старое PID-пространство
└── ребёнок: unshare, sethostname старое PID-пространство, «надзиратель»
└── внук: PID 1 новое PID-пространство
mount, pivot_root, caps, seccomp, execvp(программа)
У процесса номер 1 в Linux особая роль, и программа студента получает её вместе с номером.
- Сироты переходят к нему. Если внутри умрёт родитель, его дети станут детьми PID 1, и собирать их зомби будет программа. Обычная программа этого не делает, и зомби копятся до конца запуска. Поэтому
docker run --initсажает в PID 1 крошечныйtini, который только собирает зомби и пересылает сигналы.bs-runnerтак и делает. - Сигналы из своего пространства он получает, только если поставил обработчик.
kill -TERM 1изнутри для программы без обработчика не делает ничего: ядро защищает init от случайной смерти.SIGKILLиз внешнего пространства доходит всегда, и наш лимит времени работает. - Смерть PID 1 убивает всё пространство. Ядро посылает
SIGKILLкаждому процессу внутри. Внук, который сбежал черезsetsidв свою сессию и пережил быkill(-pgid)из урока про сигналы, здесь умирает вместе со всеми. Одна из «известных границ»zboxзакрылась сама.
Ребёнок в середине дерева называется надзирателем. Он ждёт внука и повторяет его судьбу: код возврата отдаёт своим _exit, а сигнал воспроизводит на себе, чтобы wait4 в zbox увидел WIFSIGNALED и тот же номер, а не код 128 плюс номер, как у оболочки. Приятное следствие: wait4 складывает rusage надзирателя с rusage тех, кого он дождался, поэтому cpu_user_ms и max_rss_kb в ответе относятся к программе.
А где же монтирование? Его делает внук, и это не вопрос вкуса. Файловая система procfs показывает PID-пространство того, кто её монтирует, и ядро даёт смонтировать её только процессу, чьё PID-пространство принадлежит нашему пространству пользователей. Надзиратель остался в старом PID-пространстве, ему mount("proc") ответит EPERM. Первая версия эталона монтировала в надзирателе и споткнулась ровно на этом.
Своя корневая ФС: pivot_root, а не chroot
Программа должна видеть не нашу файловую систему, а образ раннера: Debian, zig, ничего лишнего. Образ у нас будет распакованным каталогом (как его получить, ниже), и нужно сделать этот каталог корнем.
Первое, что приходит в голову, это chroot(dir). Он меняет корневой каталог процесса, и open("/etc/passwd") начинает искать от dir. Но chroot меняет только точку отсчёта для путей, а не то, что процессу доступно. Классический побег: процесс с CAP_SYS_CHROOT делает mkdir x; chroot x, его текущий каталог остаётся снаружи нового корня, и chdir("..") несколько раз подряд выводит его в настоящий /. Всё старое дерево остаётся смонтированным и достижимым. chroot придумали для сборки пакетов, а не для изоляции.
pivot_root(new_root, put_old) работает на уровне точек монтирования, а не путей. Он делает new_root корнем всего пространства монтирования, а старый корень подвешивает в put_old. После этого старый корень это обычная точка монтирования, и её можно отцепить через umount2(put_old, MNT_DETACH). Отцепили, и в нашем пространстве монтирования старого дерева нет: ни пути, ни трюка с .., которым его можно достать. Именно это делает runc под каждым docker run.
За это ядро ставит условия, и каждое станет строкой в коде:
new_rootдолжен быть точкой монтирования, а не просто каталогом. Лечится bind-монтированием каталога на самого себя:mount(rootfs, rootfs, NULL, MS_BIND | MS_REC). После этого каталог это отдельная точка с тем же содержимым.put_oldдолжен лежать внутриnew_root. Мы создаёмrootfs/oldroot.- Ни новый корень, ни старый не должны быть общими точками монтирования (shared). Это понятие из механизма распространения: монтирование в общей точке повторяется во всех её копиях в других пространствах. systemd делает
/общим, и тогда нашиmountутекли бы наружу, в пространство хозяина. Поэтому первым деломmount(NULL, "/", NULL, MS_REC | MS_PRIVATE): всё наше дерево становится приватным.
Что смонтировать внутри
После pivot_root и chdir("/") мы в новом корне, старый висит в /oldroot. Осталось собрать то, что программа ожидает увидеть.
/proc:mount("proc", "/proc", "proc", MS_NOSUID | MS_NODEV | MS_NOEXEC). Свежая procfs нашего PID-пространства: в ней только наши процессы, и/proc/selfведёт куда надо./tmp: tmpfs на 64 МБ. Место для кэша компилятора, у раннера это--tmpfs /tmp./work: каталог задачи. Его мы подвесили bind-монтированием вrootfs/workещё доpivot_root, пока путь снаружи был виден./dev: tmpfs и шесть устройств. Об этом отдельно.
И тут две ловушки, на которые нельзя не наступить.
Порядок procfs. Кажется логичным сначала отцепить старый корень, а потом монтировать своё. Так не выйдет: mount("proc") ответит EPERM. В пространстве пользователей ядро даёт смонтировать новую procfs, только если в нашем пространстве монтирования уже видна какая-то procfs целиком, без чужих монтирований поверх её файлов. Смысл правила в защите масок: Docker закрывает /proc/kcore, /proc/keys и ещё десяток опасных файлов, подвешивая поверх них /dev/null. Если бы свежая procfs давалась без условий, любой процесс в контейнере снимал бы маски одним unshare и одним mount. Поэтому procfs монтируем, пока хозяйская ещё висит в /oldroot/proc, а отцепляем старый корень после. И поэтому же в контейнере без --privileged, где маски стоят, --isolate от обычного пользователя не работает: ядро видит маски и отказывает.
Пустой /dev. Образ мы получаем через docker export, а он не берёт файлы устройств: их каждому контейнеру создаёт демон. Создать их самим нельзя, mknod требует CAP_MKNOD в корневом пространстве пользователей, а наши capabilities действуют только в нашем. Зато можно создать пустой файл и подвесить на него хозяйский /oldroot/dev/null через bind: точка монтирования остаётся тем же устройством со старшим и младшим номерами. Так мы получаем null, zero, full, random, urandom и tty. Без /dev/null в оболочке ломается даже cmd 2>/dev/null.
Последний штрих: mount(NULL, "/", NULL, MS_REMOUNT | MS_BIND | MS_RDONLY). Корень становится только для чтения, как docker run --read-only. /work и /tmp это отдельные точки монтирования, флаг их не касается, писать туда можно.
Capabilities не падают сами
Внук стал root в своём пространстве пользователей со всеми capabilities. После execve программа их унаследует? Интуиция подсказывает, что нет: обычный процесс при execve теряет всё лишнее. Но это не так, и на этом месте стоит остановиться, потому что Docker и bs-runner пишут --cap-drop ALL не для красоты.
У процесса пять наборов capabilities: permitted (что можно включить), effective (что включено сейчас), inheritable, ambient и bounding. Правила пересчёта при execve описаны в capabilities(7) формулами, и в них есть специальный случай для root: если uid процесса равен 0, ядро считает, что у запускаемого файла все capabilities. Тогда новый permitted набор это bounding set (плюс inheritable, у нас он пуст). В нашем пространстве пользователей мы uid 0, значит программа после execve получит всё, что есть в bounding set, то есть всё.
Лекарство это bounding set. Его можно только урезать, вызовом prctl(PR_CAPBSET_DROP, n) по одной capability, а вернуть нельзя. Выбрасываем все, от 0 до CAP_LAST_CAP (ядро отвечает EINVAL на номера, которых не знает, это не ошибка), и заодно чистим ambient set: он переживает execve даже без setuid. После этого CapEff программы равен нулю, хотя она uid 0.
Вторая дверь это setuid-бинарники. Файл с битом setuid при execve поднимает права до своего владельца: так работают sudo, su, passwd. Внутри песочницы это лазейка. Её закрывает prctl(PR_SET_NO_NEW_PRIVS, 1): после этого бита execve не прибавляет прав ни через setuid, ни через capabilities файла. Бит наследуется всеми потомками и снять его нельзя. Это docker run --security-opt no-new-privileges.
У no_new_privs есть и вторая работа. Ядро не даёт поставить фильтр seccomp процессу, у которого нет CAP_SYS_ADMIN в его пространстве пользователей и нет этого бита. Причина та же: без бита фильтр мог бы запретить setuid-программе, скажем, setuid() для сброса прав, и она продолжила бы работать от root, не заметив ошибки. У нашего внука в момент установки фильтра CAP_SYS_ADMIN ещё есть (bounding set не трогает effective до execve), но бит нам нужен ради первой двери, и ставим мы его до фильтра.
Seccomp: фильтр из байтов
Последний и самый интересный слой. Всё, что было выше, говорит ядру, что процесс видит и чем он владеет. Seccomp говорит, какие системные вызовы он вообще может сделать. Даже если в ядре найдётся дыра в обработке, скажем, keyctl, программа, которой keyctl запрещён, до неё не дотянется.
Механизм такой. Процесс отдаёт ядру маленькую программу на классическом BPF. На каждом системном вызове ядро запускает её, и на вход ей даётся структура seccomp_data:
struct seccomp_data {
int nr; // смещение 0: номер сисколла
__u32 arch; // смещение 4: AUDIT_ARCH_*, какой ABI
__u64 instruction_pointer; // смещение 8: откуда позвали
__u64 args[6]; // смещение 16: аргументы
};
Программа возвращает 32-битный вердикт: старшие 16 бит говорят, что делать, младшие несут данные. Нам нужны три.
| Вердикт | Значение | Что делает ядро |
|---|---|---|
SECCOMP_RET_ALLOW | 0x7fff0000 | выполняет вызов |
SECCOMP_RET_ERRNO | e | 0x00050000 | e | не выполняет, программа получает -1 и errno = e |
SECCOMP_RET_KILL_PROCESS | 0x80000000 | убивает весь процесс сигналом SIGSYS |
Есть ещё TRAP (сигнал, который программа может поймать), TRACE (разбудить трассировщик), LOG (разрешить и записать в журнал) и USER_NOTIF (спросить процесс-надзиратель в пространстве пользователя, так gVisor и LXD перехватывают mount). Фильтров на процессе может быть несколько, ставятся они только добавлением, и ядро гоняет все: побеждает самый строгий вердикт в порядке KILL_PROCESS, KILL_THREAD, TRAP, ERRNO, USER_NOTIF, TRACE, LOG, ALLOW. Снять фильтр нельзя, он переживает execve и наследуется потомками: execve заменяет адресное пространство, как ты видел в уроке про отображение памяти, но фильтр висит на самом процессе, а не в его памяти. Поставить второй, ещё строже, можно: так делает программа, которая сама себя сажает в клетку.
Фильтр в четыре инструкции
Начнём с игрушки: разрешить всё, кроме uname. Сначала программа целиком, потом разберём байты.
//! Самый маленький фильтр seccomp: всё разрешено, кроме `uname`.
//! Только Linux; на другой системе программа просто сообщает об этом.
const std = @import("std");
const builtin = @import("builtin");
const linux = std.os.linux;
const BPF = linux.BPF;
const RET = linux.SECCOMP.RET;
const Insn = extern struct { code: u16, jt: u8, jf: u8, k: u32 };
const Prog = extern struct { len: c_ushort, filter: [*]const Insn };
fn stmt(code: u16, k: u32) Insn {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn jeq(k: u32, jt: u8, jf: u8) Insn {
return .{ .code = BPF.JMP | BPF.JEQ | BPF.K, .jt = jt, .jf = jf, .k = k };
}
fn machine(uts: *linux.utsname) []const u8 {
return switch (linux.errno(linux.uname(uts))) {
.SUCCESS => std.mem.sliceTo(&uts.machine, 0),
else => |err| @tagName(err),
};
}
pub fn main() void {
if (builtin.os.tag != .linux) {
std.debug.print("seccomp есть только в Linux\n", .{});
return;
}
var uts: linux.utsname = undefined;
std.debug.print("до фильтра: uname -> {s}\n", .{machine(&uts)});
const nr_uname: u32 = @intFromEnum(linux.SYS.uname);
const program = [_]Insn{
stmt(BPF.LD | BPF.W | BPF.ABS, 0), // A = seccomp_data.nr
jeq(nr_uname, 0, 1), // uname? дальше, иначе через одну
stmt(BPF.RET | BPF.K, RET.ERRNO | @as(u32, @intFromEnum(linux.E.PERM))),
stmt(BPF.RET | BPF.K, RET.ALLOW),
};
const prog: Prog = .{ .len = program.len, .filter = &program };
// Без этого бита ядро не даст непривилегированному процессу фильтр.
_ = linux.prctl(@intFromEnum(linux.PR.SET_NO_NEW_PRIVS), 1, 0, 0, 0);
const rc = linux.seccomp(linux.SECCOMP.SET_MODE_FILTER, 0, &prog);
std.debug.print("seccomp: {t}\n", .{linux.errno(rc)});
std.debug.print("после фильтра: uname -> {s}\n", .{machine(&uts)});
}
Классический BPF знает аккумулятор A и горстку инструкций. Каждая инструкция это 8 байт, struct sock_filter:
struct sock_filter {
__u16 code; // что делать: класс плюс режим
__u8 jt; // куда прыгнуть, если условие истинно
__u8 jf; // куда прыгнуть, если ложно
__u32 k; // константа: смещение, число или вердикт
};
Нам хватит трёх кодов. BPF_LD | BPF_W | BPF_ABS (0x20) загружает в A 32-битное слово из seccomp_data по смещению k. BPF_JMP | BPF_JEQ | BPF_K (0x15) сравнивает A с k и прыгает на jt или jf инструкций вперёд от следующей: ноль значит «следующая», один значит «через одну». Назад прыгать нельзя вовсе, поэтому программа всегда завершается, и ядро может проверить её перед установкой. BPF_RET | BPF_K (0x06) возвращает k как вердикт.
Наша программа читается сверху вниз: загрузить номер вызова; если это uname, перейти к следующей инструкции, иначе перепрыгнуть одну; вернуть EPERM; вернуть ALLOW. Контейнер с Linux на arm64, пользователь runner:
$ ./nouname
до фильтра: uname -> aarch64
seccomp: SUCCESS
после фильтра: uname -> PERM
Ядро не выполнило uname и вернуло -EPERM. Всё остальное, включая печать, работает как раньше. Программа по-прежнему думает, что говорит с ядром, просто ядро стало менее сговорчивым.
Дыра в игрушке: архитектура
В этом фильтре есть ошибка, и на x86-64 она серьёзная. Номер сисколла имеет смысл только вместе с ABI, через который его позвали. На x86-64 процесс может войти в ядро двумя дорогами: 64-битной инструкцией syscall, которую ты звал руками в уроке про ассемблерные вставки, и старой 32-битной int 0x80, и у них разные таблицы номеров. Номер 41 через syscall это socket, а через int 0x80 это dup. Номер uname в 64-битной таблице 63, а в 32-битной 63 это dup2, а сам uname там 122. Фильтр, который смотрит только на nr, запрещает одно, а пропускает то же самое, позванное другим входом.
Поэтому правильный фильтр первой инструкцией загружает поле arch и сравнивает его с AUDIT_ARCH своей архитектуры. Чужая архитектура означает, что кто-то хитрит, и вердикт для неё жёсткий: KILL_PROCESS. Раннер курса поступает так же: в его профиле archMap перечисляет, какие ABI разрешены вообще.
Как выглядит фильтр zbox
Вот раскладка, которую строит сборщик zbox, для разрешённых номеров nr_0, nr_1, …:
0 ld [4] A = arch
1 jeq #ARCH, +1, 0 наша? через одну
2 ret KILL_PROCESS чужая
3 ld [0] A = nr
4 jeq #nr_0, 0, +1 совпал? следующая, иначе через одну
5 ret ALLOW
6 jeq #nr_1, 0, +1
7 ret ALLOW
...
n ret ERRNO | EPERM ни один не совпал
Почему пара «сравнение плюс разрешение» на каждый номер, а не цепочка сравнений, которые все прыгают на один общий ret ALLOW в конце? Цепочка вдвое короче, но у условного прыжка jt и jf занимают по восемь бит: прыгнуть можно не дальше чем на 255 инструкций. Список в триста сисколлов в один прыжок до общего ALLOW не уложится. Пара не зависит от длины списка вообще: каждый jeq смотрит только на соседа. Цена этой простоты линейный поиск: вызов из конца списка проходит сотни сравнений. libseccomp и Docker строят дерево бинарного поиска по номерам, и обычный сисколл проходит десяток инструкций. Для учебного раннера линейной цепочки хватает, а упражнение в конце урока предлагает дерево.
Лимит ядра, BPF_MAXINSNS, 4096 инструкций. Наша программа занимает 4 + 2n + 1, то есть до 2045 сисколлов. Их во всём ядре меньше пятисот.
Для тестов удобно видеть программу байтами. Разрешим на x86-64 read (0), write (1) и exit_group (231); одиннадцать инструкций, по восемь байт, little endian:
20 00 00 00 04 00 00 00 ld [4]
15 00 01 00 3e 00 00 c0 jeq #0xc000003e, +1, 0
06 00 00 00 00 00 00 80 ret KILL_PROCESS
20 00 00 00 00 00 00 00 ld [0]
15 00 00 01 00 00 00 00 jeq #0, 0, +1
06 00 00 00 00 00 ff 7f ret ALLOW
15 00 00 01 01 00 00 00 jeq #1, 0, +1
06 00 00 00 00 00 ff 7f ret ALLOW
15 00 00 01 e7 00 00 00 jeq #231, 0, +1
06 00 00 00 00 00 ff 7f ret ALLOW
06 00 00 00 01 00 05 00 ret ERRNO | EPERM
Первые два байта код (0x0020, 0x0015, 0x0006), потом jt и jf по байту, потом k четырьмя байтами. В последней строке k равен 0x00050001: ERRNO в старшей половине, EPERM (1) в младшей. Эту же таблицу сверяет тест шага.
Откуда взять список
Самая скучная часть фильтра и самая ответственная: какие вызовы разрешить. Мало, и программа студента упадёт на ровном месте. Много, и фильтр ничего не запрещает.
Первый источник это трасса. Своим zt strace из урока про сисколлы снимем, что делает минимальная программа на Zig, которая печатает привет, и оболочка с одной командой (контейнер, arm64):
$ zt strace -c ./hello
привет
+++ exited with 0 +++
calls errors syscall
---------- --------- ----------------
13 munmap
9 mmap
8 rt_sigaction
1 execve
1 exit_group
1 prlimit64
1 sched_getaffinity
1 sigaltstack
1 writev
---------- --------- ----------------
36 total
$ zt strace -c sh -c 'echo hi'
hi
+++ exited with 0 +++
calls errors syscall
---------- --------- ----------------
7 mmap
7 rt_sigaction
4 fstatat64
4 mprotect
3 brk
3 munmap
2 close
2 geteuid
2 openat
1 execve
1 exit_group
1 1 faccessat
1 getegid
1 getgid
1 getpid
1 getppid
1 getrandom
1 getuid
1 prlimit64
1 read
1 rseq
1 set_robust_list
1 set_tid_address
1 write
---------- --------- ----------------
49 1 total
Статическая программа на Zig обходится девятью разными вызовами, sh с динамической libc двадцатью четырьмя. Компилятор, который открывает сотни файлов и запускает потоки, прибавит к ним clone3, futex, getdents64, pread64 и ещё несколько десятков. Трасса хороша, чтобы понять, чего точно не хватает. Но собрать весь список по трассам нельзя: ты увидишь только те пути программы, которые прошёл. Задача, которая в редком случае зовёт nanosleep, получит EPERM на проверке, а не у тебя.
Второй источник это профиль раннера курса, runner/sandbox/seccomp.json. Он построен на профиле Docker по умолчанию: defaultAction у него SCMP_ACT_ERRNO, дальше списки имён с действием SCMP_ACT_ALLOW. Основной список это 352 имени, которые разрешены без условий. Остальные записи с условиями, и две из них интересны тем, что наш фильтр так не умеет:
socketразрешён, кроме семейства адресов 40 (AF_VSOCK, канал к гипервизору): в записи стоит условие на первый аргумент,args[0] != 40.cloneразрешён, только если в его флагах нет ни одногоCLONE_NEW*: условиеSCMP_CMP_MASKED_EQс маской0x7e020000. Обычныйforkпроходит, новые пространства имён нет. Поэтому шаги этого урока в песочнице курса не проверить:zbox --isolateтам споткнётся на первом жеunshare.
Условия на аргументы в BPF это те же ld и jeq, только по смещению 16 + 8 * i в seccomp_data (и две загрузки на 64-битный аргумент, потому что классический BPF работает словами по 32 бита). Наш сборщик их не делает: мы или разрешаем вызов целиком, или нет.
Таблица zbox устроена так: берём основной список раннера и вычитаем две группы. Сокеты (socket, connect, bind, sendto и компания): сети внутри и так нет, а отказ на уровне сисколла мгновенный и заметный, EPERM вместо ENETUNREACH. И всё, что Docker даёт только с CAP_SYS_ADMIN: mount, unshare, setns, pivot_root, sethostname, то есть песочница внутри песочницы. Чёрный список раннера (ptrace, bpf, keyctl, kexec_load, модули ядра) нам не нужен вовсе: фильтр запрещает всё, чего нет в списке. Все вызовы из обеих трасс выше в таблице есть.
Номера у каждой архитектуры свои, а имена общие. Поэтому таблица хранит имена, а номера для x86-64 и aarch64 подставляет компилятор на этапе сборки из std.os.linux.syscalls. Имени, которого на архитектуре нет, просто не будет в её списке: на aarch64 нет open, pipe, dup2, stat и всего старого набора без at, а arch_prctl есть только на x86. Итог: 278 номеров на x86-64 (программа 561 инструкция) и 238 на aarch64 (481 инструкция).
Шаг проекта: zbox изолируется
Все части на месте. Флаг --isolate ROOTFS запускает программу в корневой ФС из каталога ROOTFS, флаг --work DIR задаёт каталог задачи, который она увидит как /work. Заодно появится --pids N: лимит процессов в той же группе cgroups, что и лимит памяти.
Порядок действий в ребёнке после fork (всё, что было до этого урока, остаётся на месте: группа cgroups, пайпы, лимит адресов):
unshare(CLONE_NEWUSER | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWUTS | CLONE_NEWIPC)одним вызовом. Ядро разбирает флаги в правильном порядке: сначала заводит пространство пользователей, в нём мы root, и остальные пять уже не требуют настоящегоCAP_SYS_ADMIN. Потом отображение uid и gid. Если пространства пользователей запрещены, а мы настоящий root, те же пять флагов безCLONE_NEWUSER.sethostname("zbox"): UTS-пространство уже наше.fork. Ребёнок становится надзирателем, внук это PID 1.- Во внуке: приватное дерево, bind корня на себя, bind каталога задачи,
pivot_root,/proc,/tmp,/dev, отцепить/oldroot, корень только для чтения,chdir("/work"). - Там же: bounding set в ноль,
no_new_privs, seccomp, чистое окружение,execvp.
Одно общее правило для всего, что ниже: между fork и execvp можно звать только async-signal-safe функции, как в уроке про процессы. Поэтому пути собираются в родителе заранее, в ребёнке только сырые системные вызовы из std.os.linux, а программа seccomp строится в массиве на стеке, без аллокатора.
seccomp.zig: сборщик фильтра
Чистый файл: ни одного системного вызова, только байты. Поэтому все его тесты идут и на macOS.
//! Сборщик seccomp-фильтра: из таблицы разрешённых сисколлов получается
//! программа классического BPF. Чистый код: ни одного системного вызова,
//! только байты, поэтому тесты идут на любой машине. Установку в ядро делает
//! `isolate.zig`.
//!
//! Ядро гоняет программу на каждом системном вызове. На вход ей приходит
//! `seccomp_data`: номер сисколла `nr`, архитектура `arch`, аргументы.
//! Программа умеет только загрузить слово из этой структуры в аккумулятор,
//! сравнить и вернуть вердикт. Наш фильтр выглядит так:
//!
//! ```
//! 0 ld [arch] A = архитектура вызова
//! 1 jeq #ARCH, +1, 0 наша? тогда через одну инструкцию
//! 2 ret KILL_PROCESS чужая (например, 32-битный вход на x86-64)
//! 3 ld [nr] A = номер сисколла
//! 4 jeq #nr_0, 0, +1 совпал? следующая инструкция, иначе через одну
//! 5 ret ALLOW
//! 6 jeq #nr_1, 0, +1
//! 7 ret ALLOW
//! ...
//! n ret ERRNO(EPERM) ни один не совпал
//! ```
//!
//! Пара «сравнение плюс разрешение» на каждый сисколл вдвое длиннее общей
//! цепочки с прыжком к одному `ret ALLOW`, зато у условного прыжка BPF
//! смещение всего восемь бит: длинный список (в раннере их триста с лишним)
//! в общий `ret ALLOW` уже не допрыгнул бы. Проверка архитектуры стоит
//! первой не для красоты: номера сисколлов у разных ABI разные, и без неё
//! `nr` = 41 на x86-64 это `socket`, а через 32-битный вход тот же 41 это
//! `dup`. libseccomp и Docker строят то же самое, только с бинарным поиском.
const std = @import("std");
const linux = std.os.linux;
const BPF = linux.BPF;
const SECCOMP = linux.SECCOMP;
/// `struct sock_filter` из `<linux/filter.h>`: одна инструкция, 8 байт.
pub const SockFilter = extern struct {
/// Код операции: класс (ld, jmp, ret) плюс режим адресации.
code: u16,
/// Куда прыгать при истинном условии: на сколько инструкций вперёд
/// после текущей. Ноль значит «следующая».
jt: u8,
/// То же при ложном.
jf: u8,
/// Константа: смещение для ld, число для сравнения, вердикт для ret.
k: u32,
};
/// `struct sock_fprog`: то, что принимает `seccomp(SECCOMP_SET_MODE_FILTER)`.
pub const SockFprog = extern struct {
len: c_ushort,
filter: [*]const SockFilter,
};
/// Что получает сисколл не из списка.
pub const Denied = enum(u32) {
/// Вызов не выполняется, программа получает `EPERM` и живёт дальше.
/// Так настроен профиль раннера курса (`defaultAction: SCMP_ACT_ERRNO`).
eperm = SECCOMP.RET.ERRNO | @as(u32, @intFromEnum(linux.E.PERM)),
/// Ядро убивает весь процесс сигналом SIGSYS. Жёстче, зато в журнале
/// видно, какой вызов не понравился.
kill = SECCOMP.RET.KILL_PROCESS,
};
/// Предел ядра на длину программы, `BPF_MAXINSNS`.
pub const max_len = BPF.MAXINSNS;
/// `AUDIT_ARCH_*` из `<linux/audit.h>`: номер машины из ELF (`EM_X86_64` = 62,
/// `EM_AARCH64` = 183) плюс биты «64-битная» и «little endian». В std 0.16
/// есть `linux.AUDIT.ARCH`, но её таблица ссылается на архитектуру, которой
/// нет в `std.elf.EM`, и любое обращение к ней не компилируется.
pub const arch_x86_64: u32 = 0xc000003e;
pub const arch_aarch64: u32 = 0xc00000b7;
/// Архитектура машины, на которой собран zbox.
pub const native_arch: u32 = switch (@import("builtin").cpu.arch) {
.x86_64 => arch_x86_64,
.aarch64 => arch_aarch64,
else => 0,
};
pub const Error = error{ TooManyInstructions, NoSpaceLeft };
comptime {
std.debug.assert(@sizeOf(SockFilter) == 8);
std.debug.assert(@offsetOf(SECCOMP.data, "nr") == 0);
std.debug.assert(@offsetOf(SECCOMP.data, "arch") == 4);
}
/// Длина программы для списка из `count` сисколлов: четыре инструкции
/// на архитектуру и загрузку номера, две на каждый сисколл, одна на отказ.
pub fn programLen(count: usize) usize {
return 4 + 2 * count + 1;
}
/// Собирает фильтр в `out` и возвращает заполненную часть. `arch` это
/// значение `AUDIT_ARCH_*` (у нас `linux.AUDIT.ARCH.current`), `allowed`
/// номера сисколлов этой архитектуры.
pub fn build(arch: u32, allowed: []const u32, denied: Denied, out: []SockFilter) Error![]SockFilter {
const len = programLen(allowed.len);
if (len > max_len) return error.TooManyInstructions;
if (len > out.len) return error.NoSpaceLeft;
out[0] = load(@offsetOf(SECCOMP.data, "arch"));
out[1] = jumpIfEqual(arch, 1, 0);
out[2] = ret(SECCOMP.RET.KILL_PROCESS);
out[3] = load(@offsetOf(SECCOMP.data, "nr"));
for (allowed, 0..) |nr, i| {
out[4 + 2 * i] = jumpIfEqual(nr, 0, 1);
out[5 + 2 * i] = ret(SECCOMP.RET.ALLOW);
}
out[len - 1] = ret(@intFromEnum(denied));
return out[0..len];
}
/// `BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offset)`: слово из `seccomp_data` в A.
pub fn load(offset: u32) SockFilter {
return .{ .code = BPF.LD | BPF.W | BPF.ABS, .jt = 0, .jf = 0, .k = offset };
}
/// `BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, value, jt, jf)`.
pub fn jumpIfEqual(value: u32, jt: u8, jf: u8) SockFilter {
return .{ .code = BPF.JMP | BPF.JEQ | BPF.K, .jt = jt, .jf = jf, .k = value };
}
/// `BPF_STMT(BPF_RET | BPF_K, verdict)`.
pub fn ret(verdict: u32) SockFilter {
return .{ .code = BPF.RET | BPF.K, .jt = 0, .jf = 0, .k = verdict };
}
Константы BPF.LD, BPF.JEQ, SECCOMP.RET.ALLOW и структура SECCOMP.data есть в std.os.linux, а struct sock_filter нет: её мы объявляем сами как extern struct, чтобы Zig не переставил поля и не добавил выравнивания. Блок comptime в середине файла проверяет наши предположения на этапе компиляции: восемь байт на инструкцию, nr по смещению 0, arch по смещению 4. Если std когда-нибудь поменяет раскладку, сборка упадёт, а не фильтр начнёт молча читать не то поле.
Обрати внимание на AUDIT_ARCH: в std 0.16 есть linux.AUDIT.ARCH, но её таблица ссылается на архитектуру, которой нет в std.elf.EM, и любое обращение к ней не компилируется. Две константы проще написать руками, чем ждать исправления.
allowlist.zig: одна таблица имён на все архитектуры
//! Разрешённые сисколлы. Список один, имена как в `syscalls(2)`; номера
//! для каждой архитектуры подставляются на этапе компиляции из таблиц
//! `std.os.linux.syscalls`. Имени, которого на архитектуре нет (`open`,
//! `pipe`, `dup2` и весь набор x86-64 без `at`-вариантов на aarch64;
//! `arch_prctl` вне x86), просто не будет в её списке.
//!
//! Откуда список. Профиль раннера курса `runner/sandbox/seccomp.json` это
//! профиль Docker по умолчанию с запретами сверху. Из него взято всё, что
//! разрешено без условий, и вычтено две группы:
//!
//! * сокеты (`socket`, `connect`, `bind`, `sendto`, ...): сети внутри и так
//! нет, а запрет на уровне сисколла делает отказ мгновенным и заметным,
//! `EPERM` вместо `ENETUNREACH`;
//! * всё, что Docker даёт только с `CAP_SYS_ADMIN` (`mount`, `unshare`,
//! `setns`, `pivot_root`, `sethostname`): песочница внутри песочницы
//! никому не нужна.
//!
//! Чёрный список раннера (`ptrace`, `bpf`, `kexec_load`, модули ядра,
//! `keyctl`, `userfaultfd`) сюда не попал сам собой: фильтр запрещает всё,
//! чего нет в списке.
const std = @import("std");
const builtin = @import("builtin");
const syscalls = std.os.linux.syscalls;
const names =
\\_llseek _newselect access adjtimex alarm brk cachestat capget capset
\\chdir chmod chown chown32 clock_adjtime clock_adjtime64 clock_getres
\\clock_getres_time64 clock_gettime clock_gettime64 clock_nanosleep
\\clock_nanosleep_time64 clone clone3 close close_range copy_file_range creat
\\dup dup2 dup3 epoll_create epoll_create1 epoll_ctl epoll_ctl_old epoll_pwait
\\epoll_pwait2 epoll_wait epoll_wait_old eventfd eventfd2 execve execveat exit
\\exit_group faccessat faccessat2 fadvise64 fadvise64_64 fallocate fanotify_mark
\\fchdir fchmod fchmodat fchmodat2 fchown fchown32 fchownat fcntl fcntl64
\\fdatasync fgetxattr flistxattr flock fork fremovexattr fsetxattr fstat fstat64
\\fstatat64 fstatfs fstatfs64 fsync ftruncate ftruncate64 futex futex_requeue
\\futex_time64 futex_wait futex_waitv futex_wake futimesat get_robust_list
\\get_thread_area getcpu getcwd getdents getdents64 getegid getegid32 geteuid
\\geteuid32 getgid getgid32 getgroups getgroups32 getitimer getpgid getpgrp
\\getpid getppid getpriority getrandom getresgid getresgid32 getresuid
\\getresuid32 getrlimit getrusage getsid gettid gettimeofday getuid getuid32
\\getxattr inotify_add_watch inotify_init inotify_init1 inotify_rm_watch
\\io_cancel io_destroy io_getevents io_pgetevents io_pgetevents_time64 io_setup
\\io_submit ioctl ioprio_get ioprio_set ipc kill landlock_add_rule
\\landlock_create_ruleset landlock_restrict_self lchown lchown32 lgetxattr link
\\linkat listxattr llistxattr lremovexattr lseek lsetxattr lstat lstat64 madvise
\\map_shadow_stack membarrier memfd_create memfd_secret mincore mkdir mkdirat
\\mknod mknodat mlock mlock2 mlockall mmap mmap2 mprotect mq_getsetattr
\\mq_notify mq_open mq_timedreceive mq_timedreceive_time64 mq_timedsend
\\mq_timedsend_time64 mq_unlink mremap msgctl msgget msgrcv msgsnd msync munlock
\\munlockall munmap name_to_handle_at nanosleep newfstatat open openat openat2
\\pause pidfd_open pidfd_send_signal pipe pipe2 pkey_alloc pkey_free
\\pkey_mprotect poll ppoll ppoll_time64 prctl pread64 preadv preadv2 prlimit64
\\process_mrelease pselect6 pselect6_time64 pwrite64 pwritev pwritev2 read
\\readahead readlink readlinkat readv remap_file_pages removexattr rename
\\renameat renameat2 restart_syscall rmdir rseq rt_sigaction rt_sigpending
\\rt_sigprocmask rt_sigqueueinfo rt_sigreturn rt_sigsuspend rt_sigtimedwait
\\rt_sigtimedwait_time64 rt_tgsigqueueinfo sched_get_priority_max
\\sched_get_priority_min sched_getaffinity sched_getattr sched_getparam
\\sched_getscheduler sched_rr_get_interval sched_rr_get_interval_time64
\\sched_setaffinity sched_setattr sched_setparam sched_setscheduler sched_yield
\\seccomp select semctl semget semop semtimedop semtimedop_time64 sendfile
\\sendfile64 set_robust_list set_thread_area set_tid_address setfsgid setfsgid32
\\setfsuid setfsuid32 setgid setgid32 setgroups setgroups32 setitimer setpgid
\\setpriority setregid setregid32 setresgid setresgid32 setresuid setresuid32
\\setreuid setreuid32 setrlimit setsid setuid setuid32 setxattr shmat shmctl
\\shmdt shmget sigaltstack signalfd signalfd4 sigprocmask sigreturn splice stat
\\stat64 statfs statfs64 statx symlink symlinkat sync sync_file_range syncfs
\\sysinfo tee tgkill time timer_create timer_delete timer_getoverrun
\\timer_gettime timer_gettime64 timer_settime timer_settime64 timerfd_create
\\timerfd_gettime timerfd_gettime64 timerfd_settime timerfd_settime64 times tkill
\\truncate truncate64 ugetrlimit umask uname unlink unlinkat utime utimensat
\\utimensat_time64 utimes vfork vmsplice wait4 waitid waitpid write writev
\\arch_prctl
;
/// Номера сисколлов из списка для таблицы `Sys` (одна из `syscalls.X64`,
/// `syscalls.Arm64`, ...). Считается на этапе компиляции: в двоичнике лежит
/// готовый массив чисел.
pub fn numbers(comptime Sys: type) []const u32 {
comptime {
@setEvalBranchQuota(200_000);
var out: []const u32 = &.{};
var words = std.mem.tokenizeAny(u8, names, " \n");
while (words.next()) |name| {
if (@hasField(Sys, name)) out = out ++ &[_]u32{@intFromEnum(@field(Sys, name))};
}
return out;
}
}
pub const x86_64 = numbers(syscalls.X64);
pub const aarch64 = numbers(syscalls.Arm64);
/// Список для машины, на которой собран zbox.
pub const native: []const u32 = switch (builtin.cpu.arch) {
.x86_64 => x86_64,
.aarch64 => aarch64,
else => &.{},
};
numbers работает целиком на этапе компиляции: разбивает строку на имена, спрашивает @hasField у перечисления сисколлов архитектуры и собирает срез чисел конкатенацией ++. В двоичнике zbox лежит готовый массив, никакого разбора строк во время запуска. @setEvalBranchQuota поднимает лимит итераций для вычислений на этапе компиляции: трёхсот имён по умолчанию не хватает.
isolate.zig: всё, что делает ребёнок
Главный файл шага. Комментарий в шапке повторяет план, а каждая функция это один его пункт.
//! Изоляция как у раннера курса: пространства имён, своя корневая ФС,
//! сброс capabilities, `no_new_privs` и seccomp-фильтр. Всё это делает
//! ребёнок между `fork` и `execvp`, поэтому здесь только сырые системные
//! вызовы из `std.os.linux` и строки, собранные заранее в родителе.
//!
//! Порядок шагов важен, и почти каждый объясняется предыдущим:
//!
//! 1. `unshare` со всеми шестью флагами разом. Первым ядро заводит
//! пространство пользователей, в нём мы root, и остальные пять уже
//! не требуют настоящего `CAP_SYS_ADMIN`.
//! 2. `sethostname`: имя узла живёт в UTS-пространстве, у нас оно своё.
//! 3. Ещё один `fork`. `unshare(CLONE_NEWPID)` не переносит вызвавший процесс
//! в новое пространство: он остаётся в старом, а первым процессом нового,
//! его init с номером 1, становится следующий ребёнок. Поэтому программу
//! запускает внук, а ребёнок остаётся надзирателем: ждёт внука и повторяет
//! его судьбу (код возврата или сигнал), чтобы родительский `wait4` увидел
//! итог программы, а не надзирателя.
//! 4. Во внуке монтирование: сделать все точки приватными, превратить каталог
//! с корневой ФС в точку монтирования (bind на самого себя), подвесить
//! рабочий каталог задачи в `rootfs/work`, `pivot_root`, свежий `/proc`
//! (иначе `ps` покажет хозяйские процессы), tmpfs на `/tmp` и `/dev`
//! с шестью устройствами, отцепить старый корень, перемонтировать `/`
//! только для чтения. Именно во внуке,
//! а не в надзирателе: procfs показывает PID-пространство того, кто её
//! монтирует, и ядро даёт смонтировать её только процессу, чьё
//! PID-пространство принадлежит нашему пространству пользователей.
//! Надзиратель остался в старом, ему ядро ответит EPERM.
//! 5. Там же: сброс bounding set capabilities (после `execve` у процесса
//! не останется ни одной), `PR_SET_NO_NEW_PRIVS` (без него seccomp для
//! непривилегированного процесса не ставится, и setuid-бинарники могли бы
//! вернуть права), фильтр seccomp, `execvp`.
const std = @import("std");
const builtin = @import("builtin");
const linux = std.os.linux;
const c = std.c;
const posix = std.posix;
const allowlist = @import("allowlist.zig");
const netns = @import("netns.zig");
const seccomp = @import("seccomp.zig");
pub const supported = builtin.os.tag == .linux;
pub const PrepareError = error{ IsolateUnsupported, RootfsMissing, PathTooLong };
/// На каком шаге изоляция сорвалась. Имя ошибки уходит в stderr программы.
pub const Step = error{
Unshare,
PrivateMounts,
BindRoot,
BindWork,
PivotRoot,
Proc,
Tmp,
Dev,
OldRoot,
ReadOnly,
Work,
Hostname,
Fork,
Capabilities,
NoNewPrivs,
Seccomp,
};
const hostname = "zbox";
/// Окружение программы. Хозяйское не наследуем: в нём могут быть ключи
/// и пути, которых в новом корне всё равно нет. Так же поступает Docker.
var environment = [_:null]?[*:0]const u8{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"HOME=/work",
"HOSTNAME=" ++ hostname,
};
extern "c" var environ: [*:null]?[*:0]const u8;
/// Пути собраны в родителе: в ребёнке форматировать строки уже нельзя.
pub const Isolate = struct {
rootfs: Path = .{},
old_root: Path = .{},
work_mount: Path = .{},
work: Path = .{},
const Path = struct {
buf: [256]u8 = undefined,
len: usize = 0,
fn set(path: *Path, comptime fmt: []const u8, args: anytype) PrepareError!void {
const text = std.fmt.bufPrintZ(&path.buf, fmt, args) catch return error.PathTooLong;
path.len = text.len;
}
fn z(path: *const Path) [:0]const u8 {
return path.buf[0..path.len :0];
}
};
/// Зовётся до fork. `rootfs` это каталог с распакованной корневой ФС,
/// `work` каталог задачи, который программа увидит как `/work`.
pub fn prepare(iso: *Isolate, rootfs: []const u8, work: []const u8) PrepareError!void {
if (!supported) return error.IsolateUnsupported;
const root = std.mem.trimEnd(u8, rootfs, "/");
try iso.rootfs.set("{s}", .{root});
try iso.old_root.set("{s}/oldroot", .{root});
try iso.work_mount.set("{s}/work", .{root});
try iso.work.set("{s}", .{work});
// Проверяем каталог сейчас, чтобы ответить словами и кодом 3,
// а не сообщением из ребёнка и кодом 126.
const fd = c.open(iso.rootfs.z(), .{ .ACCMODE = .RDONLY, .DIRECTORY = true });
if (fd < 0) return error.RootfsMissing;
_ = c.close(fd);
}
/// Зовётся в ребёнке. Возвращается только во внуке, готовом к `execvp`;
/// ребёнок становится надзирателем и отсюда не выходит.
pub fn enter(iso: *const Isolate) Step!void {
if (!supported) return error.Unshare;
try unshareAll();
if (linux.errno(linux.syscall2(.sethostname, @intFromPtr(hostname.ptr), hostname.len)) != .SUCCESS) return error.Hostname;
const pid = c.fork();
if (pid < 0) return error.Fork;
if (pid > 0) superviseAndExit(pid);
// Мы внук, PID 1 нового пространства.
try iso.mountRoot();
try dropCapabilities();
if (linux.errno(linux.prctl(@intFromEnum(linux.PR.SET_NO_NEW_PRIVS), 1, 0, 0, 0)) != .SUCCESS) return error.NoNewPrivs;
try installFilter();
environ = &environment;
}
fn mountRoot(iso: *const Isolate) Step!void {
const MS = linux.MS;
// Точки монтирования, унаследованные от хозяина, могут быть общими
// (systemd делает `/` shared): тогда наши mount и pivot_root утекли бы
// наружу, а ядро отказало бы в pivot_root. Делаем всё приватным.
try mount(null, "/", null, MS.REC | MS.PRIVATE, null, error.PrivateMounts);
// pivot_root принимает только точку монтирования: каталог ею не
// является, пока его не подвесить на самого себя.
try mount(iso.rootfs.z(), iso.rootfs.z(), null, MS.BIND | MS.REC, null, error.BindRoot);
_ = linux.mkdir(iso.work_mount.z(), 0o755);
try mount(iso.work.z(), iso.work_mount.z(), null, MS.BIND, null, error.BindWork);
_ = linux.mkdir(iso.old_root.z(), 0o755);
// Новый корень становится `/`, старый повисает в `/oldroot`.
if (linux.errno(linux.pivot_root(iso.rootfs.z(), iso.old_root.z())) != .SUCCESS) return error.PivotRoot;
if (linux.errno(linux.chdir("/")) != .SUCCESS) return error.PivotRoot;
// procfs своего PID-пространства: без него `/proc/self` ведёт
// в хозяйский, а `ps` показывает все процессы машины. Порядок важен:
// пока старый корень висит в /oldroot, в нашем пространстве есть
// хозяйская procfs целиком, и ядро разрешает смонтировать новую.
// Отцепи старый корень раньше, и получишь EPERM: в пространстве
// пользователей новую procfs дают, только если старая видна вся,
// иначе ей можно было бы обойти маски вроде /proc/kcore.
try mount("proc", "/proc", "proc", MS.NOSUID | MS.NODEV | MS.NOEXEC, null, error.Proc);
try mount("tmpfs", "/tmp", "tmpfs", MS.NOSUID | MS.NODEV, "size=64m,mode=1777", error.Tmp);
try mountDev();
// Старый корень отцепляем лениво (MNT_DETACH): он ещё занят нами же.
// Пока он видим, вся изоляция ФС стоит ровно ничего.
if (linux.errno(linux.umount2("/oldroot", linux.MNT.DETACH)) != .SUCCESS) return error.OldRoot;
_ = linux.rmdir("/oldroot");
// Корень только для чтения, как `docker run --read-only`. Писать
// можно в `/work` и `/tmp`: это отдельные точки, флаг их не касается.
try mount(null, "/", null, MS.REMOUNT | MS.BIND | MS.RDONLY, null, error.ReadOnly);
if (linux.errno(linux.chdir("/work")) != .SUCCESS) return error.Work;
}
};
/// Минимальный `/dev`, как его даёт Docker. В распакованном образе каталог
/// пустой: `docker export` не берёт файлы устройств, их каждому контейнеру
/// создаёт демон. Сделать их самим нельзя: `mknod` требует `CAP_MKNOD`
/// в корневом пространстве пользователей. Зато подвесить хозяйский
/// `/dev/null` на пустой файл через bind можно, и он остаётся тем же
/// устройством. Без `/dev/null` даже `cmd 2>/dev/null` в оболочке ломается.
fn mountDev() Step!void {
const MS = linux.MS;
try mount("tmpfs", "/dev", "tmpfs", MS.NOSUID | MS.NOEXEC, "size=1m,mode=755", error.Dev);
inline for (.{ "null", "zero", "full", "random", "urandom", "tty" }) |name| {
const target = "/dev/" ++ name;
const fd = linux.open(target, .{ .ACCMODE = .WRONLY, .CREAT = true }, 0o666);
if (linux.errno(fd) != .SUCCESS) return error.Dev;
_ = linux.close(@intCast(fd));
try mount("/oldroot" ++ target, target, null, MS.BIND, null, error.Dev);
}
}
/// Все шесть пространств одним вызовом. Без права на пространство
/// пользователей (например, sysctl `user.max_user_namespaces = 0`)
/// остаётся путь для настоящего root: те же пять без `CLONE_NEWUSER`.
fn unshareAll() Step!void {
const CLONE = linux.CLONE;
const all = CLONE.NEWUSER | CLONE.NEWPID | CLONE.NEWNS | CLONE.NEWNET | CLONE.NEWUTS | CLONE.NEWIPC;
if (netns.unshareUser(all)) return;
if (linux.getuid() != 0) return error.Unshare;
if (linux.errno(linux.unshare(all & ~@as(usize, CLONE.NEWUSER))) != .SUCCESS) return error.Unshare;
}
fn mount(special: ?[*:0]const u8, dir: [*:0]const u8, fstype: ?[*:0]const u8, flags: u32, data: ?[*:0]const u8, comptime failure: Step) Step!void {
const data_ptr: usize = if (data) |text| @intFromPtr(text) else 0;
if (linux.errno(linux.mount(special, dir, fstype, flags, data_ptr)) != .SUCCESS) return failure;
}
/// Надзиратель: ждёт внука и повторяет его судьбу. Код возврата отдаём
/// как есть, сигнал воспроизводим на себе, чтобы `wait4` родителя увидел
/// именно сигнал, а не код 128 плюс номер.
fn superviseAndExit(pid: c.pid_t) noreturn {
var status: c_int = 0;
while (true) {
const reaped = c.waitpid(pid, &status, 0);
if (reaped == pid) break;
if (posix.errno(reaped) != .INTR) c._exit(1);
}
const word: u32 = @bitCast(status);
if (c.W.IFEXITED(word)) c._exit(c.W.EXITSTATUS(word));
const sig = c.W.TERMSIG(word);
posix.sigaction(sig, &.{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 }, null);
_ = c.kill(c.getpid(), sig);
c._exit(128 + @as(u8, @intCast(@intFromEnum(sig))));
}
/// Bounding set это верхняя граница capabilities, которые процесс может
/// получить через `execve`. Пустой bounding set плюс uid 0 в пространстве
/// пользователей дают после `execve` процесс без единой capability: ровно
/// то, что делает `docker run --cap-drop ALL`. Дроп идёт по одной, ядро
/// отвечает EINVAL на номера, которых ещё не знает.
fn dropCapabilities() Step!void {
var cap: usize = 0;
while (cap <= linux.CAP.LAST_CAP) : (cap += 1) {
switch (linux.errno(linux.prctl(@intFromEnum(linux.PR.CAPBSET_DROP), cap, 0, 0, 0))) {
.SUCCESS, .INVAL => {},
else => return error.Capabilities,
}
}
// Ambient set переживает execve даже без setuid: чистим и его.
const ambient_clear_all = 4;
_ = linux.prctl(@intFromEnum(linux.PR.CAP_AMBIENT), ambient_clear_all, 0, 0, 0);
}
/// Фильтр собирается здесь же, во внуке: сборщик чистый, ему хватает
/// массива на стеке. Установленный фильтр переживает `execve` и наследуется
/// всеми потомками, снять его нельзя.
fn installFilter() Step!void {
var program: [seccomp.max_len]seccomp.SockFilter = undefined;
const filter = seccomp.build(seccomp.native_arch, allowlist.native, .eperm, &program) catch return error.Seccomp;
const prog: seccomp.SockFprog = .{ .len = @intCast(filter.len), .filter = filter.ptr };
if (linux.errno(linux.seccomp(linux.SECCOMP.SET_MODE_FILTER, 0, &prog)) != .SUCCESS) return error.Seccomp;
}
Несколько мест, которые легко пропустить.
Isolate.prepareзовётся в родителе доfork. Он собирает все пути в буферы внутри структуры (форматировать строки в ребёнке нельзя) и заодно проверяет, что каталог корневой ФС существует. Так ошибка в пути даёт понятное сообщение и код 3 от самогоzbox, а не загадочный код 126 из ребёнка.- Шаги возвращают ошибки из множества
Step. Имя ошибки известно на этапе компиляции, иrun.zigпечатает его тремя сырымиwrite:zbox: изоляция не удалась на шаге Proc. По этой строке сразу видно, какое правило ядра ты нарушил. environ = &environmentподменяет окружение передexecvp. Хозяйское окружение внутрь не идёт: в нём могут быть токены и пути, которых в новом корне нет. Docker поступает так же.superviseAndExitвоспроизводит сигнал внука на себе: сбрасывает обработчик вSIG_DFLи посылает себе тот же сигнал. Если сигнал по умолчанию не убивает (такого у нас не бывает, но код честный), остаётся_exit(128 + n).- Порядок в конце
enter: capabilities, потомno_new_privs, потом фильтр. Фильтр идёт последним, потому что после него запрещеныmount,pivot_rootиsethostname, а вся настройка должна успеть отработать до него. Хорошее правило для любой песочницы: сначала настроить, потом запереть.
netns.zig: тот же путь с другими флагами
В уроке про сокеты обходной путь без CAP_SYS_ADMIN заводил пространство пользователей ради одного CLONE_NEWNET, и уже там функция unshareUser(flags) принимала любой набор флагов, а becomeRootInUserNamespace была её частным случаем. Шагу 67 нужен тот же код с шестью флагами разом, и unshareAll в isolate.zig просто зовёт netns.unshareUser(all). Сам netns.zig шаг не меняет.
run.zig: куда встаёт изоляция
run.zig целиком, потому что правки разбросаны по всему файлу.
//! Запуск чужой программы: `fork`, `execvp`, `wait4`.
//! Сигналы и лимит времени живут рядом, в `watchdog.zig`.
const std = @import("std");
const c = std.c;
const args = @import("args.zig");
const capture_mod = @import("capture.zig");
const isolate_mod = @import("isolate.zig");
const memlimit = @import("memlimit.zig");
const netns = @import("netns.zig");
const report = @import("report.zig");
const watchdog = @import("watchdog.zig");
// В std.c есть execve, но нет execvp. Нам нужен именно он: пусть libc
// сама найдёт `sleep` или `sh` по PATH, как это делает оболочка.
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
pub const Error = error{ ForkFailed, WaitFailed, OutOfMemory, NetnsUnsupported } || memlimit.CgroupError || capture_mod.Error || isolate_mod.PrepareError;
/// Код возврата ребёнка, если `execvp` не удался. Так же поступает оболочка:
/// 127 значит, что команда не найдена.
pub const exec_failed_code = 127;
/// Ребёнок не смог поставить себе лимит памяти или отобрать у себя сеть.
/// Запускать программу без заказанной изоляции нельзя, поэтому до `execvp`
/// дело не доходит.
pub const limit_failed_code = 126;
pub fn run(arena: std.mem.Allocator, command: args.Command) Error!report.Outcome {
const argv = command.argv;
// Пространства имён есть только в Linux: на другой системе отказываем
// сразу, а не после fork, чтобы ответить словами, а не кодом 126.
if (command.no_net and !netns.supported) return error.NetnsUnsupported;
// Пути для изоляции тоже собираем до fork; заодно проверяем, что
// корневая ФС на месте.
var isolate: isolate_mod.Isolate = .{};
if (command.isolate) |rootfs| try isolate.prepare(rootfs, command.work);
// Массив для execvp собираем до fork. После fork в ребёнке безопасны
// только async-signal-safe функции, а аллокатор к ним не относится.
const c_argv = try arena.allocSentinel(?[*:0]const u8, argv.len, null);
for (argv, c_argv) |arg, *slot| slot.* = try arena.dupeZ(u8, arg);
const cwd_z: ?[*:0]const u8 = if (command.cwd) |cwd| try arena.dupeZ(u8, cwd) else null;
// Группу cgroups тоже готовим до fork: каталог, лимит и путь к
// `cgroup.procs`. Ребёнку останется один open и один write.
var cgroup: memlimit.Cgroup = .{};
if (command.usesCgroup()) try cgroup.create(command.cgroup_root, command.mem_mb, command.pids);
defer if (command.usesCgroup()) cgroup.remove();
// Пайпы и файл для stdin открываем тоже до fork: после него у родителя
// и ребёнка окажутся копии одних и тех же дескрипторов.
var capture: capture_mod.Capture = try .open(arena, command.stdin_path, @as(usize, command.out_kb) * 1024);
const started_ms = watchdog.nowMs();
// Сигналы блокируем до fork. Иначе короткая программа вроде `true`
// завершится раньше, чем родитель приготовится ждать, SIGCHLD придёт
// в пустоту, и родитель уснёт навсегда.
const old_mask = watchdog.arm();
const pid = c.fork();
if (pid < 0) {
_ = watchdog.disarm(&old_mask);
capture.abandon();
return error.ForkFailed;
}
if (pid == 0) {
watchdog.childSetup(&old_mask);
// С этой строки дескриптор 2 это пайп: сообщения об ошибках ниже
// попадут в поле stderr ответа, а не на экран.
capture.childSetup();
// Сначала группа, потом execvp: память программы с первой страницы
// считается уже в группе с лимитом.
if (command.usesCgroup() and !memlimit.Cgroup.joinSelf(cgroup.procsPath())) {
childFail(limit_failed_code, "zbox: не удалось поставить лимит памяти\n");
}
// Сеть отбираем до лимита адресов: обходной путь через пространство
// пользователей пишет в /proc, и делать это лучше без лишних оков.
// Полная изоляция включает и сеть, отдельный unshare ей не нужен.
if (command.no_net and command.isolate == null and !netns.dropNetwork()) {
childFail(limit_failed_code, "zbox: не удалось выключить сеть, unshare отказал\n");
}
if (command.as_mb) |as_mb| {
if (!memlimit.limitAddressSpace(as_mb)) childFail(limit_failed_code, "zbox: не удалось поставить лимит памяти\n");
}
if (cwd_z) |dir| {
if (c.chdir(dir) != 0) childFail(exec_failed_code, "zbox: не удалось перейти в рабочий каталог\n");
}
// Изоляция идёт последней: после seccomp нам самим мало что позволено.
// Отсюда возвращается уже внук, PID 1 нового пространства.
if (command.isolate != null) isolate.enter() catch |step| childFailIsolate(step);
// Мы в ребёнке. Успешный execvp не возвращается: образ процесса
// заменён, этого кода в памяти больше нет.
_ = execvp(c_argv[0].?, c_argv.ptr);
childFail(exec_failed_code, "zbox: не удалось запустить программу\n");
}
// Мы в родителе.
var sampler: memlimit.Sampler = .{ .pid = pid };
watchdog.startTimer(pid, command.time_ms);
capture.parentSetup();
capture.pump(&old_mask, &sampler);
const alarm_fired = watchdog.disarm(&old_mask);
// Ребёнок завершился, но мог оставить в группе фоновых внуков. Пока он
// зомби, его pid и pgid заняты, так что чужую группу мы не заденем.
watchdog.killGroup();
// wait4 это waitpid, который заодно отдаёт rusage ребёнка. Ждать уже
// не придётся: ребёнок мёртв, вызов только забирает зомби.
var status: c_int = 0;
var usage: c.rusage = undefined;
while (true) {
const reaped = c.wait4(pid, &status, 0, &usage);
if (reaped == pid) break;
// Сигнал мог прервать ожидание, тогда просто ждём дальше.
if (std.posix.errno(reaped) == .INTR) continue;
return error.WaitFailed;
}
var outcome = decodeStatus(@bitCast(status));
outcome.cpu_user_ms = report.timevalMs(usage.utime.sec, usage.utime.usec);
outcome.cpu_sys_ms = report.timevalMs(usage.stime.sec, usage.stime.usec);
outcome.max_rss_kb = report.maxRssKbNative(@intCast(usage.maxrss));
outcome.wall_ms = watchdog.nowMs() - started_ms;
outcome.stdout = capture.out.sink.written();
outcome.stderr = capture.err.sink.written();
outcome.stdout_truncated = capture.out.sink.truncated;
outcome.stderr_truncated = capture.err.sink.truncated;
outcome.vm_peak_kb = sampler.last.vm_peak_kb;
outcome.vm_hwm_kb = sampler.last.vm_hwm_kb;
if (command.mem_mb != null) {
// Счётчики читаем после wait4: к этому моменту ядро их уже обновило.
const usage_now = cgroup.usage();
outcome.cgroup_peak_kb = usage_now.peak_kb;
outcome.oom_kills = usage_now.events.oom_kill;
}
// Будильник мог прозвенеть в тот же миг, когда программа закончила сама.
// Лимит превышен, только если её действительно убил наш SIGKILL.
outcome.timed_out = alarm_fired and outcome.signal == @intFromEnum(std.posix.SIG.KILL);
outcome.net = if (command.no_net or command.isolate != null) .none else .host;
return outcome;
}
/// Изоляция сорвалась на одном из шагов. Имя ошибки это строка, известная
/// на этапе компиляции, поэтому три `write` подряд остаются async-signal-safe.
fn childFailIsolate(step: isolate_mod.Step) noreturn {
const prefix = "zbox: изоляция не удалась на шаге ";
const name = @errorName(step);
_ = c.write(2, prefix.ptr, prefix.len);
_ = c.write(2, name.ptr, name.len);
_ = c.write(2, "\n", 1);
c._exit(limit_failed_code);
}
/// Слово состояния из wait4 упаковано по-разному на разных системах,
/// поэтому разбираем его макросами W*, а не сдвигами руками.
fn decodeStatus(status: u32) report.Outcome {
if (c.W.IFEXITED(status)) return .{ .exit_code = c.W.EXITSTATUS(status) };
if (c.W.IFSIGNALED(status)) return .{ .signal = @intFromEnum(c.W.TERMSIG(status)) };
// Без WUNTRACED остановленного ребёнка wait4 не вернёт.
unreachable;
}
/// Выход из ребёнка с сообщением. Только async-signal-safe вызовы.
fn childFail(code: u8, message: []const u8) noreturn {
_ = c.write(2, message.ptr, message.len);
// Именно _exit, а не exit: обычный exit сбросил бы буферы stdio
// и выполнил atexit-обработчики, унаследованные от родителя.
c._exit(code);
}
Что изменилось в этом шаге:
- до
fork:isolate.prepare, если запрошен--isolate, и группа cgroups теперь заводится для любого из двух лимитов,--mem-mbили--pids(command.usesCgroup()); - в ребёнке:
isolate.enter()последним, прямо передexecvp. Из него возвращается уже внук. ОтдельныйdropNetworkпод--isolateне нужен, сетевое пространство входит в шесть; childFailIsolateпишет имя шага, на котором сорвалась изоляция;- в ответе
netравенnoneи для--no-net, и для--isolate.
Ещё одну правку шаг вносит в serve.zig и main.zig: zbox serve получает флаг --isolate. Её разберём в конце урока, когда песочница будет готова целиком.
Посмотри, как мало поменялось в родителе. Он по-прежнему ждёт SIGCHLD от своего прямого ребёнка и зовёт wait4 на его pid. То, что ребёнок стал надзирателем, а программа живёт во внуке, родителю не видно, и это заслуга superviseAndExit: итог внука приходит к родителю в том же виде, в каком пришёл бы без изоляции. Лимит времени убивает группу ребёнка, в ней и надзиратель, и внук (внук унаследовал группу при fork), а смерть внука, PID 1, уносит всё пространство.
Мелкие правки
Три новых флага командной строки:
/// Лимит памяти через cgroups v2 (`memory.max`), мегабайты. Только Linux.
mem_mb: ?u32 = null,
+ /// Лимит числа процессов через cgroups v2 (`pids.max`). Только Linux.
+ pids: ?u32 = null,
/// Каталог cgroups v2, внутри которого zbox заводит свою группу.
@@
/// Программа получает пустое сетевое пространство имён. Только Linux.
no_net: bool = false,
+ /// Каталог с корневой ФС: программа запускается в ней через `pivot_root`,
+ /// в своих пространствах имён и под seccomp. Только Linux.
+ isolate: ?[]const u8 = null,
+ /// Каталог задачи, виден программе как `/work`. Только с `--isolate`.
+ work: []const u8 = ".",
@@
/// Программа и её аргументы: то, что уйдёт в `execvp`.
argv: []const []const u8,
+
+ /// Нужна ли запуску своя группа cgroups: любой из её лимитов.
+ pub fn usesCgroup(command: Command) bool {
+ return command.mem_mb != null or command.pids != null;
+ }
};
@@
-/// `run [--time-ms N] [--as-mb N] [--mem-mb N] [--cgroup-root DIR]
-/// [--stdin FILE] [--out-kb N] [--no-net] <cmd> [args...]`.
+/// `run [--time-ms N] [--as-mb N] [--mem-mb N] [--pids N] [--cgroup-root DIR]
+/// [--stdin FILE] [--out-kb N] [--no-net] [--isolate ROOTFS] [--work DIR]
+/// <cmd> [args...]`.
@@
} else if (std.mem.eql(u8, flag, "--mem-mb")) {
command.mem_mb = try positive(value);
+ } else if (std.mem.eql(u8, flag, "--pids")) {
+ command.pids = try positive(value);
} else if (std.mem.eql(u8, flag, "--cgroup-root")) {
@@
} else if (std.mem.eql(u8, flag, "--out-kb")) {
command.out_kb = try positive(value);
+ } else if (std.mem.eql(u8, flag, "--isolate")) {
+ if (value.len == 0) return error.BadUsage;
+ command.isolate = value;
+ } else if (std.mem.eql(u8, flag, "--work")) {
+ if (value.len == 0) return error.BadUsage;
+ command.work = value;
} else return error.BadUsage;
Лимит процессов пишется в ту же группу cgroups, что и лимит памяти, в файл pids.max. Контроллер pids включается в cgroup.subtree_control родителя так же, как memory:
- pub fn create(group: *Cgroup, root: []const u8, mem_mb: u32) CgroupError!void {
+ /// `mem_mb` пишется в `memory.max`, `pids` в `pids.max`; null значит
+ /// «без этого лимита». Каждому файлу нужен свой контроллер в
+ /// `cgroup.subtree_control` родителя: `+memory` и `+pids`.
+ pub fn create(group: *Cgroup, root: []const u8, mem_mb: ?u32, pids: ?u32) CgroupError!void {
if (!is_linux) return error.CgroupUnsupported;
@@
var number_buf: [24]u8 = undefined;
- const bytes = std.fmt.bufPrint(&number_buf, "{d}", .{@as(u64, mem_mb) * bytes_per_mb}) catch unreachable;
- if (!group.writeFile("memory.max", bytes)) return error.CgroupUnavailable;
- // Без этой строки ядро сначала вытеснит страницы в своп, и программа
- // вместо смерти начнёт ползти. Файла нет, если своп выключен: не беда.
- _ = group.writeFile("memory.swap.max", "0");
+ if (mem_mb) |mb| {
+ const bytes = std.fmt.bufPrint(&number_buf, "{d}", .{@as(u64, mb) * bytes_per_mb}) catch unreachable;
+ if (!group.writeFile("memory.max", bytes)) return error.CgroupUnavailable;
+ // Без этой строки ядро сначала вытеснит страницы в своп, и программа
+ // вместо смерти начнёт ползти. Файла нет, если своп выключен: не беда.
+ _ = group.writeFile("memory.swap.max", "0");
+ }
+ if (pids) |limit| {
+ // Считаются все процессы и потоки группы вместе с внуками:
+ // fork сверх лимита получает EAGAIN, и вилочная бомба гаснет.
+ // Это `docker run --pids-limit`.
+ const text = std.fmt.bufPrint(&number_buf, "{d}", .{limit}) catch unreachable;
+ if (!group.writeFile("pids.max", text)) return error.CgroupUnavailable;
+ }
}
fork сверх лимита получает EAGAIN, и вилочная бомба гаснет, не успев разгореться. Лимит считает все процессы и потоки группы, внуков тоже. Это docker run --pids-limit.
В main.zig справка и две новые ветки ошибок с кодом 3:
\\ --mem-mb N лимит памяти через cgroups v2, memory.max (только Linux)
+ \\ --pids N лимит числа процессов через cgroups v2, pids.max (только Linux)
\\ --cgroup-root DIR где заводить группу, по умолчанию /sys/fs/cgroup
@@
\\ --no-net пустое сетевое пространство имён, unshare(CLONE_NEWNET) (только Linux)
+ \\ --isolate ROOTFS свои пространства имён, pivot_root в каталог ROOTFS, корень только
+ \\ для чтения, без capabilities, seccomp-фильтр (только Linux)
+ \\ --work DIR каталог задачи, программа видит его как /work (по умолчанию текущий)
@@
error.CgroupUnsupported, error.CgroupUnavailable, error.PathTooLong => {
- try out.print("zbox: группа cgroups недоступна ({t}): нужен Linux с cgroups v2, права на запись в каталог и контроллер memory в его cgroup.subtree_control\n", .{err});
+ try out.print("zbox: группа cgroups недоступна ({t}): нужен Linux с cgroups v2, права на запись в каталог и контроллеры memory и pids в его cgroup.subtree_control\n", .{err});
@@
error.NetnsUnsupported => {
try out.writeAll("zbox: --no-net работает только в Linux: сетевые пространства имён есть лишь там\n");
try out.flush();
std.process.exit(3);
},
+ error.IsolateUnsupported => {
+ try out.writeAll("zbox: --isolate работает только в Linux: пространства имён, pivot_root и seccomp есть лишь там\n");
+ try out.flush();
+ std.process.exit(3);
+ },
+ error.RootfsMissing => {
+ try out.print("zbox: нет каталога корневой ФС для --isolate: {s}\n", .{command.isolate.?});
+ try out.flush();
+ std.process.exit(3);
+ },
В src/root.zig три новые строки, файл целиком:
//! Корень модуля zbox. Программа и тесты берут части песочницы отсюда.
pub const allowlist = @import("box/allowlist.zig");
pub const args = @import("box/args.zig");
pub const capture = @import("box/capture.zig");
pub const isolate = @import("box/isolate.zig");
pub const memlimit = @import("box/memlimit.zig");
pub const memory = @import("box/memory.zig");
pub const netns = @import("box/netns.zig");
pub const report = @import("box/report.zig");
pub const run = @import("box/run.zig");
pub const seccomp = @import("box/seccomp.zig");
pub const serve = @import("box/serve.zig");
pub const watchdog = @import("box/watchdog.zig");
В build.zig шаг попадает в список: const project_steps = [_]u8{ 48, 49, 54, 61, 64, 66, 67 };.
Корневая ФС: образ раннера в каталоге
Для --isolate нужен каталог с распакованной корневой ФС. Лучший кандидат это образ, в котором раннер курса гоняет задачи на Zig: там Debian bookworm, zig 0.16 и ничего лишнего. docker export отдаёт файловую систему контейнера одним tar без слоёв и метаданных:
#!/bin/sh
# Корневая ФС для `zbox run --isolate`: распакованный образ раннера курса.
#
# rootfs/build.sh -> rootfs/root (в .gitignore, около 450 МБ)
# rootfs/build.sh IMAGE DIR -> любой другой образ и каталог
#
# `docker export` отдаёт файловую систему контейнера одним tar без слоёв
# и метаданных, tar раскладывает её в каталог. Внутри Debian bookworm
# и zig 0.16: ровно то, в чём песочница курса гоняет задачи. Владельцем
# файлов становится тот, кто распаковывал, и это удобно: `--isolate` с
# пространством пользователей делает его root внутри.
set -eu
image="${1:-ghcr.io/bondiano/runner-zig:dev}"
dir="${2:-$(cd "$(dirname "$0")" && pwd)/root}"
id="$(docker create "$image")"
trap 'docker rm -f "$id" >/dev/null' EXIT
mkdir -p "$dir"
docker export "$id" | tar -x -C "$dir"
# Точки монтирования для pivot_root и рабочего каталога задачи.
mkdir -p "$dir/oldroot" "$dir/work" "$dir/proc" "$dir/tmp" "$dir/dev"
echo "корневая ФС из $image распакована в $dir"
Владельцем распакованных файлов становится тот, кто распаковывал. Под --isolate с пространством пользователей это удобно: он же root внутри, и файлы его. Каталог весит около 450 МБ и лежит в .gitignore.
Подопытная программа
Интеграционные тесты шага гоняют netcheck из урока про сокеты без изменений. Ветка, которая печатает socket EPERM, если не удался даже socket, была в нём с самого начала и ждала именно этого шага: под нашим фильтром сокет не создать.
Прогон
Контейнер --privileged на linux/arm64, Linux 7.0, пользователь runner (uid 10001). Внутри контейнера нет docker, поэтому корневой ФС служит корень самого контейнера, подвешенный вторым именем: mount --bind / /tmp/rootfs (pivot_root не принимает /, а bind на себя принимает). Каталог задачи /tmp/wk.
$ Z='zbox run --isolate /tmp/rootfs --work /tmp/wk'
$ $Z sh -c 'echo pid=$$; id; hostname; grep NSpid /proc/$$/status; ls /dev; grep CapEff /proc/$$/status'
{"exit_code":0,"signal":null,"timed_out":false,"cpu_user_ms":2,"cpu_sys_ms":0,"max_rss_kb":3396,"wall_ms":3,"reason":"exited","vm_peak_kb":8188,"vm_hwm_kb":3456,"cgroup_peak_kb":null,"oom_kills":null,"stdout_truncated":false,"stderr_truncated":false,"stdout":"pid=1\nuid=0(root) gid=0(root) groups=0(root)\nzbox\nNSpid:\t1\nfull\nnull\nrandom\ntty\nurandom\nzero\nCapEff:\t0000000000000000\n","stderr":"","net":"none"}
Программа это PID 1, uid 0, на узле zbox, в /dev ровно шесть устройств, и ни одной действующей capability. NSpid показывает номер процесса в каждом PID-пространстве от внешнего к внутреннему, но наш /proc смонтирован изнутри, и для него процесс только «1». Хозяйский /proc в тот же момент видит оба номера:
$ $Z sh -c 'exec sleep 1' &
$ grep NSpid /proc/<pid sleep>/status
NSpid: 231 1
Что смонтировано внутри и кто мы для ядра:
$ $Z sh -c 'ls -d /proc/[0-9]*; pwd; cat /proc/self/uid_map; cut -d" " -f5,6 /proc/self/mountinfo'
{"exit_code":0,...,"stdout":"/proc/1\n/work\n 0 10001 1\n/ ro,relatime\n/work rw,relatime\n/proc rw,nosuid,nodev,noexec,relatime\n/tmp rw,nosuid,nodev,relatime\n/dev rw,nosuid,noexec,relatime\n/dev/null rw,nosuid\n/dev/zero rw,nosuid\n/dev/full rw,nosuid\n/dev/random rw,nosuid\n/dev/urandom rw,nosuid\n/dev/tty rw,nosuid\n","stderr":"","net":"none"}
Один процесс в /proc, текущий каталог /work, uid 0 внутри это 10001 снаружи. Дерево монтирования целиком наше: корень ro, под ним /work, /proc, /tmp, /dev и шесть bind-монтирований устройств. От /oldroot не осталось и следа.
Что программе запрещено:
$ $Z sh -c 'mount -t tmpfs none /mnt; echo rc=$?; touch /etc/x; echo rc=$?; unshare -n true; echo rc=$?; hostname other; echo rc=$?'
{"exit_code":0,...,"stdout":"rc=32\nrc=1\nrc=1\nrc=1\n","stderr":"mount: /mnt: permission denied.\n dmesg(1) may have more information after failed mount system call.\ntouch: cannot touch '/etc/x': Read-only file system\nunshare: unshare failed: Operation not permitted\nhostname: you must be root to change the host name\n","net":"none"}
$ $Z sh -c 'grep -E "Seccomp|NoNewPrivs|CapBnd|CapPrm" /proc/$$/status'
{"exit_code":0,...,"stdout":"CapPrm:\t0000000000000000\nCapBnd:\t0000000000000000\nNoNewPrivs:\t1\nSeccomp:\t2\nSeccomp_filters:\t1\n","stderr":"","net":"none"}
$ $Z /work/netcheck 1.1.1.1 80
{"exit_code":0,...,"stdout":"socket EPERM\n",...}
$ $Z --time-ms 300 sleep 5
{"exit_code":null,"signal":9,"timed_out":true,"cpu_user_ms":0,"cpu_sys_ms":0,"max_rss_kb":3232,"wall_ms":303,"reason":"time_limit","vm_peak_kb":8188,"vm_hwm_kb":3460,"cgroup_peak_kb":null,"oom_kills":null,"stdout_truncated":false,"stderr_truncated":false,"stdout":"","stderr":"","net":"none"}
mount внутри отбивают сразу трое: нет capability, вызова нет в таблице seccomp, корень только для чтения. unshare и sethostname отбиты фильтром. Seccomp: 2 в /proc/self/status это режим фильтра, NoNewPrivs: 1 наш бит. netcheck не дошёл даже до connect: сокет не создать. Лимит времени работает сквозь надзирателя, как и раньше.
И лимит процессов, от настоящего root (пространство пользователей тут ни при чём, а в группу cgroups писать может только он):
$ zbox run --pids 4 sh -c 'for i in 1 2 3 4 5 6 7 8; do sleep 1 & done; wait; echo done'
{"exit_code":2,"signal":null,"timed_out":false,"cpu_user_ms":0,"cpu_sys_ms":1,"max_rss_kb":3232,"wall_ms":2,"reason":"exited","vm_peak_kb":null,"vm_hwm_kb":null,"cgroup_peak_kb":null,"oom_kills":null,"stdout_truncated":false,"stderr_truncated":false,"stdout":"","stderr":"sh: 0: Cannot fork\n","net":"host"}
Тесты шага
//! Шаг 67: изоляция как у раннера. Сборщик seccomp-фильтра проверяется
//! по байтам на любой машине, остальное в Linux с корневой ФС из
//! переменной окружения `ZBOX_ROOTFS` (см. README и `rootfs/build.sh`).
//! Последний тест замыкает цикл: настоящий `zbox serve 0 --isolate`
//! принимает исходник по HTTP, собирает и запускает его в песочнице.
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const tiny = @import("tiny");
const zbox = @import("zbox");
const support = @import("support.zig");
const testing = std.testing;
const seccomp = zbox.seccomp;
const SockFilter = seccomp.SockFilter;
const arch_x86_64: u32 = 0xc000003e;
const arch_aarch64: u32 = 0xc00000b7;
test "build: программа для трёх сисколлов, байт в байт" {
var out: [16]SockFilter = undefined;
// x86-64: read = 0, write = 1, exit_group = 231.
const program = try seccomp.build(arch_x86_64, &.{ 0, 1, 231 }, .eperm, &out);
try testing.expectEqual(11, program.len);
// Little endian, по восемь байт на инструкцию: code:u16, jt:u8, jf:u8, k:u32.
const expected = [_]u8{
0x20, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, // ld [arch]
0x15, 0x00, 0x01, 0x00, 0x3e, 0x00, 0x00, 0xc0, // jeq #AUDIT_ARCH_X86_64, +1, 0
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, // ret KILL_PROCESS
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ld [nr]
0x15, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // jeq #0, 0, +1
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, // ret ALLOW
0x15, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, // jeq #1, 0, +1
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, // ret ALLOW
0x15, 0x00, 0x00, 0x01, 0xe7, 0x00, 0x00, 0x00, // jeq #231, 0, +1
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, // ret ALLOW
0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x05, 0x00, // ret ERRNO | EPERM
};
try testing.expectEqualSlices(u8, &expected, std.mem.sliceAsBytes(program));
}
test "build: пустой список это одна проверка архитектуры и отказ" {
var out: [8]SockFilter = undefined;
const program = try seccomp.build(arch_aarch64, &.{}, .kill, &out);
try testing.expectEqual(5, program.len);
try testing.expectEqual(seccomp.load(4), program[0]);
try testing.expectEqual(seccomp.jumpIfEqual(arch_aarch64, 1, 0), program[1]);
try testing.expectEqual(seccomp.ret(0x80000000), program[2]);
try testing.expectEqual(seccomp.load(0), program[3]);
try testing.expectEqual(seccomp.ret(0x80000000), program[4]);
}
test "build: архитектура первой, отказ последним, ALLOW после каждого сравнения" {
var out: [64]SockFilter = undefined;
const program = try seccomp.build(arch_aarch64, &.{ 63, 64, 93, 94, 172 }, .eperm, &out);
try testing.expectEqual(seccomp.programLen(5), program.len);
try testing.expectEqual(0x20, program[0].code);
try testing.expectEqual(4, program[0].k);
try testing.expectEqual(0x00050001, program[program.len - 1].k);
var i: usize = 4;
while (i + 1 < program.len) : (i += 2) {
try testing.expectEqual(0x15, program[i].code);
try testing.expectEqual(0, program[i].jt);
try testing.expectEqual(1, program[i].jf);
try testing.expectEqual(0x7fff0000, program[i + 1].k);
}
}
test "build: лимит ядра 4096 инструкций и размер буфера" {
const too_many = [_]u32{0} ** 2046;
var big: [seccomp.max_len + 8]SockFilter = undefined;
try testing.expectError(error.TooManyInstructions, seccomp.build(arch_x86_64, &too_many, .eperm, &big));
const fits = too_many[0..2045];
try testing.expectEqual(4095, (try seccomp.build(arch_x86_64, fits, .eperm, &big)).len);
var small: [6]SockFilter = undefined;
try testing.expectError(error.NoSpaceLeft, seccomp.build(arch_x86_64, &.{0}, .eperm, &small));
}
fn contains(list: []const u32, nr: u32) bool {
return std.mem.indexOfScalar(u32, list, nr) != null;
}
test "allowlist: номера подставлены по архитектуре, сокетов и mount нет" {
const x64 = std.os.linux.syscalls.X64;
const arm64 = std.os.linux.syscalls.Arm64;
try testing.expect(contains(zbox.allowlist.x86_64, @intFromEnum(x64.openat)));
try testing.expect(contains(zbox.allowlist.x86_64, @intFromEnum(x64.arch_prctl)));
try testing.expect(!contains(zbox.allowlist.x86_64, @intFromEnum(x64.socket)));
try testing.expect(!contains(zbox.allowlist.x86_64, @intFromEnum(x64.mount)));
try testing.expect(!contains(zbox.allowlist.x86_64, @intFromEnum(x64.ptrace)));
try testing.expect(contains(zbox.allowlist.aarch64, @intFromEnum(arm64.openat)));
try testing.expect(!contains(zbox.allowlist.aarch64, @intFromEnum(arm64.socket)));
try testing.expect(!contains(zbox.allowlist.aarch64, @intFromEnum(arm64.unshare)));
// На aarch64 нет open, pipe и dup2: имена пропущены, список короче.
try testing.expect(zbox.allowlist.aarch64.len < zbox.allowlist.x86_64.len);
try testing.expect(seccomp.programLen(zbox.allowlist.x86_64.len) < seccomp.max_len);
}
test "parse: --isolate и --work" {
const command = try zbox.args.parse(&.{ "run", "--isolate", "rootfs/root", "--work", "/tmp/t", "sh" });
try testing.expectEqualStrings("rootfs/root", command.isolate.?);
try testing.expectEqualStrings("/tmp/t", command.work);
const plain = try zbox.args.parse(&.{ "run", "sh" });
try testing.expectEqual(null, plain.isolate);
try testing.expectEqualStrings(".", plain.work);
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--isolate", "sh" }));
}
test "parse: --pids" {
const command = try zbox.args.parse(&.{ "run", "--pids", "8", "sh" });
try testing.expectEqual(8, command.pids);
try testing.expect(command.usesCgroup());
try testing.expect(!(try zbox.args.parse(&.{ "run", "sh" })).usesCgroup());
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--pids", "0", "sh" }));
}
test "linux: pids.max гасит вилочную бомбу" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Восемь фоновых sleep при лимите в четыре процесса (sh плюс три):
// лишние fork получают EAGAIN, оболочка жалуется «Cannot fork».
const script = "for i in 1 2 3 4 5 6 7 8; do sleep 1 & done; wait; echo done";
const raw = try support.zboxRaw(arena, &.{ "run", "--pids", "4", "sh", "-c", script });
if (std.meta.eql(raw.term, std.process.Child.Term{ .exited = 3 })) {
std.debug.print("пропуск: cgroups v2 недоступны, нужен root и контроллер pids в cgroup.subtree_control\n", .{});
return error.SkipZigTest;
}
const limited = (try support.parseReply(arena, raw.stdout)).reply;
try testing.expect(std.mem.indexOf(u8, limited.stderr, "fork") != null);
const free = try support.zbox(arena, &.{ "run", "sh", "-c", script });
try testing.expectEqualStrings("done\n", free.reply.stdout);
try testing.expectEqualStrings("", free.reply.stderr);
}
test "не Linux: --isolate даёт код 3 и объяснение" {
if (builtin.os.tag == .linux) return error.SkipZigTest;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const raw = try support.zboxRaw(arena_state.allocator(), &.{ "run", "--isolate", "/", "true" });
try testing.expectEqual(std.process.Child.Term{ .exited = 3 }, raw.term);
try testing.expect(std.mem.indexOf(u8, raw.stdout, "только в Linux") != null);
}
test "linux: нет такого каталога это код 3" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const raw = try support.zboxRaw(arena_state.allocator(), &.{ "run", "--isolate", "/zbox/no/such/rootfs", "true" });
try testing.expectEqual(std.process.Child.Term{ .exited = 3 }, raw.term);
}
/// Каталог задачи для интеграционных тестов: туда копируются подопытные
/// программы, внутри песочницы он виден как `/work`.
const Sandbox = struct {
tmp: testing.TmpDir,
rootfs: []const u8,
work: []const u8,
fn open(arena: std.mem.Allocator) !Sandbox {
if (builtin.os.tag != .linux) return error.SkipZigTest;
const rootfs = std.mem.span(std.c.getenv("ZBOX_ROOTFS") orelse {
std.debug.print("пропуск: нет ZBOX_ROOTFS, собери корневую ФС через rootfs/build.sh\n", .{});
return error.SkipZigTest;
});
var tmp = testing.tmpDir(.{});
errdefer tmp.cleanup();
for ([_][]const u8{ support.netcheck_exe, support.hog_exe }) |exe| {
try std.Io.Dir.cwd().copyFile(exe, tmp.dir, std.fs.path.basename(exe), testing.io, .{});
}
const work = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path});
return .{ .tmp = tmp, .rootfs = rootfs, .work = work };
}
fn close(box: *Sandbox) void {
box.tmp.cleanup();
}
/// `zbox run --isolate ROOTFS --work DIR [flags...] argv...`. Если ядро
/// не даёт пространств имён, ребёнок выходит с кодом 126 и объяснением.
fn run(box: *const Sandbox, arena: std.mem.Allocator, flags: []const []const u8, argv: []const []const u8) !support.Run {
const full = try std.mem.concat(arena, []const u8, &.{ &.{ "run", "--isolate", box.rootfs, "--work", box.work }, flags, argv });
const result = try support.zbox(arena, full);
if (result.reply.exit_code == zbox.run.limit_failed_code) {
std.debug.print("пропуск: {s}", .{result.reply.stderr});
return error.SkipZigTest;
}
return result;
}
};
test "linux: программа это PID 1 своего пространства, /proc свой" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var box = try Sandbox.open(arena);
defer box.close();
const pid = try box.run(arena, &.{}, &.{ "sh", "-c", "echo $$" });
try testing.expectEqualStrings("1\n", pid.reply.stdout);
try testing.expectEqualStrings("none", pid.reply.net);
// NSpid: номер в каждом пространстве, от внешнего к нашему. Последний 1.
const nspid = try box.run(arena, &.{}, &.{ "sh", "-c", "grep NSpid /proc/$$/status" });
try testing.expect(std.mem.startsWith(u8, nspid.reply.stdout, "NSpid:"));
try testing.expect(std.mem.endsWith(u8, nspid.reply.stdout, "\t1\n"));
// Без своего /proc здесь были бы все процессы машины.
const count = try box.run(arena, &.{}, &.{ "sh", "-c", "ls -d /proc/[0-9]* | wc -l" });
try testing.expect(std.fmt.parseInt(u32, std.mem.trim(u8, count.reply.stdout, " \n"), 10) catch 999 <= 3);
}
test "linux: имя узла zbox, корень только для чтения, /work и /tmp пишутся" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var box = try Sandbox.open(arena);
defer box.close();
const host = try box.run(arena, &.{}, &.{ "sh", "-c", "cat /proc/sys/kernel/hostname; uname -n" });
try testing.expectEqualStrings("zbox\nzbox\n", host.reply.stdout);
const script = "(echo x > /etc/probe) 2>/dev/null && echo root=writable || echo root=readonly; echo ok > /work/probe && cat /work/probe; echo ok > /tmp/probe && cat /tmp/probe; pwd; ls /dev | tr '\\n' ' '";
const fs = try box.run(arena, &.{}, &.{ "sh", "-c", script });
try testing.expectEqualStrings("root=readonly\nok\nok\n/work\nfull null random tty urandom zero ", fs.reply.stdout);
// Файл из /work виден снаружи: это тот же каталог.
const probe = try box.tmp.dir.readFileAlloc(testing.io, "probe", arena, .limited(16));
try testing.expectEqualStrings("ok\n", probe);
}
test "linux: seccomp отвечает EPERM на socket, mount и unshare невозможны" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var box = try Sandbox.open(arena);
defer box.close();
const socket = try box.run(arena, &.{}, &.{ "/work/netcheck", "127.0.0.1", "1" });
try testing.expectEqualStrings("socket EPERM\n", socket.reply.stdout);
const script = "mkdir -p /tmp/m; for cmd in 'mount -t tmpfs none /tmp/m' 'unshare -n true' 'hostname other'; do $cmd 2>/dev/null && echo \"$cmd: ok\" || echo \"$cmd: denied\"; done";
const denied = try box.run(arena, &.{}, &.{ "sh", "-c", script });
try testing.expectEqualStrings("mount -t tmpfs none /tmp/m: denied\nunshare -n true: denied\nhostname other: denied\n", denied.reply.stdout);
// Capabilities сброшены: даже uid 0 внутри не может ничего лишнего.
const caps = try box.run(arena, &.{}, &.{ "sh", "-c", "grep CapEff /proc/$$/status" });
try testing.expectEqualStrings("CapEff:\t0000000000000000\n", caps.reply.stdout);
}
test "linux: лимиты работают сквозь надзирателя, сигналы внуков доходят" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var box = try Sandbox.open(arena);
defer box.close();
const code = try box.run(arena, &.{}, &.{ "sh", "-c", "echo раз; echo два >&2; exit 4" });
try testing.expectEqual(4, code.reply.exit_code);
try testing.expectEqualStrings("раз\n", code.reply.stdout);
try testing.expectEqualStrings("два\n", code.reply.stderr);
// PID 1 не получает сигналы из своего же пространства без обработчика,
// поэтому убиваем внука: оболочка внутри оболочки.
const signaled = try box.run(arena, &.{}, &.{ "sh", "-c", "sh -c 'kill -SEGV $$'; echo rc=$?" });
try testing.expectEqualStrings("rc=139\n", signaled.reply.stdout);
const slow = try box.run(arena, &.{ "--time-ms", "300" }, &.{ "sleep", "5" });
try testing.expect(slow.reply.timed_out);
try testing.expectEqual(9, slow.reply.signal);
try testing.expect(slow.reply.wall_ms < 4000);
const limited = try box.run(arena, &.{ "--out-kb", "1" }, &.{ "sh", "-c", "yes" });
try testing.expectEqualStrings("output_limit", limited.reply.reason);
}
test "не Linux: serve --isolate отказывается стартовать с кодом 3" {
if (builtin.os.tag == .linux) return error.SkipZigTest;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const raw = try support.zboxRaw(arena_state.allocator(), &.{ "serve", "0", "--isolate", "/" });
try testing.expectEqual(std.process.Child.Term{ .exited = 3 }, raw.term);
try testing.expect(std.mem.indexOf(u8, raw.stdout, "IsolateUnsupported") != null);
}
/// Программа для сквозного теста: имя узла, свой номер и попытка открыть
/// сокет. Сырые вызовы `std.os.linux`, чтобы обойтись без libc.
const snoop =
\\const std = @import("std");
\\const linux = std.os.linux;
\\pub fn main(init: std.process.Init) !void {
\\ var buf: [256]u8 = undefined;
\\ var out = std.Io.File.stdout().writerStreaming(init.io, &buf);
\\ var uts: linux.utsname = undefined;
\\ _ = linux.uname(&uts);
\\ try out.interface.print("узел {s}, pid {d}\n", .{ std.mem.sliceTo(&uts.nodename, 0), linux.getpid() });
\\ try out.interface.print("socket: {t}\n", .{linux.errno(linux.socket(linux.AF.INET, linux.SOCK.STREAM, 0))});
\\ try out.interface.flush();
\\ std.process.exit(4);
\\}
;
test "linux: curl шлёт исходник, zbox serve --isolate собирает и гоняет его в песочнице" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
const rootfs = std.mem.span(std.c.getenv("ZBOX_ROOTFS") orelse {
std.debug.print("пропуск: нет ZBOX_ROOTFS, собери корневую ФС через rootfs/build.sh\n", .{});
return error.SkipZigTest;
});
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Настоящий сервер: порт выбирает ядро, zbox печатает его в stderr.
var child = try std.process.spawn(testing.io, .{
.argv = &.{ build_options.zbox_exe, "serve", "0", "--isolate", rootfs },
.stdin = .ignore,
.stdout = .ignore,
.stderr = .pipe,
});
defer child.kill(testing.io);
var err_buf: [256]u8 = undefined;
var err_reader = child.stderr.?.readerStreaming(testing.io, &err_buf);
const line = try err_reader.interface.takeDelimiterExclusive('\n');
const prefix = "zbox: listening on port ";
try testing.expect(std.mem.startsWith(u8, line, prefix));
const port = try std.fmt.parseInt(u16, line[prefix.len..], 10);
// Запрос, как его отправил бы curl --data-binary @req.json.
const json = try std.json.Stringify.valueAlloc(arena, .{ .source = snoop }, .{});
const raw = try std.fmt.allocPrint(arena, "POST /run HTTP/1.0\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n{s}", .{ json.len, json });
const fd = try tiny.socket.openClientfd("127.0.0.1", port);
defer tiny.socket.close(fd);
try tiny.fdio.writen(fd, raw);
var response: std.ArrayList(u8) = .empty;
var chunk: [4096]u8 = undefined;
while (true) {
const n = try tiny.fdio.readn(fd, &chunk);
try response.appendSlice(arena, chunk[0..n]);
if (n < chunk.len) break;
}
try testing.expect(std.mem.startsWith(u8, response.items, "HTTP/1.0 200"));
const split = std.mem.indexOf(u8, response.items, "\r\n\r\n") orelse return error.NoHeaderEnd;
const body = response.items[split + 4 ..];
const reply = (try support.parseReply(arena, body)).reply;
// Пространства не дались (контейнер без --privileged): ребёнок ответил 126.
if (reply.exit_code == zbox.run.limit_failed_code) {
std.debug.print("пропуск: {s}", .{reply.stderr});
return error.SkipZigTest;
}
try testing.expect(std.mem.indexOf(u8, body, "\"stage\":\"run\"") != null);
try testing.expectEqual(4, reply.exit_code);
try testing.expectEqualStrings("узел zbox, pid 1\nsocket: PERM\n", reply.stdout);
try testing.expectEqualStrings("none", reply.net);
}
Тесты делятся на три группы.
- Чистые идут везде: сборщик фильтра байт в байт на трёх сисколлах, пустая таблица, порядок инструкций на пяти, оба лимита (4096 инструкций и размер буфера), таблица разрешённых вызовов (сокетов,
mountиptraceв ней нет), разбор флагов. - Два теста «не Linux» проверяют, что на macOS
run --isolateиserve --isolateдают код 3 и объяснение, а не падение. - Интеграционные гоняют настоящий
zboxпод--isolateи требуют Linux и переменнуюZBOX_ROOTFSс путём к корневой ФС. Без неё они пропускаются и говорят почему. Если ядро не дало пространств имён (контейнер без--privileged), ребёнок выходит с кодом 126, и тест тоже пропускается с его сообщением.
Последний тест, сквозной, замыкает цикл через HTTP; к нему вернёмся в разделе про полный цикл. Тест «лимиты работают сквозь надзирателя» проверяет, что изоляция ничего не сломала в старых шагах: код возврата 4, раздельные stdout и stderr, сигнал внука (оболочка внутри оболочки, потому что PID 1 не получает сигналы из своего же пространства без обработчика), лимит времени и лимит вывода.
На macOS:
$ zig build test -Dstep=67 --summary all
test success
+- run test 9 pass, 7 skip (16 total)
В контейнере --privileged на linux/arm64 от root проходят 14 из 16 (пропущены только два теста «не Linux»), от пользователя runner 13 (плюс пропуск --pids: писать в cgroupfs ему нельзя). Все тесты проекта вместе от root: 79 из 82, три пропуска это тесты «не Linux» шагов 64 и 67. Рецепт контейнера, где готовится дерево cgroups и корневая ФС:
docker run --rm --platform linux/arm64 --privileged --cgroupns private --user 0 \
-v "$PWD/..":/w:ro ghcr.io/bondiano/runner-zig:dev sh -c '
mkdir -p /tmp/p /tmp/rootfs /work /oldroot && mount --bind / /tmp/rootfs &&
for d in our-runner our-tiny; do mkdir /tmp/p/$d && (cd /w/$d &&
tar --exclude=.zig-cache --exclude=zig-out --exclude=rootfs/root -cf - .) | tar -xf - -C /tmp/p/$d; done &&
cd /tmp/p/our-runner &&
mkdir /sys/fs/cgroup/init &&
for p in $(cat /sys/fs/cgroup/cgroup.procs); do echo $p > /sys/fs/cgroup/init/cgroup.procs; done;
echo +memory +pids > /sys/fs/cgroup/cgroup.subtree_control &&
ZBOX_ROOTFS=/tmp/rootfs zig build test -Dstep=67 --summary all --cache-dir /tmp/p/.zig-cache --global-cache-dir /tmp/g'
Три строки про /sys/fs/cgroup ты видел в уроке про память: в cgroups v2 группа с процессами не может раздавать контроллеры детям, поэтому процессы контейнера переезжают в группу init. Монтируется родительский каталог: build.zig.zon ссылается на соседний our-tiny из урока 66, и оба проекта копируются рядом. Чтобы прогнать изоляцию без root, после подготовки выполни chown -R 10001 /tmp/p и запусти тесты через setpriv --reuid=10001 --regid=10001 --clear-groups env ZBOX_ROOTFS=/tmp/rootfs zig build test ....
Полный цикл: curl шлёт исходник
Вспомним, чем закончился урок 66. zbox serve <port> поднимает TINY с одним маршрутом POST /run. Запрос это обычный POST из урока про HTTP, тело это JSON с исходником и лимитами:
{"source": "<текст main.zig>", "time_ms": 2000, "mem_mb": 64, "stdin": "1 2\n"}
Сервер кладёт исходник в свежий каталог /tmp/zbox-XXXXXX, собирает его zig build-exe под щедрым лимитом в 60 секунд, потом запускает собранную программу под лимитами из запроса, обе стадии через ту же run(), и отвечает тем же JSON, что печатает zbox run, плюс поле stage. На macOS это выглядит так:
$ zbox serve 8081
zbox: listening on port 8081
$ curl -s --data-binary @hello.json http://localhost:8081/run
{"exit_code":4,"signal":null,"timed_out":false,"cpu_user_ms":21,"cpu_sys_ms":5,"max_rss_kb":2976,"wall_ms":403,"reason":"exited","vm_peak_kb":null,"vm_hwm_kb":null,"cgroup_peak_kb":null,"oom_kills":null,"stdout_truncated":false,"stderr_truncated":false,"stdout":"привет\n","stderr":"","net":"host","stage":"run"}
Посмотри на "net":"host". Программа из запроса, то есть код незнакомого человека из интернета, работает от имени сервера, видит его файлы и его сеть. В уроке 66 это было честно названо учебным стендом. Теперь у нас есть --isolate, и осталось его подключить.
zbox serve PORT --isolate ROOTFS
Изменений немного, потому что вся изоляция уже живёт в run(): достаточно передать в обе стадии поля isolate и work. Сложность одна, и она про пути. Внутри песочницы временный каталог виден как /work, поэтому компилятору и программе нужно говорить /work/main и /work/.zig-cache, а не хозяйский /tmp/zbox-XXXXXX/.... И zig надо искать по PATH уже внутри корневой ФС: путь из хозяйской переменной ZBOX_ZIG там ничего не значит. Образ раннера, который мы распаковали, для этого и нужен, в нём есть zig 0.16.
Тело runSource переезжает в runSourceIn с необязательным rootfs, а сама runSource становится однострочной обёрткой с null. Так тесты шага 66 и сервер без флага ведут себя ровно как раньше.
/// Кладёт исходник во временный каталог, собирает и запускает. Каталог
/// удаляется на выходе. `zig` берётся из `ZBOX_ZIG` или из `PATH`.
pub fn runSource(arena: std.mem.Allocator, io: std.Io, request: Request) Error!Reply {
+ return runSourceIn(arena, io, request, null);
+}
+
+/// То же в песочнице: с `rootfs` обе стадии идут под `--isolate`. Каталог
+/// виден им как `/work`, а `zig` ищется по `PATH` уже внутри корневой ФС.
+pub fn runSourceIn(arena: std.mem.Allocator, io: std.Io, request: Request, rootfs: ?[]const u8) Error!Reply {
var template = "/tmp/zbox-XXXXXX".*;
@@
- const zig = std.mem.span(c.getenv("ZBOX_ZIG") orelse "zig");
- const cache = try std.fmt.allocPrint(arena, "{s}/.zig-cache", .{dir_path});
- const exe = try std.fmt.allocPrint(arena, "{s}/main", .{dir_path});
+ // Под изоляцией программа видит каталог по другому пути.
+ const inside = if (rootfs != null) "/work" else dir_path;
+ const zig = if (rootfs != null) "zig" else std.mem.span(c.getenv("ZBOX_ZIG") orelse "zig");
+ const cache = try std.fmt.allocPrint(arena, "{s}/.zig-cache", .{inside});
+ const exe = try std.fmt.allocPrint(arena, "{s}/main", .{inside});
const emit = try std.fmt.allocPrint(arena, "-femit-bin={s}", .{exe});
@@
.time_ms = compile_ms,
.out_kb = 256,
.cwd = dir_path,
+ .isolate = rootfs,
+ .work = dir_path,
.argv = &.{ zig, "build-exe", "main.zig", emit, "--cache-dir", cache },
@@
.stdin_path = try std.fmt.allocPrint(arena, "{s}/stdin", .{dir_path}),
.cwd = dir_path,
+ .isolate = rootfs,
+ .work = dir_path,
.argv = &.{exe},
Файл для stdin остаётся хозяйским путём, и это правильно: его открывает родитель до fork, ребёнок ставит его на дескриптор 0 через dup2, как в уроке про переадресацию, и программа пути не видит.
В main.zig командная строка serve принимает необязательную пару --isolate ROOTFS и кладёт путь в глобальную переменную рядом с server_io. Корневую ФС проверяем сразу при старте тем же Isolate.prepare: сервер, который не может изолировать, не должен и слушать порт, иначе первый же клиент получил бы ошибку 500 вместо песочницы.
\\ zbox serve <port> HTTP: POST /run с исходником main.zig в JSON, порт 0 это любой свободный
+ \\ zbox serve <port> --isolate ROOTFS
+ \\ то же, но сборка и запуск в песочнице из каталога ROOTFS (только Linux)
@@
if (args.len > 1 and std.mem.eql(u8, args[1], "serve")) {
- const port = zbox.serve.parsePort(args[1..]) catch {
+ // `serve <port> --isolate ROOTFS`: обе стадии в песочнице.
+ if (args.len == 5 and std.mem.eql(u8, args[3], "--isolate")) server_rootfs = args[4];
+ const port = zbox.serve.parsePort(if (server_rootfs != null) args[1..3] else args[1..]) catch {
try out.writeAll(usage);
try out.flush();
std.process.exit(2);
};
+ if (server_rootfs) |rootfs| {
+ // Корневую ФС проверяем при старте, а не на первом запросе:
+ // сервер, который не может изолировать, не должен и слушать.
+ var probe: zbox.isolate.Isolate = .{};
+ probe.prepare(rootfs, "/") catch |err| {
+ try out.print("zbox: serve --isolate невозможен ({t}): нужен Linux и каталог корневой ФС {s}\n", .{ err, rootfs });
+ try out.flush();
+ std.process.exit(3);
+ };
+ }
return serve(init, port);
@@
var server_io: std.Io = undefined;
+/// Каталог корневой ФС для `serve --isolate`; null значит без песочницы.
+var server_rootfs: ?[]const u8 = null;
@@
- const reply = try zbox.serve.runSource(arena, server_io, request);
+ const reply = try zbox.serve.runSourceIn(arena, server_io, request, server_rootfs);
Последние два теста в step_67.zig (листинг выше) закрепляют это. «Не Linux: serve --isolate» проверяет отказ с кодом 3 на macOS. Сквозной тест поднимает настоящий zbox serve 0 --isolate $ZBOX_ROOTFS, узнаёт порт из первой строки stderr, шлёт по сокету тот же POST /run, что отправил бы curl, и ждёт от программы внутри узел zbox, pid 1 и socket: PERM.
Прогон цикла
Контейнер --privileged на linux/arm64, сервер запущен от пользователя runner, порт проброшен наружу, curl на Mac. Сначала знакомая программа:
$ zbox serve 8081 --isolate /tmp/rootfs
zbox: listening on port 8081
$ curl -si --data-binary @hello.json http://localhost:8081/run
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 316
Content-type: application/json
{"exit_code":4,"signal":null,"timed_out":false,"cpu_user_ms":1,"cpu_sys_ms":1,"max_rss_kb":4152,"wall_ms":4,"reason":"exited","vm_peak_kb":9588,"vm_hwm_kb":4100,"cgroup_peak_kb":null,"oom_kills":null,"stdout_truncated":false,"stderr_truncated":false,"stdout":"привет\n","stderr":"","net":"none","stage":"run"}
Тот же ответ, только "net":"none". Компилятор отработал внутри песочницы: под seccomp, без сети, с корнем только для чтения. Значит, таблицы разрешённых вызовов хватает и ему, со всеми его потоками и сотнями открытых файлов.
Теперь программа, которая пробует выглянуть наружу:
const std = @import("std");
const linux = std.os.linux;
pub fn main(init: std.process.Init) !void {
var buf: [256]u8 = undefined;
var out = std.Io.File.stdout().writerStreaming(init.io, &buf);
const w = &out.interface;
var uts: linux.utsname = undefined;
_ = linux.uname(&uts);
try w.print("узел {s}, pid {d}\n", .{ std.mem.sliceTo(&uts.nodename, 0), linux.getpid() });
try w.print("socket: {t}\n", .{linux.errno(linux.socket(linux.AF.INET, linux.SOCK.STREAM, 0))});
try w.print("mkdir /etc/x: {t}\n", .{linux.errno(linux.mkdir("/etc/x", 0o755))});
try w.flush();
}
$ curl -s --data-binary @snoop.json http://localhost:8081/run
{"exit_code":0,...,"stdout":"узел zbox, pid 1\nsocket: PERM\nmkdir /etc/x: ROFS\n","stderr":"","net":"none","stage":"run"}
$ curl -s --data-binary '{"source":"pub fn main() void { while (true) {} }","time_ms":300}' http://localhost:8081/run
{"exit_code":null,"signal":9,"timed_out":true,"cpu_user_ms":0,"cpu_sys_ms":0,"max_rss_kb":4064,"wall_ms":313,"reason":"time_limit",...,"net":"none","stage":"run"}
$ curl -s --data-binary '{"source":"pub fn main() void { const x: u8 = 300; _ = x; }"}' http://localhost:8081/run
{"exit_code":1,"signal":null,"timed_out":false,"cpu_user_ms":1603,"cpu_sys_ms":113,"max_rss_kb":361396,"wall_ms":1411,"reason":"exited",...,"stderr":"main.zig:1:36: error: type 'u8' cannot represent integer value '300'\n...","net":"none","stage":"compile"}
Программа живёт на узле zbox, она PID 1, сокет ей не открыть (EPERM от seccomp), каталог в /etc не создать (EROFS, корень только для чтения). Бесконечный цикл убит по лимиту через 313 мс, а ошибка компиляции пришла с stage: compile и текстом компилятора. Компилятор съел 361 МБ и полторы секунды: вот почему лимиты из запроса ему не достаются.
Вот он, полный цикл, ради которого zbox рос весь раздел: curl отправляет исходник, сервер собирает и запускает его в изоляции с лимитами из блоков про процессы, память и ввод-вывод и отвечает JSON-ом. Это ровно то, что происходит, когда ты нажимаешь «Запустить» у задачи курса, только вместо docker run здесь твои системные вызовы.
Наш вызов и флаг docker run
Теперь можно открыть runner/bs-runner/src/sandbox.rs и прочитать функцию, которая собирает командную строку docker run для каждой задачи курса. Вот она, строчка за строчкой, против того, что сделали мы.
| Что делает zbox | Чем это включает bs-runner |
|---|---|
fork, execvp, wait4 (урок 48) | docker run --rm |
unshare(CLONE_NEWPID) плюс второй fork: программа это PID 1 | у каждого контейнера свой PID 1; --init сажает туда tini, у нас эту роль играет сама программа |
setitimer плюс kill(-pgid, SIGKILL) (урок 49) | у Docker лимита по настенным часам нет, bs-runner держит таймер сам и убивает контейнер |
memory.max, memory.swap.max в группе cgroups (урок 54) | --memory, --memory-swap |
pids.max в той же группе | --pids-limit |
| нет | --cpus: доля процессора через cpu.max (домашнее задание) |
setrlimit(RLIMIT_AS) (урок 54) | --ulimit (раннер ставит nofile и nproc, адреса не режет) |
пайпы и poll (урок 61) | флага нет: bs-runner читает stdout и stderr самого docker run такими же пайпами |
unshare(CLONE_NEWNET), lo выключен (урок 64) | --network none; Docker при этом поднимает lo, и 127.0.0.1 внутри контейнера работает |
unshare(CLONE_NEWNS), bind образа, pivot_root, umount2(MNT_DETACH) | имя образа в конце команды |
mount(NULL, "/", MS_REMOUNT | MS_BIND | MS_RDONLY) | --read-only |
bind каталога задачи в /work, tmpfs на /tmp | -v <dir>:/work-init:ro плюс --tmpfs /work:rw,exec,... и --tmpfs /tmp:rw,noexec,... |
tmpfs на /dev и шесть устройств через bind | /dev контейнера, который создаёт демон |
mount("proc", "/proc") | Docker монтирует /proc всегда и маскирует опасные файлы |
unshare(CLONE_NEWUTS), sethostname("zbox") | --hostname (по умолчанию первые 12 символов id контейнера) |
unshare(CLONE_NEWIPC) | своё пространство IPC у каждого контейнера по умолчанию |
unshare(CLONE_NEWUSER), uid 0 внутри это 10001 снаружи | --user runner: без пространства пользователей, программа не root даже внутри; ближе всего к нам userns-remap демона |
prctl(PR_CAPBSET_DROP, n) для всех, сброс ambient | --cap-drop ALL |
prctl(PR_SET_NO_NEW_PRIVS, 1) | --security-opt no-new-privileges |
seccomp(SECCOMP_SET_MODE_FILTER, 0, &prog) | --security-opt seccomp=runner/sandbox/seccomp.json |
| нет | --security-opt apparmor=bs-runner-task: мандатный контроль доступа к файлам, ещё один слой |
Три строки, где совпадение неполное, стоят отдельного слова.
Пользователь. Мы делаем программу root внутри своего пространства пользователей, а раннер оставляет её обычным пользователем runner без всякого пространства пользователей. Результат для программы похожий: прав на чужое нет ни там, ни там. Но у нас внутри uid 0, и защиту держат bounding set, no_new_privs и seccomp, а у раннера программа не root с самого начала, и снаружи у неё тот же uid, что внутри. Наш путь нужен, чтобы вся изоляция работала без root у самого zbox; Docker может себе позволить демон от root.
PID 1. У раннера в PID 1 сидит tini и собирает зомби за инструментами сборки, которые не ждут своих детей. У нас PID 1 это сама программа. Для zig build-exe и собранного бинарника этого хватает, для make с десятком фоновых процессов зомби будут копиться до конца запуска. Надзиратель у нас живёт снаружи пространства, и собирать чужих зомби он не может: они не его дети.
Лимит времени. Docker его не умеет. Раннер держит таймер в своём коде на Rust точно так же, как zbox держит его на setitimer, и убивает контейнер docker kill. Здесь мы и раннер совпали до способа.
Чего zbox не умеет
Список границ, чтобы ты не принял учебную песочницу за боевую.
- Фильтр не смотрит на аргументы.
socketзапрещён целиком,cloneразрешён целиком. Профиль раннера различаетcloneс флагамиCLONE_NEW*и без. У насunshareполучитEPERM(его нет в таблице), аcloneс теми же флагами пройдёт: вложенное пространство пользователей заводится без прав, и программа станет в нём root. Толку от этого мало,mount,pivot_rootиsetnsв таблице тоже нет, но это лишний кусок ядра, открытый для атаки, и раннер закрывает его условием на аргумент. - Линейный фильтр. Сисколл из конца таблицы проходит почти три сотни сравнений, в среднем больше сотни. На задачах курса это незаметно, на сервере с миллионом вызовов в секунду заметно.
- Нет лимита процессора и дискового места.
cpu.maxв домашнем задании, размер/workограничен только тем, куда смотрит bind. - Каталог задачи смонтирован для записи напрямую. Программа пишет в наш каталог, раннер же копирует задачу из
/work-init(только для чтения) в свежий tmpfs и не боится, что программа испортит исходники. loвыключен,/dev/ptsи/dev/shmнет. Задачам курса не нужны. Здесь мы строже Docker: у--network noneloподнят.- Нужен
--privilegedконтейнер или настоящая машина. В обычном контейнере Docker маски на/procне дадут смонтировать свою procfs, а seccomp самого Docker не дастunshare.
На macOS
Весь этот урок про Linux. Пространства имён, pivot_root, capabilities, prctl и seccomp есть только в его ядре; у macOS своя песочница (sandbox-exec и профили на языке SBPL), устроенная совсем иначе, и её мы не трогаем.
Что работает на Mac напрямую:
- сборка
zboxи все чистые тесты шага: сборщик фильтра и таблица разрешённых вызовов,zig build test -Dstep=67даёт 8 зелёных и 6 пропущенных; zbox run --isolate ...отвечает кодом 3 и объяснением, что изоляция есть только в Linux;- код-задача урока: она чистая и гоняется в песочнице курса как обычно.
Что только через контейнер. Нужен --privileged: без него Docker маскирует /proc, запрещает unshare своим seccomp-профилем и монтирует cgroupfs только для чтения. На Apple Silicon бери ghcr.io/bondiano/runner-zig:dev (linux/arm64, работает нативно, без эмуляции), рецепт выше. OrbStack и Docker Desktop разрешают пространства пользователей в --privileged контейнере, поэтому проверяются оба пути, от root и от uid 10001. Если твой Docker их запрещает, zbox сам уйдёт на путь без CLONE_NEWUSER, когда запущен от root.
Своя трасса zt strace для списка сисколлов работает в том же контейнере на arm64. Под эмуляцией x86-64 (Rosetta) ptrace видит регистры эмулятора, а не программы, и zt strace честно отказывается, как ты видел в уроке про сисколлы.
Практика
Собери фильтр seccomp сам. Функция buildFilter(arch, allowed, out) получает архитектуру (AUDIT_ARCH_*), список разрешённых номеров и буфер под инструкции, а возвращает готовую программу той раскладки, что в уроке: проверка архитектуры, загрузка номера, пара «сравнение плюс разрешение» на каждый номер, отказ с EPERM последним. Коды операций, вердикты и смещения уже объявлены в файле задачи, структура SockFilter тоже.
Тесты сверяют:
- байты программы для пустой таблицы, одного сисколла и пяти, на x86-64 и на aarch64;
- что проверка архитектуры стоит первой, а отказ последним, при любом списке;
error.TooManyInstructionsдля списка, который не влезает в 4096 инструкций, даже если буфер больше;error.NoSpaceLeft, когда программа влезла бы в ядро, но не в буфер.
Упражнения
Итоги
- Контейнер это обычный процесс, которому ядро иначе отвечает на три вопроса: что он видит (пространства имён), сколько может взять (cgroups) и что ему можно сделать (capabilities,
no_new_privs, seccomp). CLONE_NEWUSERразрешён без прав и даёт полный набор capabilities, но только над своим пространством. В нём мы root, и остальные пять пространств заводятся однимunshare. Отображение0 <uid> 1пишется вuid_mapодин раз, передgid_mapнуженdenyвsetgroups.unshare(CLONE_NEWPID)не переносит вызвавший процесс: PID 1 нового пространства становится его следующий ребёнок. Отсюда надзиратель и внук. PID 1 собирает сирот, не получает сигналы изнутри без обработчика, а его смерть убивает всё пространство.pivot_rootменяет корень пространства монтирования, и послеumount2(MNT_DETACH)старого дерева просто нет, в отличие отchroot, из которого выходят через... Условия: новый корень это точка монтирования (bind на себя), старый внутри нового, ничего общего (MS_PRIVATE).- procfs монтирует только процесс из нового PID-пространства и только пока хозяйская procfs видна целиком, то есть до отцепления старого корня.
/devпослеdocker exportпуст, устройства подвешиваются bind-ом с хозяйских. - Capabilities при
execveу uid 0 не падают: новый permitted набор это bounding set. Обнуляем его черезPR_CAPBSET_DROP.no_new_privsзакрывает setuid-бинарники и нужен для seccomp без привилегий. - Seccomp это программа на классическом BPF по восемь байт на инструкцию, которую ядро гоняет на каждом сисколле над
seccomp_data. Первой проверяетсяarch, иначе номер из другого ABI проскочит. Пара «jeq,ret ALLOW» на номер не упирается в восьмибитные прыжки. Вердикты складываются по старшинству, фильтр снять нельзя. - Таблицу вызовов собирают из профиля (у раннера это профиль Docker с запретами) и проверяют трассой. Трасса показывает только пройденные пути.
zbox serve PORT --isolate ROOTFSсобирает и запускает присланный исходник внутри песочницы:runSourceInпередаёт обеим стадиямisolateиwork, пути внутри начинаются с/work,zigищется в образе. Компилятору таблицы вызовов хватает.- Каждый флаг
docker runвbs-runnerэто один или два системных вызоваzbox. Совпадение неполное в трёх местах: пользователь (у раннера не root без пространства пользователей), PID 1 (tini) и лимит времени, которого у Docker нет вовсе.
Дальше
zbox дорос до песочницы: запускает чужую программу в своей корневой ФС, без сети, без прав, под фильтром сисколлов и с лимитами на время, память, процессы и вывод, а сверху принимает исходник по HTTP. Но принимает по одному: пока один студент компилирует свою задачу десять секунд, остальные ждут в очереди listen. Итеративный сервер из урока про сокеты на этом заканчивается.
Следующий блок про конкурентность, и начнётся он с трёх способов обслужить много клиентов сразу: сервер, который делает fork на каждое соединение, сервер на одном процессе с select, poll и epoll, и сервер на потоках. Мы сравним их на одном и том же эхо-сервере, а потом вернёмся к zbox с пулом воркеров, очередью задач и нагрузочным тестом на десять одновременных запусков.
домашка