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 <nazwa pliku>
- wyświetli podstawowe
informacje zawarte w nagłówkach sekcji,
# objdump -x <nazwa pliku>
- wyświetli podstawowe
informacje zawarte we wszystkich nagłówkach pliku obiektowego,
# objdump -d <nazwa pliku>
- pokaże nam
zdezasemblowany kod wykonywalnych sekcji programu,
# objdump -D <nazwa pliku>
- pokaże nam
zdezasemblowany kod wszystkich sekcji programu,
# objdump -D <nazwa pliku>
- pokaże nam
zdezasemblowany kod wszystkich sekcji programu,
# objdump -s <nazwa pliku>
- pokaże nam
w postaci szesnastkowej, a tam gdzie się da w ASCII,
zawartość wszystkich sekcji,
# objdump -g <nazwa pliku>
- pokaże nam
zawartość sekcji związanych z debugowaniem,
# objdump -t <nazwa pliku>
- pokaże nam
zawartość tablicy symboli (używanych przy dezasemblacji kodu),
# objdump -T <nazwa pliku>
- pokaże nam
zawartość tablicy dynamicznie ładowanych symboli,
# objdump -j <nazwa sekcji> <nazwa pliku>
- pokaże nam
zawartość wskazanej sekcji pliku.
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.
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.
__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:
0x401046
wypełniany jest
rejestr %r9
podanym w %rdx
adresem
funkcji terminującej działanie bibliotek dzielonych załadowanych
przed kodem z obecnego pliku.
0x401049
wypełniany jest
rejestr %rsi
znajdującą się na szczycie stosu
liczbą argumentów polecenia.
0x40104a
wypełniany jest rejestr
%rdx
adresem szczytu stosu, który po wykonanej
wcześniej instrukcji pop
wskazuje na tablicę
argumentów polecenia.
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.
0x401051
instrukcja push
wstawia wartość, która jest nieistotna z punktu widzenia
działania programu. To jest po prostu śmieciowa wartość.
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.
0x401053
i 0x401056
instrukcje xor
zerują
rejestry %r8
, %rcx
(trzeba sobie przypomnieć, co dokładnie robi xor
na
młodszej połówce rejestru).
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.
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
.
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:
i
z funkcji main()
?
sum
z funkcji main()
?
i
z funkcji process()
?
sum
z funkcji process()
?
ar
z
funkcji process()
?
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łącznik | Wielkość |
---|---|
sum.c | 234 bajty |
sum_objdump.txt | 43.73 KB |
sum_array.c | 330 bajtów |
sum_array_objdump.txt | 41.36 KB |