Analiza plików obiektowych oraz ABI

Analiza plików obiektowych oraz ABI


Podstawowa struktura plików obiektowych

Po skompilowaniu programy lub biblioteki są zapisywane w systemie w postaci plików. Pliki te mają standaryzowany format, ale w różnych systemach operacyjnych odmienny. W systemach uniksowych (czyli we wszystkich linuksach) obecnie dominuje format ELF (ang. Executable and Linkable Format), który jest dostępny dla wielu architektur maszyn (np. x86, M68k, x86-64 czy ARM lub ARM 64-bits). Tego formatu można się spodziewać w plikach o rozszerzeniach: .axf, .bin, .elf, .o, .out, .prx, .puff, .ko, .mod and .so, ale też i w plikach bez rozszerzeń. Format ELF jest odmienny od formatu PE (ang. Portable Executable), w którym zapisane są pliki w systemach Windows. Również innym formatem - Mach-O - posługują się systemy firmy Apple. Na potrzeby tej lekcji skupimy się na formacie ELF i narzędziach służących do jego oglądania.

Podstawowym narzędziem, którego będziemy używali do oglądania plików w formacie ELF jest polecenie objdump. Za jego pomocą możemy się dowiadywać różnych ciekawych informacji na temat plików z kodem wykonywalnym. Na przykład podstawowe informacje wynikające z nagłówka pliku ELF możemy uzyskać za pomocą:

  # objdump -d <nazwa_pliku>

Oto kilka przykładowych wyników działania tego polecenia:

# objdump -f /bin/ls

/bin/ls:     file format elf64-x86-64
architecture: i386:x86-64, flags 0x00000150:
HAS_SYMS, DYNAMIC, D_PAGED
start address 0x0000000000006b10

# objdump -f /lib/libasm.so.1

/lib/libasm.so.1:     file format elf32-i386
architecture: i386, flags 0x00000150:
HAS_SYMS, DYNAMIC, D_PAGED
start address 0x00002760

# objdump -f /lib64/libasm.so.1

/lib64/libasm.so.1:     file format elf64-x86-64
architecture: i386:x86-64, flags 0x00000150:
HAS_SYMS, DYNAMIC, D_PAGED
start address 0x0000000000002750

# objdump -f /lib64/libg.a
In archive /lib64/libg.a:

dummy.o:     file format elf64-x86-64
architecture: i386:x86-64, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x0000000000000000

Z których możemy wyczytać, że tylko trzy z nich działają w architekturze 64-bitowej (x86-64), trzy mogą brać udział w dynamicznym ładowaniu (DYNAMIC), wszystkie mają tablicę symboli (HAS_SYMS), dla trzech strony procesu dla kodu wykonywalnego pochodzącego z danego pliku mogą być przydzielane dynamicznie (D_PAGED). Dla jednego pliku możemy wyczytać, że jest archiwum bibliotecznym, a dla wszystkich mamy podany adres, pod który skoczy proces po załadowaniu pliku do pamięci, gdy potraktujemy kod jako plik wykonywalny (start address).

Ten ostatni adres jest szczególnie interesujący dla pliku /bin/ls, ale więcej o tym, co tam się znajduje, dowiemy się za chwilę. Wcześniej przyjrzyjmy się dokładniej strukturze plików ELF, w szczególności plików wykonywalnych.

Ogólna, typowa struktura pliku ELF ma następującą postać:

            /-------------------\
            | Nagłówek ELF      |---\
/---------> >-------------------<   | e_shoff
|           |                   |<--/
| Section   | Nagłówek sekcji 0 |
|           |                   |---\ sh_offset
| Header    >-------------------<   |
|           | Nagłówek sekcji 1 |---|--\ sh_offset
| Table     >-------------------<   |  |
|           | Nagłówek sekcji 2 |---|--|--\
\---------> >-------------------<   |  |  |
            | Sekcja 0          |<--/  |  |
            >-------------------<      |  | sh_offset
            | Sekcja 1          |<-----/  |
            >-------------------<         |
            | Sekcja 2         |<--------/
            \-------------------/

Jak widać, początki sekcji są wyznaczane przez odpowiednie wskaźniki. Oznacza to, że w zasadzie faktyczna kolejność sekcji może być odmienna. Typowy układ sekcji wygląda tak:

 /-------------------\
 | Nagłówek ELF      |
 >-------------------<
 | Nagłówki sekcji   |
 >-------------------<
 | .text             |  - sekcja ze skompilowanymi instrukcjami programu
 >-------------------<
 | .init             |  - sekcja ze instrukcjami programu służącymi do
 |                   |    jego inicjalizacji
 >-------------------<
 | .rodata           |  - sekcja z danymi tylko do odczytu 
 >-------------------<
 | .data             |  - sekcja z danymi inicjalizującymi, ale
 |                   |    zmienialnymi w trakcie działania programu
 >-------------------<
 | .bss              |  - dane statyczne inicjalizowane na zero
 >-------------------<
 | .symtab           |  - tablica symboli pozwalających wiązać różnego
 |                   |    rodzaju nazwy symboliczne z pozycjami w pliku
 |                   |    obiektowym
 >-------------------<
 | .rel.text         |  - sekcja ze skompilowanym kodem relokowalnym
 >-------------------<
 | .rel.data         |  - sekcja z danymi w formacie relokowalnym
 >-------------------<
 | .debug            |  - sekcja zawiera informacje potrzebne przy
 |                   |    debugowaniu, format zależny od debuggera
 |                   |    sprzężonego z kompilatorem
 >-------------------<
 | .line             |  - sekcja ta zawiera informacje potrzebne przy
 |                   |    debuggowaniu, a odpowiadające za powiązanie
 |                   |    kodu źródłowego z kodem asemblerowym w pliku obiektowym
 >-------------------<
 | .strtab           |  - napisy, zwykle napisy reprezentujące nazwy w
 |                   |    tablicy symboli 
 \-------------------/

Sekcje zawierają informacje potrzebne przy linkowaniu - czyli komponowaniu plików składowych w pliki wykonywalne, a na samym końcu w początkowy obraz procesu. W ostatecznym rachunku sekcje tworzą segmenty programu wykonywalnego. Oczywiście kolejność występowania sekcji w pliku jest dowolna i nie musi ona wyglądać tak jak na powyższym rysunku. Nazwy te mają charakter umowny i czasami wyglądają nieco inaczej (np. możemy mieć .rdata zamiast .rodata lub możemy mieć inne umiejscowienie fragmentów nazw wskazujących na relokowalny kod czy dane albo też bardziej rozbudowane nazwy sekcji z danymi do debugowania). Typowe przełożenie sekcji na segmenty w pamięci roboczej procesu wygląda następująco:

 /----------------------\
 | Pamięć jądra,        | -\ 
 | adresy niedostępne   |   > pamięć niewidoczna dla kodu procesu
 | dla procesu          | -/
 >----------------------<
 >----------------------<
 | Stos procesu         |
 | użytkownika          |
 >----------------------< <-- %esp (wskaźnik wierzchołka stosu)
 |           |          |
 |           v          |
 |                      |
 |           ^          |
 |           |          |                       
 >----------------------<
 | Region przeznaczony  |
 | na mapowanie         |
 | bibliotek dzielonych |
 >----------------------<
 |                      |
 |           ^          |
 |           |          |                       
 >----------------------< <-- rozszerzane za pomocą brk
 | Sterta procesu       |
 | (alokacja z pomocą   |
 |  malloc itp.)        |                           
 |                      |
 >----------------------<
 | Segment danych do    | -\
 | odczytu i zapisu     |  |
 | (sekcje .data .bss)  |  |  
 >----------------------<  >  dane ładowane z pliku
 | Segment danych tylko |  |  wykonywalnego
 | do odczytu (sekcje   |  |
 | .init .text .rodata) | -/
 >----------------------<  <-- zwykle: 0x0000000000400000 (64)
 |                      |              0x0000000008048000 (32)
 |                      |
 \----------------------/  <-- 0x0000000000000000

Oto jeszcze kilka przydatnych opcji programu objdump:

  • # objdump -h &lt;nazwa pliku> - wyświetli podstawowe informacje zawarte w nagłówkach sekcji,
  • # objdump -x &lt;nazwa pliku> - wyświetli podstawowe informacje zawarte we wszystkich nagłówkach pliku obiektowego,
  • # objdump -d &lt;nazwa pliku> - pokaże nam zdezasemblowany kod wykonywalnych sekcji programu,
  • # objdump -D &lt;nazwa pliku> - pokaże nam zdezasemblowany kod wszystkich sekcji programu,
  • # objdump -D &lt;nazwa pliku> - pokaże nam zdezasemblowany kod wszystkich sekcji programu,
  • # objdump -s &lt;nazwa pliku> - pokaże nam w postaci szesnastkowej, a tam gdzie się da w ASCII, zawartość wszystkich sekcji,
  • # objdump -g &lt;nazwa pliku> - pokaże nam zawartość sekcji związanych z debugowaniem,
  • # objdump -t &lt;nazwa pliku> - pokaże nam zawartość tablicy symboli (używanych przy dezasemblacji kodu),
  • # objdump -T &lt;nazwa pliku> - pokaże nam zawartość tablicy dynamicznie ładowanych symboli,
  • # objdump -j&lt;nazwa sekcji> &lt;nazwa pliku> - pokaże nam zawartość wskazanej sekcji pliku.

Typowa sekwencja startowa programu

W części tej zaczniemy przedstawiać podstawowe informacje dotyczące ABI. ABI to skrót od angielskiego Application Binary Interface, czyli specyfikacji, która opisuje, jak kod programu współpracuje z jego otoczeniem wykonawczym. W szczególności właśnie tam jest opisana procedura inicjalizacji procesu, ale także lokalizacja kodu i danych, sposób korzystania ze stosu, konwencja wywoływania procedur czy zasady układania danych na odpowiednich granicach adresowych i.in. W obecnym materiale skupimy się nad popularnym ABI używanym w świecie systemów uniksowych działających na procesorach Intel x64, czyli nad System V AMD64 ABI.

Popracujemy teraz nad kodem następującego prostego programu

#include <stdio.h>

int process(int a) {
  int b = 3;
  return a+b;
}

int main(void)
{
    int n = 10;
    int i = 1;
    int sum = 0;
    for(;i<=n;i++)
        sum+=process(i);
    printf("\n Sum is : [%d]\n",sum);
    return 0;
}

umieszczonego w pliku o nazwie sum.c. Po jego skompilowaniu:

# gcc -g sum.c -o sum

Otrzymamy plik wykonywalny sum, dla którego

# objdump -fsRrd sum

da zawartość załączonego pliku sum_objdump.txt.

Na początek zwróćmy uwagę, że adresem startowym programu jest 0x0000000000401040. Pod tym adresem, jak widać w załączonym wyniku objdump, znajduje się symbol o nazwie _start i to rzeczywiście w to miejsce, a nie do funkcji main jest wykonywany skok na początku działania programu.

Przyjrzyjmy się kodowi związanemu z tym symbolem

0000000000401040 <_start>:
  401040:       f3 0f 1e fa             endbr64
  401044:       31 ed                   xor    %ebp,%ebp
  401046:       49 89 d1                mov    %rdx,%r9
  401049:       5e                      pop    %rsi
  40104a:       48 89 e2                mov    %rsp,%rdx
  40104d:       48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
  401051:       50                      push   %rax
  401052:       54                      push   %rsp
  401053:       45 31 c0                xor    %r8d,%r8d
  401056:       31 c9                   xor    %ecx,%ecx
  401058:       48 c7 c7 3e 11 40 00    mov    $0x40113e,%rdi
  40105f:       ff 15 8b 2f 00 00       call   *0x2f8b(%rip)        # 403ff0 <__libc_start_main@GLIBC_2.34>
  401065:       f4                      hlt

Powyższy kod zaczyna się od instrukcji endbr64, której funkcją jest zabezpieczanie przed atakami związanymi ze skokami sterowanymi przez dane (programowanie zorientowane na powroty czy skoki). Część instrukcji skoku działa tak, że wykonanie skoku zakończy się wyjątkiem, jeśli punkt docelowy nie zaczyna się od właśnie tej instrukcji.

Następna instrukcja pod adresem 0x401044 zeruje zawartość rejestru %ebp, który to rejestr zawiera wskaźnik na rekord aktywacji aktualnej procedury zwany też ramką procedury. Działanie to jest wymagane przez ABI. Właśnie ono przepisuje, że wskaźnik ramki dla najbardziej zewnętrznej procedury powinien być zerowy. W przypadku pozostałych procedur wskaźnik ramki będzie dosyć niedaleki od adresu szczytu stosu, znajdującego się w rejestrze %rsp. Uwaga - xor na dolnej części rejestru %rbp, czyli właśnie na %ebp, powoduje wyzerowanie jego górnej części.

Kolejna instrukcja pod adresem 0x401046 zajmuje się już przygotowaniem parametrów wejściowych dla wywołania funkcji __libc_start_main, które ma miejsce pod adresem 0x40105f. W związku z tym opiszemy teraz używaną w opisywanym tutaj ABI konwencję wołania funkcji/procedur.

Konwencja obsługi funkcji/procedur w ABI - wołanie

W skrócie, według tej konwencji najpierw argumentom przypisywane są odpowiednie klasy (np. POINTER, INTEGER, SSE, MEMORY i.in.), a następnie w zależności od klasy przydzielane im są w kolejności od lewej do prawej odpowiednie lokacje sprzętowe. I tak pierwsze sześć lokacji dla argumentów całkowitoliczbowych (INTEGER) lub wskaźnikowych (POINTER) to odpowiednio rejestry %rdi, %rsi, %rdx %rcx, %r8, %r9 (ciekawostka: rejestr %r10 jest używany w językach takich jak Pascal, które oferują możliwość deklarowania funkcji/procedur zagnieżdżonych, wtedy pokazuje on na ramkę procedury, wewnątrz której zadeklarowana została wołana procedura). Dla argumentów zmiennoprzecinkowych pierwsze osiem lokacji to %xmm0, %xmm1, %xmm2, %xmm3, %xmm4, %xmm5, %xmm6, %xmm7. Dalsze argumenty, ale także argumenty o większych rozmiarach, są przekazywane na stosie.

Jeśli nie są używane specjalne opcje kompilatora, to adres powrotu z funkcji znajduje się tuż obok siódmego argumentu całkowitoliczbowego na stosie. Dodatkowo poniżej wskaźnika stosu istnieje tak zwana czerwona strefa, która z założenia nie będzie używana przez żadne procedury obsługi sygnałów czy przerwań. Kompilatory umieszczają tam czasami zmienne lokalne bez zmieniania zawartości rejestrów obsługujących stos %rbp czy %rsp. Jednak należy pamiętać, że takie optymalizacje mają sens tylko w przypadku funkcji, które nie wywołują już żadnych innych funkcji - wywołania tych ostatnich popsują zawartość tego obszaru.

Analiza wywołania __libc_start_main w _start

Wiedząc tyle na temat ABI, możemy wrócić do analizy funkcji _start. Zanim jeszcze tam przejdziemy warto wiedzieć, że przed wywołaniem tej funkcji stos ma następującą zawartość:

/-----------------\
|       NULL      |
>-----------------<
|       ...       |
|       envp      |
|       ...       |
>-----------------< 
|       NULL      |
>-----------------<
|       ...       |
|       argv      |
|       ...       |
>-----------------<
|       argc      | <- rsp
\-----------------/

Dodatkowo rejestr %rdx zawiera przy wejściu do _start adres funkcji zajmującej się terminacją obsługi bibliotek dzielonych. Pomoże nam to zrozumieć, dlaczego następne instrukcje mają kształt widoczny na listingu.

Jak wspomnieliśmy od adresu 0x401046 znajduje się kod, który przygotowuje parametry wejściowe dla wywołania z biblioteki standardowej funkcji __libc_start_main. Funkcja ta ma następujący interfejs:

  int __libc_start_main (
      int (*main) (int, char **, char **),  // adres kodu funkcji main, %rdi
      int argc,                             // liczba argumentów polecenia, %rsi
      char **argv,                          // tablica argumentów polecenia, %rdx
      __typeof (main) init,                 // konstruktor programu, %rcx
      void (*fini) (void),                  // destruktor programu, %r8
      void (*rtld_fini) (void),             // adres funkcji terminacji dla
                                            // bibliotek dzielonych załadowanych
                                            // przed kodem obecnego pliku, %r9
      void *stack_end                       // wskaźnik na szczyt stosu, na stosie
      )

W komentarzach opisane zostało znaczenie poszczególnych argumentów oraz wynikające z ABI pozycje argumentów w rejestrach i na stosie. Wszystkie argumenty są albo całkowitoliczbowe, albo wskaźnikowe, dlatego wypełnione zostają kolejno wszystkie rejestry odpowiedzialne za przekazywanie argumentów. Dzieje się to w następujący sposób:

  • Pod adresem 0x401046 wypełniany jest rejestr %r9 podanym w %rdx adresem funkcji terminującej działanie bibliotek dzielonych załadowanych przed kodem z obecnego pliku.
  • Pod adresem 0x401049 wypełniany jest rejestr %rsi znajdującą się na szczycie stosu liczbą argumentów polecenia.
  • Pod adresem 0x40104a wypełniany jest rejestr %rdx adresem szczytu stosu, który po wykonanej wcześniej instrukcji pop wskazuje na tablicę argumentów polecenia.
  • Pod adresem 0x40104d instrukcja and powoduje wyrównanie wskaźnika stosu przez obniżenie jego adresu do najbliższej pełnej wielokrotności 16, co jest wymagane przez ABI.
  • Pod adresem 0x401051 instrukcja push wstawia wartość, która jest nieistotna z punktu widzenia działania programu. To jest po prostu śmieciowa wartość.
  • Pod adresem 0x401052 za to instrukcja push powoduje zapisanie adresu czubka stosu, czyli siódmego argumentu funkcji __libc_start_main. Przy okazji - dwie instrukcje push obniżają szczyt stosu o 16 bajtów.
  • Pod adresami 0x401053 i 0x401056 instrukcje xor zerują rejestry %r8, %rcx (trzeba sobie przypomnieć, co dokładnie robi xor na młodszej połówce rejestru).
  • Pod adresem 0x401058 wreszcie wypełniany jest rejestr %rdi, jak łatwo stwierdzić na podstawie danych w pliku, adresem funkcji main.

Po tych wszystkich działaniach następuje rzeczywiście skok do __libc_start_main, która wykonuje wszystkie operacje niezbędne do uruchomienia kodu w C, a następnie woła funkcję main. Gdy zaś ta zakończy działanie, obsługuje też wszystkie działanie związane z wyjściem z procesu. W związku z tym w zasadzie wyjście z niej nie powinno nigdy nastąpić. Gdyby tak się jednak z jakiegoś powodu stało, to tuż za wywołaniem tej funkcji występuje instrukcja hlt, której wykonanie poza kodem jądra spowoduje błąd i proces zakończy natychmiast działanie w sposób awaryjny.

Konwencja obsługi funkcji/procedur w ABI - wewnątrz funkcji

Przyjrzyjmy się teraz temu, jak swoje działanie organizuje funkcja, która została wywołana. Zrobimy to na przykładzie funkcji process z naszego programu sum.c.

Funkcja ta po zdezasemblowaniu ma taką postać:

0000000000401126 :
  401126:       55                      push   %rbp
  401127:       48 89 e5                mov    %rsp,%rbp
  40112a:       89 7d ec                mov    %edi,-0x14(%rbp)
  40112d:       c7 45 fc 03 00 00 00    movl   $0x3,-0x4(%rbp)
  401134:       8b 55 ec                mov    -0x14(%rbp),%edx
  401137:       8b 45 fc                mov    -0x4(%rbp),%eax
  40113a:       01 d0                   add    %edx,%eax
  40113c:       5d                      pop    %rbp
  40113d:       c3                      ret    

Procedury/funkcje w C muszą się trzymać zasady, że muszą zachowywać zawartość pewnych rejestrów. Chronione w ten sposób rejestry to %rbx, %rsp, %rbp oraz %r12-%r15.

