Заразить во благо: как мы исполняем паразитный код
В последнее время мы много говорим про CRIU — систему живой миграции контейнеров. Но сегодня речь пойдет о еще более любопытной разработке: живом патчинге приложений, а также о библиотеке Compel, которая позволяет вытворять все эти безобразия, придавая гиперконвергентным системам новый уровень гибкости.
В прошлом посте мы говорили о том, что живая миграция актуальна для различных операционных систем, которые в отличие от контейнеров работают непрерывно и содержат массу полезных данных. Однако на сегодняшний день это только одна сторона разработок в данной сфере. Ведь не менее чувствительными к остановке оказываются приложения с длительным сроком жизнедеятельности, такие как системы управления базами данных, файловые хранилища и так далее.
Возьмем к примеру СУБД. Да, ее можно убить, как контейнер и запустить заново. Но сколько времени потребуется на повторяю загрузку — одному богу известно. Процесс восстановления кэша из терабайтов разнообразных данных может потребовать массу дополнительных ресурсов. Стоит ли говорить, что во время его повторного формирования производительность всех систем может быть значительно снижена? А ведь такой процесс может продолжаться несколько часов. Другой пример — сервисы, обслуживающие долгоиграющие сетевые соединения. Увы, далеко не все протоколы предоставляют сегодня REST-style API. На практике встречается много случаев, при которых надо поддерживать соединение с клиентом в течение длительного времени. Перезагрузка такого приложения чревата потерей доступа к сервису.
Библиотека Compel
В проекте CRIU были использована одна очень интересная возможность — выполнение процессом паразитного кода. Это было необходимо для того, чтобы подготовить контейнер к миграции. Однако практика показала, что у данной возможности есть куда больше применений, и мы вынесли ее в отдельную библиотеку — Compel. Теперь любой желающий может написать программку на С, скомпилировать её особым образом, а потом взять и «загрузить» её в живой процесс. Она там поработает, а потом процесс-жертва вернётся к своей основной работе.
Кстати, на Compel работает также наша система живого патчинга приложений, которая является первым «сторонним» (по мимо самого Criu) пользователем этой библиотеки. Происходит это следующим образом: при помощи Compel запущенную программу «патчат» прямо во время её работы. То есть можно поставить себе на машину обновленную версию софта и запустить эту обновлённую версию почти мгновенно.
Трудностей в этом вопросе сразу две: во-первых, нужно сгенерировать сам «патч», так как обновляется двоичный код, то есть исполняемые инструкции, а не исходный текст программы. Во-вторых, нужно приложить эти изменения так, чтобы работающая программа не упала. Это сродни операции на бьющемся сердце. До сегодняшнего дня такое решение применялось только для ядра ОС, так как перезапуск нового ядра означал перезагрузку машины, а это долго. Для обновления приложений так не делали, но раз можно для ядра — почему нельзя для приложений? И недавно мы реализовали технологию живого патчинга, она уже выложена на Github.
static long process_syscall (struct process_ctx_s *ctx, int nr,
unsigned long arg1, unsigned long arg2,
unsigned long arg3, unsigned long arg4,
unsigned long arg5, unsigned long arg6)
{
int ret;
long sret = -ENOSYS;
ret = compel_syscall (ctx→ctl, nr, &sret,
arg1, arg2, arg3, arg4, arg5, arg6);
if (ret < 0) {
pr_err («Failed to execute syscall %d in %d\n», nr, ctx→pid);
return ret;
}
if (sret < 0) {
errno = -sret;
return -1;
}
return sret;
}
Пример кода, который заставляет процесс сделать системный вызов
Технические детали
Compel — это библиотека для подготовки, заброски в произвольный процесс, исполнения и выгрузки паразитного кода. Для того, чтобы всё это провернуть Compel пользуется интерфейсом отладчика — системным вызовом ptrace. Технически это выглядит следующим образом: Compel присоединяется к процессу, останавливает его, потом начинает править ему память и регистры.
Впрочем, сделать все это было не так просто. C остановкой процесса имеются свои тонкости: ведь процесс может быть остановлен и без отладчика с помощью сигнала SIGSTOP. Поэтому долгое время при работе с ядром существовала серьезная проблема: при остановке отладчиком (ptrace) процесса, который был до этого остановлен сигналом (sigstop), процесс снова «пробуждался», как только отладчик отключался. Конечно, можно было до отключения отладчика послать процессу ещё один сигнал STOP, и тогда бы он оказался остановленным в любом случае. Но при этом невозможно было без танцев с бубном узнать, был ли процесс остановлен сигналом stop при подключении или нет. Для отладки такая ситуация более-менее приемлема, но для программы, которая фотографирует состояние процессов (т.е. для Criu или патчинга приложений) — нет. Специально для обхода этого момента был разработан альтернативный способ остановки процессов, который не терял информацию о том был ли он остановлен сигналом или нет. Этот способ называется PTRACE_SEIZE и сегодня он есть во всех дистрибутивных ядрах и используется в том числе утилитой strace.
int compel_interrupt_task (int pid)
{
int ret;
ret = ptrace (PTRACE_SEIZE, pid, NULL, 0);
if (ret) {
/*
* ptrace API doesn’t allow to distinguish
* attaching to zombie from other errors.
* All errors will be handled in compel_wait_task ().
*/
pr_warn («Unable to interrupt task: %d (%s)\n», pid, strerror (errno));
return ret;
}
/*
* If we SEIZE-d the task stop it before going
* and reading its stat from proc. Otherwise task
* may die _while_ we’re doing it and we’ll have
* inconsistent seize/state pair.
*
* If task dies after we seize it but before we
* do this interrupt, we’ll notice it via proc.
*/
ret = ptrace (PTRACE_INTERRUPT, pid, NULL, NULL);
if (ret < 0) {
pr_warn («SEIZE %d: can’t interrupt task: %s», pid, strerror (errno));
if (ptrace (PTRACE_DETACH, pid, NULL, NULL))
pr_perror («Unable to detach from %d», pid);
}
return ret;
}
Код для SEIZE
Кстати, strace, пытается действовать по-старинке, если операция SEIZE не удаётся. Но для CRIU — это бесполезно. Если SEIZE не работает, то сохранение состояния процесса невозможно. Нас иногда спрашивают, можно ли сделать так, чтобы CRIU работал на тех ядрах, где нет SEIZE. Мы говорим, что теоретически это возможно, для этого надо будет написать в Compel поддержку SEIZE. Однако это не делается сознательно, так как тогда гарантировать корректную работу Criu на остановленных процессах будет невозможно.
Есть и еще один нюанс, касающийся обработки сигналов. Остановленному отладчиком процессу можно отправить сигнал, и для его обработки будет разбужен сам отладчик, который и будет решать, что делать с прибывшим сигналом. В процессе загрузки паразитного кода Compel, безусловно сталкивается с ситуациями, при которых «препарируемый» в данный момент процесс получает сигналы извне.
Сначала мы пробовали написать код, который мог бы разрулить данную ситуацию, но он оказался слишком сложным для сопровождения, а при любых изменениях возникал огромный риск, что обработка сигналов даст сбой. Так что мы решили пойти другим путем. К счастью, в Linux есть возможность заблокировать сигналы для процесса, в этом случае его отладка становится гораздо проще. Однако интерфейс блокировки устроен таким образом, что блокировать сигналы процесс может только сам себе. Вы спросите: мы ведь загружаем в процесс паразитный код и можем заблокировать сигналы из него, в чем проблема? Но проблема есть: пока паразит загружается, сигналы необходимо обрабатывать, а загрузка паразита, как вы понимаете, достаточно сложна сама по себе, хотя и отсутствие необходимости обрабатывать сигналы после неё не сильно упрощает задачу.
Для облегчения жизни себе и, как вскоре оказалось, разработчикам отладчика gdb, в ядре был добавлен способ блокировать сигналы отлаживаемому процессу. Это было сделано как ещё одно расширение к вызову ptrace. После этого весь код по работе с паразитом очень сильно облегчился, но, увы Compel (и Criu) потеряли возможность работы на ядрах без этого интерфейса. Впрочем, в отличие от операции SEIZE, обучить Criu и Compel работать без возможности заблокировать сигналы произвольному процессу можно, хоть и потребует огромных усилий.
static int arasite_run (pid_t pid, int cmd, unsigned long ip, void *stack,
user_regs_struct_t *regs, struct thread_ctx *octx)
{
k_rtsigset_t block;
ksigfillset (&block);
if (ptrace (PTRACE_SETSIGMASK, pid, sizeof (k_rtsigset_t), &block)) {
pr_perror («Can’t block signals for %d», pid);
goto err_sig;
}
parasite_setup_regs (ip, stack, regs);
if (ptrace_set_regs (pid, regs)) {
pr_perror («Can’t set registers for %d», pid);
goto err_regs;
}
if (ptrace (cmd, pid, NULL, NULL)) {
pr_perror («Can’t run parasite at %d», pid);
goto err_cont;
}
return 0;
err_cont:
if (ptrace_set_regs (pid, &octx→regs))
pr_perror («Can’t restore regs for %d», pid);
err_regs:
if (ptrace (PTRACE_SETSIGMASK, pid, sizeof (k_rtsigset_t), &octx→sigmask))
pr_perror («Can’t restore sigmask for %d», pid);
err_sig:
return -1;
}
Метод блокирования сигналов для отлаживаемого процесса
К счастью, сегодня эта проблема перестала быть острой. И SEIZE, и блокировка сигналов стали частью функциональности ядра Linux, начиная с 3.11 (и, понятное дело, любых более новых версий), поэтому минимальные системные требования к запуску Compel и Criu в частности — использование ядра версии 3.11 или новее.
Заражайте, пользуйтесь!
В настоящий момент Compel доступен в Github и может быть использован любым желающим для запуска паразитного кода в любом процессе. Вы можете просто использовать трамплин на ассемблере, который помогает присоединиться к процессу и заставить его сделать что-то — выгрузить часть памяти, заменить ему данные или обновить его. Сегодня существует немало процессов, которые хорошо бы починить без остановки, и Compel позволяет сделать это вашим способом…ну или можете воспользоваться готовой утилитой для патчинга приложений.