На Хабре уже писали про перехват системных вызовов с помощью ptrace; Алекса написал про это намного более развёрнутый пост, который я решил перевести.


С чего начать


Общение между отлаживаемой программой и отладчиком происходит при помощи сигналов. Это существенно усложняет и без того непростые вещи; ради развлечения можете прочесть раздел BUGS в man ptrace.

Есть как минимум два разных способа начать отладку:

  1. 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»?) Если у текущего процесса уже был отладчик, то вызов не удастся.
  2. 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 <sys/reg.h>

/* Получить номер системного вызова. */
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, то с его смертью умрёт и отлаживаемый им родитель.

Теперь вы знаете, как реализовать пару процессов, которые при получении любого сигнала оба зависают вечным сном, и умирают только вместе. Зачем это может быть нужно на практике, я пояснять не буду :-)

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


  1. Prototik
    18.02.2019 17:23
    +1

    Где-нибудь ещё в манах вы видели фразу «probably shouldn't»?

    Конечно видели :)
    $ find /usr/share/man -type f -name '*.gz' -exec sh -c 'gzip -dc {} | grep -C3 "probably shouldn"' \;
    There is one exception. If you use an alphanumeric character as the
    delimiter of your pattern (which you probably shouldn't do for readability
    reasons), you have to escape the delimiter if you want to match
    it. Perl won't warn then. See also \*(L"Gory details of parsing
    quoted constructs\*(R" in perlop.
    ...
    You probably shouldn't rely upon the \f(CW\*(C`warn()\*(C'\fR being podded out forever.
    Not all pod translators are well-behaved in this regard, and perhaps
    the compiler will become pickier.
    ...
    Please, unless you're hacking the internals, or debugging weirdness, don't
    think about the \s-1UTF8\s0 flag at all. That means that you very probably shouldn't
    use \f(CW\*(C`is_utf8\*(C'\fR, \f(CW\*(C`_utf8_on\*(C'\fR or \f(CW\*(C`_utf8_off\*(C'\fR at all.
    ...
    You probably shouldn't use these functions.
    ...
    These functions expose internal values from the \s-1TLS\s0 handshake, for
    use in low-level protocols.  You probably should not use them, unless
    entropy pool is considered to be initialized.
    ...
    Unless you are doing long-term key generation (and most likely not even
    then), you probably shouldn't be reading from the
    .IR /dev/random
    ...
    is not intended for use in applications or interactive sessions\&. Its purpose is to allow an external transaction manager to perform atomic global transactions across multiple databases or other transactional resources\&. Unless you\*(Aqre writing a transaction manager, you probably shouldn\*(Aqt be using
    


  1. ser-mk
    18.02.2019 20:22
    +1

    Зачем это может быть нужно на практике, я пояснять не буду :-)

    Я так понимаю что бы защитить от отладки свою программу?