To właśnie ta zasada powoduje, że w pierwszej instrukcji pod adresem 0x401126 na stos kładziona jest zawartość rejestru %rbp. Potem wartość ta jest odtwarzana pod adresem 0x40113c. Jednocześnie w tym samym miejscu niejawnie jest odtwarzana zawartość rejestru wskaźnika stosu %rsp. Pozostałe rejestry w kodzie nie są używane, więc nie zachodzi potrzeba ich odtwarzania.

Po zapamiętaniu oryginalnej wartości rejestru %rbp do rejestru tego wstawiany jest adres aktualnego szczytu stosu. Następnie w instrukcjach spod adresów 0x40112a i 0x40112d inicjalizowane są pod czubkiem stosu, we wspomnianej wcześniej czerwonej strefie, zmienne lokalne, odpowiednio a i b. Następnie pod adresami 0x401134 i 0x401137 następuje załadowanie wartości zmiennych do rejestrów roboczych, odpowiednio %edx i %eax. Wreszcie pod adresem 0x40113a następuje dodanie tych wartości. Wynik operacji trafia do rejestru %eax. Wykorzystana tutaj jest konwencja wychodzenia.

Konwencja wychodzenia z procedury/funkcji każe wartości całkowitoliczbowe lub wskaźnikowe o rozmiarze do 64 bitów umieszczać w rejestrze %rax (%eax to młodsza połówka tego właśnie rejestru), zaś o rozmiarze do 128 bitów w połączonych rejestrach %rax (młodsza połówka) i %rdx (starsza połówka). Wartości zmiennoprzecinkowe są na podobnej zasadzie podawane w rejestrach %xmm0 i %xmm1.

Ostatnia instrukcja procedury/funkcji to ret, która to instrukcja przenosi sterowanie pod adres wskazany na szczycie stosu. Jeśli włamywacze niczego nie popsuli, to jest to adres procedury/funkcji, z której nasza funkcja była wołana. W przypadku naszego przykładowego kodu będzie to zawsze adres funkcji main.

Dalsze lektury

  • Pełny opis ABI
  • Poszerzyć wiedzę o plikach ELF można dzięki temu tutorialowi.
  • Tutorial na temat procedury uruchamiania procesu można przeczytać tutaj.
  • I jeszcze jeden tutaj.

Ćwiczenie

Rozważmy następujący program:

#include <stdio.h>

int process(int a) {
  int ar[8] = { 0, 1, 2, 3, 4, 5, 6, 7 };
  ar[3]=a;
  int sum = 0;
  for (int i=0;i<8;i++) 
    sum+=ar[i];
  return sum;
}

int main(void)
{
    int n = 10;
    int i = 1;
    int sum = 0;
    for(;i<=n;i++)
        sum+=process(i);
    printf("\n Sum is : [%d]\n",sum);
    return 0;
}

Po skompilowaniu i użyciu objdump uzyskaliśmy zrzut dezasemblera z pliku sum_array_objdump.txt. Przyjrzyj się temu zrzutowi i dopisz w komentarzach po dwuznaku // umieszczonych w pobliżu miejsca związanego z pytaniem odpowiedzi na następujące pytania:

  • Jaki jest adres lub rejestr, w którym przechowywana jest zmienna i z funkcji main()?
  • Jaki jest adres lub rejestr, w którym przechowywana jest zmienna sum z funkcji main()?
  • Jaki jest adres lub rejestr, w którym przechowywana jest zmienna i z funkcji process()?
  • Jaki jest adres lub rejestr, w którym przechowywana jest zmienna sum z funkcji process()?
  • Jaki jest adres początku tablicy ar z funkcji process()?
  • Jak jest zlokalizowany w stosunku do adresu początku i końca tablicy ar adres, pod którym znajduje się adres powrotu z funkcji process()?

Wartości adresów podaj symbolicznie, np. obecny adres szczytu stosu minus 16. Zmodyfikowany tak przez dodanie komentarzy plik należy przesłać do Moodle.

ZałącznikWielkość
sum.c234 bajty
sum_objdump.txt43.73 KB
sum_array.c330 bajtów
sum_array_objdump.txt41.36 KB