Laborator 03: Toolchain

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

C basics: GOTOs

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

The "WHYs" of goto

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

De ce abordează acest curs/laborator un astfel de subiect?
Când vom studia limbajul de asamblare vom observa că o bună parte din workflow seamănă cu un program format din goto-uri, chiar dacă majoritatea instrucțiunilor unui limbaj de nivel înalt, chiar și precum C, sunt inexistente. Gândirea și programarea cu goto-uri ne pregătește pentru lucrul în limbajul de asamblare.

În orice alt caz, această formă de programare ar trebui evitată pe cât posibil.

De la C la program în rulare

Etapele prin care trece un program din momentul în care este scris până când este rulat ca un proces sunt, in ordine:

  • preprocesare
  • compilare
  • asamblare
  • link editare

În imaginea de mai jos sunt reprezentate si detaliate aceste etape:

 Etapele procesului de compilare: de la program la proces

Î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 cazul compilatorului gcc este de ajuns să adăugați flag-ul -S și vă va genera un fișier *.s cu codul aferent.

Î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 

Pentru a genera sintaxa intel pe 32 de biți, se pot folosi aceste opțiuni:

cc -Wall -m32 -S -masm=intel  -o hello.s hello.i

Toolchain

În cadrul laboratoarelor vom folosi:

  • asamblorul NASM
  • linkerul din cadrul suitei gcc

Pentru analiza codului si debugging vom folosi gdb si Ghidra.

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.

Exerciții

În cadrul laboratoarelor vom folosi repository-ul de git al materiei IOCLA - https://github.com/systems-cs-pub-ro/iocla. Repository-ul este clonat pe desktop-ul mașinii virtuale. Pentru a îl actualiza, folosiți comanda 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.

1. Online C Compiling

Un tool interesant pentru a observa cum se traduce codul C în limbaj de asamblare este Compiler Explorer.

  1. Intrați pe Compiler Explorer.
  2. Încărcați programul “sum over array” din exemple (accesibile folosind butonul de load, în formă de dischetă).
  3. Asigurați-vă că x86-64 gcc 4.8.2 este selectat la Compiler:.
  4. Folosiți opțiunea -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).
  5. Dacă vedeți mesajul <Compilation failed>, adăugați opțiunea -std=c99.
  6. În continuare codul este destul de greoi. Pentru a putea fi mai human-readable adăugați opțiunea -O2 la opțiunile de compilare (Compiler options).
  7. Se poate observa existența simbolurilor .L3: și .L4:. Acestea reprezintă puncte fixe în program, label-uri, destul de asemănătoare cu ceea ce se găsește și în C.
  8. Treceți, pe rând, prin compilatoarele corespunzătoare următoarelor arhitecturi: ARM, ARM64, AVR, PowerPC. Atenție: pentru ARM, ARM64 și AVR va trebuie să renunțați la flag-ul -m32 setat anterior. Se poate observa cum codul generat diferă de la o arhitectură la alta.
  9. Mai încercați și următoarele compilatoare: clang și icc. După cum se poate observa, deși este același cod C și aceeași arhitectură, codul generat diferă. Acest lucru se întâmplă pentru că fiecare compilator poate avea o strategie de optimizare și generare de cod diferită.

clang este un compilator open-source de C\C++. Adesea este folosit în IDE-uri datorită mesajelor de eroare de compilare foarte sugestive pe care le produce.

icc este compilatorul de C\C++ al celor de la compania Intel.


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.

2. C: Warm-up GOTOs

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.

Când scrieți cod cu etichete (label-uri) țineți cont de următoarele recomandări de indentare:

  • Nu indentați etichetele (label-urile). “Lipiți-le” de marginea din stânga a ecranului de editare.
  • O etichetă este singură pe linie. Nu există cod după etichetă.
  • Nu țineți cont de etichete în indetarea codului. Codul trebuie indendat în același mod și cu etichete și fără etichete.
  • Puneți o linie liberă înaintea liniei care conține o etichetă.

Caz în care goto poate fi util

3. C: GOTOs

Intrați în directorul 3-goto-algs.

Pentru algoritmii de mai jos scrieți cod în C fără a folosi:

  • definiții / apeluri de funcţii (exceptând scanf() şi printf())
  • else
  • for
  • while
  • do {} while;
  • construcțiile if care conțin return
  • if-uri imbricate

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

Reiterăm ideea că scenariile de utilizare ale instrucțiunii 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.

4. Tutorial Ghidra: Decompilare

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.

Pentru a rula Ghidra, intrați într-o fereastră de terminal și utilizați comanda 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”.

5. Reverse: Old hits

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ții

Soluțiile pentru exerciții sunt disponibile aici.

ihs/laboratoare/laborator-03.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