Strace w Linuxie: historia, struktura i zastosowanie

Strace w Linuxie: historia, struktura i zastosowanie

W systemach operacyjnych podobnych do Unix komunikacja programu z otoczeniem i systemem operacyjnym odbywa się za pośrednictwem niewielkiego zestawu funkcji — wywołań systemowych. Dlatego w celach debugowania przydatne może być podglądanie wywołań systemowych realizowanych przez uruchomione procesy.

Pomaga w tym narzędzie strace, które jest przedmiotem tego artykułu. Do przykładów zastosowania "szpiegowskiego" narzędzia dołączona jest krótka historia strace i opis działania podobnych programów.

Spis treści

O pochodzeniu gatunków

Głównym interfejsem między programami a jądrem systemu operacyjnego w Unix są wywołania systemowe (ang. system calls, syscalls), interakcja programów ze światem zewnętrznym odbywa się wyłącznie przez nie.

Jednak w pierwszej publicznej wersji Unix (Version 6 Unix, 1975 rok) nie było wygodnych sposobów śledzenia zachowania procesów użytkownika. Aby rozwiązać ten problem, Bell Labs zaproponował w kolejnej wersji (Version 7 Unix, 1979 rok) nowe wywołanie systemowe — ptrace.

Rozwijane ptrace przede wszystkim dla interaktywnych debuggerów, jednak pod koniec lat 80. (w czasach komercyjnych już System V Release 4) na tej podstawie pojawiły się i uzyskały szerokie zastosowanie specjalistyczne debugery — tracer systemowych wywołań.

Pierwsza wersja strace została opublikowana przez Paula Kronenburga w grupie dyskusyjnej comp.sources.sun w 1992 roku jako alternatywa dla zamkniętego narzędzia trace od Sun. Zarówno klon, jak i oryginał były przeznaczone dla SunOS, ale do 1994 roku strace zostały przeniesione na System V, Solaris i zyskującego popularność Linux.

Dziś strace wspiera tylko Linux i opiera się na tym samym ptrace, które obrosło licznymi rozszerzeniami.

Nowoczesny (i bardzo aktywny) konserwator straceDmitrij Lewin. Dzięki niemu narzędzie zyskało zaawansowane możliwości takie jak wstrzykiwanie błędów w wywołania systemowe, wsparcie dla szerokiego zakresu architektur i, co najważniejsze, maskotkęNieoficjalne źródła twierdzą, że wybór padł na strusia z powodu podobieństwa rosyjskiego słowa „страус” do angielskiego "strace".

Również istotne jest to, że wywołanie systemowe ptrace i debugerzy nigdy nie zostały uwzględnione w POSIX, pomimo długiej historii i dostępnej implementacji w Linuxie, FreeBSD, OpenBSD oraz tradycyjnym Unixie.

Struktura strace w dwóch słowach: Piglet Trace

"Nie oczekuj, że to zrozumiesz" (Dennis Ritchie, komentarz w kodzie źródłowym Version 6 Unix)

Od dzieciństwa nie znosiłem czarnych skrzynek: nie bawiłem się zabawkami, ale starałem się zrozumieć ich działanie (dorośli używali słowa „łamać”, ale nie wierzcie złym językom). Dlatego być może tak bliskie są mi nieformalne kultury wczesnego Unixu i współczesnego ruchu open-source.

W ramach tego artykułu analiza kodu źródłowego strace, który rozwinął się przez lata, nie ma sensu. Jednak czytelnicy nie powinni być pozbawieni wiedzy. Dlatego aby pokazać zasadę działania podobnych programów strace, przedstawiam kod miniaturowego debugera — Piglet Trace (ptr). Nie robi nic szczególnego, ale przede wszystkim — wyświetla wywołania systemowe programu:

$ gcc examples/piglet-trace.c -o ptr
$ ptr echo test > /dev/null
BRK(12) -> 94744690540544
ACCESS(21) -> 18446744073709551614
ACCESS(21) -> 18446744073709551614
unknown(257) -> 3
FSTAT(5) -> 0
MMAP(9) -> 140694657216512
CLOSE(3) -> 0
ACCESS(21) -> 18446744073709551614
unknown(257) -> 3
READ(0) -> 832
FSTAT(5) -> 0
MMAP(9) -> 140694657208320
MMAP(9) -> 140694650953728
MPROTECT(10) -> 0
MMAP(9) -> 140694655045632
MMAP(9) -> 140694655070208
CLOSE(3) -> 0
unknown(158) -> 0
MPROTECT(10) -> 0
MPROTECT(10) -> 0
MPROTECT(10) -> 0
MUNMAP(11) -> 0
BRK(12) -> 94744690540544
BRK(12) -> 94744690675712
unknown(257) -> 3
FSTAT(5) -> 0
MMAP(9) -> 140694646390784
CLOSE(3) -> 0
FSTAT(5) -> 0
IOCTL(16) -> 18446744073709551591
WRITE(1) -> 5
CLOSE(3) -> 0
CLOSE(3) -> 0
unknown(231)
Tracee terminated

Piglet Trace rozpoznaje około stu wywołań systemowych Linuxa (zob. tabelę) i działa tylko na architekturze x86-64. Dla celów edukacyjnych to wystarczy.

Przyjrzyjmy się pracy naszego klona. W przypadku Linuksa w debugerach i programach śledzących używa się, jak już wspomniano, wywołania systemowego ptrace. Działa on, przekazując w pierwszym argumencie identyfikatory poleceń, z których potrzebujemy tylko PTRACE_TRACEME, PTRACE_SYSCALL i PTRACE_GETREGS.

Działanie debugera rozpoczyna się w tradycyjnym stylu Unix: fork(2) uruchamia proces podrzędny, a ten z kolei za pomocą exec(3) uruchamia badany program. Jedynym szczegółem jest tutaj wywołanie ptrace(PTRACE_TRACEME) przed exec: proces podrzędny oczekuje, że proces nadrzędny będzie go śledzić:

pid_t child_pid = fork();
switch (child_pid) {
case -1:
    err(EXIT_FAILURE, "fork");
case 0:
    /* Dziecko tutaj */
    /* Tryb śledzenia musi być włączony. Rodzic będzie musiał czekać(2), aby to
     * się wydarzyło. */
    ptrace(PTRACE_TRACEME, 0, NULL, NULL);
    /* Zastąp siebie programem do uruchomienia. */
    execvp(argv[1], argv + 1);
    err(EXIT_FAILURE, "exec");
}

Proces rodzicielski teraz musi wywołać wait(2) w procesie potomnym, czyli upewnić się, że przełączenie w tryb śledzenia nastąpiło:

/* Parent */

/* First we wait for the child to set the traced mode (see
 * ptrace(PTRACE_TRACEME) above) */
if (waitpid(child_pid, NULL, 0) == -1)
    err(EXIT_FAILURE, "traceme -> waitpid");

Na tym przygotowania się kończą i można przystąpić bezpośrednio do śledzenia wywołań systemowych w nieskończonej pętli.

Wywołanie ptrace(PTRACE_SYSCALL) zapewnia, że następny wait rodzica zakończy się albo przed wykonaniem wywołania systemowego, albo zaraz po jego zakończeniu. Między tymi dwoma wywołaniami można wykonać jakieś czynności: zastąpić wywołanie alternatywnym, zmienić argumenty lub wartość zwracaną.

Nam wystarczy dwukrotnie wywołać polecenie ptrace(PTRACE_GETREGS), aby uzyskać stan rejestru rax przed wywołaniem (numer wywołania systemowego) i tuż po (wartość zwracana).

Właściwie, pętla:

/* A system call tracing loop, one interation per call. */
for (;;) {
    /* A non-portable structure defined for ptrace/GDB/strace usage mostly.
     * It allows to conveniently dump and access register state using
     * ptrace. */
    struct user_regs_struct registers;

    /* Enter syscall: continue execution until the next system call
     * beginning. Stop right before syscall.
     *
     * It's possible to change the system call number, system call
     * arguments, return value or even avoid executing the system call
     * completely. */
  if (ptrace(PTRACE_SYSCALL, child_pid, NULL, NULL) == -1)
      err(EXIT_FAILURE, "enter_syscall");
  if (waitpid(child_pid, NULL, 0) == -1)
      err(EXIT_FAILURE, "enter_syscall -> waitpid");

  /* According to the x86-64 system call convention on Linux (see man 2
   * syscall) the number identifying a syscall should be put into the rax
   * general purpose register, with the rest of the arguments residing in
   * other general purpose registers (rdi,rsi, rdx, r10, r8, r9). */
  if (ptrace(PTRACE_GETREGS, child_pid, NULL, &registers) == -1)
      err(EXIT_FAILURE, "enter_syscall -> getregs");

  /* Note how orig_rax is used here. That's because on x86-64 rax is used
   * both for executing a syscall, and returning a value from it. To
   * differentiate between the cases both rax and orig_rax are updated on
   * syscall entry/exit, and only rax is updated on exit. */
  print_syscall_enter(registers.orig_rax);

  /* Exit syscall: execute of the syscall, and stop on system
   * call exit.
   *
   * More system call tinkering possible: change the return value, record
   * time it took to finish the system call, etc. */
  if (ptrace(PTRACE_SYSCALL, child_pid, NULL, NULL) == -1)
      err(EXIT_FAILURE, "exit_syscall");
  if (waitpid(child_pid, NULL, 0) == -1)
      err(EXIT_FAILURE, "exit_syscall -> waitpid");

  /* Retrieve register state again as we want to inspect system call
   * return value. */
  if (ptrace(PTRACE_GETREGS, child_pid, NULL, &registers) == -1) {
      /* ESRCH is returned when a child terminates using a syscall and no
       * return value is possible, e.g. as a result of exit(2). */
      if (errno == ESRCH) {
          fprintf(stderr, "nTracee terminatedn");
          break;
      }
      err(EXIT_FAILURE, "exit_syscall -> getregs");
  }

  /* Done with this system call, let the next iteration handle the next
   * one */
  print_syscall_exit(registers.rax);
}

To cały śledzik. Teraz wiesz, od czego zacząć kolejne portowanie DTrace na Linuxa.

Podstawy: uruchamianie programu pod kontrolą strace

Jako pierwszy przykład użycia strace, warto przytoczyć najprostszy sposób — uruchomienie aplikacji pod zarządzaniem strace.

Aby nie grzebać w nieskończonym wykazie wywołań typowego programu, napiszemy minimalny program wokół write:

int main(int argc, char *argv[])
{
    char str[] = "write me to stdoutn";
    /* write(2) to prosta osłona wokół syscall, więc powinno być łatwo
     * znaleźć w śledzeniu wywołań systemowych. */
    if (sizeof(str) != write(STDOUT_FILENO, str, sizeof(str))){
        perror("write");
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

Skompilujmy program i upewnijmy się, że działa:

$ gcc examples/write-simple.c -o write-simple
$ ./write-simple
write me to stdout

I w końcu uruchomimy go pod zarządzaniem strace:

$ strace ./write-simple
pexecve("./write", ["./write"], 0x7ffebd6145b0 /* 71 vars */) = 0
brk(NULL)                               = 0x55ff5489e000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (Nie ma takiego pliku ani katalogu)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (Nie ma takiego pliku ani katalogu)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=197410, ...}) = 0
mmap(NULL, 197410, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f7a2a633000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (Nie ma takiego pliku ani katalogu)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "177ELF2113        3 >
Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster