Как стать автором
Обновить
564.32
OTUS
Цифровые навыки от ведущих экспертов

Как работает ptrace в Linux и зачем он тебе

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров7K

Привет, Хабр! Сегодня у нас на столе инструмент, о котором многие слышали, но мало кто использовал по-настоящему — ptrace.

С ptrace можно подключаться к чужим процессам, читать и менять их память, перехватывать системные вызовы — и даже вежливо уволить sleep 9999.

Что такое ptrace и зачем он нужен

Когда вызывается ptrace(PTRACE_ATTACH, pid, ...), мы не просто «подключаемся к процессу», а даем ядру команду установить отношение трассировки между двумя процессами. Это отношение фиксируется в task_struct обеих сторон: у родителя выставляется ptrace-флаг, у ребёнка — блокировка выполнения до следующего сигнала.

Фактически процесс становится дебаженным, и теперь каждый раз, когда он вызывает системный вызов или получает сигнал — он ставится на паузу и родитель (трассирующий процесс) уведомляется через waitpid(). Сам механизм очень похож на сигнал SIGSTOP, но с контролем через PTRACE_SYSCALL, PTRACE_SINGLESTEP и прочие вкусности.

Контроль через сигналы

ptrace работает вокруг сигнальной системы. Т.е каждый раз, когда «дитя» делает системный вызов, ядро ловит это и генерирует специальный SIGTRAP, на который реагирует трассирующий процесс. Как это выглядит:

  1. Вызываем PTRACE_SYSCALL, процесс продолжает выполнение до следующего системного вызова.

  2. Ядро перехватывает вход в syscall и приостанавливает процесс.

  3. Родитель через waitpid ловит WIFSTOPPED, понимает: ага, мы на входе в syscall.

  4. Хочешь — можно прочитать регистры, изменить аргументы вызова и т.д.

  5. Повторяешь PTRACE_SYSCALL, и процесс идёт дальше до выхода из syscall.

  6. Повторно ловишь SIGTRAP — на выходе из вызова.

Можно подменить syscall прямо в orig_rax (x86_64) — и процесс выполнит совершенно другой вызов. Или подменить аргументы, написав в регистры rdi, rsi, rdx, если ты знаете ABI.

Когда мы цепляемся к процессу, ядро принудительно замораживает его. Именно поэтому, когда делаем PTRACE_ATTACH, нужнно обязательно нужно вызвать waitpid(pid, ...) — иначе оставим процесс в подвешенном состоянии, и он не сможет продолжить работу.

ptrace — опасная штука

Если трассирующий процесс не завершает цикл взаимодействия правильно — например, не продолжает выполнение целевого процесса через PTRACE_CONT или PTRACE_SYSCALL — то тот остаётся в состоянии stopped бесконечно. Это не просто паузится процесс: он блокирует ресурсы, может висеть в состоянии зомби или в T статусе в ps, пока за ним кто-то не придёт и не доделает цикл отладки до конца. Это классическая ошибка при написании наивных ptrace-трейсеров: повесился на процесс — и бросил, забыв, что теперь ты за него отвечаешь.

Вторая грань проблемы — если ты лезешь в память процесса и случайно затираешь критический участок (например, .text сегмент с инструкциями или .got.plt), то ты легко вызовешь SIGSEGV при следующем исполнении. Или хуже — модифицируешь return address на стеке, и получишь не просто краш, а непредсказуемое поведение. При этом, если ты криво реализуешь waitpid() — например, не вычитываешь все статусы (в т.ч WSTOPPED, WIFEXITED, WIFSIGNALED) — процесс зависнет или твой управляющий код потеряет поток исполнения. Ну и напоследок: ты не root — ты не лезешь в чужие процессы. Система проверяет UID/GID, а при попытке отладки setuid-бинарей вообще сбрасывает setuid-бит.

ptrace vs seccomp vs capabilities

Некоторые системы (особенно в контейнерах) ограничивают ptrace через:

  • yama/ptrace_scope=1: по дефолту запрещает PTRACE_ATTACH между несвязанными процессами (можно обойти через родство);

  • seccomp: может блокировать системные вызовы, даже если ты их подменяешь;

  • capabilities: нужны CAP_SYS_PTRACE, если хочешь цепляться к чужим процессам вне user'а.

Запускаем процесс под трассировкой

Первый вариант — ты хочешь не вмешиваться в уже идущий процесс, а сам всё создать, обвязать, подцепить и проконтролировать. Сценарий классический: создаем дочерний процесс, который сам говорит ядру: «я согласен быть трассируемым». Это делается через PTRACE_TRACEME, и сразу после этого вызывается exec нужной программы.

Вот пример, где родитель не просто запускает, а перехватывает каждый системный вызов дочернего процесса:

#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <unistd.h>
#include <errno.h>

int main() {
    pid_t child = fork();

    if (child == 0) {
        // Дочерний процесс: объявляем себя трассируемым
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", NULL);
        perror("execl");
        exit(1);
    } else {
        // Родитель: следим за каждым системным вызовом
        int status;
        waitpid(child, &status, 0);

        ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD);

        while (1) {
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
            waitpid(child, &status, 0);
            if (WIFEXITED(status)) break;

            if (WIFSTOPPED(status) && WSTOPSIG(status) & 0x80) {
                struct user_regs_struct regs;
                ptrace(PTRACE_GETREGS, child, NULL, &regs);
                printf("Syscall: %llu\n", regs.orig_rax);
            }
        }
    }

    return 0;
}

fork() создаёт дочерний процесс. Он вызывает PTRACE_TRACEME, и ядро теперь будет ставить его на паузу при каждом системном вызове. Родитель снаружи слушает, что происходит, и печатает каждый системный вызов.

Подключаемся к уже живому процессу

Теперь второй способ: процесс уже работает, ты не хочешь его убивать, перезапускать — просто хочешь подглядеть. Тогда тебе нужен PTRACE_ATTACH:

#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <unistd.h>
#include <errno.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <pid>\n", argv[0]);
        return 1;
    }

    pid_t pid = atoi(argv[1]);

    if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) == -1) {
        perror("ptrace attach");
        return 1;
    }

    waitpid(pid, NULL, 0);
    printf("Attached to process %d\n", pid);

    // Делаем что хотим…

    ptrace(PTRACE_DETACH, pid, NULL, NULL);
    printf("Detached\n");

    return 0;
}

После PTRACE_ATTACH процесс приостанавливается. Пока ты с ним не закончишь, он не продолжит выполнение. Поэтому всегда делаем PTRACE_DETACH или PTRACE_CONT — иначе он зависнет.

Чтение и запись памяти

Если хочется получить доступ к оперативке процесса:

long word = ptrace(PTRACE_PEEKTEXT, pid, (void *)addr, NULL);
printf("Data at %p: 0x%lx\n", addr, word);

А теперь впишем туда свою строчку:

long new_word = 0x64636261; // 'abcd' в ASCII (LE)
ptrace(PTRACE_POKETEXT, pid, (void *)addr, (void *)new_word);

Это прямой доступ в адресное пространство процесса. Главное знать точный адрес. Для этого пригодятся gdb, maps, nm, или просто ltrace.

Работа с регистрами

Работаешь на низком уровне? Снимаем и изменяем состояние процессора у чужого процесса:

#include <sys/user.h>

struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, NULL, &regs);
printf("RIP: %llx, RAX: %llx\n", regs.rip, regs.rax);

Изменим rip, чтобы перескочить инструкцию:

regs.rip += 2;
ptrace(PTRACE_SETREGS, pid, NULL, &regs);

Здесь можно эмулировать выполнение, модифицировать параметры вызовов, делать всё, что может gdb, но вручную.

Убиваем sleep 9999 без kill

Итак, допустим:

sleep 9999 &

Он просто ждёт. Мы не хотим слать SIGKILL, а хотим заставить сам процесс вызвать exit(0). Для этого нужно:

  1. Прикрепиться.

  2. Подменить rax (номер syscall) на 60 (это exit).

  3. В rdi положить 0 (код выхода).

  4. Продолжить выполнение.

Реализация:

#include <stdio.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <unistd.h>

int main() {
    pid_t pid = 4242; // Подставь свой PID

    ptrace(PTRACE_ATTACH, pid, NULL, NULL);
    waitpid(pid, NULL, 0);

    struct user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, NULL, &regs);

    regs.rax = 60;    // syscall exit
    regs.rdi = 0;     // exit code 0
    ptrace(PTRACE_SETREGS, pid, NULL, &regs);

    ptrace(PTRACE_CONT, pid, NULL, NULL);
    return 0;
}

Процесс сам себя завершает через вызов exit(0), как будто дошёл до конца main. Никаких kill, никакого вмешательства в сигналы.


Спасибо за прочтение! Если вы уже использовали ptrace в своих задачах, расскажите об этом в комментариях.

Если вам близка идея ручного контроля над процессами, системными вызовами и инфраструктурой — вам точно будет интересно посмотреть, как этот подход масштабируется на уровне среды. На открытом уроке «Из песочницы в продакшен» разберём, как Ansible, Vagrant и Terraform помогают уходить от точечных решений к управляемой и воспроизводимой инфраструктуре. Без магии — только практика и системный подход.

Теги:
Хабы:
+17
Комментарии2

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS