Laborator 06: Analiza statică și dinamică a programelor. GDB

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

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:

  • identifică zona exactă unde poate aparea o eroare
  • ușurează înțelegerea codului de către alți (posibili) cititori ai codului
  • nu este nevoie să fie rulat codul (avantaj în cazul în care rularea codului necesită resurse foarte mari)
  • identifică erori ce nu pot fi găsite printr-un alt tip de analiză: cod mort (“unreachable code”), variabile nefolosite, funcții neapelate etc.

Dezavantajele analizei statice:

  • consumă foarte mult timp dacă este realizată manual
  • nu poate detecta vulnerabilități apărute la runtime (spre exemplu “segmentation fault”)
  • ai nevoie de codul sursă pentru a putea realiza acest tip de analiză

Câteva din programele utile pentru analiza statică pe care le vom folosi și în cadrul tutorialelor/exercițiilor sunt:

  • nm - utilitar folosit pentru inspectarea simbolurilor și secțiunilor din executabile;
  • objdump - program folosit pentru dezasamblarea (traducerea din cod-mașină în limbaj de asamblare) programelor binare;
  • IDA - o unealtă foarte puternică pentru dezasamblarea și inspectarea fișierelor obiect și executabile;
  • coverity, clang-analyzer, cppcheck - utilitare pentru identificare statică a problemelor de tip: memory-leak, buffer overflow, NULL pointer dereferences.

Analiza dinamică

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:

  • identifică erorile apărute la runtime: segmentation fault, arithmetic exception etc.
  • oferă posibilitatea analizării programului chiar dacă nu avem acces la codul sursă
  • identifică vulnerabilități care ar fi putut fi fals negative în momentul analizei statice
  • permite validarea rezultatelor analizei statice
  • poate fi realizată pentru orice aplicație

Dezavantajele analizei dinamice:

  • este mai greu de localizat unde exact în cod are loc eroarea
  • programele ce automatizează analiza dinamică produc falsuri pozitive și falsuri negative
  • nu poate garanta acoperirea completă a tuturor cazurilor unui fișier sursă

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.

Tutoriale și exerciții

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

[0.5p] 1. Tutorial: Asamblarea din linia de comandă

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.

Tutoriale și exerciții analiză statică

[1p] 3. Tutorial objdump

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:

  • .data - date inițializate de tip read-write
  • .bss - date neinițializate de tip read-write
  • .rodata - date read-only (const)
  • .text - instrucțiuni executabile
  • .init și .fini - secțiuni pe care probabil nu le veți întâlni în fișierele obiect, dar ele sunt legate de etapele de inițializare/terminare a proceselor pornite din executabile ELF. Cu alte cuvinte, sistemul face astfel încât codul din aceste secțiuni să fie executat înaintea funcției main, respectiv în urma terminării cu success a execuției.

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.

Pentru generarea unui binar .o, puteți folosi comanda gcc objd_tutorial.c -c

Verificați corectitudinea folosind comanda 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.

[1.5p] 4. 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):

  • U - simbol nedefinit; cu alte cuvinte, el este utilizat în cadrul sursei curente, dar nu este declarat aici. Acest lucru este posibil în C datorită cuvântului cheie extern; locația exactă va fi determinată în urma etapei de linkare sau chiar la runtime
  • C - simbol comun; identifică variabilele neinițializate, nestatice; în cazul în care aceeași variabilă este declarată și inițializată într-un alt fișier sursă folosit la generarea executabilului final, această definiție va fi cea luată în considerare la nivelul modulului; în acest caz, variabila se va comporta ca un simbol nedefinit (valabil în C; în C++ acest tip de simbol va fi plasat în zona de date neinițializate (.BSS), iar link-editarea binarelor din acest scenariu va genera o eroare)

Î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

Pentru link-editare (generarea unui executabil dintr-un număr de fișiere obiect) folosim: gcc file1.o [file2.o] [filen.o] -lm -o nume_executabil

  • opținea -lm are rolul de a încărca biblioteca ce conține funcții matematice

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

Majuscula identifică (pentru majoritatea tipurilor de simboluri afișate de nm) entitățile globale, ce pot fi accesate și din alte fișiere. Minuscula (litera d în cazul nostru) identifică entitățile statice, vizibile doar în cadrul fișierului în care a fost definită.

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

[2p] 5. Exercițiu objdump

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

Secțiunile din cadrul funcției main care se ocupă de citirea și afișarea datelor sunt irelevante în cadrul acestui exercițiu. De interes este doar codul delimitat prin flag-urile BEGINNING_AREA_OF_INTEREST: și END_AREA_OF_INTEREST:; de asemenea, valoarea afișată de funcție va fi preluată din registrul eax

[0.5p] 6. Cppcheck

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:

  • Dead pointers
  • Division by zero
  • Buffer and integer overflow
  • Uninitialized data
  • Null pointers dereferences
  • Memory leaks

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

Tutoriale și exerciții analiză dinamică

[3p] 7. Tutorial: GDB

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.

Când rulați în GDB să dați acel parametru ca argument comenzii 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

Recomandăm folosirea extensiei PEDA (Python Exploit Development Assistance) pentru GDB. Pentru a putea să o folosiţi, verificați faptul că fişierul ~/.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.

Lansarea în execuție a programului

Pentru a lansa programul urmărit în execuție există două comenzi disponibile:

  • run - această comandă va lansa în execuție programul
  • start - spre deosebire de run, această comandă va începe execuția programului, însă se va opri imediat după intrarea în main

Aceste două comenzi mai pot fi folosite în două feluri:

  1. start 1 2 3 4
  2. start < file.in

În locul comenzii 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.

Breakpoints

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.

Variaţii:
break label - breakpoint la labelul label
break *(label + <offset>) - breakpoint la label + offset

Parcurgerea instrucțiunilor

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 - care practic trimite o instrucțiune spre execuție și după execuția acesteia întoarce control-ul la debugger (programul se oprește)
  • nexti - comandă similară cu 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).

Dezasamblarea programului

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.

Default, sintaxa folosită de GDB la dezasamblare este cea “AT&T”. Pentru a folosi sintaxa cunoscută vouă (sintaxa intel), executați în GDB comanda 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.

Dacă ați intrat într-o funcție lungă și nu vreți să dați de nexti de foarte multe ori, vă recomandăm instrucțiunea GDB finish, care “termină” o funcție. Atenție la funcțiile recursive.

disassemble label, +<length> - afişează <length> bytes de cod dezasamblat începând de la labelul label.

Inspectarea memoriei și a registrelor

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șate
  • f 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.

[1p] 8. Afișarea unor informații la fiecare trecere printr-un breakpoint

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

Hint: folosiți comanda commands.

[2p] 9. Bonus: Depanarea unui Segfault

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.

Codul ce se ocupă cu printarea - delimitat în mod explicit în cod - nu este relevant pentru rezolvarea exercițiului. Totuși ar putea fi de interes faptul că registrul ebx este cel din care se preiau datele pentru printare

Puteți să vă folosiți și de objdump pentru a vedea exact conținutul secțiunii .data din cadrul binarului segfault

Cu ce este diferit objdump -d de objdump -D?

ihs/laboratoare/laborator-06-new.txt · Last modified: 2021/09/20 18:05 (external edit)
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