Stacktrace dla ubogich

Przypuśćmy, że naszą ambicją jest, by skromny program w C dorównał programom napisanym w językach wysokiego poziomu. Komunikaty o błędnym wykonaniu programu napisanego w Javie mają postać długich (czasami ciągnących się kilometrami) wydruków ze zrzutem stosu wywołań metod. Nie trzeba specjalnie przekonywać o przydatności takiej informacji w procesie debugowania (zwłaszcza w analizie post-mortem, gdzie nie mamy dostępu do działającego programu). W zestawieniu z wydrukami wyjątków w Javie generyczne komunikaty o błędach w natywnych programach bywają daleko bardziej lapidarne:

Segmentation fault (core dumped)

Naszym celem będzie uzyskanie informacji o stosie wywołań funkcji w programie napisanym w C (niestety, nie tak obszernej informacji jak w Javie — stąd tytuł: stacktrace dla ubogich). Z czysto użytkowego punktu widzenia najwygodniejszym rozwiązaniem byłoby zamknięcie takiej funkcjonalności w jedną funkcję, którą programista może wywołać w dowolnym miejscu swojego kodu. Na przykład spodziewamy się, że dla następującego programu:

otrzymamy wynik w rodzaju:

bar
foo
main

Generowanie takiego wydruku możemy także umieścić w funkcji obsługi sygnału/wyjątku (zależnie od systemu operacyjnego) tak, by zamiast standardowego komunikatu o nieprawidłowym zakończeniu programu otrzymać informację, która pozwoli zidentyfikować miejsce w kodzie, w którym wystąpił błąd.

Należy oczywiście spodziewać się, że potrzebne nam informacje o nazwach funkcji nie są zapisywane w sposób przenośny i metody dostępu do nich są inne w różnych systemach operacyjnych.


