Делая пере-реализацию QInst на LLVM, я столкнулся с такой проблемой: QEMU в режиме эмуляции одного процесса естественным образом перехватывает все «гостевые» системные вызовы. В итоге плагин инструментации имеет единую точку входа для их предобработки, где можно по номерам SYS_* и значениям аргументов принимать решения. Это довольно удобно. Проблема в том, что все системные вызовы делает, в основном, libc и, переписывая код статически, мы просто до этой части в большинстве случаев не доберёмся. Конечно, можно было бы использовать ptrace, который как раз предназначен и для этого в том числе. Но тогда не уверен, что получилось бы обойтись без отдельного процесса, а семантика QInst предполагала тривиальный «синхронный» перехват — пришлось бы как-то инжектить вызов обработчика, а это сильно сложнее привычного LD_PRELOAD. Можно оборачивать каждый системный вызов — но это, как минимум, неудобно (к тому же, можем что-нибудь пропустить, ведь в этом случае мы перехватываем на самом не системные вызовы, а их конкретные обёртки). Под катом — решение, не привязанное к LLVM, но заточенное под Linux на x86_64 (но адаптируемое для Linux на других архитектурах). Примечание: в этой статье не представлено универсального готового решения — оно лишь подходит для достаточно широкого списка случаев. Зато эту статью можно считать обзорно-пятничной: интересная (надеюсь) новая (для большинства?) информация, щепотка брутфорс-программирования и рабочие примеры. И пусть нашу пятницу не омрачает даже тот факт, что сегодня четверг! Для начала определимся: в этой статье не ставится целью обход антиотладочных механизмов, и вообще предполагается, что программа не сопротивляется перехвату системных вызовов. С другой стороны, вручную переписывать исходники, чтобы программа нам помогала, тоже так себе перспектива чисто с точки зрения трудоёмкости. Поэтому LD_PRELOAD. Впрочем, если вы захотите статически вкомпилировать такой механизм в собственную программу, то LD_PRELOAD здесь абсолютно не существенен. Лирическое отступление: как вообще выполняются системные вызовы в Linux? Для этого есть несколько способов. Когда-то процесс (на x86) просто клал в eax номер системного вызова, в ebx первый аргумент и так далее, после чего дёргал int 0x80. В какой-то момент решили, что это то ли не очень быстро, то ли не дружественно к кешу, то ли ещё что-то, и переделали. Потом ещё раз. На данный момент используется vDSO — это вообще инжектируемый в каждый процесс честный shared object, у которого можно дёргать функции, чтобы делать частые системные вызовы, не требующие переключения контекста (например, time). Казалось бы, на этом можно заканчивать статью, но нет: там описано около четырёх отдельных системных вызовов (точное число, скорее всего, зависит от версии ядра), а глобальной точки входа что-то не наблюдается... Можно было бы дальше искать общую точку, выбирающую, через какую инструкцию на этой машине делают системные вызовы (int 0x80 / sysenter / ...) — а может, её и нет вовсе — оставим это упражнением читателю. Давайте лучше обратим внимание на механизм seccomp, обеспечивающий ещё один штатный интерфейс фильтрации системных вызовов. Seccomp означает secure computing и предполагается для обработки не очень доверенного кода. Цитата из man seccomp (2): Ну, после Meltdown&Co люди уже JavaScript в несколько потоков запускать боятся — не знаю, как там с недоверенным бинарным кодом. Впрочем, безопасность в частично доверенном окружении — отдельная тема, и скажу лишь, что хотя seccomp и может работать, например, на уровне отдельных потоков, я бы, естественно, не осмелился сидеть в одном адресном пространстве с запущенным недоверенным машинным кодом. В общем, безопасное «препарирование» malware тоже не является темой этой статьи. Меня в данном случае интересует перехват системных вызовов скорее с точки зрения инструментации. К счастью, кроме режима SECCOMP_SET_MODE_STRICT, принудительно завершающего процесс при выполнении любого вызова кроме read, write, _exit и sigreturn, есть SECCOMP_SET_MODE_FILTER — он позволяет задать BPF-программу, которая будет фильтровать только интересующие системные вызовы, и для отфильтрованных решать, что сделать. Вот тут уже выбор намного больше, но меня сейчас интересует SECCOMP_RET_TRAP: при выдаче этого вердикта конкретному потоку посылается SIGSYS, то есть ровно то, что нужно: можно синхронно обработать запрос и вернуть управление, будто системный вызов произошёл штатно. Для нашего обработчика создадим файл syscall-handler.c: Опишем функцию, обрабатывающую системные вызовы — с этим всё довольно понятно: функция принимает номер системного вызова, (максимум) шесть его аргументов и (возвращаемый) признак, что системный вызов был не только учтён, но и проэмулирован, и реально его выполнять не нужно. Далее всё ещё проще: функция initialize_handler будет настраивать диспетчеризацию всего этого. А что это за загадочная функция constr? Она лишь вызывает initialize_handler, но, поскольку помечена атрибутом constructor, будет вызвана в процессе инициализации библиотеки (или основного выполняемого файла), содержащего эту функцию. Если же нам захочется включить перехват уже после запуска программы, можно заifdefить constr и вызвать initialize_handler вручную. Теперь нужно написать байткод BPF, который будет просто реагировать на нужные номера системных вызовов выдачей SECCOMP_RET_TRAP. Документация на формат программ лежит, в частности, в исходниках ядра в Documentation/networking/filter.txt, при этом для seccomp нужно смотреть на старую версию, не eBPF. Сами файлы linux/seccomp.h, linux/filter.h, а также linux/bpf_common.h, на который тот ссылается, тоже будут весьма полезны. Если сгрести в кучу эти источники, пример из man seccomp и потрясти, получится что-то такое: Поскольку писал я это несколько недель назад, да и тогда это был подрихтованный код из примера, то по старой традиции скажем, что это очевидно оставим читателю в качестве упражнения. В принципе, здесь явно просматриваются опкоды (LD / JMP со смещениями 0 и 1 / RET), условия (JEQ), указание использовать immediate operand (BPF_K) — подробности смотрите в уже упоминавшемся filter.txt. А макросы позволяют не задумываться об упаковке этого всего в слова команд. Зачем нам константа MARKER? Это такой костыль, который позволит всё-таки пропустить системный вызов, если наш userspace-фильтр так решит. Коряво, костыльно — но я и не обещал показать универсальное решение. Впрочем, показанное решение тоже в большинстве случаев работает вполне успешно, при этом тривиально устроено.  Этот самый MARKER мы кладём в шестой аргумент системного вызова в надежде, что это «лишняя деталь» и все шесть аргументов используются редко. Вычитываем его при помощи самой первой инструкции нашей BPF-программы: отсюда это загадочное выражение — offsetof(struct seccomp_data, args[5]) — ядро нам передаёт структуру seccomp_data (см. man seccomp): … и мы из неё вычитываем переменную по требуемому смещению: offsetof(struct seccomp_data, args[5]). Спонсор этого абзаца — aol-nnov Используется это как-то так: handle_sigsys — это new-style обработчик сигналов, так сказать, используемый вместе с функцией sigaction. Про его третий параметр в man sigaction говорится: Что ж, у нас не совсем common case, поэтому для разнообразия будем неиспользовать второй аргумент — там тоже очень много всего интересного, но нет аргументов системного вызова. Здесь начинаются пляски с бубном, причём бубен нужен от правильной архитектуры. Вполне возможно, libseccomp успешно инкостылирует эти архитектурные детали, и просто выдаёт массив аргументов — а если она сейчас этого и не делает, то, возможно, будет потом — но раз уж у нас ознакомительная статья, то будем считать, что такая низкоуровневость — не баг, а фича… Поэтому просто сверимся со второй таблицей из man syscall для требуемой архитектуры, в моём случае — x86_64. В этом же обработчике мы видим ещё одну забавную функцию: syscall — собственно, именно от неё мы man только что и читали. Ей просто передаётся номер системного вызова и аргументы, и она его выполняет. На некоторых архитектурах бывают всякие хитрые ABI с «выравниванием» 64-битных значений в 32-битных регистрах (вычитано всё в той же man-странице) — будем надеяться, на x86_64 это не актуально, иначе бы нам пришлось это парсить в зависимости от номера вызова, вычитывая регистры, поскольку syscall() уже скрывает в себе эту логику. Кроме таких вот странных случаев, как здесь, когда номер вызова определяется динамически, она может использоваться, если у нас по какой-то причине нет libc-шной обёртки для системного вызова. Например, именно так я вызываю seccomp в следующей функции: Этот странный prctl нужен по соображениям безопасности: в man seccomp приводится пример, когда без этого требования можно было бы поместить незадачливый set-user-ID бинарник в виртуальную реальность, в которой setuid вернёт ноль, но ничего не сделает, что будет очень опасно с точки зрения повышения привилегий. Скомпилируем и воспользуемся нашей библиотечкой: Как видим, как минимум на моей системе нужно указывать путь к библиотеке. Хоть относительный, но путь. Хорошо хоть, что загрузчик предупредил об ошибке. Во втором случае всё вроде бы работает. Как мы отлаживаем системные вызовы? Можно, конечно, через команду catch syscall в gdb, но в данном случае (как и в большинстве других, честно говоря) нам поможет чудесный инструмент strace: Параметр -E говорит о том, что нужно выставить указанную переменную для исследуемого процесса. В противном случае пришлось бы вгружать отлаживаемую библиотеку в сам отладчик — так себе перспектива. Сначала мы видим просто процесс линковки динамических библиотек (да-да, в userspace), собственно же обработчик устанавливается где-то здесь, и начинается веселье... Как мы видим, теперь каждый системный вызов печатается дважды, а между ними — пришедший SIGSYS, причём возвращаемые значения в первом напечатанном вызове заполнены мусором. Обратите внимание, например, на fstat: возвращаемые значения — это не только код возврата! Итак, что-то запустить удалось. Попробуем теперь что-то осмысленное: обработать openat, через который libc открывает файлы: впишем в handle_syscall Перекомпилируем и запустим: Причина оказалась проста: мы не указали флаг SA_NODEFER при регистрации обработчика сигнала, поэтому второй SIGSYS должен был бы прийти после выхода из предыдущего обработчика, и получился бы deadlock. Укажем этот флаг в initialize_handler: Дело в том, что есть один широко известный в узких кругах системный вызов с шестью аргументами. mmap называется :) Как известно, используется он даже для выделения памяти, если запрошено больше какого-то определённого размера. example.c: Эм… Оно работает?!? Не получилось: когда mmap вернул ошибку, libc просто воспользовалась brk. А если так... «Ага! — Сказали суровые сибирские лесорубы...» Получили 0xffffffffffffffff, он же -1, он же MAP_FAILED. Поэтому давайте просто представим, что нам неинтересен перехват mmap, и перепишем программу следующим образом: Теперь всё работает: Программист