Poprzedni :: Spis treści :: Następny
3. Teoria w praktyce
3.1 Prosty program i jego kod
3.1.1 Kompilacja programu
3.1.2 Kompilacja programu do kodu Assemblera
3.1.3 Oglądanie kodu programu przy pomocy gdb
3.2 Analiza wykonania przykładu
Aby zrozumieć proces przepełniania bufora trzeba wiedzieć jak uzyskać dostęp
do kodu Assemblera do jakiego kompiluje się nasz program oraz umieć z tego
kodu wyciągać interesujące nas informacje. Podstawowe informacje na temat tego
jak wygląda przedstawiona w rozdziale 2 teoria w praktyce, zdobędziemy poprzez
analizę prostego programu:
przyklad1.c:
int funkcja(int a, int b, int c) { int x; } int main() { funkcja(1,2,3); }
Prezentowane tutaj przykłady warto po prostu skompilować i spróbować
uruchomić. W tym celu korzystamy z polecenia:
gcc -o nazwa_pliku -ggdb nazwa_pliku.c
Użycie flagi -ggdb spowoduje dodanie informacji o symbolach przydatnych podczas debugowania programu. Symbole te mogą pomóc w przypadku, gdy program nie zadziała tak jak powinien i chcielibyśmy dowiedzieć się dlaczego.
Aby obejrzeć kod Assemblera do jakiego zostanie skompilowany nasz program, należy podczas kompilacji dodać opcję -S:
gcc -S -o nazwa_pliku.s nazwa_pliku.c
Kod wygenerowany przy pomocy tej komendy z przykładowego programu wygląda następująco:
.file "przyklad1.c" .text .globl funkcja .type funkcja, @function funkcja: pushl %ebp movl %esp, %ebp subl $4, %esp leave ret .size funkcja, .-funkcja .globl main .type main, @function main: pushl %ebp movl %esp, %ebp subl $24, %esp andl $-16, %esp movl $0, %eax addl $15, %eax addl $15, %eax shrl $4, %eax sall $4, %eax subl %eax, %esp movl $3, 8(%esp) movl $2, 4(%esp) movl $1, (%esp) call funkcja leave ret .size main, .-main .section .note.GNU-stack,"",@progbits .ident "GCC: (GNU) 3.4.5 (Gentoo 3.4.5, ssp-3.4.5-1.0, pie-8.7.9)"
Inną bardzo pożyteczna metodą oglądania kodu Assemblera do jakiego został skompilowany nasz program, jest wykorzystanie gdb. Aby z tej metody skorzystać musimy wykonać następujące kroki:
gcc -o nazwa_pliku -ggdb nazwa_pliku.c gdb nazwa_pliku
Po wykonaniu powyższych komend, otworzy się konsola programu gdb. Teraz aby obejrzeć kod wybranej funkcji należy wpisać komendę:
disassemble funkcja
Dla naszego przykładowego programu, odpowiedni kod wygląda następująco (mamy dwie funkcje, a więc trzeba wydać odpowiednią komendę dla obu z nich).
(gdb) disassemble main Dump of assembler code for function main: 0x0804836c <main+0>: push %ebp 0x0804836d <main+1>: mov %esp,%ebp 0x0804836f <main+3>: sub $0x18,%esp 0x08048372 <main+6>: and $0xfffffff0,%esp 0x08048375 <main+9>: mov $0x0,%eax 0x0804837a <main+14>: add $0xf,%eax 0x0804837d <main+17>: add $0xf,%eax 0x08048380 <main+20>: shr $0x4,%eax 0x08048383 <main+23>: shl $0x4,%eax 0x08048386 <main+26>: sub %eax,%esp 0x08048388 <main+28>: movl $0x3,0x8(%esp) 0x08048390 <main+36>: movl $0x2,0x4(%esp) 0x08048398 <main+44>: movl $0x1,(%esp) 0x0804839f <main+51>: call 0x8048364 <funkcja> 0x080483a4 <main+56>: leave 0x080483a5 <main+57>: ret 0x080483a6 <main+58>: nop 0x080483a7 <main+59>: nop 0x080483a8 <main+60>: nop 0x080483a9 <main+61>: nop 0x080483aa <main+62>: nop 0x080483ab <main+63>: nop 0x080483ac <main+64>: nop 0x080483ad <main+65>: nop 0x080483ae <main+66>: nop 0x080483af <main+67>: nop End of assembler dump. (gdb) disassemble funkcja Dump of assembler code for function funkcja: 0x08048364 <funkcja+0>: push %ebp 0x08048365 <funkcja+1>: mov %esp,%ebp 0x08048367 <funkcja+3>: sub $0x4,%esp 0x0804836a <funkcja+6>: leave 0x0804836b <funkcja+7>: ret End of assembler dump.
Warto w tym momencie zauważyć, że kod jaki można obejrzeć dzięki gdb niesie ze sobą znacznie więcej informacji niż ten uzyskiwany dzięki kompilacji z flagą -S. Ta forma prezentacji kodu daje nam bowiem oprócz samych instrukcji, także ich adresy w pamięci oraz offset względem początku funkcji, co jak się później okaże jest nie do przecenienia.
Prześledźmy teraz co się dzieje podczas wykonania programu (będzie nam głównie zależało na zrozumieniu jak poszczególne instrukcje wpływają na stos).
Początkowo, przed wywołaniem funkcji, stos wygląda tak:
int main() { |
|
0x0804836c <main+0>: push %ebp 0x0804836d <main+1>: mov %esp,%ebp 0x0804836f <main+3>: sub $0x18,%esp 0x08048372 <main+6>: and $0xfffffff0,%esp 0x08048375 <main+9>: mov $0x0,%eax 0x0804837a <main+14>: add $0xf,%eax 0x0804837d <main+17>: add $0xf,%eax 0x08048380 <main+20>: shr $0x4,%eax 0x08048383 <main+23>: shl $0x4,%eax 0x08048386 <main+26>: sub %eax,%esp |
|
Wykonanie programu rozpoczyna się od skoku do funkcji main, jest to funkcja taka jak każda inna, w związku z tym należy zadbać o zapamiętanie EBP z poprzedniej funkcji oraz o uaktualnienie EBP aby wskazywał na aktualną ramkę. Instrukcje assemblerowe zaczynające się od main+3 odpowiedzialne są w celu przyspieszenia działania funkcji. Ich działanie nie jest dla nas istotne i to jak wpływają na stos nie będzie zaznaczane. | |
funkcja(1,2,3); |
|
0x08048388 <main+28>: movl $0x3,0x8(%esp) 0x08048390 <main+36>: movl $0x2,0x4(%esp) 0x08048398 <main+44>: movl $0x1,(%esp) 0x0804839f <main+51>: call 0x8048364 <funkcja> |
|
Na stos odkładane są poszczególne argumenty dla funkcji funkcja. Widać tutaj, że w C atrybuty odkładane są w kolejności od ostatniego do pierwszego. Dodatkowo warto zauważyć tutaj, że umieszczanie argumentów na stosie odbywa się nie przy pomocy instrukcji PUSH tylko za pomocą instrukcji MOVL. Takie podejście jest możliwe ponieważ wierzchołek stosu jest na tyle "wysoko" aby postępując w ten sposób nie uszkodzić żadnych istotnych dla programu komórek pamięci. Dodatkowo instrukcja CALL odkłada na stosie adres powrotu oraz powoduje przekazanie sterowania do kodu funkcji funkcja. | |
int funkcja (int a, int b, int c) { |
|
0x08048364 <funkcja+0>: push %ebp 0x08048365 <funkcja+1>: mov %esp,%ebp |
|
Na wstępie wywoływana funkcja dba o to aby zapamiętać adres ramki funkcji wywołującej i uaktualnia EBP aby wskazywał na aktualną ramkę. | |
int x; |
|
0x08048367 <funkcja+3>: sub $0x4,%esp |
|
Funkcja rezerwuje pamięć na zmienną x. Jest to zmienna typu int a więc ma
4 bajty. Dostęp do tej zmiennej jest możliwy poprzez odwołanie się do
rejestru EBP.
&x = EBP + 4 |
|
} |
|
0x0804836a <funkcja+6>: leave 0x0804836b <funkcja+7>: ret |
|
Wyjście z funkcji funkcja. Instrukcja LEAVE zapewnia, że w momencie wywołania RET na szczycie stosu będzie się znajdował adres pod jaki należy skoczyć. Instrukcja RET zdejmuje ten adres i wykonuje odpowiedni skok. | |
} |
|
0x080483a4 <main+56>: leave 0x080483a5 <main+57>: ret |
|
Wyjście z funkcji main. Stos jest w takim stanie w jakim był przed wykonaniem funkcji. |
Poprzedni :: Spis treści :: Następny