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

от автора

Привет, Хабр! Сегодня у нас на столе инструмент, о котором многие слышали, но мало кто использовал по-настоящему — 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 помогают уходить от точечных решений к управляемой и воспроизводимой инфраструктуре. Без магии — только практика и системный подход.


ссылка на оригинал статьи https://habr.com/ru/articles/898448/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *