This is an old revision of the document!
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/exec-process-demo.zip
și apoi decomprimăm arhiva
unzip exec-process-demo.zip
și accesăm directorul rezultat în urma decomprimării
cd exec-process-demo/
Acum putem parcurge secțiunile cu demo-uri de mai jos.
Pentru a obține un executabil, folosim linker-ul pentru a lega unul sau mai multe module obiect cu biblioteci. Legarea (linking) presupune:
.text
) sunt comasate într-o singură secțiuneÎn modul simplu, legarea este statică (static linking) iar rezultatul este un executabil static. Un executabil static realizează complet toți pașii de mai sus și are în cadrul său toate sețiunile de care are nevoie și toate referințele rezolvate.
O alternativă la legarea statică este legarea dinamică (dynamic linking) care are ca rezultat un executabil dinamic. Un executabil dinamic nu include toate secțiunile și menține referințe nerezolvate; rezolvarea acestora este amânată la momentul lansării în execuție a executabilului, numit și încărcare (loading, load time), când se creează procesul corespunzător.
Un executabil dinamic nu include în secțiunile sale părțile corespunzătoare din biblioteci. Aceste biblioteci sunt biblioteci dinamice și vor fi adăugate în spațiul virtual de adrese al procesului după încărcare (loading), fără a încărca executabilul.
Astfel, un executabil dinamic va fi semnificativ mai mic decât un executabil static. Pe lângă dimensiunea redusă a executabilului, un alt avantaj este partajarea zonelor de cod din cadrul bibliotecilor dinamice cu alte procese. Întrucât aceste zone nu sunt modificate, sunt mapate în spațiul virtual al tuturor proceselor care le foloesc. De exemplu, în cazul bibliotecii standard C, folosită de toate procesele sistemului, zona sa de cod este prezentă o singură dată în memoria RAM și mapată în spațiul virtual de adrese al tuturor proceselor provenite din executabile dinamice. Un proces obținut dintr-un executabil static nu poate partaja nici o parte din zona sa cu alte procese obținute din executabile static, însemnând un consum mai mare de memorie. Bibliotecile dinamice, partajabile, poartă în Linux denumirea de shared objects
; de aici și extensia fișierelor de tip bibliotecă dinamică .so
.
Executabilele dinamice au dezavantajul unui timp mai mare de încărcare. La orice lansare în execuție a executabilului trebuie realizată procesul de rezolvare a referințelor, numit dynamic binding. Suplimentar, dacă un executabil dinamic este transferat pe un sistem unde nu este prezentă o bibliotecă dinamică de care are nevoie, sau este prezentă o versiune incompatibilă, acesta nu va rula.
Sumarizând, executabilele statice și dinamice au următoarele avantaje:
.text
, .rodata
) din bibilioteci cu alte procese obținute din executabile dinamice care folosesc aceleași biblioteci.
În subdirectorul static-dynamic/
se găsește fișierul sursă hello.c
și un fișier Makefile
pe care îl folosim pentru a compila un executabil static (hello-static
) și unul dinamic (hello-dynamic
):
$ ls hello.c Makefile $ make cc -Wall -fno-PIC -c -o hello.o hello.c cc -no-pie -static -o hello-static hello.o cc -no-pie -o hello-dynamic hello.o $ ls -F hello.c hello-dynamic* hello.o hello-static* Makefile
Putem observa că executabilul static are dimensiunea mai mare decât cel dinamic (de circa 100 de ori mai mare) și că are mai multe simboluri:
$ ls -lh hello-* -rwxr-xr-x 1 student sstudent 8.4K Mar 29 21:57 hello-dynamic -rwxr-xr-x 1 student student 826K Mar 29 21:57 hello-static $ nm hello-static | wc -l 1664 $ nm hello-dynamic | wc -l 38
Așa cum am discutat, un executabil dinamic are referințe nerezolvate către biblioteci (în cazul de față biblioteca standard C), referințe ce vor fi rezolvate la load time. Executabilul static nu are referințe nerezolvate:
$ nm hello-dynamic | grep ' U ' U fflush@@GLIBC_2.2.5 U fgets@@GLIBC_2.2.5 U __libc_start_main@@GLIBC_2.2.5 U printf@@GLIBC_2.2.5 U puts@@GLIBC_2.2.5 U __stack_chk_fail@@GLIBC_2.4 $ nm hello-static | grep ' U ' $
Utilitarul ldd
afișează bibliotecile dinamice necesare pentru executabilele dinamice, iar pentru un executabil static nu afișează nimic:
$ ldd hello-dynamic linux-vdso.so.1 (0x00007ffcda18d000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6dbc088000) /lib64/ld-linux-x86-64.so.2 (0x00007f6dbc479000) $ ldd hello-static not a dynamic executable
La rularea oricărui executabil (loading), acesta așteaptă să apăsăm Enter
ca să continue. Acest lucru ne permite să investigăm spațiul de adresă pentru procesul proaspăt pornit folosind utilitarul pmap
ca mai jos:
$ pmap $(pidof hello-dynamic) 24227: ./hello-dynamic 0000000000400000 4K r-x-- hello-dynamic 0000000000600000 4K r---- hello-dynamic 0000000000601000 4K rw--- hello-dynamic 0000000001986000 132K rw--- [ anon ] 00007f556ba27000 1948K r-x-- libc-2.27.so 00007f556bc0e000 2048K ----- libc-2.27.so 00007f556be0e000 16K r---- libc-2.27.so 00007f556be12000 8K rw--- libc-2.27.so 00007f556be14000 16K rw--- [ anon ] 00007f556be18000 156K r-x-- ld-2.27.so 00007f556c01d000 8K rw--- [ anon ] 00007f556c03f000 4K r---- ld-2.27.so 00007f556c040000 4K rw--- ld-2.27.so 00007f556c041000 4K rw--- [ anon ] 00007ffeceb5d000 132K rw--- [ stack ] 00007ffeceba4000 12K r---- [ anon ] 00007ffeceba7000 4K r-x-- [ anon ] ffffffffff600000 4K --x-- [ anon ] total 4508K $ pmap $(pidof hello-static) 24245: ./hello-static 0000000000400000 728K r-x-- hello-static 00000000006b6000 24K rw--- hello-static 00000000006bc000 4K rw--- [ anon ] 000000000091c000 140K rw--- [ anon ] 00007ffdb1a33000 132K rw--- [ stack ] 00007ffdb1b80000 12K r---- [ anon ] 00007ffdb1b83000 4K r-x-- [ anon ] ffffffffff600000 4K --x-- [ anon ] total 1048K
Observăm că în cazul executabilului dinamic, spațiul virtual de adrese cuprinde bibliotecile dinamice partajate (biblioteca standard C libc-2.27.so
și dynamic linker/loader-ul ld-2.27.so
). Spațiul virtual de adrese al executabilului static este mai redus, cuprinzând doar zonele specifice executabilului și zonele dinamice ale procesului (stivă, heap, mapări anonime), fără biblioteci.
sudo apt install nasm
În mod obișnuit un executabil este obținut prin legarea unui sau a mai multor module obiect la biblioteca standard C (libc
) și, la nevoie, alte biblioteci. Așa cum am prezentat mai sus, în cazul legării statice (static linking) linker-ul adaugă în executabil toate componentele necesare (cod, date) din cadrul bibliotecii statice; în cazul legării dinamice (dynamic linking) linker-ul adaugă referințe la simbolurile din bibliotecile dinamice, iar rezolvarea acestor simboluri și adăugarea componentelor se întămplă la încărcare (load time).
Putem crea programe și folosi linker-ul să nu folosească deloc biblioteca standard C. Acest lucru este util pentru medii de lucru specializate (numite free standing environments) în care nu avem acces la o bibliotecă standard C sau nu o putem folosi. Astfel de programe vor implementa singure funcționalități similare celor oferite de biblioteca standard C sau vor realiza direct apeluri de sistem.
În directorul no-libc/
avem un exemplu de astfel de program, care folosește direct apeluri de sistem pentru a afișa un mesaj la ieșirea standard. Programul este scris în limbaj de asamblare pentru arhictectura x86_64 în sintaxa AT&T folosind asamblorul GNU (gas
parte din GCC) în fișierul hello.s
și în sintaxa Intel folosind asamblorul NASM în fișierul hello.asm
.
În ambele fișiere (hello.s
și hello.asm
) invocăm apelul de sistem write(1, “Hello, world!\n”, 14);
. Pentru invocarea acestui apel de sistem, urmărim convenția de apel de sistem specifică arhitecturii x86_64 pe Linux:
rax
reține numărul apelului de sistem (1
pentru apelul de sistem sys_write
)rdi
reține primul argument (1
pentru descriptorul de fișier corespunzător ieșirii standard - standard output)rsi
reține al doilea argument (adresa mesajului de afișat, adică adresa șirului Hello, world!\n
)rdx
reține al treilea argument (14
pentru lungimea mesajului de afișat)
Pentru încheierea execuției, invocăm apelul de sistem exit(0)
.
Pentru a obține executabilele este nevoie de un pas de asamblare, care obține fișierul obiect corespunzător și un pas de legare care obține fișierul executabil. Realizăm acești pași prin intermediul comenzii make
:
$ ls hello.asm hello.s Makefile $ make nasm -f elf64 -o hello-nasm.o hello.asm cc -nostdlib -no-pie -Wl,--entry=main -Wl,--build-id=none hello-nasm.o -o hello-nasm gcc -c -o hello-gas.o hello.s cc -nostdlib -no-pie -Wl,--entry=main -Wl,--build-id=none hello-gas.o -o hello-gas $ ls hello.asm hello-gas hello-gas.o hello-nasm hello-nasm.o hello.s Makefile
În urma rulării comenzii am obținut fișierele obiect hello-nasm.o
și hello-gas.o
și fișierele executabile hello-nasm
și hello-gas
.
Rulăm cele două executabile care vor afișa mesajul Hello, world!
la ieșirea standard:
$ ./hello-nasm Hello, world! $ ./hello-gas Hello, world!
Putem observa diferențe între aceste executabile (obținute fără suport de bibliotecă standard C) și cele de la demo-ul anterior, obținute cu suport de bibliotecă standard C.
Fișierele sunt mai mici, nu conțin elemente de setup și cleanup din biblioteca standard C, au doar miminul necesar pentru a funcționa: au minimul de simboluri, minimul de secțiuni, minimul de cod, așa cum putem observa rulând comenzile de mai jos:
$ ls -l hello-gas hello-nasm $ nm hello-gas $ nm hello-nasm $ objdump -d hello-gas $ objdump -d -M intel hello-nasm $ readelf -S hello-gas $ readelf -S hello-nasm
Putem obține aceste executabile fără să apelăm comanda gcc
cu un număr complicat de parametri. Putem invoca direct linker-ul folosind comenzile:
ld -o hello-gas --entry=main hello-gas.o ld -o hello-nasm --entry=main hello-nasm.o
Nu în ultimul rând, putem reduce dimensiunea executabilelor eliminând tabela de simboluri cu ajutorul comenzii strip
:
$ strip hello-nasm $ strip hello-gas
În mod obișnuit, un program execută fluxul de apel dat de codul său și de intrarea venită de la utilizator sau alte procese sau alte forme de I/O. În anumite situații putem dori să declanșăm mai repede un apel al unei funcționalități prezente în executabil, dar care nu se găsește pe fluxul de apel curent.
La nivel de provocare (challenge) în lumea competițiilor de tip Capture the Flag (CTF), sunt situații în care un program conține funcționalități ascunse; adică sunt funcții și variabile prezente în program dar care nu sunt apelate sau folosite explicit. Un astfel de exemplu este în subdirectorul hidden/
.
În subdirectorul hidden/
se găsește executabilul hidden
obținut din compilarea și legarea fișierelor hidden.c
și reader.c
. Obiectivul nostru este să afișăm mesajul success
cu ajutorul acestui program, în orice mod posibil.
Pentru început, observăm că acest program se apelează funcția hidden_function()
. Numai că apelul se realizează astfel încât depinde de valori aleatoare. În plus valorile variabilelor test
și success_message
nu ajută la obiectivul nostru: afișarea mesajului success
. Identificăm următoarele limitări ale programului, limitări peste care trebuie să trecem:
test
are o valoare predefinită care nu este modificată în programsuccess_message
are o valoare predefinită care nu este modificată în programhidden_function()
depinde de două valori aleatoare, generate la rulare (run time)
Pentru început ne propunem să realizăm primul apel al funcției hidden_function()
, adică apelul hidden_function(100)
apel ce nu depinde de valori aleatoare generate la rulare. Putem să realizăm acest apel folosind doar analiză statică (pe executabil) și modificând executabilul. Adică urmărim:
test
cu valoarea 100
, argumentul apelului hidden_function()
. În felul acesta vom trece de condiția if
din cadrul funcției hidden_function()
(linia 11
din fișierul hidden.c
).success_message
cu valoarea success\0
. în felul acesta apelul funcției puts()
din cadrul funcției hidden_function()
(linia 12
din fișierul hidden.c
).Pentru a realiza aceste suprascrieri trebuie să știm la ce offset în cadrul fișierului executabil se găsesc cele două valori. Pentru a determina acest offset, vom face pașii:
test
și success_message
). Fie aceste adrese test_address
și success_message_address
..data
în care se găsesc cele două variabile. Fie această adresă data_address
..data
. Fie acest offset data_offset
.test_offset = test_address - data_address + data_offset
, success_message_offset = success_message_address - data_address + data_offset
.
Pasul 1
îl putem realiza folosind utilitarul nm
sau utiltarul readelf
:
$ nm hidden | grep ' test$' | awk -F '[ \t]+' '{print $1;}' 0000000000601080 $ readelf -s hidden | grep ' test$' | awk -F '[ \t]+' '{print $3;}' 0000000000601080
Pasul 2
și pasul 3
îi realizăm folosind utilitarul readelf
:
$ readelf -S hidden | grep ' .data ' | awk -F '[ \t]+' '{print $5;}' 0000000000601060 $ readelf -S hidden | grep ' .data ' | awk -F '[ \t]+' '{print $6;}' 00001060
Pasul 4
îl realizăm prin operații aritmetice simple.
O dată aflat offset-ul putem folosi utilitarul dd
pentru a suprascrie părți din fișier, cu template-ul:
$ echo -en "<value>" | dd seek=<offset> of=hidden bs=1 conv=notrunc
Acești pași sunt realizați în scriptul rewrite_exec.sh
. În urma rulării acestui script obținem fișierul hidden_copy
cu modificările realizate pentru a afișa mesajul success
:
$ ./rewrite_exec.sh test_offset: 0x1080 success_message_offset: 0x10a0 Generated updated executable in hidden_copy. $ ./hidden_copy success Give number: ^C
În acest moment avem realizat cu succes primul apel al funcției hidden_function()
în urmă căruia este apelat mesajul success
.
Folosind doar analiză statică (lucru pe executabil) am realizat primul apel reușit al funcției hidden_function()
. Urmărim să realizăm și al doilea apel. Pentru al doilea apel depindem de informații aleatoare, a căror valoarea este știută doar la rulare (run time). De aceea avem nevoie de analiză dinamică, adică investigația folosind un debugger, în cazul nostru GDB.
Pentru a apela corespunzător a doua oară funcția hidden_function()
, trebuie ca cele două condiții if
(liniile 11
și 26
din fișierul hello.c
) să fie satisfăcute. Pentru aceasta trebuie:
r1
și r2
generate aleatorin
valoarea variabilei r1
ca să satisfacem condiția if
de la linia 26
test
cu valorea variabilei r2
ca să satisfacem condiția if
de la linia 11
Ne vom folosi de facilitățile GDB pentru acest lucru:
Adică vom urma pașii:
r1
și r2
.test
în memorie.test
cu valoarea variabilei r2
ca să satisfacem condiția if
de la linia 11
.read_uint()
) vom transmite valoarea variabilei r1
ca să satisfacem condiția if
de la linia 26
.hidden_function()
care va apela a doua oară mesajul success
.Pașii sunt urmați în GDB în secvența de mai jos:
$ gdb -q ./hidden_copy Reading symbols from ./hidden_copy...done. (gdb) start Temporary breakpoint 1 at 0x400702: file hidden.c, line 20. Starting program: /home/student/so/demo/exec-process/hidden/hidden_copy Temporary breakpoint 1, main () at hidden.c:20 20 hidden_function(100); (gdb) set disassembly-flavor intel (gdb) disass Dump of assembler code for function main: 0x00000000004006fa <+0>: push rbp 0x00000000004006fb <+1>: mov rbp,rsp 0x00000000004006fe <+4>: sub rsp,0x10 => 0x0000000000400702 <+8>: mov edi,0x64 0x0000000000400707 <+13>: call 0x4006d7 <hidden_function> 0x000000000040070c <+18>: mov edi,0x0 0x0000000000400711 <+23>: call 0x4005d0 <time@plt> 0x0000000000400716 <+28>: mov edi,eax 0x0000000000400718 <+30>: call 0x4005a0 <srand@plt> 0x000000000040071d <+35>: call 0x4005e0 <rand@plt> 0x0000000000400722 <+40>: mov DWORD PTR [rbp-0x4],eax 0x0000000000400725 <+43>: call 0x4005e0 <rand@plt> 0x000000000040072a <+48>: mov DWORD PTR [rbp-0x8],eax 0x000000000040072d <+51>: mov edi,0x400834 0x0000000000400732 <+56>: call 0x400753 <read_uint> 0x0000000000400737 <+61>: mov DWORD PTR [rbp-0xc],eax 0x000000000040073a <+64>: mov eax,DWORD PTR [rbp-0xc] 0x000000000040073d <+67>: cmp eax,DWORD PTR [rbp-0x4] 0x0000000000400740 <+70>: jne 0x40074c <main+82> 0x0000000000400742 <+72>: mov eax,DWORD PTR [rbp-0x8] 0x0000000000400745 <+75>: mov edi,eax 0x0000000000400747 <+77>: call 0x4006d7 <hidden_function> 0x000000000040074c <+82>: mov eax,0x0 0x0000000000400751 <+87>: leave 0x0000000000400752 <+88>: ret End of assembler dump. (gdb) b *0x0000000000400732 Breakpoint 2 at 0x400732: file hidden.c, line 25. (gdb) c Continuing. success Breakpoint 2, 0x0000000000400732 in main () at hidden.c:25 25 in = read_uint("Give number: "); (gdb) x/2wd $rbp-8 0x7fffffffdc08: 1586380876 1175232931 (gdb) p test $1 = 100 (gdb) p &test $2 = (unsigned int *) 0x601080 <test> (gdb) set *0x601080=1586380876 (gdb) p test $1 = 1586380876 (gdb) c Continuing. Give number: 1175232931 success [Inferior 1 (process 28011) exited normally] (gdb) q
Comenzile GDB folosite în secvența de mai sus sunt:
start
: pentru a porni procesul și a crea un breakpoint la începutul funcției main
hidden_function
, se afișează success
set disassembly-flavor intel
: pentru a dezasambla în sintaxă Inteldisass
: pentru a dezasambla codul, să urmărim unde putem plasa un breakpointb *0x0000000000400732
: pentru a crea un breakpoint înainte de apelul funcției read_uint
; adică *după* ce au fost generate în variabilele r1
și r2
r1
și r2
sunt reținute pe stivă respectiv la adresele rbp-4
și rbp-8
; observăm în secvența dezasamblată între adresele 0x40071d
și 0x40072a
c
: pentru continuarea execuției până la breakpoint-ul de mai susr1
și r2
x/2wd $rbp-8
: pentru citirea a două (2
) numere pe 32 de biți (w
) și afișarea lor în format zecimal (d
) de la adresa rbp-8
, adică valorile de la rbp-8
(r2
) și rbp-4
(r1
). Rezultă de aici că r1
este 1175232931
și r2
este 1586380876
.p test
: pentru afișarea valorii variabilei test
, inițial 100
p &test
: pentru afișarea adresei variabilei test
, adică 0x601080
set *0x601080=1586380876
: pentru modificarea valorii de la adresa 0x601080
(adică a variabilei test
) în valoarea variabilei r2
(1586380876
)p test
: pentru verificarea noii valori a variabilei test
; se confirmă că am modificat valoarea la 1586380876c
: pentru continuarea execuției programuluiread_uint
care solicită furnizarea unui număr ce va fi comparat cu r1
; furnizăm valoarea lui r1
adică 1175232931
hidden_function
, afișează a doua oară success
q
: pentru încheierea sesiunii curente și închiderea GDB
Pașii de mai sus pot fi simplificați prin folosirea în GDB a comenzii call
care permite apelul direct al unei funcții. Astfel, la începutul sesiunii GDB a programului, putem declanșa apelul direct al funcției hidden_function
ca mai jos:
(gdb) start Temporary breakpoint 1 at 0x400702: file hidden.c, line 20. Starting program: /home/student/so/demo/exec-process/hidden/hidden_copy Temporary breakpoint 1, main () at hidden.c:20 20 hidden_function(100); (gdb) call hidden_function(100) success
În felul acesta putem apela orice funcție din executabil fără a fi nevoie să facem operații mai complicate de investigare și modificare a memoriei.
Ba mai mult, putem apela direct funcția puts
:
(gdb) call puts("success") success $1 = 8
PEDA este o versiune mai puțin menținută curentă, dar cu o istorie mai lungă în comunitatea de securitatea. pwndbg și GEF sunt proiecte mai recente, cu funcționalități mai multe și comunități active de dezvoltare.
Cele trei proiecte pot fi configurate să fie prezente simultan (dar folosite alternativ) în GDB, așa cum este descris aici.