În acest laborator vom studia modalitățile de analiză a programelor în scopul de a înțelege mai amănunțit modul lor de funcționare și pentru a găsi ușor cauzele unor probleme (debugging). Vom studia câteva utilitare de analiză, vom rezolva bug-uri, iar în final vom ști ce unelte și cum să le folosim pentru a dezvolta programe corecte și sigure. Conținutul laboratorului alternează între secțiuni de tip tutorial, cu parcurgere pas cu pas și prezentarea soluției, și exerciții care trebuie să fie rezolvate.
Analiza statică a unui program constă în inspectarea diferitelor aspecte din fișierul obiect sau executabil. Analiza statică a unui program nu implică si rularea acestuia. Aceasta presupune analiza codului imediat dupa ce s-a terminat partea de codare și înainte de rularea testelor.
Analiza statică poate fi efectuată de un program, în mod automat, prin parcurgerea codului și verificarea faptului că sursa a fost scrisă în conformitate cu regulile specifice. Un exemplu clasic în acest sens este compilatorul care identifică erorile lexicale, sintactice și, uneori, semantice dintr-un program. Remarcați faptul că programul nu este rulat atunci cand compilatorul inspectează sursa.
Analiza statică poate fi efectuată și manual atunci cand codul este revizuit (“code review”) pentru a se asigura calitatea si lizibilitatea codului.
Avantajele analizei statice:
Dezavantajele analizei statice:
Câteva din programele utile pentru analiza statică pe care le vom folosi și în cadrul tutorialelor/exercițiilor sunt:
Spre deosebire de analiza statică, analiza dinamică constă în inspectarea unui program aflat în execuție. Practic, analiza dinamică se face la runtime.
Avantajele analizei dinamice:
Dezavantajele analizei dinamice:
Unul dintre cele mai folosite programe pentru analiză dinamică este gdb. Acesta oferă o gamă largă de operații ce pot fi făcute, de la inspectarea memoriei, la schimbarea control flow-ului și până la modificarea registrelor de pe procesor, în timpul rulării unui program.
În cadrul exercițiilor vom folosi arhiva de laborator. Descărcați arhiva, decomprimați-o și accesați directorul aferent.
Deși folosirea unui mediu grafic pentru programare poate părea mai atractivă, de multe ori folosirea liniei de comandă oferă mai multă putere și control asupra a ceea ce vrem să facem. În plus, folosirea utilitarelor din linia de comandă în scripturi poate facilita automatizarea unor task-uri, lucru care ne va face viața mai ușoară în nenumărate cazuri.
În cadrul acestui laborator, vom folosi utilitare în linia de comandă atât pentru asamblarea și link-editarea fișierelor sursă, cât și pentru analiza statică și dinamică a programelor obținute din parcurgerea tutorialelor și a exercițiilor.
Deschideți fișierul hello-world.asm
din directorul 1-2-tutorial
. Pentru a asambla fișierul hello-world.asm
, vom folosi utilitarul nasm
(program care este folosit în spate și de către SASM).
nasm -g -f elf32 hello-world.asm -o hello-world.o
-g
spune asamblorului să adauge simboluri de debug în fișierul obiect rezultat
-f
menționează formatul executabilului (în cazul nostru elf32
)
Pentru a verifica “corectitudinea” asamblării, haideți să dezasamblăm fișierul hello-world.o
folosind utilitarul objdump
, astfel:
objdump -M intel -d hello-world.o
-M intel
spune dezasamblorului să folosească sintaxa Intel și nu pe cea AT&T
Putem observa similaritatea dintre codul inițial și codul dezasamblat, mai puțin la instrucțiunea call
, unde adresa pare greșită. Acest fapt se întâmplă din cauza faptului că fișierul obiect obținut nu “știe” cine este printf. Acest lucru se va afla la pasul de link-editare, iar adresa va fi modificată la cea corespunzătoare. Mai multe detalii despre instrucțiunea call
vom afla în laboratoarele următoare.
Link-editarea unuia sau mai multor fișiere obiect constă în rezolvarea tuturor simbolurilor externe și crearea unui singur fișier executabil din toate fișiere primite la intrare.
Pentru link-editare vom folosi gcc
. De asemenea și gcc este folosit de SASM pentru link-editarea fișierelor obiect.
gcc -g -m32 hello-world.o -o hello-world
-g
este folosit cu același scop ca la nasm, de a introduce simboluri de debug în executabil
-m32
specifică arhitectura pentru care executabilul este generat (în cazul nostru, arhitectură pe 32 biți)
Acum puteți rula executabilul pentru a vedea că toți pașii au funcționat. Pentru lansarea în execuție din linie de comandă folosiți construcția
./hello-world
Ar trebui să vi se afișeze pe ecran Hello, World
.
Prin dezasamblarea unui fișier binar (executabil, cod de tip obiect, bibliotecă partajată…) obținem echivalentul în limbaj de asamblare al codului (eventual, scris în C) de la care a pornit totul. În funcție de formatul fișierului binar, codul în assembly va fi structurat într-un mod specific. În cadrul laboratoarelor lucrăm în general cu binare în formatul ELF (nu ne vom intersecta cu executabile de tipul Mach O sau IEEE-695, dar și acestea au la bază idei asemănătoare). În urma dezasamblării pentru acest tip de format vom obține un ansamblu de secțiuni, fiecare corespunzând anumitor date din codul sursă; secțiunile esențiale sunt următoarele:
Zonele de memorie pentru stivă și heap vor fi, desigur, alocate la runtime și le vom putea vizualiza doar în cadrul analizei dinamice.
Pentru acest tutorial avem la dispoziție fișierul de tip obiect objd_tutorial.o
și codul sursă corespunzător acestuia, ambele aflate în directorul 3-objdump-tutorial
. Urmăriți codul din fișierul .c și dezasamblați codul obiect folosind comanda:
objdump -D -M intel <file_name>
-D
determină dezasamblarea conținutului tuturor secțiunilor, nu doar al celor ce conțin instrucțiuni
-M intel
spune dezasamblorului să folosească sintaxa Intel, nu pe cea AT&T
Se poate observa că porțiunile de cod în assembly sunt precedate de două coloane cu informații suplimentare:
0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: f3 0f 11 45 fc movss DWORD PTR [rbp-0x4],xmm0
Prima coloană reprezintă offsetul în octeți față de începutul secțiunii, iar cea de-a doua, codul mașină corespunzător fiecărei instrucțiuni, afișat în baza 16. Identificați variabilele și funcțiile din fișierul .c în secțiunile corespunzătoare din outputul comenzii objdump. Se poate observa prezența unei funcții (helper_function) ce nu se regăsește în fișierul sursă, aceasta deoarece după etapa de preprocesare conținutul header-elor incluse în fișierul .c devine parte din acesta.
Având în vedere dimensiunea tipurilor de date, ce dimensiune au secțiunile .bss și .data? Declarați una sau mai multe variabile în codul sursă astfel încât dimensiunea secțiunii .bss să crească cu 4 octeți. După modificarea codului, regenerați fișierul obiect.
gcc objd_tutorial.c -c
size objd_tutorial.o
Decomentați linia din fișierul .c, regenerați și dezasamblați fișierul obiect. În ce secțiune ar trebui să apară noua variabilă? Am putea determina motivul pentru care nu se întâmplă ceea ce ne-am aștepta adăugând opțiunea -t comenzii objdump. Totuși, există o alternativă ce prezintă acest tip de informație într-un mod mai user-friendly - comanda nm
.
În cadrul diverselor etape de compilare, dar și în etapa de linkare, entităților dintr-un program le sunt asociate anumite metadate, fiind îndeosebi de interes adresele la care acestea - funcții, variabile - pot fi găsite. În acest context, funcțiile și variabilele sunt cunoscute drept simboluri, iar informațiile corespunzătoare sunt păstrate în structuri de date asociative denumite tabele de simboluri. Aici intervine comanda nm
, care ne va oferi tot ce avem nevoie în materie de simboluri.
Outputul comenzii este alcătuit din trei coloane: valoarea simbolului, tipul și numele. Există multe tipuri de simboluri, mare parte dintre acestea corespunzând secțiunii din care datele fac parte; enumerăm aici două dintre tipurile de simboluri (puteți citi mai multe în pagina de man
a comenzii):
extern
; locația exactă va fi determinată în urma etapei de linkare sau chiar la runtime
În directorul 4-nm
aveți la dispoziție două fișiere tip obiect. Încercați să generați un executabil plecând de la fișierul source1.o
gcc file1.o [file2.o] [filen.o] -lm -o nume_executabil
Apare o problemă; codul sursă încearcă să folosească o funcție și o variabilă ale căror definiții nu le cunoaște. Folosiți comanda nm source1.o
pentru a observa tipul simbolurilor corespunzătoare variabilei, respectiv funcției ce cauzează erori. Folosiți comanda nm source2.o
pentru a vedea dacă cel de-al doilea binar conține simbolurile de care avem nevoie și, cel mai important, dacă acestea sunt defined
.
Reîncercați să generați un executabil, de data aceasta făcând linkarea ambelor fișiere obiect.
Descoperim că, deși variabila outsider_var
este definită în binarul source2.o, ea nu este vizibilă în afara fișierului în care a fost declarată.
Rezolvați erorile generate la link-editare creând un fișier sursă adițional în care veți defini variabila și funcția pe care source1.o nu le poate găsi; din acest fișier .c veți genera un fișier obiect și îl veți adăuga, alături de celelalte două, ca parametru al comenzii de link-editare. Considerați că funcția nu are parametri, iar valoarea de return este de tipul char; de asemenea, variabila outsider_var va avea tipul int.
Folosind nm pentru executabilul obținut, veți observa că multe din simbolurile nedefinite în cadrul fișierelor obiect au căpătat noi tipuri; mai exact, toate variabilele și funcțiile definite de noi; funcțiile din biblioteci standard (printf, sqrt) și-au păstrat însă titulatura, ceea ce este normal când avem de-a face cu biblioteci dinamice - locațiile de memorie vor putea fi stabilite doar la runtime.
Totuși, cât de importante sunt simbolurile? Folosiți comanda strip -s
pentru a șterge toate simbolurile prezente în executabilul final. Rulați executabilul. Procedați în același mod pentru binarul source1.o. Refaceți linkarea celor 3 fișiere obiect și rulați scriptul. Evident, linkarea în absența simbolurilor este imposibilă.
În directorul 5-objdump
găsiți un executabil care primește ca dată de input o valoare numerică n reținută pe un octet, scopul executabilului fiind acela de a calcula valoarea lui 2^(2 * n). Așa cum veți descoperi însă în urma dezasamblării, scopul nu este atins întotdeauna, întrucât codul ce stă la baza binarului conține o eroare. Folosiți objdump pentru inspectarea codului în limbaj de asamblare, descoperiți eroarea și găsiți o valoare de input pentru care se obține un rezultat corect.
BEGINNING_AREA_OF_INTEREST:
și END_AREA_OF_INTEREST:
; de asemenea, valoarea afișată de funcție va fi preluată din registrul eax
Cppcheck este un exemplu de utilitar open-source de analiză statică folosit pentru a detecta potențiale probleme neraportate de compilator. Spre deosebire de objdump care permite analiza direct pe fișierul executabil, cppcheck analizează fișierele sursă. Acest utilitar nu detectează erorile de sintaxă, ci mai degrabă problemele cauzate de comportamente detefinite și construcții periculoase.
Tipuri de probleme detectate de cppcheck:
Cppcheck oferă și o interfață web minimală pentru realizarea unui demo, disponibila aici. Urmăriți exemplele din demo și rulați cppcheck pe ele. Identificați sursa fiecărei erori din rezultat.
Următoarea funcție ar trebui să detecteze dacă un procesor este little sau big endian, însă ea nu arată tot timpul adevărul. Corectați funcția pornind de la cppcheck.
void check_endianess(void) { int a; char *p = (char *)&a; if (*p) printf("Little endian\n"); else printf("Big endian\n"); }
GDB este o unealtă foarte utilă pentru analiza dinamică a programelor. Acesta este folosit foarte des pentru găsirea cauzelor care duc la erori într-un program. În continuare vă vom prezenta câteva dintre comenzile cele mai importante.
Primul pas este să urmăriți și să înțelegeți codul din 7-8-gdb/gdb-tutorial.asm
. Pe scurt, programul primește un parametru index
și citește de la tastatură o linie. Programul afișează doar un caracter, mai exact al index
-lea caracter din șirul dat la intrare.
run
sau start
vedeți mai jos. Altfel, rularea nu va fi corespunzătoare.
După ce ați citit codul sursă, asamblați și link-editați fișierul. După ce ați obținut executabilul gdb-tutorial
(sau ce nume i-ați dat), vom porni GDB-ul cu acel fișier:
gdb ./gdb-tutorial
~/.gdbinit
conţine linia source ~/peda/peda.py
. Dacă fişierul nu există sau nu conţine acea linie, rulaţi în terminal comanda:
echo 'source ~/peda/peda.py' >> ~/.gdbinit
Dacă lucrați pe alt sistem decât cele din sala de laborator, va trebui să clonați în prealabil repository-ul PEDA, așa cum este indicat în README-ul proiectului:
git clone https://github.com/longld/peda.git ~/peda
După ce ați pornit programul gdb, toată interacțiunea cu acesta se face prin prompt-ul de gdb.
Pentru a lansa programul urmărit în execuție există două comenzi disponibile:
run
, această comandă va începe execuția programului, însă se va opri imediat după intrarea în mainAceste două comenzi mai pot fi folosite în două feluri:
start 1 2 3 4
start < file.in
start
puteți folosi mai sus comanda run
.
Utilizarea aceasta este similară cu execuția programului direct din linia de comandă (fără GDB), prima variantă însemnând că se trimit 4 parametri (1, 2, 3 și 4) programului, iar a doua, că file.in
se redirectează ca intrare standard pentru program.
Lansați programul în execuție folosind comanda GDB run
. Ce observați? Rulați din nou programul, de data aceasta dând comenzii run parametrul corespunzător.
GDB se blochează la citirile de la input. Haideți să corectăm asta folosind un fișier de intrare. Creați un fișier (spre exemplu text.in) în directorul cu executabilul care să conțină textul “ana are mere”. Porniți din nou GDB și lansați în execuție programul cu parametrul de intrare 11
și cu fișierul text.in
, redirectat.
Ce observați? Programul își termină execuția cu succes. Deoarece nu a existat niciun breakpoint setat în program, programul nu s-a oprit din execuție decât când a terminat treaba.
În cazul pornirii programului, puteți folosi instrucțiunea start
care va opri execuția după intrarea în main.
Elementul esențial al GDB-ului este breakpoint-ul. Practic, un breakpoint setat la o anumită instrucțiune face ca execuția programului să se oprească de fiecare dată când se ajunge la acest punct.
Adăugarea unui breakpoint se face cu construcția
break [location]
, unde location
poate fi numele unei funcții sau o adresă din zona .text. În cazul cel din urmă, adresa trebuie să fie precedată de *
(star). Exemplu: break *0x004013af
.
Pentru continuarea programului după eventuala sa oprire într-un breakpoint, puteți folosi comanda continue
.
Un alt lucru interesant în GDB este comanda commands
, care poate asocia unui breakpoint un bloc de comenzi GDB ce vor fi executate la fiecare oprire în breakpoint-ul respectiv.
Exemplu:
(gdb) break *0x004013af Breakpoint <n> at 0x4013af (gdb) commands <n> Type commands for breakpoint(s) <n>, one per line. End with a line saying just "end" > print $eax > x/i $eip > end
Pentru a nu rămâne blocat în breakpoint (spre exemplu dacă scrieți un script de gdb), puteți adăuga în blocul de instrucțiuni și comanda continue
.
Haideți să adăugăm un breakpoint la label-ul ok
. Dacă dăm continue
, vom observa că programul s-a oprit în breakpoint-ul tocmai creat.
Atunci când execuția programului este oprită (de exemplu la un breakpoint), putem da comenzi care continuă execuția “pas cu pas”. Pentru a face asta, cel mai des sunt folosite două comenzi:
stepi
, însă dacă instrucțiunea curentă este un apel de funcție, debugger-ul nu va intra în funcție (va chema funcția și se va opri la următoarea instrucțiune după call
)
Dacă emitem comanda stepi
, putem observa că se afișează instruction pointer-ul instrucțiunii următoare dupa cea la care am făcut break (prima de la label-ul ok
).
Pentru a dezasambla o porțiune de executabil, se poate folosi comanda disassemble
din GDB. Dacă aceasta nu primește niciun parametru, va afișa dezasamblarea funcției curente din cadrul execuției.
set disassembly-flavor intel
.
În cadrul exemplului nostru, dacă cerem dezasamblarea funcției curente (folosind disassemble
fără parametri) putem observa că ne aflăm la label-ul ok
. Observație: GDB iterpretează label-ul ok
ca o funcție din cauza codului inițial, care este scris în limbaj de asamblare.
Pentru a vedea mai clar efectul stepi
/nexti
putem rula commanda disassemble
înainte și după stepping.
nexti
de foarte multe ori, vă recomandăm instrucțiunea GDB finish
, care “termină” o funcție. Atenție la funcțiile recursive.
Pentru a afișa diferite valori accesibile GDB-ului se folosește comanda print
. De exemplu, pentru a afișa valoarea unui registru (de exemplu eax), vom folosi construcția print $eax
.
Pentru inspectarea memoriei se folosește comanda x
(examine). Modul de folosire al acestei comenzi este următorul:
x/nfu address
, unde:
n
este numărul de elemente afișatef
este formatul de afișare (x pentru hexa, d pentru zecimal, s pentru șir de caractere și i pentru instrucțiuni)u
este dimensiunea unui element (b pentru 1 octet, h pentru 2, w pentru 4 și g pentru 8 octeți)
De exemplu, o funcționalitate similară cu disassemble
o putem obține și folosind x
unde formatul este instrucțiune. Astfel, putem afișa, de exemplu, 10 instrucțiuni începând de la instrucțiunea curentă cu construcția x/10i $eip
.
Folosind executabilul creat la exercițiul anterior (gdb-tutorial.asm
), trebuie să setați un breakpoint la intrare în bucla din program (când se mută în subregistrul al
un caracter din șirul input). În plus, trebuie să adăugați o serie de comenzi astfel încât la fiecare intrare în buclă, GDB să afișeze valoarea subregistrului al
și valoarea counter-ului (în cazul nostru ecx
).
commands
.
Pornind de la executabilul segfault
, aflat in directorul 9-solve-segfault
, rulat sub gdb, analizați atât backtrace-ul cât și pas cu pas codul pentru a identifica cauza care duce la Segmentation Fault.
.data
din cadrul binarului segfault
objdump -d
de objdump -D
?