Перейти к содержанию

ptrace: как один процесс владеет другим

ptrace(2) — древний syscall, появившийся ещё в Unix V7 в 1979 году. Через него один процесс (tracer) получает почти неограниченную власть над другим (tracee): читает и пишет его память, меняет регистры, перехватывает каждый syscall, останавливает на каждой инструкции, подменяет аргументы и результаты. На этом единственном syscall построены gdb, strace, ltrace, CRIU, Firejail, gVisor, libinjector, graftcp и десятки других инструментов.

Цена этой власти — огромный overhead. Каждое наблюдаемое событие — это десятки переключений контекста tracer ↔ kernel ↔ tracee. Для syscall-heavy нагрузки замедление достигает 100× и более. Поэтому современные инструменты наблюдаемости ( eBPF, perf, ftrace) обходят ptrace полностью — они работают на стороне kernel'а и не требуют остановки tracee. Но там, где нужно именно вмешательство в выполнение — установка breakpoint'а, инжекция кода, подмена аргумента syscall — альтернатив ptrace до сих пор нет.

API

Весь интерфейс — один syscall с переменным значением первого аргумента:

#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

Семантика остальных трёх аргументов зависит от request. Возвращаемое значение — обычно 0 или прочитанное слово; ошибка сигнализируется через errno, но -1 может быть и валидным значением, поэтому errno нужно сбрасывать перед вызовом PTRACE_PEEK*.

Основные запросы:

Request Кто вызывает Назначение
PTRACE_TRACEME tracee «я готов быть трассированным» — после fork, перед exec
PTRACE_ATTACH tracer присоединиться к существующему PID, послать SIGSTOP
PTRACE_SEIZE (≥3.4) tracer attach без доставки SIGSTOP — чище, для modern tooling
PTRACE_DETACH tracer отвязаться, tracee продолжает работать
PTRACE_CONT tracer продолжить tracee до следующего сигнала
PTRACE_SYSCALL tracer продолжить, остановиться на входе/выходе syscall
PTRACE_SINGLESTEP tracer выполнить одну инструкцию и остановиться
PTRACE_PEEKTEXT/DATA tracer прочитать word из памяти tracee
PTRACE_PEEKUSER tracer прочитать word из struct user (включая регистры)
PTRACE_POKETEXT/DATA tracer записать word в память tracee
PTRACE_POKEUSER tracer записать word в struct user
PTRACE_GETREGS/SETREGS tracer все GP-регистры через struct user_regs_struct
PTRACE_GETREGSET tracer extended state (NT_PRSTATUS, NT_PRFPREG, NT_X86_XSTATE)
PTRACE_SETREGSET tracer записать тот же набор
PTRACE_INTERRUPT tracer попросить tracee остановиться (только после SEIZE)
PTRACE_SETOPTIONS tracer включить TRACEFORK/CLONE/EXEC/SECCOMP/EXITKILL
PTRACE_GETSIGINFO tracer прочитать siginfo_t доставляемого сигнала
PTRACE_SETSIGINFO tracer подменить siginfo_t (или подавить, передав 0)
PTRACE_GETEVENTMSG tracer данные PTRACE_EVENT_* (новый PID при fork, exit code)

Семантика addr/data зависит от request. У PEEK* слово возвращается, у POKE* записывается из data; у GETREGS указатель на буфер передаётся через data, а addr игнорируется. Такая несогласованность — наследие 70-х: syscall задумывался для маленького CRT-терминала с adb-отладчиком.

Tracer/tracee и состояния

Любой процесс в Linux после attach всегда находится в одном из четырёх состояний:

  • running — обычное выполнение, как у нетрассированного процесса
  • ptrace-stop — kernel остановил tracee и ждёт реакции tracer'а
  • group-stop — job-control остановка (SIGSTOP/SIGTSTP), не связана с ptrace напрямую
  • exited / zombie — завершён, ждёт wait() от parent
stateDiagram-v2
    [*] --> running
    running --> ptrace_stop: syscall / signal / event
    ptrace_stop --> running: PTRACE_CONT / SYSCALL
    running --> group_stop: SIGSTOP (job control)
    group_stop --> running: SIGCONT (или PTRACE_LISTEN после SEIZE)
    running --> exited: exit(), kill(SIGKILL)
    ptrace_stop --> exited: kill
    group_stop --> exited: kill
    exited --> [*]
    ptrace_stop: ptrace-stop (kernel ждёт TR)
    group_stop: group-stop

Tracer узнаёт о каждом stop через waitpid() — wait возвращает PID остановленного потока, а status через макросы WIFSTOPPED/WSTOPSIG сообщает причину. Это означает, что весь API ptrace построен на парах «дать команду продолжить → дождаться следующего stop через wait → прочитать состояние».

Классификация stops

Под общим именем «ptrace-stop» скрывается несколько разных событий, и tracer обязан различать их, чтобы реагировать правильно.

flowchart TB
    Top["tracee остановлен"]
    GS["group-stop<br/>(SIGSTOP, SIGTSTP)<br/>это не ptrace,<br/>это job control"]
    PS["ptrace-stop"]
    SD["signal-delivery-stop<br/>tracee получил сигнал,<br/>kernel дал tracer'у право решить"]
    SS["syscall-stop<br/>enter / exit<br/>при входе и выходе syscall<br/>(PTRACE_SYSCALL включён)"]
    PE["PTRACE_EVENT_*<br/>FORK/CLONE/EXEC/EXIT/SECCOMP/STOP<br/>событие, на которое подписался<br/>через PTRACE_SETOPTIONS"]
    SST["single-step-stop<br/>(после PTRACE_SINGLESTEP)"]
    Top --> GS
    Top --> PS
    PS --> SD
    PS --> SS
    PS --> PE
    PS --> SST

signal-delivery-stop. Tracee получает любой сигнал (кроме SIGKILL — этот не остановить). Kernel ставит tracee в ptrace-stop ДО доставки и сообщает tracer'у. Tracer может через PTRACE_GETSIGINFO прочитать siginfo_t, изменить его через PTRACE_SETSIGINFO, подавить доставку (передав 0 в data для PTRACE_CONT) или подменить сигнал на другой. Это ключевой механизм — именно так gdb перехватывает breakpoint'ы: после INT3 tracee получает SIGTRAP, gdb видит signal-delivery-stop, откатывает rip на байт назад, восстанавливает оригинальный байт и продолжает.

syscall-enter-stop / syscall-exit-stop. Возникают только если tracer использует PTRACE_SYSCALL вместо PTRACE_CONT. Kernel останавливает tracee на самой границе пользователь/ядро — на входе перед выполнением syscall и на выходе перед возвратом в userspace. Между двумя stops syscall реально выполняется ядром, но tracer может его подменить ( изменив orig_rax) или подделать результат (изменив rax на выходе).

PTRACE_EVENT_* stops. Опциональные события: создание дочернего процесса/потока (FORK, VFORK, CLONE), вызов exec ( EXEC), завершение (EXIT), срабатывание seccomp-фильтра (SECCOMP), реакция на PTRACE_INTERRUPT (STOP). Tracer подписывается на них через PTRACE_SETOPTIONS. Данные события (например, PID нового ребёнка) читаются через PTRACE_GETEVENTMSG.

single-step-stop. После PTRACE_SINGLESTEP процессор устанавливает TF (Trap Flag) в EFLAGS, выполняет ровно одну инструкцию и генерирует SIGTRAP. Tracer видит это как signal-delivery-stop с особым si_code.

group-stop. Job-control остановка, не связанная с ptrace. Возникает при доставке SIGSTOP/SIGTSTP/SIGTTIN/SIGTTOU. До Linux 2.6.38 это путалось с ptrace-stop и приводило к гонкам; после введения PTRACE_SEIZE появилась чёткая семантика: PTRACE_LISTEN позволяет дождаться выхода из group-stop, не возобновляя выполнение.

Минимальный strace

Чтобы увидеть, как всё работает, достаточно ~50 строк C. Этот код запускает /bin/ls, перехватывает каждый syscall и печатает его номер и возвращаемое значение.

// minstrace.c — gcc -o minstrace minstrace.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>

int main(int argc, char *argv[]) {
    if (argc < 2) { fprintf(stderr, "usage: %s prog args...\n", argv[0]); return 1; }

    pid_t pid = fork();
    if (pid == 0) {
        // tracee: разрешаем трассировку и зовём exec
        ptrace(PTRACE_TRACEME, 0, 0, 0);
        execvp(argv[1], &argv[1]);
        perror("execvp"); _exit(1);
    }

    // tracer: ждём первого stop после exec
    int status;
    waitpid(pid, &status, 0);
    // отличать syscall-stop от обычного SIGTRAP по биту 7
    ptrace(PTRACE_SETOPTIONS, pid, 0,
           PTRACE_O_TRACESYSGOOD | PTRACE_O_EXITKILL);

    int in_syscall = 0;
    long syscall_nr = 0;
    while (1) {
        if (ptrace(PTRACE_SYSCALL, pid, 0, 0) < 0) break;
        if (waitpid(pid, &status, 0) < 0) break;
        if (WIFEXITED(status)) break;

        // syscall-stop: WSTOPSIG == 0x80 | SIGTRAP
        if (WIFSTOPPED(status) && WSTOPSIG(status) == (SIGTRAP | 0x80)) {
            struct user_regs_struct regs;
            ptrace(PTRACE_GETREGS, pid, 0, &regs);
            if (!in_syscall) {
                syscall_nr = regs.orig_rax;
                fprintf(stderr, "syscall(%ld, %llx, %llx, %llx) = ",
                        syscall_nr,
                        (unsigned long long)regs.rdi,
                        (unsigned long long)regs.rsi,
                        (unsigned long long)regs.rdx);
                in_syscall = 1;
            } else {
                fprintf(stderr, "%lld\n", (long long)regs.rax);
                in_syscall = 0;
            }
        }
    }
    return 0;
}

Скомпилируйте и запустите: gcc -o minstrace minstrace.c && ./minstrace /bin/ls. Вы увидите весь поток syscall'ов от запуска ls.

Syscall tracing по тактам

Главное, что нужно понимать: PTRACE_SYSCALL даёт два stop'а на каждый syscall — на входе и на выходе. Это позволяет не только видеть, что было вызвано, но и видеть, что вернулось.

sequenceDiagram
    participant Tracee
    participant Kernel
    participant Tracer
    Note over Tracer: tact 0: ждёт в waitpid()
    Note over Tracee: tact 1: mov rax,0 (read);<br/>mov rdi,fd; syscall
    Tracee->>Kernel: syscall → ring 0
    Note over Kernel: tact 2: видит PT_PTRACED,<br/>ставит tracee в ptrace-stop
    Kernel-->>Tracer: waitpid() returns
    Tracer->>Kernel: tact 3: ptrace(GETREGS) → orig_rax=0
    Note over Tracer: решает: пропустить как есть
    Tracer->>Kernel: tact 4: ptrace(SYSCALL)
    Note over Kernel: sys_read() реально работает,<br/>читает данные с диска/сокета
    Note over Kernel: tact 5: перед return to userspace,<br/>второй ptrace-stop
    Kernel-->>Tracer: waitpid() returns
    Tracer->>Kernel: tact 6: ptrace(GETREGS) → rax=N
    Note over Tracer: лог "read = N"
    Tracer->>Kernel: tact 7: ptrace(SYSCALL)
    Kernel->>Tracee: возврат в userspace, rax = N
    Note over Tracer: ждёт следующего stop

На входе в syscall есть нюанс: rax в момент stop равен -ENOSYS (kernel ставит это как маркер «syscall ещё не выполнен»), а реальный номер syscall — в orig_rax. На x86-64 аргументы передаются через rdi, rsi, rdx, r10, r8, r9 согласно SysV AMD64 ABI (в обычных функциях четвёртый аргумент — rcx, но syscall затирает rcx для сохранения rip).

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

PTRACE_PEEKDATA возвращает ровно одно машинное слово (на x86-64 — 8 байт). Чтобы прочитать строку — нужен цикл с проверкой на NUL. Это медленно: каждый word — отдельный syscall.

Linux 3.2 добавил process_vm_readv(2) и process_vm_writev(2) — scatter-gather копирование памяти между двумя процессами одним syscall'ом. Требует тех же прав, что и ptrace (CAP_SYS_PTRACE или соответствие Yama-политике), но не требует attach и не требует stop. Это быстрейший способ.

Альтернатива — /proc/PID/mem: открыть, lseek на нужный адрес, read/write. Быстро для больших буферов, но требует, чтобы tracee был остановлен (иначе данные могут меняться во время чтения).

Способ Скорость Stop нужен Нужен attach Размер за раз
PTRACE_PEEKDATA / POKEDATA медленно да да 1 word
/proc/PID/mem read/write быстро для буферов да нет¹ любой
process_vm_readv / writev (3.2+) максимум нет² нет любой, iovec

¹ Технически открыть /proc/PID/mem без ptrace можно, но read/write требуют, чтобы целевой процесс был ptrace-stop'нут (защита от race condition). ² Tracee может изменить буфер прямо в момент чтения — это race условие, ответственность вызывающего.

Регистры

PTRACE_GETREGS копирует struct user_regs_struct в буфер tracer'а. На x86-64 структура содержит все GP-регистры ( rax..r15), rip, rflags, селекторы сегментов, fs_base/gs_base для TLS, и специальное поле orig_rax для syscall tracking.

struct user_regs_struct {
    unsigned long r15, r14, r13, r12;
    unsigned long rbp, rbx;
    unsigned long r11, r10, r9, r8;
    unsigned long rax, rcx, rdx, rsi, rdi;
    unsigned long orig_rax;          // оригинальный rax до syscall
    unsigned long rip, cs;
    unsigned long eflags;
    unsigned long rsp, ss;
    unsigned long fs_base, gs_base;
    unsigned long ds, es, fs, gs;
};

PTRACE_GETREGSET — более портабельная альтернатива: вместо фиксированной структуры передаётся struct iovec и тип набора через addr (NT_PRSTATUS для GP-регистров, NT_PRFPREG для FPU, NT_X86_XSTATE для полного XSAVE-area c AVX/AVX-512). На ARM/RISC-V GETREGS либо вовсе отсутствует, либо имеет другую структуру; GETREGSET работает везде.

Изменить регистры — SETREGS/SETREGSET. Самое мощное применение: переписать rip и продолжить — tracee выполнится из совершенно другого места. Так работает gdb'шная команда jump, а с подменой нескольких регистров — и call function().

Дочерние процессы и потоки

По умолчанию ptrace следит только за тем процессом, к которому был сделан attach. Если tracee делает fork() или clone() — потомок выходит из-под наблюдения. Чтобы автоматически захватывать всё дерево, нужны опции:

Опция Эффект
PTRACE_O_TRACEFORK stop при fork; новый PID → GETEVENTMSG; child автоматически attached
PTRACE_O_TRACEVFORK то же для vfork
PTRACE_O_TRACECLONE то же для clone (включая pthread_create)
PTRACE_O_TRACEEXEC stop при exec, можно проверить новый бинарь
PTRACE_O_TRACEEXIT stop перед самим exit, регистры ещё валидны
PTRACE_O_TRACESYSGOOD syscall-stop приходит как SIGTRAP \| 0x80 — отличить от обычного
PTRACE_O_TRACESECCOMP stop по SECCOMP_RET_TRACE (комбинация с seccomp-фильтром)
PTRACE_O_EXITKILL если tracer умирает — kill всех tracee; защита от orphan'ов

TRACESYSGOOD — практически обязательная опция. Без неё syscall-stop приходит как обычный SIGTRAP, и tracer не может отличить его от breakpoint'а или single-step. С опцией бит 7 SIGTRAP'а взводится, и WSTOPSIG == 0x85.

Multi-threaded tracing — отдельная боль. Каждый поток (tracee thread) ptrace рассматривает как независимую сущность; attach к одному потоку не attach'ит остальные. PTRACE_O_TRACECLONE помогает, но gdb всё равно вынужден работать с массивом TID'ов и синхронизировать group-stop вручную — отсюда set scheduler-locking on/off и сложности с пошаговой отладкой многопоточного кода.

PTRACE_SEIZE: почему ATTACH плох

PTRACE_ATTACH имеет неприятную особенность: он отправляет tracee SIGSTOP. Это означает, что tracee оказывается одновременно в ptrace-stop и в group-stop — два разных состояния со своей логикой. Tracer не может сразу делать ptrace-операции; ему нужно сначала дождаться доставки SIGSTOP через wait, потом подавить его, и только потом начинать работу. Гонки между group-stop и signal-delivery-stop приводят к багам, которые ловились в strace и gdb годами.

PTRACE_SEIZE (Linux 3.4+) решает это радикально:

  • Не отправляет SIGSTOP — tracee продолжает работать как ни в чём не бывало
  • Tracer может включить опции (PTRACE_SETOPTIONS) прямо в момент seize через data
  • Для остановки используется PTRACE_INTERRUPT — явный, отдельный механизм без сигналов
  • PTRACE_LISTEN позволяет дождаться выхода tracee из group-stop без возобновления выполнения

Modern tooling — strace ≥ 4.9, gdb ≥ 7.4 — использует SEIZE по умолчанию. ATTACH остаётся ради совместимости со старыми ядрами.

Code injection через ptrace

Если tracer может писать в память и менять rip, он может выполнить произвольный код от имени tracee. Эта техника лежит в основе DLL-инжекторов, CRIU parasite code и команды call в gdb. Алгоритм:

sequenceDiagram
    participant Tracer
    participant Tracee
    Tracer->>Tracee: 1. PTRACE_ATTACH (или SEIZE + INTERRUPT)
    Note over Tracee: ptrace-stop
    Note over Tracer: 2. PTRACE_GETREGS — сохранить rip, rsp, rax, ...
    Note over Tracer: 3. PTRACE_PEEKDATA(rip) — сохранить старые байты<br/>save: 48 89 e5 ... (8 байт)
    Note over Tracer: 4. POKETEXT(rip, syscall_stub)<br/>stub: mov rax,9 / mov rdi,0 / ... / syscall<br/>(вызов mmap для shellcode page)
    Tracer->>Tracee: 5. PTRACE_CONT
    Note over Tracee: выполняет stub, mmap → rax=addr, trap
    Note over Tracer: 6. GETREGS → rax = адрес новой страницы
    Note over Tracer: 7. POKETEXT(rax_новая_страница, shellcode)<br/>(или process_vm_writev)
    Tracer->>Tracee: 8. SETREGS rip = rax_новая_страница; PTRACE_CONT
    Note over Tracee: выполняет shellcode<br/>(открывает sock, грузит .so, ...)
    Tracee-->>Tracer: 9. SIGTRAP после int3 в конце shellcode
    Note over Tracer: 10. POKETEXT — восстановить байты<br/>SETREGS — восстановить регистры
    Tracer->>Tracee: PTRACE_DETACH
    Note over Tracee: продолжает обычную жизнь

Тонкости. mmap нужен потому, что обычно нет готового исполняемого региона, куда можно безопасно писать произвольный код (а если есть — например .text существующей библиотеки — это всё равно безопаснее не трогать). Stub должен заканчиваться int3 (опкод 0xCC) — это безусловный SIGTRAP, по которому tracer узнаёт, что stub отработал. Реальные инжекторы дополнительно сохраняют сигналы (через PTRACE_GETSIGMASK Linux 3.11+), чтобы не потерять in-flight доставку.

