[Перевод] Приключения с ptrace(2)
На Хабре уже писали про перехват системных вызовов с помощью ptrace
; Алекса написал про это намного более развёрнутый пост, который я решил перевести.
С чего начать
Общение между отлаживаемой программой и отладчиком происходит при помощи сигналов. Это существенно усложняет и без того непростые вещи; ради развлечения можете прочесть раздел BUGS в man ptrace
.
Есть как минимум два разных способа начать отладку:
ptrace(PTRACE_TRACEME, 0, NULL, NULL)
сделает родителя текущего процесса отладчиком для него. Никакого содействия от родителя при этом не требуется;man
ненавязчиво советует: «A process probably shouldn’t make this request if its parent isn’t expecting to trace it.» (Где-нибудь ещё в манах вы видели фразу «probably shouldn’t»?) Если у текущего процесса уже был отладчик, то вызов не удастся.ptrace(PTRACE_ATTACH, pid, NULL, NULL)
сделает текущий процесс отладчиком дляpid
. Если уpid
уже был отладчик, то вызов не удастся. Отлаживаемому процессу шлётсяSIGSTOP
, и он не продолжит работу, пока отладчик его не «разморозит».
Эти два метода полностью независимы; можно пользоваться либо одним, либо другим, но нет никакого смысла их сочетать. Важно отметить, что PTRACE_ATTACH
действует не мгновенно: после вызова ptrace(PTRACE_ATTACH)
, как правило, следует вызов waitpid(2)
, чтобы дождаться, пока PTRACE_ATTACH
«сработает».
Запустить дочерний процесс под отладкой при помощи PTRACE_TRACEME
можно следующим образом:
static void tracee(int argc, char **argv)
{
if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0)
die("child: ptrace(traceme) failed: %m");
/* Остановиться и дождаться, пока отладчик отреагирует. */
if (raise(SIGSTOP))
die("child: raise(SIGSTOP) failed: %m");
/* Запустить процесс. */
execvp(argv[0], argv);
/* Сюда выполнение дойти не должно. */
die("tracee start failed: %m");
}
static void tracer(pid_t pid)
{
int status = 0;
/* Дождаться, пока дочерний процесс сделает нас своим отладчиком. */
if (waitpid(pid, &status, 0) < 0)
die("waitpid failed: %m");
if (!WIFSTOPPED(status) || WSTOPSIG(status) != SIGSTOP) {
kill(pid, SIGKILL);
die("tracer: unexpected wait status: %x", status);
}
/* Если требуются дополнительные опции для ptrace, их можно задать здесь. */
/*
* Обратите внимание, что в предшествующем коде нигде
* не указывается, что мы собирается отлаживать дочерний процесс.
* Это не ошибка -- таков API у ptrace!
*/
/* Начиная с этого момента можно использовать PTRACE_SYSCALL. */
}
/* (argc, argv) -- аргументы для дочернего процесса, который мы собираемся отлаживать. */
void shim_ptrace(int argc, char **argv)
{
pid_t pid = fork();
if (pid < 0)
die("couldn't fork: %m");
else if (pid == 0)
tracee(argc, argv);
else
tracer(pid);
die("should never be reached");
}
Без вызова raise(SIGSTOP)
могло бы оказаться, что execvp(3)
выполнится раньше, чем родительский процесс будет к этому готов; и тогда действия отладчика (например, перехват системных вызовов) начнутся не с начала выполнения процесса.
Когда отладка начата, то каждый вызов ptrace(PTRACE_SYSCALL, pid, NULL, NULL)
будет «размораживать» отлаживаемый процесс до первого входа в системный вызов, а потом — до выхода из системного вызова.
Телекинетический ассемблер
ptrace(PTRACE_SYSCALL)
не возвращает отладчику никакой информации; он просто обещает, что отлаживаемый процесс дважды остановится при каждом системном вызове. Чтобы получать информацию о том, что происходит с отлаживаемым процессом — например, в каком именно системном вызове он остановился — нужно лезть в копию его регистров, сохранённую ядром в struct user
в формате, зависящем от конкретной архитектуры. (Например, на x86_64 номер вызова будет в поле regs.orig_rax
, первый переданный параметр — в regs.rdi
, и т.д.) Алекса комментирует: «ощущение, как будто пишешь на Си ассемблерный код, работающий с регистрами удалённого процессора».
Вместо структуры, описанной в sys/user.h
, может быть удобнее пользоваться константами-индексами, определёнными в sys/reg.h
:
#include
/* Получить номер системного вызова. */
long ptrace_syscall(pid_t pid)
{
#ifdef __x86_64__
return ptrace(PTRACE_PEEKUSER, pid, sizeof(long)*ORIG_RAX);
#else
// ...
#endif
}
/* Получить аргумент системного вызова по номеру. */
uintptr_t ptrace_argument(pid_t pid, int arg)
{
#ifdef __x86_64__
int reg = 0;
switch (arg) {
case 0:
reg = RDI;
break;
case 1:
reg = RSI;
break;
case 2:
reg = RDX;
break;
case 3:
reg = R10;
break;
case 4:
reg = R8;
break;
case 5:
reg = R9;
break;
}
return ptrace(PTRACE_PEEKUSER, pid, sizeof(long) * reg, NULL);
#else
// ...
#endif
}
При этом две остановки отлаживаемого процесса — на входе в системный вызов и на выходе из него — никак не различаются с точки зрения отладчика; так что отладчик должен сам помнить, в каком состоянии находится каждый из отлаживаемых процессов: если их несколько, то никто не гарантирует, что пара сигналов от одного процесса придёт подряд.
Потомки
Одна из опций ptrace
, а именно PTRACE_O_TRACECLONE
, обеспечивает, что все дети отлаживаемого процесса будут автоматически браться под отладку в момент выхода из fork(2)
. Дополнительный тонкий момент здесь в том, что потомки, взятые под отладку, становятся «псевдо-детьми» отладчика, и waitpid
будет реагировать не только на остановку «непосредственных детей», но и на остановку отлаживаемых «псевдо-детей». Man предупреждает по этому поводу: «Setting the WCONTINUED flag when calling waitpid (2) is not recommended: the «continued» state is per-process and consuming it can confuse the real parent of the tracee.» — т.е. у «псевдо-детей» получается по два родителя, которые могут ждать их остановки. Для программиста отладчика это означает, что waitpid(-1)
будет ждать остановки не только непосредственных детей, а любого из отлаживаемых процессов.
Сигналы
(Бонус-контент от переводчика: этой информации нет в англоязычной статье)
Как уже было сказано в самом начале, общение между отлаживаемой программой и отладчиком происходит при помощи сигналов. Процесс получает SIGSTOP
при подключении к нему отладчика, и затем SIGTRAP
каждый раз, когда в отлаживаемом процессе происходит что-то «интересное» — например, системный вызов или получение внешнего сигнала. Отладчик, в свою очередь, получает SIGCHLD
каждый раз, когда один из отлаживаемых процессов (не обязательно непосредственный ребёнок) «замерзает» или «размерзает».
«Разморозка» отлаживаемого процесса осуществляется вызовом ptrace(PTRACE_SYSCALL)
(до первого сигнала либо системного вызова) либо ptrace(PTRACE_CONT)
(до первого сигнала). Когда сигналы SIGSTOP/SIGCONT
используются ещё и для целей, не связанных с отладкой, то с ptrace
могут возникнуть проблемы: если отладчик «разморозит» отлаживаемый процесс, получивший SIGSTOP
, то извне это будет выглядеть, как будто сигнал был проигнорирован; если же отладчик не станет «размораживать» отлаживаемый процесс, то и внешний SIGCONT
не сможет его «разморозить».
Теперь самое интересное: Linux запрещает процессам отлаживать самих себя, но не препятствует созданию циклов, когда родитель и ребёнок отлаживают друг друга. В этом случае, когда один из процессов получает любой внешний сигнал, то он «замерзает» по SIGTRAP
— тогда второму процессу шлётся SIGCHLD
, и тот тоже «замерзает» по SIGTRAP
. Вытащить таких «со-отладчиков» из дедлока невозможно посылкой SIGCONT
извне; единственный способ — убить (SIGKILL
) ребёнка, тогда родитель выйдет из-под отладки и «размёрзнет». (Если убивать родителя, то ребёнок умрёт вместе с ним.) Если же ребёнок включит опцию PTRACE_O_EXITKILL
, то с его смертью умрёт и отлаживаемый им родитель.
Теперь вы знаете, как реализовать пару процессов, которые при получении любого сигнала оба зависают вечным сном, и умирают только вместе. Зачем это может быть нужно на практике, я пояснять не буду :-)