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


3. Teoria w praktyce

3.1 Prosty program i jego kod

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);
}       

3.1.1 Kompilacja programu

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.

3.1.2 Kompilacja programu do kodu Assemblera

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)"

3.1.3 Oglądanie kodu programu przy pomocy gdb

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.

3.2 Analiza wykonania przykładu

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:

przykład 1 - stos 1
int main()
{       
przykład 1 - stos 2
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);
przykład 1 - stos 4
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)
{
przykład 1 - stos 5
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;
przykład 1 - stos 6
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
}
przykład 1 - stos 7
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.
}
przykład 1 stos 8
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


Valid XHTML 1.0 Strict