EnglishРусский Map

Переписывание каждого syscall в Linux-бинарнике во время загрузки

title
Переписывание каждого syscall в Linux-бинарнике во время загрузки
type
summary
summary
Бинарная модификация: замена инструкций syscall на ловушки INT3 для полной изоляции процесса
tags
linux, security, sandboxing, ai-agents
created
2026-04-15
updated
2026-07-22
lang
ru
source_updated
2026-07-22
translated
2026-09-01
translator
lllm/antigravity/gemini-3.7-flash-medium

Глубокий технический разбор перехвата каждого syscall'а в Linux-процессе через модификацию бинарника во время загрузки. Первая часть серии из семи статей о создании минимального VM-рантайма для выполнения AI-агентов.

Проблема

Контейнеры делят ядро с хостом. Типичная однопроцессная нагрузка задействует около 40 системных вызовов, тогда как ядро предоставляет порядка 450. Это 410 лишних syscall'ов поверхности атаки, которые процессу не нужны, но которые он может прощупывать, эксплойтить или комбинировать непредвиденным образом. Для ненадёжного кода - сторонних библиотек, сгенерированного кода, AI-агентов - это создаёт проблему безопасности.

Почему существующие подходы не работают

ptrace - два переключения контекста на каждый syscall, накладные расходы в 10-20 мкс. Спроектирован для отладки, а не для применения политик безопасности.

seccomp-bpf - работает быстро (внутри ядра), но BPF-фильтр видит только значения регистров, а не память. Нельзя проверить имя открываемого через open() файла или буфер, передаваемый в write(). Варианты действий грубые: разрешить, убить процесс, вернуть ошибку или вызвать trap (что возвращает накладные расходы ptrace).

eBPF - позволяет наблюдать и блокировать, но намеренно не даёт менять состояние процесса. Можно отклонить connect(), но нельзя изменить адрес назначения или вернуть собственный результат. Только разрешить или запретить.

Перехват на уровне компилятора или libc - слишком много путей к инструкции syscall. Go выполняет системные вызовы напрямую. В некоторых местах так же поступает musl. JIT-компиляторы генерируют прямую инструкцию syscall. Упустите хотя бы один путь - и процесс выйдет из-под контроля.

Суть идеи

Все пути - вывод компилятора, обёртки libc, JIT, ассемблер ручной сборки - сходятся к одним и тем же двум байтам: 0F 05, опкоду syscall. Если работать на этом уровне, ловить придётся только одно.

Техника

Секция .text ELF-бинарника сканируется во время загрузки. Но просто искать 0F 05 нельзя: эти байты могут встретиться внутри более длинной инструкции (как непосредственный операнд или смещение). Наивная замена повредила бы несвязанный код.

Вместо этого бинарник обходится по инструкциям с помощью декодера длины инструкций (Instruction Length Decoder, ILD). ILD не делает полный дизассемблинг, а лишь вычисляет длину каждой инструкции. Этого достаточно, чтобы перемещаться по границам инструкций и отличать опкоды от операндов.

Найдя 0F 05 на позиции опкода, заменяем его:

// Patch: SYSCALL (0F 05) → INT3 (CC) + NOP (90)
code[opc] = 0xCC;     // INT3
code[opc + 1] = 0x90; // NOP

Обе последовательности занимают 2 байта. Границы инструкций не сдвигаются, релокация не требуется.

На статически скомпонованном бинарнике Python 3.12 (19 МБ, из них 8.7 МБ исполняемого кода): 363 syscall'а пропатчены за 48 мс.

Shim

Переписанный бинарник запускается внутри KVM VM без операционной системы. Небольшой shim (несколько килобайт на Rust) - единственная прослойка между процессом и аппаратурой.

Перед запуском гостевой системы гипервизор настраивает IDT так, чтобы вектор 3 указывал на обработчик shim'а. Когда срабатывает INT3:

  1. CPU сохраняет в стек RIP/CS/RFLAGS и переходит к обработчику
  2. Обработчик считывает rax (номер syscall'а) и rdi-r9 (аргументы)
  3. Диспетчеризация: проверка политики -> отказ с возвратом -EPERM, локальная эмуляция или передача запроса гипервизору
  4. Запись результата в rax
  5. Возврат в гостевую систему через IRETQ

В обычном случае весь путь занимает менее 1 мкс. Процесс не видит разницы: он получает стандартные возвращаемые значения syscall'ов.

Самовосстановление для JIT

Бинарный переписчик отрабатывает один раз при загрузке. Но JIT-компиляторы (V8, LuaJIT, регулярные выражения в Python) генерируют новый код с инструкциями syscall, которых ещё не было во время модификации.

Решение: настроить MSR LSTAR в качестве страховки. На архитектуре x86-64 инструкция syscall передаёт управление по адресу из LSTAR. Направляем его на самовосстанавливающийся обработчик:

  1. Выполняется сгенерированный JIT'ом syscall -> происходит переход к обработчику LSTAR
  2. Обработчик сохраняет адрес и патчит его на месте (0F 05 -> CC 90)
  3. Формирует фрейм прерывания и передаёт управление в обработчик INT3

Первое выполнение идёт по медленному пути через LSTAR. Все последующие попадают на уже пропатченный INT3. Система восстанавливается сама: каждый новый syscall перехватывается и патчится при первом же обращении.

Что это даёт

Когда каждый syscall проходит через ваш обработчик:

  • open("/etc/shadow") -> отклонить, вернуть -ENOENT
  • connect("pastebin.com:443") -> отклонить, вернуть -EPERM
  • write(socket_fd, buffer_containing_pii) -> заблокировать до того, как хоть один байт уйдёт в сеть

Процесс не может обнаружить перехват (сама инструкция syscall заменена), не может обойти его (любой путь идёт через shim) и не догадывается, что находится в песочнице.

Краевые случаи

Оптимизации LLVM - переменная static, инициализированная нулями, удаляется при свёртке констант. Чтение таблицы политик должно выполняться через read_volatile, чтобы оптимизатор не полагался на её значение.

Лишние данные от компоновщика - lld создаёт секции .dynamic, .dynsym, .gnu.hash даже в бинарниках с no_std. Явное указание /DISCARD/ в скрипте компоновщика позволяет сохранить минимальный размер shim'а.

Связанные работы

Это иной подход по сравнению с hazmat, где используются пользовательская изоляция в macOS, Seatbelt и фаервол pf. Подход с переписыванием бинарника специфичен для Linux, но даёт более гранулярный контроль: можно не только разрешать или запрещать, но и проверять и модифицировать аргументы syscall'ов.

Патчинг через INT3, фрейм прерывания и вопрос "почему ptrace такой медленный" пришли из области разработки отладчиков. В статье Sy Brand building-a-debugger с нуля создаётся другой потребитель тех же механизмов - программные точки останова, пошаговое выполнение, раскрутка стека через DWARF; к ней стоит обратиться, если интересна отладочная сторона этой механики.

Серия продолжается статьёй о том, как "библиотечное ядро" (library kernel) с поддержкой порядка 40 syscall'ов обрабатывает перехваченные вызовы.