В этой статье мы разберём небольшой инструмент для трассировки системных вызовов. В отличие от strace и аналогов, здесь трассировка будет происходить внутри процесса, без применения ptrace() или эквивалентных вещей. Должен добавить, что это всего лишь демонстрационный пример, поэтому на практике он и близко не сравнится с strace. В частности, он пока не может точно выводить в консоль аргументы для большинства системных вызовов.

Именно на примере системных вызовов удобно продемонстрировать цепочную загрузку, и на то есть три причины.

Во-первых, это практически неосуществимо при помощи LD_PRELOAD, действующего в паре с более высокоуровневой библиотекой C. Динамический линковщик здесь тоже не поможет, так как просто не видит системные вызовы.

Во-вторых, описанные здесь операции функционально близки именно к тому, что очень часто люди пытаются проделать при помощи LD_PRELOAD, а именно, изменить поведение конкретных системных вызовов. Нам больше не придётся переопределять функционал библиотечных обёрток C; мы сможем перехватывать сами системные вызовы и отлавливать определённые случаи, остающиеся невидимыми на уровне библиотеки C.

В-третьих, именно перехват системных вызовов обеспечивает основу для более универсальной предзагрузки и произвольного инструментального оснащения программ. Если нам удастся отловить все системные вызовы, привносящие в адресное пространство новые инструкции, и при этом не допустим туда попадание инструкций другими путями, то буквально сможем обеспечить полное инструментирование всего кода.

Несколько лет назад мы с Патриком Ламбейном написали библиотеку, которая может патчить и эмулировать большинство системных вызовов на x86_64 Linux. Для этого мы применили совершенно простой приём с двойным захватом: сначала затираем системный вызов инструкцией ud2, которая в x86 «всегда недопустима», а затем обрабатываем результирующий SIGILL. Обработчик может выполнить как реальный системный вызов, так и любую другую требуемую операцию, а затем вернуться. Это не самая быстрая, но надёжная и простая реализация — как минимум в 90% случаев. Например, можно дизассемблировать функцию _exit из библиотеки C, в которой ранее содержались следующие инструкции:

... 
44 89 c0                mov    %r8d,%eax
0f 05                   syscall  
48 3d 00 f0 ff ff       cmp    $0xfffffffffffff000,%rax
76 e2                   jbe    0x7f173bf269c0 <_exit+32>
...

В нашей библиотеке это затиралось и принимало вид:

... 
44 89 c0                mov    %r8d,%eax
0f 0b                   ud2  
48 3d 00 f0 ff ff       cmp    $0xfffffffffffff000,%rax
76 e2                   jbe    0x7f173bf269c0 <_exit+32>
...

И всегда приводило к SIGILL. Наш обработчик SIGILL наскребает аргументы системных вызовов, затем собирает их в структуру и передаёт тому зарегистрированному обработчику, который у нас установлен. Например, вот как выглядит обработчик трассировки, выводящий в консоль информацию и возобновляющий работу системного вызова.

void print_pre_syscall(void *stream, struct generic_syscall *gsp, void *calling_addr, struct link_map *calling_object, void *ret) {
        char namebuf[5];
        snprintf(namebuf, sizeof namebuf, "%d", gsp->syscall_number);
        fprintf(stream, "== %d == > %p (%s+0x%x) %s(%p, %p, %p, %p, %p, %p)\n",
                getpid(), 
                calling_addr,
                calling_object ? calling_object->l_name : "(unknown)",
                calling_object ? (char*) calling_addr - (char*) calling_object->l_addr : (ptrdiff_t) 0,
                syscall_names[gsp->syscall_number]?:namebuf,
                gsp->args[0],
                gsp->args[1],
                gsp->args[2],
                gsp->args[3],
                gsp->args[4],
                gsp->args[5]
        );
        fflush(stream);
}
// Что именно вызывается из обработчика SIGILL 
void systrap_pre_handling(struct generic_syscall *gsp)
{
        void *calling_addr = generic_syscall_get_ip(gsp);
        struct link_map *calling_object = get_highest_loaded_object_below(calling_addr);
        print_pre_syscall(traces_out, gsp, calling_addr, calling_object, NULL);
}

Чтобы гарантировать правильное инструментирование всего загруженного кода с использованием ud2s, я недавно стал дополнительно применять в данном случае предзагрузку. Ранее я ограничивался перехватом вызовов mmap()mremap() и mprotect(), тем самым гарантируя, что если гостевое приложение попросит создать для него ещё немного исполняемой памяти, то мы сначала инструментируем его содержимое, а затем позволим ему выполняться.

Но как вообще ввести весь этот код в процесс? Сначала я пользовался для этого старым добрым LD_PRELOAD. Мы предзагружали библиотеку, инициализатор которой пробирался через загруженный текст, затирал системные вызовы, превращая их в ud2, и устанавливал обработчик SIGILL. Способ куда ни шло, но в таком случае упускается самое начало выполнения. При таком подходе трассировщик не в состоянии отследить что-либо, происходящее до срабатывания инициализатора — например, в инициализаторах других библиотек или внутри динамического линковщика. Получилось следующее:

$ LD_PRELOAD=`pwd`/trace-syscalls.so /bin/true
== 26439 == > 0x7f6fb2a4f9d4 (/lib/x86_64-linux-gnu/libc.so.6+0xc99d4) exit_group(0, 0x3c, 0, 0x7fff766064b0, 0xe7, 0xffffffffffffff80)

Приятно, что при true, по-видимому, выполняется всего один системный вызов, а именно, exit_group() — по идее, так и должно быть. Но, если попробуем strace, то увидим, что этот процесс делает гораздо больше сверх выполнения одного системного вызова.

$ strace /bin/true
brk(0)                                  = 0x2447000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f6ba773c000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/usr/lib/x86_64-linux-gnu/libfakeroot/tls/x86_64/x86_64/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
stat("/usr/lib/x86_64-linux-gnu/libfakeroot/tls/x86_64/x86_64", 0x7ffd49ca38e0) = -1 ENOENT (No such file or directory)
... snipped a *lot*! ...
arch_prctl(ARCH_SET_FS, 0x7f6ba7578740) = 0
mprotect(0x7f6ba7732000, 12288, PROT_READ) = 0
mprotect(0x605000, 4096, PROT_READ)     = 0
mprotect(0x7f6ba7765000, 4096, PROT_READ) = 0
exit_group(0)                           = ?
+++ exited with 0 +++

Все эти прочие более ранние системные вызовы происходят на этапе динамического связывания, вернее, ещё до того, как начнёт работу наш конструктор, собирающий системные вызовы в ud2. (На странице со справкой по strace сетуют: «прискорбно, что при работе систем, использующих в ходе трассировки разделяемые библиотеки, остаётся так много мусора». Я же считаю, что лучше знать истину!)

Давайте переупакуем наш код в виде цепочного загрузчика, как было описано выше. В таком случае он всегда будет выполняться в первую очередь. Поскольку именно он отвечает за загрузку динамического линковщика, мы можем быть уверены, что все его системные вызовы изначально будут перехватываться. После того, как мы отобразим в память «настоящий» динамический линковщик, нам потребуется инструментировать его код. Для этого обернём функцию enter() из библиотеки donald.

void __wrap_enter(void *entry_point)
{
        __libsystrap_force_init();
        init_fds();
        /* ... */
        trap_all_mappings(); // only ld.so is loaded right now
        install_sigill_handler();
        /* ... */
}

А что насчёт кода, загружаемого динамическим линковщиком? Попытаемся разобраться с ним на этапе предзагрузки. Всякий раз, когда код загружается в процесс, за это отвечает какой-то системный вызов. Поэтому, пока нам будет удаваться отловить именно этот вызов, мы сможем перехватывать системные вызовы во время загрузки. Немного поколдовав с макросами, сможем сгенерировать обёртки, а также нам потребуются ещё некоторые не упомянутые здесь манипуляции, чтобы изготовить структуру ELF-уровня из файла, отображаемого в память.

#define mmap_args(v, sep) \
    v(void *, addr, 0) sep   v(size_t, length, 1) sep \
    v(int, prot, 2) sep      v(int, flags, 3) sep \
    v(int, fd, 4) sep        v(unsigned long, offset, 5)
bootstrap_proto(mmap)
{
    if (!(prot & PROT_EXEC)) return mmap(addr, length, prot, flags, fd, offset);
    // иначе... всегда отображаем файл, который исходно доступен для записи и не является исполняемым 
    int temporary_prot = (prot | PROT_WRITE | PROT_READ) & ~PROT_EXEC;
    void *mapping = (void*) mmap(addr, length, temporary_prot, flags, fd, offset);
    if (mapping != MAP_FAILED)
    {
        /* ... */
        if (/* нужно перехватывать*/)
        {
            inferred_vaddr = (uintptr_t) mapping - matched->p_vaddr;
            trap_one_executable_region_given_shdrs(mapping, (void*) mapping + length,
                    filename, /* доступен для записи */ 1, /* доступен для чтения */ 1,
                    shdrs, ehdr.e_shnum,
                    inferred_vaddr);
            // Хорошо, прерывание выполнено, теперь давайте позаботимся о защите(всегда делается по-разному)
            ret = mprotect(mapping, length, prot);
            if (ret != 0) goto fail;
            /* ... */
        }
        /* ... */
    }
    /* ... */
}

Чтобы всё это работало под Linux/x86 (32- и 64-разрядном), в некоторых случаях потребовался «интересный» код сверх описанного фокуса с цепочным загрузчиком. В актуальной версии присутствуют некоторые неподдерживаемые фичи (в частности, sigaltstack()), но, тем не менее, многие функции работают благодаря следующим хитростям.

  • Имитация vDSO. Особенно важна в 32-разрядной версии x86, где большинство библиотечных вызовов на C реализуются как системные через механизм vDSO от Linux, а именно через его системный вызов __kernel_vsyscall — это делается из соображений производительности. Ядро выдаёт его в такой форме, которая позволяет использовать самый быстрый механизм выполнения системных вызовов на конкретной микроархитектуре. На 32-разрядных машинах Intel это обычно sysenter; в AMD предусмотрен собственный syscall, и такая структура позже была унаследована в реализациях x86-64 от обоих вендоров. Один из самых трудноуловимых багов возникал в ситуации, когда ядро возобновляло работу после вызова sysenter, учитывая, что эта инструкция нигде не сохраняла значение программного счётчика. (Я вас не разыгрываю. Детали я опускаю, но можете сами посмотреть код, хотя, мне он не кажется особенно удобочитаемым.)

  • Процессы, в которых используются развилки или потоки. Для них требуется путаный код. В Linux работа с потоками выполняется при помощи обобщённого вызова clone(). При успешном clone() стек потомка заменяется новым стеком, который предоставляет вызывающая сторона. Следовательно, если пользовательский код делает clone(), то с ним сразу возникает проблема. Описанная выше замена на ud2 позволит сохранять в стеке значительную часть состояния, в том числе, информацию о том, откуда именно возобновлять выполнение программы, когда будет закончена обработка SIGILL. Но clone() полностью заменит стек! Как возобновить выполнение программы после предусмотренной пользователем точки перехвата, уже после того, как мы выполнили клонирование в нашем обработчике сигнала? Если вам в самом деле интересно… правда, это такая кроличья нора, которая заслуживает отдельного поста.

  • Проникновение через execve. В обычных условиях LD_PRELOAD проникает до дочерних процессов (впрочем, за исключением случаев, в которых предок устанавливает собственный LD_PRELOAD и затирает его, и в некоторых случаях с setuid и...). Чтобы всё то же самое произошло без нашего участия, нужно что-то сделать в execve(). Эмулированная нами версия просто вызывает реальную, но вставляет цепочный загрузчик в командную строку. Удобно, что на уровне системных вызовов есть всего два вызова из семейства execve (execve() и execveat()); такие варианты как execl()execlp()execv() и т.д. в библиотечном коде сводятся к execve(). Но и вставка цепочного загрузчика — не самая тривиальная операция. В то время, как ядро с готовностью применяет execve() при работе с некоторыми достаточно экзотическими файлами, цепочному загрузчику не обойтись без файла ELF в качестве аргумента. Поэтому приходится работать через #! в пользовательском пространстве. (Также пока откажем себе в binfmt-misc, хотя, поддержку этого механизма можно добавить.)

    • Кстати, интересный вопрос: почему бы не изыскать способ запустить код ядра, который именно это и делает? Это можно было бы сделать в адресном пространстве, либо путём отображения двоичного файла ядра. Это не привилегированный код. В идеале это должна быть своего рода библиотека, доступ к которой открыт как из пользовательского кода, так и из кода ядра. Подобное вынесение кода за скобки реализовано в концепции «остаточного ядра» (rump kernel) в NetBSD. Если обустроить такую библиотечную архитектуру, то какую долю ядра можно было бы подтянуть в эту библиотеку? В идеале хотелось бы поднять сюда и загрузчик, чтобы нам вообще не пришлось писать цепочный загрузчик самостоятельно!

  • Перехват sigreturn() Есть и ещё один системный вызов, который нельзя обрабатывать наравне с другими: sigreturn(). (Также rt_sigreturn() в 64-разрядной архитектуре x86.) Если пользовательский код не устанавливает никаких обработчиков сигналов, то и беспокоиться об этом незачем. Но в противном случае, когда его обработчик справится с задачей, он попытается самостоятельно выполнить возвращение сигнала. Если перехватить эту операцию, то, благодаря SIGILL, получится «вложенный фрейм сигнала». Любая попытка обработать его на общих основаниях, пожалуй, не сработает, поскольку программа попытается откусывать сигнальные фреймы в неверном порядке: «последним пришёл — первым обслужен». Сначала она просто очистит наш собственный фрейм сигнала (с перехватом), в результате мы будем не в состоянии ничего сделать с исходным пользовательским возвратом сигнала: (напомню: гостевая программа просит выполнить возврат сигнала!). В основном выполнение конкретных системных вызовов из контекста фрейма сигнала происходит прозрачно, поскольку системный вызов не касается пользовательского стека и даже не заглядывает в него. В случае с sigreturn сам вызов предлагает ядру манипулировать стеком. Тем временем мы уже успели вставить здесь наш собственный фрейм сигнала, поэтому указатель стека направлен не туда, куда нужно. В данном случае указатель стека можно расценивать просто как дополнительный аргумент к любому системному вызову, но обычно этот аргумент игнорируется, а в случае с sigreturn — нет. Конечно, то же самое касается и clone(), и я обрабатывал его по‑особому. Мне всё равно придётся работать с sigreturn; пока я просто схитрил и не стал перехватывать эти системные вызовы. Пожалуй, в данном случае было бы правильно целиком эмулировать возврат сигнала в пользовательском пространстве, необратимо изменить сохранённый контекст, а затем выполнить собственный (настоящий) возврат сигнала, чтобы таким образом покинуть контекст прерывания. (См. дискуссию в Твиттере о том, как это может работать и может ли вообще.)

Но есть и хорошая новость: обычно это срабатывает. В частности, можно сделать вот так:

$ ./trace-syscalls-ld.so /bin/true

и получить «полный» вывод, точно, как от strace, а не старый вывод, в котором недоставало многих системных вызовов.

Насколько всё это вообще корректно? Такой подход с предзагрузкой обеспечивает нам 100% покрытие системных вызовов лишь при некоторых допущениях.

  • 1. Любой системный вызов может выполняться только через выделенную инструкцию.

  • 2. Мы контролируем, когда именно эти инструкции добавляются в адресное пространство и добавляются ли вообще. В частности,

    • 2a. Ввести в процесс новые исполняемые инструкции можно только при помощи системного вызова, обеспечивающего отображение в память (в том числе, mprotect() и ему подобных).

    • 2b. Можно игнорировать или (как-нибудь) предотвратить возможность перехода прямо в середину инструкции.

Допущение 1 не соблюдается для gettimeofday() в vdso Linux, который реализует эту функцию как операцию чтения из памяти ядра. Никакие «интересные» системные вызовы так не реализуются. Но в самом ли деле мы имеем в виду только «системные вызовы»? Можно утверждать, что пути для обработки отказов — тоже своего рода системные вызовы. Мы не можем отслеживать входы и выходы в них; впрочем, и strace этого не делает. Доставка сигнала также немного похожа на системный вызов, а мы такую операцию не отслеживаем, в отличие от strace (которая получает все события, предоставляемые ptrace, в том числе, сигналы). Некоторые из таких операций можно было бы отследить, установив для всех сигналов наши собственные обработчики, но тогда пришлось бы очень постараться, чтобы сохранить оригинальную семантику программы… Для начала тут в полный рост встаёт проблема «прозрачности» в том виде, как она была артикулирована здесь.

Чтобы соблюдалось допущение 2a, необходимо запретить такие отображения, которые одновременно доступны для чтения и для записи. Некоторые программы, например, динамические компиляторы V8 или PyPy, всё равно склонны создавать исполняемую память и записывать в неё. Если разрешить подобные отображения, то мы никак не сможем синхронно модифицировать код прежде, чем он станет исполняемым. Насколько я могу судить, луче всего и далее поддерживать в этих программах возможность эмуляции отображений «сразу для чтения и выполнения», адаптивно переключаясь между реально доступными для записи и реально исполняемыми единицами. Ошибки сегментирования при этом нужно обрабатывать в пользовательском пространстве. Если всё это делать без оптимизаций, то издержки должны быть очень велики, но ситуацию должен серьёзно облегчать адаптивный гистерезис (возможно, настолько простой как «изменить знак после N отказов»).

Более тонкий вариант аналогичной проблемы заключается в необходимости избегать явно выраженных одновременных отображений W и X одного и того же объекта, например, файла, который MAP_SHARED. Эффект был бы такой же, как от отображения файла, одновременно доступного для записи и исполнения. Такая проверка, в принципе, легко осуществима, но для неё потребуется отслеживать индексный дескриптор и номер устройства за каждым имеющимся отображением файла. (Дескрипторы файлов — OK, поскольку по мере надобности к ним можно применить fstat().) Вероятно, при попытках повторно отобразить один и тот же объект должно возвращаться -ETXTBSY. Пока могу оставить вам эту задачу для самостоятельного изучения.

Допущение 2b подводит нас к проблеме «явных» и «неоднозначных» инструкций. В x86 отсутствует требование согласования инструкций, поэтому вполне можно заглянуть в середину инструкции и обнаружить, что она делает совсем не то, что предполагалось, и это может также распространяться на системные вызовы. Например, безобидная инструкция

 mov    $0x9090050f,%eax

на ассемблере принимает вид:

 b8 0f 05 90 90

Но, если углубиться в эту последовательность на один байт, то обнаружится syscall, за которым следуют два nop. Предотвратить это сложно. Пожалуй, можно просто поверить, что код не будет делать такого. Может быть, при написании программы мы задействовали какой-то тяжеловесный механизм для обеспечения целостности потока управления (а также можем проверить, удовлетворяет ли динамически загруженный код его требованиям). Может быть, мы работаем в архитектуре с обязательным согласованием инструкций (то есть, не x86). В x86, где есть достаточно универсальный трамплинный механизм, основанный на переходах, который сам каламбурит инструкции, можно принудительно заменить заглушками любые инструкции или пары инструкций, внутри которых содержится байтовая последовательность системного вызова. Этой меры будет достаточно лишь при условии, что сам ваш трамплин гарантированно не содержит таких инструкций. При наличии непосредственных констант дать такие гарантии достаточно сложно… так, требовалось бы запретить, к примеру, любые включения байт 0f 05. То же касается адресов или смещений, но ситуация упрощается при адресации относительно регистра PC (нам самим придётся выбирать, где поставить трамплин). Скорее всего, можно просто положиться на крайнюю редкость таких непосредственных констант и считать, что, если просто затереть инструкцию, заменив её на ud2, а затем эмулировать чем-нибудь вроде libx86emulate, никто не заметит никакого замедления.

Уф. Не думал, что мы настолько углубимся в тему. Но суть поста в том, что, судя по первым впечатлениям, всё это возможно. Мы можем перехватывать системные вызовы в рамках цепочного загрузчика, обеспечивая таким образом предзагрузку, и тем самым управлять взаимодействиями программы в пределах всего интерфейса системных вызовов. Так мы сможем разрабатывать всевозможные инструменты, подобные LD_PRELOAD, но непосредственно на уровне системных вызовов и с более уверенным покрытием.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud  в нашем Telegram-канале 

Перейти ↩

? Читайте также:

Комментарии (0)