Применения за пределами эксплоитов: gdb call function() ровно это и делает (только без mmap — gdb пишет stub поверх _start или другой невостребованной области и аккуратно восстанавливает). CRIU использует «parasite code» — .so с заранее известными адресами, который инжектируется во все процессы перед dump'ом, чтобы из него же читать состояние памяти, открытых fd, mmap-регионов. libinjector делает то же для DLL-инжекции в Windows-эмулятор Wine.

PTRACE_O_TRACESECCOMP

seccomp-фильтр может возвращать SECCOMP_RET_TRACE — это означает «не убивать, не пропускать, а позвать tracer'а». В сочетании с PTRACE_O_TRACESECCOMP это даёт мощную модель:

  • seccomp-bpf фильтрует быстро в kernel'е — пропускает 99% syscall'ов без оверхеда
  • Интересные syscall'ы попадают в ptrace-stop, и tracer решает: разрешить, подменить, заблокировать

Это основа gVisor (Google) — userspace-гипервизора, который перехватывает все syscall'ы гостевой ОС и эмулирует их в userspace «гофере», но через seccomp+ptrace фильтрует трафик так, чтобы дорогие ptrace-stops случались только когда действительно нужно. Похожим образом устроен Firejail для нестандартных filter actions, которые не выразимы в чистом seccomp-bpf.

Безопасность: Yama LSM

В классической модели Unix tracer мог attach'иться к любому процессу того же UID. Это удобно для разработчика и катастрофично для безопасности: если злоумышленник получил RCE в любом процессе пользователя, он может attach'иться к запущенному ssh-agent или браузеру и украсть ключи/cookies.

Linux 3.4 ввёл LSM-модуль Yama, ограничивающий ptrace. Управляется через /proc/sys/kernel/yama/ptrace_scope:

Значение Семантика
0 classic Unix: любой процесс того же UID может attach
1 restricted (default Ubuntu/Debian/Fedora): только родитель в цепочке fork
2 admin-only: нужен CAP_SYS_PTRACE
3 disabled: ptrace полностью выключен, требует перезагрузки чтобы вернуть

При значении 1 не-родитель всё ещё может attach, если tracee явно разрешил это через prctl(PR_SET_PTRACER, tracer_pid) — этим пользуется GNOME для crash reporter'а.

Дополнительные ограничения, всегда активные:

  • ptrace не работает для setuid/setgid бинарей — kernel сбрасывает effective UID при ptrace-stop, либо отказывает в attach (зависит от того, в какой момент попытались)
  • Один tracer на одного tracee: нельзя attach'нуть две gdb-сессии к одному процессу
  • Внутри user namespace capabilities считаются относительно ns; root в ns может trace-ить процессы только из своего ns

Производительность

Цена ptrace-наблюдения видна в простом расчёте. Каждый syscall с PTRACE_SYSCALL даёт 2 stop'а (enter + exit). Каждый stop — это:

  • Переход kernel → kernel: tracee останавливается, ставится в очередь ожидания
  • Переход kernel → tracer: wakeup tracer'а, его waitpid() возвращает
  • tracer делает несколько ptrace-вызовов (GETREGS, возможно PEEKDATA для строк) — каждый syscall сам по себе
  • Tracer делает ptrace(PTRACE_SYSCALL) — kernel размораживает tracee
  • Context switch tracer → tracee

Суммарно: 10–20 context switch'ей на каждый syscall. На современном x86 context switch стоит ~1–3 µs; ptrace добавляет ~20–50 µs к каждому наблюдаемому syscall. Для CPU-heavy workload это незаметно, для syscall-heavy (сетевой сервер, компилятор, веб-краулер) замедление 10–100× — норма, а 1000× — не редкость.

Поэтому современная observability ушла с ptrace на kernel-side трассировщики:

Инструмент Где работает Overhead syscall tracing Может менять syscall
strace (ptrace) userspace 10×–1000× да
perf trace kernel-side 1.5×–3× нет
bpftrace (eBPF) kernel-side 1.05×–1.2× нет¹
ftrace kernel минимальный нет
uprobes / USDT kernel+user средний нет

¹ eBPF может модифицировать поведение через bpf_override_return, но это требует CAP_BPF и ограничено.

ptrace остаётся незаменимым там, где нужно именно синхронное вмешательство: остановиться на breakpoint, изменить аргумент connect перед выполнением, инжектировать код. Для пассивного наблюдения — eBPF.

Где используется ptrace

Инструмент Как использует ptrace
gdb, lldb breakpoints через POKETEXT INT3; SINGLESTEP; GETREGS для backtrace; watchpoints через DR-регистры
strace PTRACE_SYSCALL + читает регистры и память, декодирует аргументы по таблице
ltrace PTRACE_SYSCALL + breakpoints на PLT для перехвата вызовов библиотечных функций
CRIU seize всех процессов, инжекция parasite code для dump памяти/fd/mmap'ов
Firejail seccomp-bpf + TRACESECCOMP для kill/log/modify нестандартных syscall'ов
bubblewrap то же
gVisor seize гостевых процессов, перехват каждого syscall, эмуляция в userspace gofer
libinjector shellcode injection в чужой процесс через классический POKETEXT+SETREGS flow
rr (Mozilla) record & replay debugger: ptrace + seccomp для детерминированной перезаписи
graftcp прозрачное проксирование connect() через подмену sockaddr в syscall-enter-stop

graftcp: прозрачное проксирование через ptrace

graftcp — пример нестандартного использования ptrace, выходящего за рамки «debugger/tracer». Задача: запустить произвольную программу так, чтобы её TCP-соединения шли через SOCKS5/HTTP-прокси, не требуя:

  • root-прав (как iptables REDIRECT)
  • LD_PRELOAD (не работает со static binaries и обходится через прямой syscall(2))
  • поддержки прокси самим приложением

Архитектура

graftcp состоит из двух компонентов:

graph TB
    U["пользователь:<br/>$ graftcp -- curl https://api.example.com"]
    GC["graftcp (C)<br/>tracer, fork+exec+TRACEME,<br/>перехватывает connect/clone/close"]
    CURL["curl (tracee)<br/>не знает о прокси,<br/>думает что соединяется напрямую"]
    GL["graftcp-local (Go)<br/>слушает 127.0.0.1:2233"]
    UP["upstream proxy<br/>127.0.0.1:1080"]
    U --> GC
    GC -->|ptrace| CURL
    CURL -->|"TCP к 1.2.3.4:443?<br/>→ подменено на 127.0.0.1:2233"| GL
    GL -->|SOCKS| UP
  • graftcp — tracer на C, запускает целевую программу и ptrace'ит её
  • graftcp-local — Go-демон, слушает на localhost:2233, говорит SOCKS5/HTTP с настоящим upstream-proxy

Связь между ними — через Unix socket. Когда tracer перехватывает connect, он сообщает graftcp-local: «соединение на fd N от PID P должно идти на 1.2.3.4:443». graftcp-local запоминает это и, когда tracee подключится к 127.0.0.1:2233, поднимет SOCKS5-туннель в правильное место.

Перехват connect

Главный трюк — подмена sockaddr в памяти tracee прямо в syscall-enter-stop:

sequenceDiagram
    participant Tracee
    participant Tracer
    participant GL as graftcp-local
    participant Proxy as upstream proxy
    Note over Tracee: fd = socket(AF_INET, ...)<br/>sockaddr = 1.2.3.4:443<br/>connect(fd, &sockaddr, 16)
    Tracee->>Tracer: ENTER stop (connect)
    Note over Tracer: GETREGS:<br/>orig_rax=42 (connect),<br/>rdi=fd, rsi=&sockaddr, rdx=16
    Note over Tracer: process_vm_readv:<br/>sockaddr из памяти
    Note over Tracer: фильтр AF_INET? да<br/>сохранить {pid,fd} → 1.2.3.4:443
    Tracer->>GL: "pid=P fd=N target=1.2.3.4:443"
    Note over GL: зарегистрировал ожидающее соединение
    Note over Tracer: POKETEXT/writev:<br/>подменить sockaddr → 127.0.0.1:2233
    Tracer->>Tracee: PTRACE_SYSCALL
    Note over Tracee: kernel выполняет connect на 127.0.0.1:2233
    Tracee->>GL: accept()
    GL->>Proxy: socks5 handshake
    GL->>Proxy: connect 1.2.3.4
    Proxy-->>GL: proxy connected
    Tracee->>Tracer: EXIT stop (rax = 0)
    Note over Tracer: (опционально восстановить байты)
    Tracer->>Tracee: PTRACE_SYSCALL
    Note over Tracee,Proxy: read/write на fd → graftcp-local → upstream proxy → настоящий destination

Перехватываемые syscall'ы:

  • connect — главный, описан выше
  • clone — иначе forked children не наследуют ptrace; graftcp должен явно подхватывать новые потоки и процессы через PTRACE_O_TRACECLONE / TRACEFORK
  • close — для очистки таблицы соответствий {pid,fd} → original_dest

Тонкости

Подмена sockaddr in-place. Buffer длиной до 28 байт (sockaddr_in6) пишется обратно через POKETEXT пословно или одним вызовом process_vm_writev. addrlen (rdx) обычно подменять не нужно, так как 127.0.0.1:port укладывается в любой sockaddr_in*.

IPv6. sockaddr_in6 длиннее (28 байт), формат другой. graftcp проверяет sa_family и обрабатывает оба случая. Если IPv6-цель не поддерживается upstream-прокси — соединение можно либо отклонить, либо завернуть через IPv4-loopback (как делает graftcp).

TCP only. UDP перехватывать практически бесполезно: SOCKS5 UDP-ассоциация — отдельный механизм с собственным форматом пакетов, и подменой sockaddr в sendto дело не ограничивается — пришлось бы инкапсулировать каждый пакет. graftcp осознанно UDP не трогает.

Cleanup. После EXIT-stop tracer может восстановить оригинальные байты sockaddr (вдруг tracee ещё раз использует тот же буфер?). На практике это редко критично — большинство приложений выделяют sockaddr на стеке и забывают про него.

Сравнение с альтернативами

Подход Static binaries Root Влияет на весь хост Лимит
proxychains (LD_PRELOAD) нет нет нет обходится прямым syscall
iptables REDIRECT + transparent SOCKS да да да сложно настроить
redsocks + iptables да да да то же
graftcp да нет нет overhead ptrace

graftcp выигрывает в нише «обычный пользователь хочет завернуть один процесс через прокси без правки системы». Цена — ptrace-overhead на каждое соединение: для long-lived connection незаметно, для краулера с тысячами коротких HTTP-запросов — десятки процентов CPU.

Альтернативы ptrace

Для каждой задачи, исторически решавшейся через ptrace, сейчас есть более лёгкая альтернатива:

Задача ptrace Современная альтернатива
debugger gdb через ptrace gdbserver (тоже ptrace, но удалённый)
syscall tracing strace bpftrace, perf trace, audit framework
userspace function tracing ltrace (PLT breakpoints) uprobes, USDT, eBPF uprobe
sandbox enforcement seccomp + ptrace чистый seccomp-bpf (без ptrace)
code injection POKETEXT + SETREGS LD_PRELOAD (если не static), kernel-модули
process snapshotting CRIU + parasite альтернатив пока нет, ptrace вне конкуренции
transparent connect proxy graftcp iptables (с root); LD_PRELOAD (не для static)

Связанные темы

Источники