Table of Contents

Code understanding

Bitdefender este un lider recunoscut în domeniul securității IT, care oferă soluții superioare de prevenție, detecție și răspuns la incidente de securitate cibernetică. Milioane de sisteme folosite de oameni, companii și instituții guvernamentale sunt protejate de soluțiile companiei, ceea ce face Bitdefender cel mai de încredere expert în combaterea amenințărilor informatice, în protejarea intimității și datelor și în consolidarea rezilienței la atacuri. Ca urmare a investițiilor susținute în cercetare și dezvoltare, laboratoarele Bitdefender descoperă 400 de noi amenințări informatice în fiecare minut și validează zilnic 30 de miliarde de interogări privind amenințările. Compania a inovat constant în domenii precum antimalware, Internetul Lucrurilor, analiză comportamentală și inteligență artificială, iar tehnologiile Bitdefender sunt licențiate către peste 150 dintre cele mai cunoscute branduri de securitate din lume. Fondată în 2001, compania Bitdefender are clienți în 170 de țări și birouri pe toate continentele. Mai multe detalii sunt disponibile pe www.bitdefender.ro.

Resposabili:

Cuprins

De ce este nevoie să înțelegem codul sursă?

Mai exact, de ce este nevoie să avem această abilitate? Pentru că orice inginer lucrează într-o echipă și va avea întotdeauna de-a face cu cod scris de alte persoane, porțiuni care nu au mai fost întreținute de ani de zile, care trebuie refactorizate sau îmbunătățite. Nu în ultimul rând, este foarte important să înțelegem ce face propriul nostru cod, astfel încât atunci când vom reveni asupra lui peste o săptămână, o lună sau un an, să nu ne întrebăm de ce am făcut anumite alegeri.

Desigur, documentația și comentariile ne pot ajuta acolo unde codul nu este expresiv, dar asta nu înseamnă că trebuie să sacrificăm lizibilitatea în totalitate. Un exemplu relativ celebru în lumea C de astfel de caz este funcția:

float foo(float x) {
    float xhalf = 0.5f*x;
    int i = *(int*)&x;
    i = 0x5f3759df - (i>>1);
    x = *(float*)&i;
    x = x*(1.5f-xhalf*x*x);
    return x;
}

Luați-vă câteva minute pentru a citi cu atenție fiecare instrucțiune. Ce credeți că returnează foo(x)?

Spoiler

Răspunsul este inversul radicalului lui x! Funcția a fost folosită prima dată în engine-ul jocului Quake III, pornind din nevoia programatorilor de a folosi cât mai eficient resursele limitate ale calculatoarelor din perioada respectivă. Pentru cei curioși, puteți citi explicațiile lui Chris Lamont din Fast inverse square root, în care demonstrează eficiența acestui algoritm. Canalul de Youtube Nemean oferă explicații pentru fiecare instrucțiune în parte și de ce funcția a fost atât de importantă.

Ce avem în vedere în acest tutorial?

În cele ce urmează, vom studia câteva secvențe de cod scrise atât de studenți ACS, cât și de programatori din industrie, acestea rulând la un moment dat pe software din producție.

Exemple de cod

reverse_by… what?

// în programul original, v este de fapt pointer către un întreg reprezentat pe 8 octeți
void reverse_by_2(char* v)
{
    char* aux = (char*)malloc (8 * sizeof (char));
    aux[0] = v[6];
    aux[1] = v[7];
    aux[2] = v[4];
    aux[3] = v[5];
    aux[4] = v[2];
    aux[5] = v[3];
    aux[6] = v[0];
    aux[7] = v[1];
    memmove(v, aux, 8);
    free (aux);
}

Care este scopul funcției de mai sus? Cât timp v-a luat să vă dați seama? Cum credeți că ar putea fi îmbunătățită?

Spoiler

Deși numele reverse_by_2 nu este foarte bine formulat, acesta oferă totuși un mic hint: funcția inversează ordinea octeților 2 câte 2 din întregul *v (în aux se pun în ordine octeții 6, 7, 4, 5, 2, 3, 0, 1). Astfel, un nume mai potrivit ar fi fost reverse_byte_order_2_by_2. Nu este recomandat să aveți nume foarte lungi de funcții, în special cele care sunt apelate uzual, dar prioritizați mereu o variantă mai explicită și lungă în defavoarea uneia scurte și criptice.

În general, o listă de asignări devine destul de greu de urmărit de la o anumită mărime. Este de preferat să folosiți bucle, făcând astfel codul mai scurt, mai ușor de citit și urmărit. De asemenea, deși câteodată este util să folosiți variabile ajutătore, în acest caz putem implementa funcționalitatea dorită (generalizând pentru un șir de caractere v de lungime 2 * N) astfel:

for (int i = 0; i < N; i += 2) {
    swap(&v[i], &v[2 * N - 2 - i]);
    swap(&v[i + 1], &v[2 * N - 1 - i]);
}

Fun fact: puteți implementa funcția de swap fără a folosi o a treia variabilă

void swap(int* a, int* b)
{
    *a ^= *b;
    *b ^= *a;
    *a ^= *b;
}

De ce funcționează asta? hint

Mutating strings again

// n este lungimea șirului de caractere v și este garantat să fie număr par mai mic decât 256
char* change_value(char* v, int n)
{
    char aux[256];
    memmove(aux, v, n);
    for (int i = 0; i < n; i = i + 2) {
        swap(aux[i], aux[i + 1]);
    }
    reverse_char_array(aux, n);
    return aux;
}
 
void reverse_char_array(char* v, int n)
{
    for (int i = 0; i < n; i++) {
        swap(v[i], v[n - 1]);
        n = n--;
    }
}

Din nou, analizați funcția change_value. Ce valoare returnează? Vedeți ceva în neregulă cu implementările celor 2 funcții? Cum ar putea fi îmbunătățite?

Spoiler

Funcția change_value este o generalizare a lui reverse_by_2, inversând 2 câte 2 caracterele din v: interschimbăm v[i] cu v[i + 1], deci ordinea lor în v este v[i + 1] → v[i], iar când inversăm tot șirul, ordinea lor o să fie din nou v[i] → v[i + 1], doar că o să se afle pe pozițiile n - i - 2 și n - i - 1.

Codul, însă, în varianta sa actuală, generează comportament nedefinit pentru că change_value returnează un pointer alocat local, iar după ce se termină execuția funcției, memoria la care acesta pointează este dezalocată. Evident, această problemă poate fi rezolvată ușor alocând dinamic șirul aux, dar hai să sesizăm și faptul că numele funcției poate duce la erori de logică atunci când este folosită de o altă persoană. change_value implică faptul că valoarea șirului primit ca parametru, v, este modificată în vreun fel, dar de fapt funcția returneaza un șir nou ce conține modificările aferente.

Din nou, în industrie este foarte important să dăm nume sugestive funcțiilor, iar acestea să nu facă modificări în plus sau în minus față de cele sugerate de acel nume. Dacă în varianta de la punctul anterior, când modificam șirul efectiv, am propus reverse_byte_order_2_by_2, acum mai potrivit ar fi reversed_byte_order_2_by_2.

Deși reverse_char_array este un nume foarte sugestiv și ați putea fi tentați să nu mai verificați implementarea acestei funcții, aceasta nu este garantată să producă mereu rezultatul dorit.

În primul rând, încercați întotdeauna să simplificați codul acolo unde se poate: notând cu N lungimea șirului de caractere v, bucla for va înlocui v[0] cu v[N - 1], v[1] cu v[N - 2], … , atâta timp cât primul indice este mai mic decât cel de-al doilea, dar este un mod foarte ofuscat de a realiza o operație atât de simplă precum inversarea unui șir. Optați mereu pentru versiunea ce interschimbă v[i] cu v[N - 1 - i], i de la 0 la N / 2.

În al doilea rând, deși pare doar o instrucțiune inutilă la prima vedere, asignarea n = n-- produce de fapt comportament nedefinit. Am putea interpreta această linie de cod ca “asignează lui n fosta lui valoare, după care decrementează-l”, însă ceea ce facem de fapt este să încercăm să-i modificăm valoarea lui n de 2 ori între 2 sequence point-uri consecutive.

În esență, un sequence point este un punct din execuția unui program în care este garantat că toate efectele laterale ale operațiilor trecute s-au terminat, deci putem fi siguri că schimbările pe care vrem ca acestea să le producă au avut loc. În cazul nostru, nu putem ști cu siguranță în ce ordine se efectuează operațiile de decrementare si asignare. Spre exemplu, este posibil ca mai întâi n-- să îl decrementeze pe n și să returneze vechea valoare, lăsându-l pe n neschimbat.

Ca o regulă generală, nu are sens să încercați să deslușiți ce poate face o bucată de cod ce generează comportament nedefinit. Mai multe detalii despre sequence points puteți găsi pe Wikipedia și Stack Overflow.

Reinventing the wheel

void my_strcat(char** s1, char** s2, char** dest)
{
    int cntr = 0;
    char trmn = '\0';
    int s1_len = strlen(*s1);
    int s2_len = strlen(*s2);
    int trmn_size = strlen(&trmn);
    char* dest_char = (char*)malloc(s1_len+s2_len+trmn_size);
    for (int i = 0; i < s1_len; i++) {
        dest_char[cntr] = (*s1)[i];
        cntr++;
    }
    for (int i = 0; i < s2_len; i++) {
        dest_char[cntr] = (*s2)[i];
        cntr++;
    }
    dest_char[cntr] = trmn;
    *dest = &(dest_char[0]);
}

Urmăriți cu atenție codul de mai sus, acesta ascunde o eroare.

Spoiler

Avem un exemplu clasic de segmentation fault! Pentru că trmn este caracterul nul (\0), atunci când se apelează strlen pe adresa acestuia, vom obține valoarea 0: sunt 0 caractere între adresa de început și caracterul terminal.

Astfel, nu se alocă suficientă memorie pentru șirul dest_char, iar când se scrie la finalul acestuia trmn, ceea ce se întâmplă de fapt este un acces ilegal la memorie.

Observați cum o funcție la prima vedere banală și cu o implementare “straight forward”, care probabil a fost trecută cu vederea de mulți programatori, poate genera erori chiar și în proiecte din industrie.

Recomandăm să nu rescrieți funcționalități din bibliotecile standard (dacă nu există un motiv întemeiat), acelea sunt verificate că funcționează cum trebuie în toate situațiile si probabil conțin și optimizări față de variantele voastre de implementare. De asemenea, încercați să nu abuzați de acestea precum în exemplul de mai sus, unde, probabil din exces de zel, autorul a vrut să folosească strlen peste tot.

Working in security

Imaginați-vă că lucrați într-o companie de securitate precum Bitdefender. Trebuie să îmbunătățiți un password manager folosit în toată compania. Vreți să rescrieți procesul de adăugare a unei parole noi și după multe căutări, găsiți secvența de cod care vă interesează:

void add_password(struct account user, char* password, int new_user)
{
    ...
    // nu returnează 0 niciodată
    int pass_hash = hash(password);
 
    // new_user este 1 sau 0, în funcție dacă operația este efectuată de un utilizator nou sau nu
    if (pass_hash == new_user ? 0 : get_old_pass_hash(user)) {
        // tratează cazul în care se introduce aceeași parolă
        ...
        return;
    }
 
    // returneaza un număr între -128 și 127
    int power = check_strength(password);
 
    // nu vrem să stocăm parolele utilizatorilor nicăieri, așa că suprascriem locația de memorie cu zerouri
    memset(password, 0, sizeof(*password));
 
    if (power >= 70) {
        set_strength(user, pass_hash, "very strong");
    } else if (power > 0 || power <= 69) {
        set_strength(user, pass_hash, "strong");
    } else {
        set_strength(user, pass_hash, "weak");
    }
    ...
}

Fiind studenți la ACS, vă dați seama imediat că există câteva bug-uri în implementarea curentă și vă apucați să le reparați.

Spoiler

Un prim bug provine din precendența operatorilor! Cum se verifică faptul că utilizatorul a introdus parola precedentă?

Intenția este ca în cazul în care new_user este 1, să comparăm hashul cu 0 (valoare invalidă pentru un hash), iar în cazul în care new_user este 0, să-l comparăm cu vechiul hash. În forma sa actuală, însă, deoarece == are precedență mai mare decât ?:, secvența de cod este de fapt echivalentă cu

if ((pass_hash == new_user) ? 0 : get_old_pass_hash(user))

În majoritatea cazurilor pass_hash va fi diferit de 0 sau 1, deci expresia se va evalua la get_old_pass_hash(user), care este un număr nenul, ceea ce inseamnă că se va intra aproape mereu pe ramura de if! Soluția este una simplă, și anume de a pune între paranteze membrii operatorului ternar:

if (pass_hash == (new_user ? 0 : get_old_pass_hash(user)))

Următorul bug reprezintă o eroare de logică: parola nu va avea niciodată puterea weak. Orice număr mai mic decât 70 este ori mai mare decât 0, ori mai mic sau egal cu 69, deci acea ramură else nu va fi niciodată accesată. Soluția corectă presupune folosirea operatorului și (&&) în loc de sau (||).

Un ultim bug este însă mai ascuns și nu reprezintă o greșeală propriu-zisă în cod. Funcția memset nu este garantată să fie rulată, compilatorul considerând-o inutilă din moment ce nu se mai accesează locația de memorie password după aceea (vezi Zeroing memory, compiler optimizations and memset_s). Atunci când doriți să fiți siguri că ați șters informația de la o locație de memorie, folosiți memset_s sau bzero.

Concluzie

Înțelegerea codului sursă este o abilitate pe care cu toții ne-o rafinăm de-a lungul carierelor noastre. Vă veți confrunta cu funcționalități ale căror creatori au plecat din companie, cu proiecte open-source și, cum probabil v-ați obișnuit deja, veți căuta soluții pe Stack Overflow. Toate aceste experiențe au capabilitatea să vă invețe ceva nou, să deprindeți un obicei ce vă va ajuta nu numai pe voi, ci și pe toți oamenii care vă vor utiliza codul.