În acest laborator, vom trece prin fiecare nivel de procesare al unui limbaj de nivel înalt și prin toolchain-ul pe care îl vom folosi de acum încolo.
Un concept mai puțin abordat în tutoriale de C este instrucțiunea goto. Prin instrucțiunea goto, un program poate sări în puncte intermediare în cadrul unei funcții. Aceste puncte intermediare se numesc label-uri (etichete). Din punct de vedere al sintaxei, o eticheta consta dintr-un nume, urmat de caracterul :.
Un exemplu de cod:
#include <stdio.h> int main() { int i, j, k; /* some code */ do_some_work: /* some other code */ work(); if (any_work()) goto do_some_work; /* some code */ return 0; }
Programul execută un job prin work(). În caz că mai sunt alte joburi neterminate, executia programului sare la eticheta do_some_work. do_some_work marcheaza punctul din program în care începe procesarea unui nou job. Pentru a sări la acest punct se folosește instrucțiunea goto urmată de numele etichetei declarate. Prin diferite combinații de if-uri si goto-uri se pot echivala alte instrucțiuni din C, cum ar fi else, for si while.
Codul dat exemplu mai sus ar putea fi un candidat care să înlocuiască o instrucțiune do { … } while ();
:
#include <stdio.h> int main() { int i, j, k; /* some code */ do { /* some other code */ work(); } while (any_work()); /* some code */ return 0; }
Această instrucțiune nu doar că adesea lipsește din tutorialele de C, dar se fac recomandări împotriva abordării ei deoarece de cele mai multe ori duce la cod ofuscat (greu de înțeles, întreținut și depanat). Există totuși cazuri în care este folosita. În codul kernel-ului de Linux (exemplu), instrucțiunile de goto sunt folosite ca o formă de try-catch din limbaje de nivel mai înalt (precum C++, Java, C#, etc.). Exemplu:
int process_data_from_mouse_device(...) { int err; int x, y; /* >>try<< instructions */ err = init_communication_with_mouse(); if (err) goto error; err = get_x_coord_from_mouse(&x); if (err) goto error; err = get_y_coord_from_mouse(&y); if (err) goto error; err = announce_upper_layers_of_mouse_movement(x, y); if (err) goto error; err = close_communication_with_mouse(); if (err) goto error; return 0; /* >>catch<< instructions' exceptions */ error: print_message("Failed to get data from mouse device. Error = %d", err); return err; }
Acest cod încearcă să proceseze datele venite de la un mouse și să le paseze altor părți superioare din kernel care le-ar putea folosi. În caz că apare vreo eroare, se afișează un mesaj de eroare și se termină procesarea datelor. Codul pare corect, dar nu este complet. Nu este complet pentru că în caz că apare o eroare în mijlocul funcției, comunicația cu mouse-ul este lăsată deschisă. O variantă îmbunătățită ar fi următoarea:
int process_data_from_mouse_device(...) { int err; int x, y; /* >>try<< instructions */ err = init_communication_with_mouse(); if (err) goto error; err = get_x_coord_from_mouse(&x); if (err) goto error_close_connection; err = get_y_coord_from_mouse(&y); if (err) goto error_close_connection; err = announce_upper_layers_of_mouse_movement(x, y); if (err) goto error_close_connection; err = close_communication_with_mouse(); if (err) goto error; return 0; /* >>catch<< instructions' exceptions */ error_close_connection: close_communication_with_mouse(); error: print_message("Failed to get data from mouse device. Error = %d", err); return err; }
În varianta îmbunătățită, dacă apare o eroare, se face și o parte de curățenie: conexiunea cu mouse-ul va fi închisă, și apoi codul va continua cu tratarea generală a oricărei erori din program (afișarea unui mesaj de eroare).
Etapele prin care trece un program din momentul în care este scris până când este rulat ca un proces sunt, in ordine:
În imaginea de mai jos sunt reprezentate si detaliate aceste etape:
În etapa de compilare codul este tradus din cod de nivel înalt în limbaj de asamblare. Limbajul de asamblare este o formă human-readable a ce ajunge procesorul să execute efectiv. Dacă programele scrise în limbaje de nivel înalt ajung să fie portate ușor pentru procesoare diferite (arm, powerpc, x86, etc.), cele scrise în limbaj de asamblare sunt implementări specifice unei anumite arhitecturi. Limbaje de nivel înalt reprezintă o formă mai abstractă de rezolvare a unei probleme, din punctul de vedere al unui procesor, motiv pentru care și acestea trebuie traduse în limbaj de asamblare în cele din urmă, pentru a se putea ajunge la un binar care poate fi rulat. Mai multe detalii în laboratorul următor.
Majoritatea compilatoarelor oferă opțiunea de a genera și un fișier cu programul scris în limbaj de asamblare.
În arhiva de TODO aveți un exemplu de trecere a unui program foarte simplu hello.c
prin cele patru faze. Îl puteți testa pe un sistem Unix/Linux și pe un sistem Windows cu suport de MinGW.
$ make cc -E -o hello.i hello.c cc -Wall -S -o hello.s hello.i cc -c -o hello.o hello.s cc -o hello hello.o $ ls Makefile hello hello.c hello.i hello.o hello.s $ ./hello Hello, World! $ tail -10 hello.i # 5 "hello.c" int main(void) { puts("Hello, World!"); return 0; } $ cat hello.s .file "hello.c" .section .rodata .LC0: .string "Hello, World!" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movl $.LC0, %edi call puts movl $0, %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (Debian 5.2.1-17) 5.2.1 20150911" .section .note.GNU-stack,"",@progbits $ file hello.o hello.o: ELF 64-bit LSB relocatable, x86-64, [...] $ file hello hello: ELF 64-bit LSB executable, x86-64, [...] $ objdump -d hello.o hello.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <main>: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: bf 00 00 00 00 mov $0x0,%edi 9: e8 00 00 00 00 callq e <main+0xe> e: b8 00 00 00 00 mov $0x0,%eax 13: 5d pop %rbp 14: c3 retq
cc -Wall -m32 -S -masm=intel -o hello.s hello.i
În cadrul laboratoarelor vom folosi:
gcc
Pentru analiza codului si debugging vom folosi gdb
si Ghidra
.
Ghidra
este o unealtă foarte utilă pentru investigarea programelor si reverse engineering
.
Dezasamblare
Procesul de dezasamblare este utilizat pentru obținerea unui fișier care conține cod de asamblare, pornind de la un fișier binar. Acest proces este întotdeauna posibil, deoarece codul mașină specific procesorului are o corespondență directă cu codul de asamblare. De exemplu, operația add eax, 0x14
, prin care la valoarea registrului eax
se adaugă 20
, se reprezintă întotdeauna folosind codul binar 83 c0 14
.
Decompilare
Utilitarul Ghidra poate fi folosit chiar și pentru decompilare. Decompilatorul poate fi folosit pentru a obține codul sursă într-un limbaj (relativ) de nivel înalt, care atunci când va fi compilat va produce un executabil al cărui comportament va fi la fel ca executabilul original. Prin comparație, un dezasamblor traduce un program executabil în limbaj de asamblare în mod exact, pentru că există relația de 1:1 între cod mașină și limbaj de asamblare.
Veți utiliza cele două opțiuni în cadrul laboratorului de astăzi, pentru a analiza niște binare simple.
git pull origin master
din interiorul directorului în care se află repository-ul (~/Desktop/iocla
). Recomandarea este să îl actualizați cât mai frecvent, înainte să începeți lucrul, pentru a vă asigura că aveți versiunea cea mai recentă.
Dacă doriți să descărcați repository-ul în altă locație, folosiți comanda git clone https://github.com/systems-cs-pub-ro/iocla ${target}
.
Pentru mai multe informații despre folosirea utilitarului git
, urmați ghidul de la Git Immersion.
Un tool interesant pentru a observa cum se traduce codul C în limbaj de asamblare este Compiler Explorer.
-m32
(la Compiler options
) pentru a afișa cod în limbaj de asamblare pe 32 de biți (față de 64 de biți în mod implicit).<Compilation failed>
, adăugați opțiunea -std=c99
.Compiler options
).-m32
setat anterior. Se poate observa cum codul generat diferă de la o arhitectură la alta.
Scrieți în zona Code editor
următoarea secvență de cod:
int simple_fn(void) { int a = 1; a++; return a; }
Observați codul în limbaj de asamblare atunci când opțiunile de compilare (Compiler options
) sunt -m32
, respectiv atunci când opțiunile de compilare sunt -m32 -O2
. Observați ce efect au opțiunile de optimizare asupra codului în limbaj de asamblare generat.
Intrați în directorul 2-warm-up-gotos
.
2.1 Modificați codul sursă din fișierul bogosort.c
(Bogosort) prin înlocuirea
instrucțiunii break
cu o instrucțiune goto
astfel încât funcționalitatea să se păstreze.
2.2 În mod asemănător modificați instrucțiunea continue
din ignore_the_comments.c
astfel încât funcționalitatea codului să se păstreze.
goto
poate fi util
Intrați în directorul 3-goto-algs
.
Pentru algoritmii de mai jos scrieți cod în C fără a folosi:
if
care conțin returnSingura instrucțiune permisă în cadrul unui if este goto.
În alte cuvinte, tot codul trebuie să fie scris în interiorul funcției main, iar modificarea fluxului de control (saltul la altă zonă de cod) se face doar prin intermediul secvențelor de tipul if (conditie) goto eticheta;
sau goto eticheta;
.
3.1 Implementați maximul dintr-un vector folosind cod C și constrângerile de mai sus.
3.2 Implementați căutare binară folosind cod C și constrângerile de mai sus.
goto
sunt limitate. Exercițiile acestea au valoare didactică pentru a vă acomoda cu instrucțiuni de salt (jump) pe care le vom folosi în dezvoltarea în limbaj de asamblare.
Intrați în directorul 4-tutorial-ghidra
.
În cadrul acestui exercițiu dorim să analizăm funcționalitatea unui binar simplu, care cere introducerea unei parole corecte pentru obținerea unei valori secrete.
ghidra
.
Pentru început, când rulăm Ghidra ne va apărea o fereastră cu proiectele noastre curente.
Putem să creăm un nou proiect și să îi dăm un nume corespunzător. Pentru asta vom folosi: File → New Project
(sau folosind combinația de taste CTRL + N
).
După ce am creat proiectul, ca să adăugăm fisierul executabil putem să folosim File → Import file
, sau să tragem fișierul în directorul pe care l-am creat. Ghidra ne va sugera formatul pe care l-a detectat, precum și compilatorul folosit, în cazuri mai speciale probabil va trebui să schimbăm aceste configurări, dar pentru scopul acestui tutorial, ce ne sugerează Ghidra este perfect.
Următorul pas este să analizăm binarul pe care l-am importat. Putem să apăsăm dublu click pe acesta. Ghidra ne va întreba daca vrem să îl analizăm. Pentru a face acest lucru, vom apăsa Yes
și apoi Analyze
.
Dupa ce executabilul a fost analizat, Ghidra afișează o interpretare a informațiilor binare, care include și codul dezasamblat al programului. În continuare, putem de exemplu să încercam să decompilăm o funcție. În partea stângă a ferestrei avem secțiunea Symbol Tree
; dacă deschidem Functions
, putem observa că Ghidra ne-a detectat anumite funcții, chiar și funcmain-ul în cazul acestui binar. Astfel dacă dăm dublu click pe main, ne apare în dreapta funcția main decompilată și în fereastra centrală codul în limbajul de asamblare aferent.
Putem să observăm acum că decompilarea nu este tocmai 1:1 cu codul sursă (din fișierul crackme.c
), dar ne da o idee destul de bună a acestuia. Urmărind codul decompilat, observăm că funcția main are doi parametri de tip long
, care se numesc param_1
și param_2
, în loc de prototipul normal main(int argc, char *argv[])
. Al doilea parametru al main-ului este de tip “vector de pointeri către date de tip caracter” (care este interpretat în mod generic ca “vector de șiruri de caractere”). Mai jos este o perspectivă generică asupra modului de reprezentare al vectorului pentru un sistem de 64 de biți. În reprezentarea de pe a doua linie, interpretați argp
ca fiind char *argp = (char *)argv
, pentru a avea sens calculul argp + N
.
argv[0] | argv[1] | argv[2] |
argp | argp + 8 | argp + 16 |
Diferența de tip a parametrilor main-ului este una legată de interpretare: binarul este compilat pentru arhitectura amd64 (care este extensia arhitecturii x86 pentru 64 de biți), iar dimensiunea unui cuvânt de procesor este de 8 octeți (sau 64 de biți). Dimensiunea unui cuvânt de procesor se reflectă în dimensiunea unui pointer, dar și în dimensiunea unui parametru unic (dacă parametrul este mai mic de un cuvânt, se face automat extensia până la dimensiunea unui cuvânt). Totodată, printr-o coincidență, dimensiunea unei variabile de tip long
este tot de 64 de biți (dimensiunile tipurilor de date în C nu sunt bine stabilite, fiind definite doar niște limite inferioare pentru date). Acest lucru face ca interpretarea celor doi parametri să fie ca long
, deoarece toți parametrii, indiferent de tip (int sau pointer) se manipulează identic. Calculul param_2 + 8
este folosit pentru a calcula adresa celui de-al doilea pointer din vectorul argv
(adică argv[1]
). Pentru un program compilat pentru arhitectura x86 de 32 de biți, adresa lui argv[1]
ar fi fost param_2 + 4
.
Folosind informațiile din codul decompilat putem să ne dăm seama că programul așteaptă o parolă ca argument și aceasta trebuie să fie din 8 caractere și caracterul de pe poziția 3 trebuie să fie 'E'. Deci putem să îi punem ca input o parolă de genul “AAAEAAAA”.
Intrați în directorul 5-old-hits
.
Folosind informațiile noi dobândite despre Ghidra, dar și cele învățate anterior despre folosirea gdb, analizați binarul și obțineți informația secretă. Programul generează o valoare aleatoare și vă cere să ghiciți o altă valoare calculată pe baza valorii aleatoare.
Mult succes!
Soluțiile pentru exerciții sunt disponibile aici.