Najpierw na warsztat weźmiemy systemy unixowe, a konkretnie te, na których mamy do dyspozycji bibliotekę GNU C, czyli popularny glibc. Tutaj możemy skorzystać z “gotowca” w postaci kombinacji funkcji backtrace oraz backtrace_symbols lub backtrace_symbols_fd (manual http://linux.die.net/man/3/backtrace_symbols_fd).

(wypisujemy frames-1 symboli, począwszy od drugiego, pomijamy w wydruku wywołanie samej funkcji print_stacktrace).

Kompilujemy i uruchamiamy program:

$ gcc -m32 -rdynamic stacktracetest.c -o stacktracetest
$ ./stacktracetest
./stacktracetest(bar+0xb)[0x8048849]
./stacktracetest(foo+0xb)[0x8048857]
./stacktracetest(main+0x16)[0x80488d0]
/lib/i386-linux-gnu/libc.so.6(__libc_start_main+0xde)[0xf752973e]
./stacktracetest[0x8048701]

Kluczowe jest dodanie tutaj opcji -rdynamic — nakazuje ona linkerowi dołączenie informacji o symbolach (również tych nierelokowanych/statycznych, a więc pomijanych w tym miejscu przy zwykłej kompilacji) do specjalnej tablicy symboli .dynsym obecnej w pliku wykonywalnym programu. Zawartość .dynsym można obejrzeć używając narzędzia operującego na plikach ELF:

$ readelf -s stacktracetest
Symbol table ‘.dynsym’ contains 29 entries:
Num: Value Size Type Bind Vis Ndx Name
[...]
6: 00000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.0 (4)
[...]
12: 080488ba 41 FUNC GLOBAL DEFAULT 13 main
[...]
15: 0804883e 14 FUNC GLOBAL DEFAULT 13 bar
[...]
22: 0804884c 14 FUNC GLOBAL DEFAULT 13 foo
[...]

Należy podkreślić, że to właśnie z tej tablicy (obecnej standardowo w plikach ELF), a nie z informacji do debugowania generowanych (opcjonalnie) przez kompilator (przełącznik -g w kompilatorze gcc), korzysta rodzina funkcji backtrace*. Bardziej szczegółowe omówienie konsekwencji zastosowania opcji -rdynamic i -g można znaleźć tutaj: http://stackoverflow.com/questions/8623884/gcc-debug-symbols-g-flag-vs-linkers-rdynamic-option.


Bardziej pouczające niż skorzystanie wprost z zestawu bibliotecznych funkcji będzie jednak samodzielne zaimplementowanie funkcji przechodzącej po ramkach stosu programu. Przyjmujemy tutaj istotne założenie, że każda funkcja wywoływana w programie taką ramkę odkłada na stosie. Wprawdzie rozwiązanie straci nieco na ogólności, ale dzięki temu nasze zadanie mocno się upraszcza.

Spacer powinniśmy rozpocząć od 4 bajtów zapisanych w pamięci pod adresem wskazywanym przez rejestr EBP. W kolejnych 4 bajtach poniżej na stosie (czyli pod adresem większym o 4 bajty — pamiętamy, że stos rośnie od wysokich adresów w stronę niskich adresów) prawdopodobnie znajdziemy zapisany adres powrotu — jest to adres instrukcji znajdujący się w funkcji, której nazwę chcemy uwzględnić w wydruku zawartości stosu.

Jak dotrzeć do zawartości rejestru EBP? Możemy utworzyć pomocniczą funkcję z jedną zmienną lokalną — liczymy tutaj na to, że zmienna zostanie zaalokowana tuż nad zapisaną zawartością rejestru EBP funkcji wywołującej. Jeśli przesuniemy się o 4 bajty w dół stosu to otrzymamy szukany adres.

Przydatność takiego rozwiązania jest, rzecz jasna, dość ograniczona — nie zadziała ono w przypadkach, gdy kompilator zastosuje wyrównanie stosu i/lub umieści na nim dodatkowe ciasteczko chroniące przed atakami z przepełnieniem bufora (security cookie, które widzieliśmy na poprzednim laboratorium). (Natomiast zgodnie z konwencją poniżej zapisanej wartości EBP znajduje się adres powrotu.)

Stąd zdecydowanie lepszym pomysłem jest bezpośrednie odczytanie zawartości rejestru EBP. Możemy to zrobić w programie w C, jeśli użyjemy wstawki asemblera obsługiwanej przez kompilator gcc. O składni i możliwościach tej konstrukcji można krótko przeczytać np. tutaj: http://wiki.osdev.org/Inline_Assembly. Przykładowo adres wierzchołka stosu można odczytać w ten sposób:

Powyższa wstawka używa składni Intela; kompilator gcc należy poinstruować o tym, by używał tej składni zamiast domyślnej składni AT&T używając przełącznika -masm=intel:

$ gcc -m32 -masm=intel stacktracetest.c -o stacktracetest

Ok, domyślamy się w jaki sposób przejść po wszystkich ramkach stosu. No, prawie… Niejasne może być to jak sprawdzić czy dana ramka stosu jest tą ostatnią, czyli jaki powinien być warunek stopu pętli. Szczęśliwie specyfikacja interfejsu ABI dla architektury x86 na Linuxach precyzuje, że rejestry przy starcie programu inicjowane są zerami (zob. http://stackoverflow.com/questions/9147455/what-is-default-register-state-when-program-launches-asm-linux). O ile prolog kompilatora nie umieści w tym rejestrze niestandardowej wartości (prolog gcc zeruje zawartość rejestru EBP) to możemy liczyć, że zapisana wartość EBP w ostatniej ramce na stosie będzie składać się z 4 zerowych bajtów.

Możemy teraz zastanowić się w jaki sposób uzyskać informację o symbolach (nazwach funkcji), których adresy znajdziemy na stosie. Wprawdzie dysponujemy tylko adresami powrotów (czyli adresami instrukcji wewnątrz funkcji, adresy te będa przesunięte względem adresu pierwszej instrukcji maszynowej funkcji), a nie dokładnymi adresami symboli, ale zidentyfikowanie tych drugich na podstawie tych pierwszych nie będzie nastręczać problemów. W tym celu użyjemy oferowanego przez glibc rozszerzenia zestawu funkcji udostępniających informacje o symbolach z plików binarnych, a mianowicie — funkcji dladdr.

$ man dladdr
....
Glibc extensions: dladdr() and dlvsym()
Glibc adds two functions not described by POSIX, with prototypes
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <dlfcn.h>
int dladdr(void *addr, Dl_info *info);
The function dladdr() takes a function pointer and tries to resolve name and file where it is located. Information is stored in the Dl_info structure:
typedef struct {
const char *dli_fname; /* Pathname of shared object that
contains address */
void *dli_fbase; /* Address at which shared object
is loaded */
const char *dli_sname; /* Name of symbol whose definition
overlaps addr */
void *dli_saddr; /* Exact address of symbol named
in dli_sname */
} Dl_info;
If no symbol matching addr could be found, then dli_sname and dli_saddr are set to NULL.
dladdr() returns 0 on error, and nonzero on success.

Podając kolejne adresy powrotu jako argumenty funkcji dladdr powinniśmy uzyskać efekt podobny jak w przypadku backtrace_symbols_fd (pamiętajmy o dołączeniu biblioteki dl przy linkowaniu — opcja -ldl):

$ gcc -m32 -masm=intel -rdynamic stacktracetest.c -o stacktracetest -ldl
$ ./stracktracetest
./stacktracetest(bar+0xb)[0x804883a]
./stacktracetest(foo+0xb)[0x8048848]
./stacktracetest(main+0x16)[0x8048861]
/lib/i386-linux-gnu/libc.so.6(__libc_start_main+0xde)[0xf761373e]

W porównaniu z poprzednim wydrukiem, brak tutaj ostatniego wpisu wskazującego na kod z prologu kompilatora, z którego wywołana jest dopiero funkcja __libc_start_main. Czym jest to spowodowane?


A jak wygląda sytuacja na środowiskach windowsowych? Tutaj wachlarz dostępnych środków jest dużo szerszy. Mamy przede wszystkim do dyspozycji bibliotekę Dbghelp z API m.in. do odczytywania informacji o symbolach zapisanych w plikach PDB (Program DataBase). Biblioteka ta jest używana jako warstwa pośrednia (stosunkowo rozbudowana — kilkadziesiąt funkcji w zestawieniu z zaledwie kilkoma funkcjami z interfejsu dostępowego do informacji linkera pod linuxem) między debuggerami a źródłami informacji niezbędnych do debugowania (w tym: plikami PDB). Korzystają z niej popularne debuggery spod znaku MS, m.in. WinDBG.

Należy jeszcze w tym miejscu zwrócić uwagę na istotną różnicę między systemami unixowymi a windowsowymi — potrzebne informacje o symbolach były umieszczone przez linker bezpośrednio w plikach ELF; natomiast pliki binarne PE takich informacji nie zawierają, przechowywane są one w zewnętrznych plikach PDB generowanych przez kompilator.

Odpowiednikiem (oczywiście przy uwzględnieniu powyższej uwagi) funkcji dladdr w środowisku windowsowym będzie SymFromAddr z biblioteki Dbghelp (https://msdn.microsoft.com/en-us/library/windows/desktop/ms681323(v=vs.85).aspx), której podajemy uchwyt do bieżącego procesu i adres symbolu (podobnie, jak w przypadku dladdr, może to być adres z pewnym przesunięciem) oraz adres struktury SYMBOL_INFO (https://msdn.microsoft.com/en-us/library/windows/desktop/ms680686(v=vs.85).aspx) — w niej otrzymamy drogą zwrotną informację o nazwie i adresie szukanego symbolu. Pewną niedogodność stanowi tutaj wymaganie, by pamięć przeznaczona na tę strukturę, a w szczególności pole, w którym umieszczona zostanie nazwa symbolu, były zaalokowane przez wołającego funkcję SymFromAddr. Wiąże się z tym pewien niuans.

Spójrzmy na definicję struktury SYMBOL_INFO.

typedef struct _SYMBOL_INFO {
ULONG SizeOfStruct;
ULONG TypeIndex;
ULONG64 Reserved[2];
ULONG Index;
ULONG Size;
ULONG64 ModBase;
ULONG Flags;
ULONG64 Value;
ULONG64 Address;
ULONG Register;
ULONG Scope;
ULONG Tag;
ULONG NameLen;
ULONG MaxNameLen;
TCHAR Name[1];
} SYMBOL_INFO, *PSYMBOL_INFO;

Pole przeznaczone na nazwę symbolu zlokalizowane jest na końcu tej struktury i, pozornie, rezerwuje jedynie pojedynczy znak na zapisanie owej nazwy. W istocie oczekuje się, że użytkownik zaalokuje więcej niż sizeof(SYMBOL_INFO) bajtów na potrzeby tej struktury, a mianowicie

sizeof(SYMBOL_INFO) + (MaxNameLen — 1) * sizeof(TCHAR)

bajtów, gdzie MaxNameLen jest ograniczeniem na długość (liczbę znaków) w nazwie symbolu, który chcemy otrzymać. A zatem nazwa ta będzie wykraczać poza granice przekazywanej struktury. Przykład alokacji i użycia struktury SYMBOL_INFO zobaczymy wkrótce.

Pozostaje nam jeszcze uzyskać obecne na stosie adresy powrotu z funkcji. Możemy to zrobić na kilka sposobów. Pierwszym polega na przejściu po ramkach stosu. W tym celu potrzebujemy znać zawartość rejestru EBP, którą możemy odczytać za pomocą wstawek asemblera, obsługiwanych przez kompilator C Visual Studio (ale tylko w trybie x86, programy kompilowane pod x64 nie mogą zawierać wstawek asemblerowych), choć posiadających nieco inną składnię niż ta właściwa kompilatorowi gcc.

Wynikiem wywołania funkcji get_ebp jest wartość rejestru EBP, a przejście po ramkach stosu wygląda identycznie jak w poprzednim (“unixowym”) przypadku.

Zamiast wstawki asemblera do odczytania zawartości EBP możemy użyć bibliotecznej funkcji RtlCaptureContext (https://msdn.microsoft.com/en-us/library/windows/desktop/ms680591(v=vs.85).aspx), która zapisze kontekst bieżącego wątku (zawartość rejestrów) w strukturze CONTEXT — dla architektury x86 struktura ta będzie zawierać pole Ebp.

Analogicznie jak w przypadku środowisk unixowych, analiza stosu wywołań na podstawie ramek może nie będzie obejmować funkcji, które takich ramek nie odkładają. By pozbyć się tego ograniczenia użyjemy funkcji CaptureStackBackTrace (https://msdn.microsoft.com/en-us/library/windows/desktop/bb204633(v=vs.85).aspx). Wypełnia ona otrzymaną tablicę adresami powrotów, które kolejno przekazujemy do funkcji SymFromAddr, by otrzymać informacje o nazwach funkcji.

Do zainicjowania struktur biblioteki Dbghelp dla procesu potrzebne jest wywołanie funkcji SymInitialize na początku programu (podobnie, zwalniamy zasoby wołając SymCleanup na końcu programu).

Program kompilujemy dodając do zależności linkera plik Dbghelp.lib i żądamy wygenerowania pełnego pliku PDB:

Po uruchomieniu programu powinniśmy otrzymać rezultat zbliżony do poniższego:

C:\> stacktrace.exe
stacktrace.exe(bar+0x23)[0xb81be3]
stacktrace.exe(foo+0x23)[0xb81c93]
stacktrace.exe(main+0x4c)[0xb81ddc]
stacktrace.exe(invoke_main+0x1e)[0xb8273e]
stacktrace.exe(__scrt_common_main_seh+0x15a)[0xb8258a]
stacktrace.exe(__scrt_common_main+0xd)[0xb8241d]
stacktrace.exe(mainCRTStartup+0x8)[0xb82758]
KERNEL32.DLL(BaseThreadInitThunk+0x24)[0x77277c04]
ntdll.dll(RtlInitializeExceptionChain+0x8f)[0x7790ad6f]
ntdll.dll(RtlInitializeExceptionChain+0x5a)[0x7790ad3a]
Like what you read? Give kdr a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.