Strace в 60 строчек код

15 minute read

Перевод статьи Liz Rice “Strace in 60 lines of Go".

Эта статья написана по мотивам моего доклада “A Go Programmer’s Guide to Syscalls". Вы можете посмотреть код тут.

Чтобы объяснить некоторые моменты работы линуксовского ptrace я решила написать свою базовую реализацию strace. И в этой статье я расскажу, как этот самодельный strace работает. Если у вас есть время, то можете посмотреть видео того самого доклада:

Брекпоинт в процессе потомке

Наша программа перехватывает все сисколы которые были вызваны в процессе работы любой команды, которую мы указываем через аргументы. Для вызова заданной команды используется exec.Command(). Указываем что хотим использовать ptrace в дочернем процессе: в настройках SysProcAttr сетим Ptrace: true. Ниже пример кода который будет у нас в main() функции:

 1fmt.Printf("Run %v\n", os.Args[1:])
 2cmd := exec.Command(os.Args[1], os.Args[2:]...)
 3cmd.Stderr = os.Stderr
 4cmd.Stdin = os.Stdin
 5cmd.Stdout = os.Stdout
 6cmd.SysProcAttr = &syscall.SysProcAttr{
 7    Ptrace: true,
 8}
 9cmd.Start()
10err := cmd.Wait()
11if err != nil {
12    fmt.Printf("Wait returned: %v\n", err)
13}

Эти настройки нужны для перевода процесса потомока в брейкпоинт состояние как только он будет создан. Если мы сейчас запустим этот код, то cmd.Wait() завершится с ошибкой.

root@vm-ubuntu:myStrace# ./myStrace echo hello
Run [echo hello]
Wait returned: stop signal: trace/breakpoint trap
root@vm-ubuntu:myStrace# hello

Видно что повился текст hello. Это кажется странным, потому что мы просто перевели дочернй процесс в брейкпоинт состояние. Если добавить небольшую задержку перед cmd.Wait(), то станет видно что это происходит только после завершения родительского процесса. Родительский процесс переводит своего потомка в брейкпоинт состояние, но после завершения родителя уже ничего не “удерживает” дочерний процесс и он продолжает работу печатая слово hello.

Узнаем сискол через реестр дочернего процесса

Теперь нам нужно получить реест для дочернего процесса. Напомню, что пид запущенного процесса можно получить из cmd.Process.Pid. А для доступа к реестру нужно использовать ptrace команду PTRACE_GETREGS. Go-шный пакет syscall неплохо упрощает нам жизнь предоставляя функции для большого количества ptrace команд:

1pid = cmd.Process.Pid
2err = syscall.PtraceGetRegs(pid, &regs)

На выходе мы получаем структуру в которой есть информация о всем реестре дочернего процесса. На моем x86 CPU идентификатор сискола указан в поле Orig_rax. Можем получить немного больше информации:

1name, _ := sec.ScmpSyscall(regs.Orig_rax).GetName()
2fmt.Printf("%s\n", name)

sec это алиас для пакета seccomp/libseccomp-golang

Получаем следующий сискол

Теперь “отпускаем” программу до момента следующего сискола. Для этого используем ptrace команду PTRACE_SYSCALL. И в пакете syscall уже есть готовая функция:

1err = syscall.PtraceSyscall(pid, 0)

Мы должны дождаться SIGTRAP

1_, err = syscall.Wait4(pid, nil, 0, nil)

И повторить

Сейчас нам снова нужно прочитать реестр, узнать название следующего сискола и потом опять “отпустить” программу до следующего сискола и так далее в цикле.

Но нам нужно понять когда пора остановиться. В своей простой реализации я останавливаюсь, когда PtraceGetRegs возвращает ошибку. Из текста ошибки видно что она возникает когда не получается прочитать данные из несуществующего процесса, что логично, так как наш дочерний процесс уже завершен.

Нюанс

Если запустить текущую версию нашей программы, то сисколы будут выводиться по два раза. Это происходит из-за PTRACE_SYSCALL который останавливает программу дважды: до и после вызова сискола. Вот более подробное описание:

Поэтому пришлось добавить переменную exit чтобы пропусакть повторные сисколы в цикле. Ниже код который выводит уже по одному сисколу:

 1for {
 2    if exit {
 3        err = syscall.PtraceGetRegs(pid, &regs)
 4        if err != nil {
 5            break
 6        }
 7        name, _ := sec.ScmpSyscall(regs.Orig_rax).GetName()
 8        fmt.Printf("%s\n", name)
 9    }
10    err = syscall.PtraceSyscall(pid, 0)
11    if err != nil {
12        panic(err)
13    }
14    _, err = syscall.Wait4(pid, nil, 0, nil)
15    if err != nil {
16        panic(err)
17    }
18    exit = !exit
19}

###Статистика по сисколам

Для подсчета количества вызовов сисколов было написано немного вспомогательного кода.

Заключение

Наша небольшая утилита работает очень похоже на обычный strace. Результаты которая выдает наш код и strace -c для команды echo hello практически одинаковые - в обоих случаях список сисколов одинаковый.

В конечной реализации выводятся параметры для каждого сискола. Если вы тоже хотите это реализовать, то вам нужно получать чуть больше информации из реестра.

В докладе было показано как можно использовать seccomp security module для предотвращения вызова определенных сисколов. Вы можеет попробовать тоже самое раскоментив disallow(). Но это все просто как идея, я не призываю писать продакшен код, который будет решать, какой сискол нужен, а какой нет. Если вам понравилась идея с “песочницей” для приложения, то можете посмотреть доклад от Jessie Frazelle.

Огромное спасибо @nelhage с докладом implementation of strace in C и Michał Łowicki с докладом deep dives into making a debugger in Go за вдохновение и информацию. и не меньшее спасибо каждому гофреу на Gophercon.