Салат Фибоначчи: ускоряем минимизатор исходников с помощью seccomp и форк-сервера

    Как говорилось в одном анекдоте, «Салат Фибоначчи готовится из того, что осталось от вчерашнего и позавчерашнего салата Фибоначчи». Вот и сейчас попробуем на практике применить перехват системных вызовов через seccomp для целей ускорения минимизации исходника при сохранении инварианта. До кучи, проблема будет решаться посредством инжектирования форк-сервера, очень похожего на тот, который используется в American Fuzzy Lop. И всё это будет управляться из Java-кода.


    Для тех, кто уже настроился почитать про модификацию чужих процессов через ptrace прямо из Java — нет, всё не настолько сурово, я просто на ходу собираю .so из .c и вгружаю через LD_PRELOAD.


    Для тех же, кто уже подумал «Знаю я этот AFL — компилятор придётся патчить и пересобирать!», скажу, что в том и смысл использования seccomp: мы на ходу поймаем момент, когда произойдёт первое обращение ко входному файлу.


    Есть конечно и ложка дёгтя: компилятор должен быть однопоточным, однопроцессным и на Линуксе, но в реально-тестовой задаче минимизации примера бага в компиляторе из OpenModelica удалось добиться ускорения раз в 5.


    DISCLAIMER: Технически, автоматическое инжектирование .so в процесс, запускаемый от имени собственного пользователя, может представлять некоторый риск с точки зрения безопасности. Например, если минимизатор распакует исходник не с теми правами или не туда, а шустрый атакующий успеет его поменять перед компиляцией, то атакующий фактически выполнит свой код от имени нашего пользователя. Впрочем, мне сложно представить, какую опасность это представляет конкретно для меня как единственного реального пользователя на домашнем компьютере (тут уже уместнее опасаться банально «взбесившегося» компилятора, рандомно пишущего куда не надо), но в общем случае реальной многопользовательской системы — я вас предупредил.


    Fork server — что это?


    Fork server — это способ уменьшить время выполнения одного процесса из тысяч и миллионов запусков программы на разных тестовых данных: общая для всех процессов инициализация занимает константное время на старте, а потом процесс начинает «почковаться». Не берусь утверждать, кто первый это придумал, но лично я познакомился с таким подходом по American Fuzzy Lop. Фаззер AFL постепенно «корёжит» входные данные и передаёт их тестовой программе, обычно по 100-1000 запусков в секунду на ядро, а то и больше. В принципе, есть подход LibFuzzer — насколько я знаю, там процесс обязан сам откатить всё своё состояние перед обработкой следующих входных данных (AFL llvm mode, вроде, умеет комбинировать такой подход с периодическим перезапуском через fork server — persistent mode, но это не точно). С другой стороны, полный перезапуск процесса в общем случае позволяет добиться намного большей стабильности поведения при намного меньших затратах на ручную подготовку тестируемого кода программистом. При этом, как я понял из изначальной статьи про fork server, динамическая линковка при загрузке тоже не бесплатная.


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


    Название «fork server», естественно, появилось не случайно: fork — это традиционный системный вызов на Unix и Unix-like системах, раздваивающий выполнивший его процесс (на Linux он обычно заменяется внутри libc на более общий clone, но в данном случае это не важно). Важно то, что у этого системного вызова вечно какие-то особенности с потоками и примитивами синхронизации. Для нашей же задачи это выглядит как тот факт, что на многих ядрах, включая Linux, в ответвившемся процессе будет только тот поток, который выполнил fork. Да и вообще, если почитать man fork(2), то после выполнения fork в многопоточном коде, там вообще мало что можно делать, кроме execve (точнее, делать что-то можно, но постоянно сверяясь с man signal-safety(7)).


    Неужели компилятор патчить?


    В «классическом» форк-сервере из AFL предполагается некая фиксированная точка, где он начинает «почковаться» — заход в main или что-то около того. В llvm-mode также поддерживается явный вызов __AFL_INIT(), чтобы при отключении автозапуска (которое внутри реализовано через проставление переменной окружения) можно было создать форк-сервер после длительного процесса инициализации. Важно то, что «подготовительная» часть, выполняемая единожды, не должна никак зависеть от входных данных.


    Увы, оба подхода требуют перекомпиляции тестируемой программы. Мне же хотелось рассматривать компилятор как некий чёрный ящик. Дело здесь даже не в том, что мне очень нужно тестировать closed source компиляторы, а в том, что и мне-то лень что-то вручную патчить, а возможно, и держать отдельную «очень отладочную» сборку, даже если я сам локально собирал компилятор. Минимизатор же хоть в целом и предназначен для программистов, но всё-таки есть разница между «Мне нужно скомпилировать свою программу компилятором» и «Мне нужно скомпилировать сам компилятор, а сначала его ещё и пропатчить». Забегая вперёд скажу, что автозапуск форк-сервера позволяет ещё и намного проще обрабатывать случай интерпретации скрипта, который сначала грузит какие-то библиотеки, а потом той же функцией грузит тестовые файлы — даже если патчить интерпретатор, получились бы хитрые костыли ручной работы.


    Итак, попробуем написать фильтр системных вызовов. Если вы не читали статью про seccomp, рекомендую предварительно с ней ознакомиться (ну или подглядывать в неё по ходу дела).


    Как я уже описывал, есть две важные части фильтра. Одна из них — это фильтр, загружающийся в ядро как BPF-байткод (не путать с eBPF), переданный системному вызову seccomp. В целом, он может быть настолько банален, как «перехватывать всё и генерировать SIGSYS». Разве что, всё-таки придётся как-то извернуться и сделать возможность пропустить насквозь вызовы, выполняемые после обработки. Ну и в таком простом случае наверняка будут существенно большие накладные расходы на постоянные вызовы обработчика сигнала. Что определённо будет раздражать в этом случае — так это мешанина в выводе strace при отладке. Для данной же задачи нас интересует буквально десяток системных вызовов (больше или меньше в зависимости от требуемой надёжности определения). Для большей простоты экспериментов программа BPF у меня генерируется на лету. Конкретный код приводить не буду — он скорее нудный, чем сложный. Устроен он таким образом: сначала я задаю список системных вызовов, которые требуется перехватить:


    static int inspected_syscalls[] = {
        // возможные точки запуска
        SYS_open, SYS_openat, SYS_stat,
        // подозрительно
        SYS_execve, SYS_execveat,
        // это тоже подозрительно
        SYS_fork, SYS_vfork, SYS_clone, SYS_clone2
    };

    Далее формирую программу, которая для указанных номеров системных вызовов возвращает SECCOMP_RET_TRAP, а для всех остальных, а также тех, у которых 5-й с нуля аргумент равен специальному маркеру, возвращает SECCOMP_RET_ALLOW. В этот раз у нас нет абсолютной надобности пропускать шестиаргументные вызовы насквозь — ну, разве что, clone и clone2 после запуска форк-сервера можно было бы пропустить насквозь, но пока они просто будут вызывать аварийное завершение программы. Можно было бы их не контролировать вообще, если пользователь уверен в этом. Проблема будет, разве что, если мы хотим запуститься ровно после первого clone. Тут уже можно было бы попробовать извратиться с отладкой через ptrace из Java и ручной посылкой сигнала, запускающего fork server, вообще без seccomp, но не в этот раз...


    Фактически, программа состоит из нескольких инструкций, проверяющих наличие маркера, и если не совпало, загружающих номер вызова из переданной ядром структуры с описанием ситуации (аргумент, предположительно содержавший маркер, мы грузили тоже оттуда). Потом идёт пачка условных переходов на выдачу вердикта TRAP, после (если ничего не совпало) выдаётся вердикт ALLOW, а уже после — та самая инструкция, возвращающая TRAP.


    Разве что, хотелось обратить внимание на не совсем очевидную синтаксическую деталь: в man-странице программа описывается как


    struct sock_filter filter[] = {
       BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, arch))),
       ...
    };

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


    filter[i] = BPF_STMT(...);

    компилироваться не будет — по крайней мере, не с любой версией стандарта C. А компилироваться оно должно ещё и на пользовательской системе. К счастью, легко нагуглить, что можно использовать синтаксис


    filter[i] = (struct sock_filter) BPF_STMT(...);

    Вторая важная часть фильтра — это обычный обработчик сигнала SIGSYS, написанный на C. Опять же, не буду полностью пересказывать его устройство в случае seccomp, напомню лишь, что нам придётся (по крайней мере, если без использования libseccomp) работать с переданным контекстом через конкретные имена регистров. Поэтому предлагаю просто описать их как


    #if defined(__i386__)
    #  define SC_NUM_REG  REG_EAX
    #  define ARG_REG_1   REG_EBX
    // ...
    #  define ARG_REG_6   REG_EBP
    #  define RET_REG_1   REG_EAX
    #elif defined(__amd64__)
    // ...
    #else
    #  error Unknown CPU architecture
    #endif

    Конкретные значения можно переписать из табличек в man syscall(2).


    Это хорошо, а Java где?


    В конечном итоге, управлять этим форк-сервером будет программа на Java. Можно, конечно, взять что-нибудь мощное вроде JNA (не путать с JNI) — с его помощью наверняка получится воссоздать AFL'овское управление через файловые дескрипторы 198 и 199 и всё остальное. А что, если обойтись без изменения списка внешних зависимостей со стороны Java? Да ещё и оставить минимально возможный интерфейс.


    У меня получился примерно следующий протокол:


    • Всё общение с форк-сервером осуществляется через stdin, stdout и stderr
      • Вообще-то, из JVM можно запросто открыть UDP-сокет и не городить костылей, но это ещё одна точка, ввод из которой придётся считать недоверенным. Возможностей же подмены stdout без прав отладчика я не припомню.
    • Чтение stdout и stderr всегда продолжается вплоть до конца строки, содержащей текст ## FORKSERVER -> SCM ## — всё, что после него и до '\n' считается текстовым возвращаемым значением. Одно из этих значений всегда пустое — можно произвольно выбрать, в каком из двух потоков, и придерживаться этого правила.
      • Чтобы как-то уравновесить этот мегакостыль, я в обязательном порядке использую ограничение максимального времени выполнения «отпочковавшегося» процесса
      • Ответ всегда пишет родительский процесс, получив информацию о коде возврата или необработанном сигнале через waitpid
    • Командой запуска очередного процесса является запись одного любого байта в stdin после получения предыдущего ответа
      • Сразу после старта форк-сервер выдаёт ответ INIT, что является самым первым приглашением для запуска, на которое можно ответить

    Вроде бы, такая вот паутинка из костылей должна обеспечивать корректную работу этого протокола, используя лишь стандартные потоки ввода-вывода, да ещё и совместно с основным процессом. Разумеется, сам компилятор никогда не должен выдавать этот самый маркер ## FORKSERVER -> SCM ##. Технически, его даже можно было бы делать переопределяемым из командной строки минимизатора или вообще случайным и передаваемым инжектируемому коду на C через переменную окружения. Кстати о птичках, с точки зрения проставляемых переменных, протокол общения выглядит так:


    • __SCM_INPUT_0, __SCM_INPUT_1 и далее, пока очередная переменная не окажется отсутствующей — имена входных файлов. Имеет смысл использовать абсолютные пути, чтобы не зависеть от смены текущего каталога.
    • __SCM_TIMEOUT — таймаут (скажем, в секундах) на запуск одного процесса-потомка

    Также потребуется выставить для ld-linux.so:


    • LD_PRELOAD — путь до инжектируемой библиотеки. Это должен быть именно путь — можно относительный, можно вида ./file.so, но просто file.so у меня не работает
    • LD_BIND_NOW — в принципе, не критично. Просто форсирует разрешение всех символов перед запуском main, чтобы не повторяться в процессам-потомках. AFL проставляет, а мы чем хуже? Другой вопрос, что без форк-сервера, скорее всего, может замедлить выполнение. Вероятно, замедление будет существенным для программ с большим количеством больших зависимостей.

    Как распространять нативные библиотеки?


    В принципе, динамические библиотеки обычно «таскают с собой», собранными подо что только можно. В моём случае это был бы только Linux под разные аппаратные архитектуры. Давайте, смеха ради, вообще таскать с собой только исходник! С одной стороны, это добавляет пользователю необходимость иметь в системе gcc или clang, но, по крайней мере, до Secure Boot, вроде бы, было обычным делом локально собирать пакеты драйверов ядра через dkms, поэтому компилятор C должен был бы быть (вот C++ — уже не факт). С другой — это радикально упрощает процесс сборки. С третьей — может открыть некую уязвимость в случае кривой работы с временными файлами (но, в целом, уязвимостью это будет разве что в true multi-user окружении).


    Я же пока опишу, как сделан мой текущий deployment через компиляцию на локальной системе:


    // Застолбим имена входного и выходного файлов
    Path input = Files.createTempFile("pmd-scm-forkserver-", ".c");
    input.toFile().deleteOnExit();
    Path output = Files.createTempFile("pmd-scm-forkserver-", ".so");
    output.toFile().deleteOnExit();
    // Распакуем исходник форк-сервера из ресурсов
    Files.copy(getClass().getResourceAsStream("forksrv-preload.c"), input,
               StandardCopyOption.REPLACE_EXISTING);
    // Поймём, чем на этой системе принято компилировать
    String compiler = System.getenv("CC");
    if (compiler == null) {
        compiler = "cc";
    }
    // Скомпилируем обычную .so'шку
    Process compilerProcess = new ProcessBuilder()
        .command(compiler, "--shared", "-fPIC", input.toString(), "-o", output.toString())
        .inheritIO()
        .start();
    int exitCode = compilerProcess.waitFor();
    if (exitCode != 0) {
        throw new IOException("Cannot compile forkserver preloaded object: compiler exited with code " + exitCode);
    }

    При запуске важно учитывать, что при делегировании разбора командной строки стандартному /bin/sh -с <...> (что выглядит наиболее логичным выбором, если вся командная строка передаётся вам в единственном строковом параметре), проставлять LD_PRELOAD придётся с помощью синтаксиса LD_PRELOAD=/path/to/library.so компилятор аргументы... внутри строки, передаваемой командному интерпретатору, потому что ProcessBuilder.environment задаст переменные и для самого интерпретатора.


    Дальше на стороне Java начинается аккуратная работа с выходными потоками компилятора (мне для этой цели хватило BufferedReader), а на стороне preloaded object — попытки понять, когда же происходит открытие входного файла. Тут есть некоторые подводные камни:


    • Как компилятор может взаимодействовать с исходниками? open("файл-с-кодом", O_RDONLY). А ещё? В некоторых случаях может потребоваться перехватить stat, которому передаётся строковое имя файла, а не уже открытый дескриптор. Иногда может потребоваться ещё что-нибудь перехватить...
    • В то время как программисты привыкли, например, к стандартной функции open, libc отправляет в ядро вызов openat(AT_FDCWD, <...>)… но это не точно: open тоже является системным вызовом, и на другой системе может выполняться именно он. Есть и другие системные вызовы с суффиксом at
    • Хорошо, мы перехватили системный вызов со строковым аргументом. Как мы узнаем, наш ли это файл — strcmp((const char *)arg, input_files[i]) == 0? Боюсь, рискованно недооценивать возможные преобразования путей: например, относительный путь может быть преобразован в абсолютный, символьная ссылка может быть разыменована и т.д. К счастью, посредством stat (2) можно узнать идентификатор устройства и номер inode файла — по этой информации сравнивать файлы, вроде бы, должно быть намного надёжнее.

    Кстати, с момента написания статьи про фильтрацию системных вызовов я узнал, что функция syscall, всё-таки преобразует возвращаемое значение. Пользователю оно, может, и удобнее, но могут потребоваться не совсем очевидные пляски с errno и кастомной обработкой результата системного вызова перед тем, как записать его в выходной регистр в контекст, переданном обработчику SIGSYS.


    Итог


    Ну и немного о том, как этим пользоваться. Тут всё зависит от тестируемого компилятора: например, отключить все нативные потоки (те, которые не green thread'ы на корутинах, а настоящие потоки с точки зрения ядра) в OpenJDK будет, вероятно, нетривиально. С другой стороны, компилятор OpenModelica, ради которого это, во многом, и делалось, тоже порождает некоторое количество потоков, но они нужны для сборщика мусора, и отключаются с помощью флагов командной строки и/или переменных окружения. В идеале — вместе со сборщиком, чтобы время не тратить :) Также придётся написать mos-скрипт, который сначала выполнит загрузку всех требуемых библиотек, а уже потом притронется к минимизируемым исходникам. То есть настройка конкретного компилятора все-таки требуется, но эти вопросы могут быть единожды описаны разработчиками в readme, и пользователю будет достаточно просто указать требуемые флаги.


    Исходный код

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое