ptrace — jak naprawdę działają strace i gdb

Odpalasz strace na procesie, który robi miliony syscalli na sekundę, i throughput spada o dwa rzędy wielkości. Podpinasz gdb do działającej usługi — i cały proces staje w miejscu. To nie są przypadkowe koszty źle napisanych narzędzi. To architektura ptrace: jednego wywołania systemowego, na którym stoi praktycznie cały ekosystem debugowania w Linuksie. strace i czytanie syscalli, gdb, ltrace, frameworki do reverse engineeringu — wszystkie sprowadzają się do tego samego interfejsu kernela i dziedziczą jego ograniczenia w pakiecie.

Zrozumienie mechaniki ptrace odpowiada na trzy pytania, które prędzej czy później zadaje sobie każdy, kto debuguje na Linuksie: dlaczego śledzenie syscalli jest tak kosztowne, dlaczego gdb -p potrafi odbić się od systemu z Operation not permitted, i czym właściwie jest breakpoint, skoro procesor nie ma pojęcia o istnieniu twojego kodu źródłowego.

Jeden syscall, cały ekosystem debuggerów

ptrace to multiplekser. Jedno wywołanie — ptrace(request, pid, addr, data) — w którym pierwszy argument wybiera operację: PTRACE_TRACEME, PTRACE_ATTACH, PTRACE_PEEKDATA, PTRACE_GETREGS, PTRACE_CONT i kilkadziesiąt innych requestów, których pełną listę dokumentuje ptrace(2). Kernel nie ma osobnego „API debuggera” — ma ten jeden syscall i zestaw semantyk zbudowanych wokół zatrzymywania procesu.

Model jest asymetryczny: tracer i tracee. Relacja działa per-thread — każdy wątek śledzony jest osobno — i jest wyłączna: jeden tracee ma dokładnie jednego tracera. Dlatego nie podepniesz gdb do procesu, który już wisi pod strace; kernel zwróci EPERM i koniec dyskusji. Komunikacja jest brutalnie prosta: tracee zatrzymuje się w jednym z kilku rodzajów stopów, kernel powiadamia tracera przez waitpid(), tracer grzebie w zamrożonym procesie — rejestry, pamięć, sygnały — i decyduje, jak go wznowić. Wszystko, co robi debugger, od wypisywania syscalli po step-debugging, to kompozycja tej jednej pętli: stop, inspekcja, wznowienie.

Trzy drogi przejęcia procesu

PTRACE_TRACEME + execve()

Klasyczny wzorzec „uruchom pod debuggerem”. Proces potomny po fork() i copy-on-write woła ptrace(PTRACE_TRACEME, ...) — deklaruje: zgadzam się być śledzony przez rodzica — a następnie wykonuje execve(). Po udanym execve kernel dostarcza dziecku SIGTRAP, więc nowy program zatrzymuje się, zanim wykona pierwszą własną instrukcję. Dokładnie w tym punkcie strace ./app i gdb ./app zaczynają sesję: mają proces zamrożony na starcie, z pełną kontrolą, zanim cokolwiek się wydarzy.

PTRACE_ATTACH

Podpięcie do już działającego procesu. ATTACH wysyła tracee SIGSTOP, co ma skutki uboczne: proces przechodzi przez group-stop widoczny dla reszty systemu, a sekwencja attach + stop jest podatna na race’y z sygnałami przychodzącymi w międzyczasie. Przez lata debuggery żyły z tymi niejednoznacznościami, obudowując je heurystykami — i przez lata było to źródło subtelnych bugów w samych narzędziach do debugowania.

PTRACE_SEIZE

Nowszy interfejs, zaprojektowany po lekcjach z ATTACH: przejmuje tracee bez zatrzymywania go. Proces działa dalej, a tracer zatrzymuje go dopiero wtedy, gdy faktycznie chce — jawnym PTRACE_INTERRUPT. SEIZE raportuje też stopy w sposób jednoznacznie rozróżnialny (PTRACE_EVENT_STOP), co eliminuje całą klasę pytań „czy to był mój SIGSTOP, czy cudzy”. Współczesny strace -p pod spodem używa właśnie SEIZE. Jeżeli piszesz własny tracer w 2026 i zaczynasz od ATTACH — zaczynasz od legacy.

Signal-delivery-stop — tracer widzi sygnały pierwszy

Śledzenie zmienia semantykę dostarczania sygnałów. Każdy sygnał adresowany do tracee — poza SIGKILL — najpierw zatrzymuje proces i ląduje u tracera. Dopiero tracer, wznawiając tracee, decyduje: dostarczyć sygnał dalej, podmienić na inny albo wyciszyć całkowicie. Mechanika dyspozycji, handlerów i przerywanych syscalli to osobny temat — rozebrany w anatomii sygnałów i handlerów w Linuksie — ale z perspektywy ptrace kluczowy jest sam fakt przechwycenia: debugger stoi w środku ścieżki dostarczania.

To dlatego strace pokazuje linie --- SIGCHLD --- pomiędzy syscallami, gdb potrafi złapać SIGSEGV, zanim proces umrze, a „wyciszenie” sygnału w debuggerze w ogóle jest możliwe. I to też dlatego proces pod debuggerem jest behawioralnie innym procesem: zmieniają się timingi dostarczania, EINTR pojawia się w miejscach, w których nigdy go nie widziałeś, a heisenbug — z definicji — znika dokładnie wtedy, gdy zaczynasz patrzeć. SIGKILL pozostaje jedynym wyjątkiem: zabija tracee natychmiast i żaden tracer tego nie zatrzyma.

Syscall-stopy, czyli minimalny strace w C

Sercem strace jest request PTRACE_SYSCALL: wznów tracee i zatrzymaj go na najbliższej granicy wywołania systemowego. Każdy syscall generuje dwa stopy — syscall-enter-stop przed wejściem do kernela i syscall-exit-stop po wyjściu. W enter-stopie numer syscalla i argumenty siedzą w rejestrach (na x86-64: orig_rax, rdi, rsi, rdx…), w exit-stopie wynik czeka w rax. Z opcją PTRACE_O_TRACESYSGOOD syscall-stopy raportują się jako SIGTRAP | 0x80, dzięki czemu odróżnisz je od zwykłego dostarczania sygnałów bez zgadywania.

Poniżej kompletny, minimalny tracer: uruchamia program i wypisuje numer oraz wynik każdego syscalla. Sześćdziesiąt linii, zero magii.

#define _GNU_SOURCE
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <unistd.h>

/* Wznawia tracee do najbliższego syscall-stopu.
 * Sygnały niebędące syscall-stopem dostarcza z powrotem przy wznowieniu.
 * Zwraca 0 na syscall-stopie, -1 gdy tracee zakończył pracę. */
static int wait_for_syscall(pid_t child) {
    int status, sig = 0;
    for (;;) {
        if (ptrace(PTRACE_SYSCALL, child, NULL, (void *)(long)sig) == -1) {
            perror("ptrace(PTRACE_SYSCALL)");
            return -1;
        }
        sig = 0;
        if (waitpid(child, &status, 0) == -1) {
            perror("waitpid");
            return -1;
        }
        if (WIFEXITED(status) || WIFSIGNALED(status))
            return -1;                        /* tracee nie żyje */
        if (WIFSTOPPED(status)) {
            int stopsig = WSTOPSIG(status);
            if (stopsig == (SIGTRAP | 0x80))  /* PTRACE_O_TRACESYSGOOD */
                return 0;                     /* syscall-stop */
            sig = stopsig;                    /* signal-delivery-stop:
                                                 wstrzyknij przy wznowieniu */
        }
    }
}

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

    pid_t child = fork();
    if (child == -1) {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (child == 0) {                         /* tracee */
        if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
            perror("ptrace(PTRACE_TRACEME)");
            _exit(127);
        }
        execvp(argv[1], &argv[1]);            /* stop: SIGTRAP po execve */
        perror("execvp");
        _exit(127);
    }

    int status;                               /* tracer */
    if (waitpid(child, &status, 0) == -1 || !WIFSTOPPED(status)) {
        fprintf(stderr, "tracee nie doszedł do execve\n");
        return EXIT_FAILURE;
    }
    if (ptrace(PTRACE_SETOPTIONS, child, 0,
               PTRACE_O_EXITKILL | PTRACE_O_TRACESYSGOOD) == -1) {
        perror("ptrace(PTRACE_SETOPTIONS)");
        return EXIT_FAILURE;
    }

    while (wait_for_syscall(child) == 0) {    /* syscall-enter-stop */
        struct user_regs_struct regs;
        if (ptrace(PTRACE_GETREGS, child, NULL, &regs) == -1) {
            perror("ptrace(PTRACE_GETREGS)");
            break;
        }
        long long nr = (long long)regs.orig_rax;

        if (wait_for_syscall(child) != 0) {   /* syscall-exit-stop */
            fprintf(stderr, "syscall(%lld) = ?\n", nr);
            break;
        }
        if (ptrace(PTRACE_GETREGS, child, NULL, &regs) == -1) {
            perror("ptrace(PTRACE_GETREGS)");
            break;
        }
        fprintf(stderr, "syscall(%lld) = %lld\n", nr, (long long)regs.rax);
    }
    return EXIT_SUCCESS;
}

Kilka decyzji w tym kodzie jest nieprzypadkowych. PTRACE_O_EXITKILL sprząta relację: gdy tracer umrze, tracee dostaje SIGKILL, zamiast wisieć w stopie do końca świata. Funkcja wait_for_syscall wstrzykuje z powrotem sygnały, które nie są syscall-stopami — bez tego pierwszy lepszy SIGCHLD albo SIGALRM rozjeżdża parzystość enter/exit i „wyniki” przestają odpowiadać wywołaniom. Odczyt rejestrów przez PTRACE_GETREGS jest specyficzny dla x86-64; przenośny kod używa PTRACE_GET_SYSCALL_INFO, który zwraca numer syscalla, argumenty i wynik w strukturze niezależnej od architektury. Prawdziwy strace dokłada do tego dekodowanie argumentów (stringi, struktury, flagi), śledzenie potomków przez PTRACE_O_TRACEFORK i tuzin innych mechanizmów — ale pętla w środku jest dokładnie ta.

Jak gdb ustawia breakpoint

Breakpoint to nie funkcja procesora zarezerwowana dla debuggerów (poza ograniczoną pulą sprzętowych watchpointów). To wandalizm za zgodą kernela: gdb przez PTRACE_POKETEXT nadpisuje pierwszy bajt instrukcji pod wskazanym adresem bajtem 0xCC — jednobajtowym opcodem int3. Kiedy wykonanie trafia w to miejsce, procesor generuje pułapkę, kernel tłumaczy ją na SIGTRAP, a SIGTRAP — jak każdy sygnał śledzonego procesu — trafia najpierw do tracera. gdb cofa RIP o jeden, przywraca oryginalny bajt, wykonuje tę jedną instrukcję przez PTRACE_SINGLESTEP, wstawia 0xCC z powrotem i wznawia proces. Cała iluzja „zatrzymałem się na linii 47″ to sekwencja podmian bajtów i sygnałów.

Dwie rzeczy z tego wynikają. Po pierwsze: sekcja .text jest zmapowana bez prawa zapisu, ale ptrace pisze przez interfejs kernela, z pominięciem ochrony stron widzianej przez sam proces — to ten sam mechanizm ochrony pamięci, który przy zwykłym zapisie z userspace skończyłby się tym, co opisuje anatomia segfaulta od MMU po core dump. Po drugie: adres breakpointu trzeba najpierw znaleźć, a między adresem w binarce a adresem w pamięci stoi ASLR, relokacja i dynamic linker — jak segmenty trafiają do pamięci, zanim ruszy main(), rozbiera ELF i dynamic linking.

Skąd bierze się narzut ptrace

Policzmy koszt jednego śledzonego syscalla w trybie PTRACE_SYSCALL. Dwa stopy na wywołanie; każdy stop to zamrożenie tracee, przełączenie kontekstu do tracera, w tracerze co najmniej waitpid() i PTRACE_GETREGS — czyli kolejne syscalle — i przełączenie kontekstu z powrotem. Cztery context switche i kilka wywołań systemowych narzutu na jedno śledzone getpid(), które samo kosztuje ułamek mikrosekundy. Do tego czytanie pamięci tracee: klasyczne PTRACE_PEEKDATA zwraca jedno słowo maszynowe na wywołanie, więc podejrzenie 4-kilobajtowego bufora write() to 512 osobnych syscalli. Nowsze tracery ratują się process_vm_readv(), które kopiuje całe bloki w jednym wywołaniu — ale stopów nie eliminuje nic.

Stąd spowolnienia o dwa rzędy wielkości na workloadach syscall-heavy — i stąd pierwsza zasada higieny: strace na produkcji to decyzja, nie odruch. Da się ją podejmować mądrzej. strace --seccomp-bpf wstrzykuje do kernela filtr seccomp, który przepuszcza nieinteresujące syscalle bez zatrzymywania procesu — stop dzieje się tylko dla wywołań z -e trace=.... A eBPF idzie o krok dalej: zbiera dane po stronie kernela bez zatrzymywania tracee w ogóle, za cenę utraty tego, co w ptrace najcenniejsze — możliwości ingerencji w stan procesu.

Metrykaptrace (PTRACE_SYSCALL)ptrace + seccomp-BPFeBPF (tracepointy)
Koszt jednego zdarzenia≥4 przełączenia kontekstu + syscalle traceraJak ptrace, ale tylko dla filtrowanych syscalliSetki nanosekund w kernelu, bez stopu
Wpływ na traceeStop-the-world dwa razy na każdy syscallStop wyłącznie na dopasowanych wywołaniachBrak zatrzymań; mały, mierzalny narzut
Modyfikacja stanu (rejestry, pamięć, sygnały)PełnaPełna dla zatrzymanych wywołańBrak — obserwacja read-only
Wymagane uprawnieniaYAMA ptrace_scope lub CAP_SYS_PTRACEJak ptrace + instalacja filtra seccompCAP_BPF + CAP_PERFMON albo root

YAMA, czyli dlaczego gdb -p rzuca „Operation not permitted”

Klasyczny model uprawnień ptrace był prosty: ten sam UID może się podpiąć. Z perspektywy bezpieczeństwa to katastrofa — dowolny skompromitowany proces użytkownika mógłby przeczytać pamięć jego przeglądarki, menedżera haseł czy agenta SSH, bo „przecież ten sam UID”. Moduł YAMA dokłada sysctl kernel.yama.ptrace_scope z czterema poziomami: 0 — model klasyczny; 1 — attach tylko do własnych bezpośrednich potomków (domyślny na większości dystrybucji); 2 — attach tylko z CAP_SYS_PTRACE; 3 — attach wyłączony na stałe, aż do rebootu.

$ cat /proc/sys/kernel/yama/ptrace_scope
1
$ gdb -p "$(pidof myapp)"
ptrace: Operation not permitted.
$ sudo sysctl -w kernel.yama.ptrace_scope=0   # doraźnie i świadomie, nie na stałe

Stąd asymetria, która regularnie myli ludzi: strace ./app działa zawsze — proces sam zgłasza się przez PTRACE_TRACEME jako dziecko tracera — ale strace -p PID na tej samej maszynie odbija się od poziomu 1. To nie jest bug w twoim strace ani „zepsute uprawnienia”. To polityka LSM działająca dokładnie tak, jak ją skonfigurowano. Wyjątek per-proces daje prctl(PR_SET_PTRACER, pid) — tak działają crash-handlery pokroju Breakpada, które muszą wpuścić zewnętrzny proces na dump własnej pamięci.

Kiedy ptrace, a kiedy nie

ptrace to chirurgia na żywym organizmie: pełna kontrola, dotykanie stanu, zatrzymany pacjent. Ten poziom inwazyjności jest właściwy w debugowaniu incydentu — i niewłaściwy jako stały element systemu. Reguły, które się bronią: strace i gdb do weryfikacji konkretnej hipotezy o konkretnym procesie, z akceptacją kosztu; ciągła obserwacja syscalli na produkcji — eBPF, nie ptrace. Jeżeli twój „observability agent” trzyma procesy pod ptrace, to nie jest observability, tylko permanentny debugger na produkcji — ze wszystkimi stopami i narzutami, o których była mowa wyżej.

I sprawa metodologiczna: ptrace daje ci mikroskop, nie kompas. Zatrzymywanie procesu i oglądanie rejestrów ma sens dopiero wtedy, gdy wiesz, czego szukasz — narzędzie służy weryfikacji hipotez, nie ich generowaniu, co rozkłada na czynniki debugowanie hipotezami zamiast zgadywania.

Insight do zabrania: cały ekosystem debugowania Linuksa — strace, gdb, ltrace i każdy tracer, który kiedyś napiszesz — to pętla na czterech operacjach jednego syscalla: zatrzymaj, obejrzyj, zmień, wznów. Wszystkie ich supermoce (przechwytywanie sygnałów, breakpointy, podglądanie pamięci) i wszystkie ograniczenia (narzut, jeden tracer na tracee, YAMA) są własnościami tej pętli. Kto rozumie pętlę, tego żaden debugger już nie zaskoczy.

Zostaw komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry