Rețineți că acest răspuns excelent îi aparține Osifrage zguduitoare că au încetat contribuția! Am făcut un copy and paste apoi am făcut comunitate. Votarea acestui răspuns nu produce nimic pentru mine. Dacă puteți, votați-le Infosec.
Din exemplul de cod furnizat, este posibil să se determine parola corectă prin cronometrarea codului atunci când i se oferă diverse intrări.
În primul rând, nu ar trebui să examinați parola direct! La foarte putin, ar trebui să indexați parola cu un hash al parolei precum Argon2id și să comparați hash-ul parolei introduse cu hash-ul parolei pe care l-ați stocat în timpul înregistrării utilizatorului (sau când utilizatorul și-a schimbat ultima oară parola).
Și mai bine, ar trebui să utilizați un protocol de acord cu cheie autentificat cu parolă, cum ar fi OPAQUE, dar acestea pot depăși nivelul dvs. de plată în acest moment, până când vor fi adoptate și implementate pe scară largă.
Ce pot face pentru a mă asigura că codul meu nu este vulnerabil la astfel de atacuri de sincronizare?
Cel mai bun mod de a începe este să utilizați o rutină de bibliotecă sau o rutină primitivă altcineva a scris deja și are un motiv să mențină. De exemplu, în NaCl/libsodium, puteți utiliza crypto_verify_32
pentru a compara două șiruri de 32 de octeți, cum ar fi două coduri hash Argon2id sau două coduri de autentificare a mesajelor HMAC-SHA256. Apoi efortul de a răspunde la această întrebare poate fi concentrat pe un singur loc care va primi multă atenție și control și va ține pasul cu progresele.
Dar să zicem că nu ai crypto_verify_32
, sau doriți să o implementați singur. Ce poti face?
Pentru a începe, trebuie să înțelegeți ce operațiuni au canale laterale. este ispititor a spune... cum au făcut și alte răspunsuri... că canalul lateral apare doar din cauza unei din timp
avorta. Dar asta nu este toată povestea. În general, există multe operatii (aici scris în C pentru ilustrare) care poate dura o perioadă de timp care depinde de valorile a intrărilor... numim aceste operaţii timp variabil operațiuni, spre deosebire de timp constant*:
pentru (i = 0; i < n; i++) dacă (x[i] == y[i]) returnează EFAIL;
evident ia mai puține iterații în buclă deci este practic garantat rularea in timp variabil in functie de valorile secrete ale x[i]
și y[i]
.
Un simplu condițional dependent de secret pentru (i = 0; i < n; i++) dacă (x[i]) rău++;
, dacă x[i]
este secret, poate rula și în timp variabil chiar dacă bucla nu se întrerupe devreme. De ce?
Iată o aproximare grosieră. Instrucțiunile mașinii pe care procesorul le poate executa arată cam așa:
0: tmp := x[i]
ramificație la 1 dacă tmp este zero
rău := rău + 1
1: i := i + 1
ramificație la 0 dacă i < n
The numărul de instrucțiuni executat depinde de ce valoare a x[i]
este la fiecare iterație: sărim rău := rău + 1
la unele iterații. Acesta este un model bun pentru modul în care timingul timpuriu atacă, de exemplu., RSA a funcționat ca în Lucrarea fundamentală a lui Kocher despre atacurile de sincronizare: bucla principală de exponențiere modulară calculează (să zicem) o pătrare modulară de 2048 de biți necondiționat, dar calculează o multiplicare modulară de 2048 de biți conditionat în funcţie de valoarea exponentului secret. Omiterea înmulțirii modifică substanțial timpul necesar întregii operațiuni.
Există, totuși, un alt motiv și are legătură cu predicție de ramură, un element cheie de design în ceea ce face ca CPU-urile moderne să ruleze atât de repede pe multe sarcini de lucru, chiar dacă scrieți aceeași cantitate de cod (să zicem, același număr de instrucțiuni ale mașinii și, într-un fel, garantați că acestea necesită același număr de cicluri pentru a calcula ) în fiecare ramură a unui condițional, timpul necesar pentru a se executa poate depinde de direcția în care a mers condiția.
În general, procesoarele sunt prost la păstrare care instrucțiuni au fost executate secret, așa că nu faceți alegerea instrucțiunilor depind de secrete.
Căutările în tabele/matrice pot dura o perioadă diferită de timp, în funcție de ce memorie a fost stocată în cache-ul CPU. În consecință, dacă locație în matrice din care citiți depinde de un secret, timpul necesar ar putea depinde de secret, care a fost exploatat pentru recuperați cheile AES prin sincronizarea cache.
(Acest lucru face ca AES să fie un design destul de îndoielnic în retrospectivă, cu utilizarea sa intenționată a căutărilor de tabele dependente de cheie! NIST's justificare publicată (§3.6.2, Atacurile asupra implementărilor: rolul operaţiilor) susține în mod curios că căutările în tabele sunt „nu sunt vulnerabile la atacurile de sincronizare” în ciuda numeroaselor astfel de atacuri care au fost raportate de atunci.)
Schimbarea la distanță variabilă ca x = y << z
poate dura mai mult timp pe unele procesoare dacă z
este mai mare și mai puțin timp dacă este mai mic.
(Asta face RC5 și finalista AES RC6 design destul de discutabil retrospectiv, cu utilizarea lor intenționată a distanțelor de rotație dependente de cheie!)
Pe unele procesoare, multiplicarea poate rula mai rapid sau mai lent, în funcție de dacă jumătatea superioară a intrărilor este zero sau nu.
Adăugarea de numere întregi pe 64 de biți pe procesoarele pe 32 de biți, în principiu, ar putea dura mai mult timp, în funcție de faptul dacă există o transferare. Asta pentru că, când X
, y
, și z
, sunt numere întregi pe 64 de biți, logica x = y + z
ar putea arăta mai mult ca:
int carry = 0;
x[0] = y[0] + z[0];
dacă (adăugarea anterioară a debordat)
purtare = 1;
x[1] = y[1] + z[1] + purtare;
În consecință, timpul necesar poate depinde de faptul dacă există o transferare de la suma jumătăților de 32 de biți inferioare la suma jumătăților de 32 de biți înalte. (În practică, aceasta este de obicei o preocupare doar pentru procesoarele exotice sau pentru alte tipuri de canale secundare, cum ar fi analiza puterii, care sunt relevante mai mult pentru cardurile inteligente decât pentru laptopuri și telefoane.)
Acest lucru ar putea suna puțin copleșitor. Ce putem face?
Există unele operații care în general fac rulează în timp constant pe majoritatea procesoarelor. Sunt:
- Operații pe biți:
X y
, x | y
, x ^ y
, ~x
, și altele care nu apar în C precum AND-cu-complement.
- Constanta-distanta schimbari si rotatii precum schimbarea
x << 3
sau rotațiax <<< 3
(nu C standard, dar comun în criptografie; înseamnă (x << 3) | (x >> (32 - 3))
, dacă X
este pe 32 de biți).
- De multe ori adunarea și scăderea întregului:
x + y
, X y
, când X
și y
sunt (să zicem) numere întregi nesemnate pe 32 de biți pe un procesor pe 32 de biți și adesea chiar numere întregi pe 64 de biți pe un procesor pe 32 de biți cu ajutorul instrucțiunilor ADD-with-carry.
- Uneori inmultire intregi, dar povestea despre înmulțire este complicat, ceea ce este nefericit pentru criptografie, deoarece multiplicarea amestecă biți destul de frumos și are proprietăți algebrice utile.
Ca să fiu clar: nu vreau să spun asta un compilator C garantează rularea acestor operațiuni în timp constant dacă le utilizați într-un program C; Folosesc doar notația C pentru operațiile care CPU-uri se execută în general în timp constant. (Mai multe despre această avertizare într-un moment.)
„Dar stai,” protestezi, „cum pot să scriu un program util din aceste operațiuni? Fără condiționale? Fără bucle? Fără matrice?â
În primul rând, nu trebuie să evitați condiționalele, buclele sau matricele cu totul. Pur și simplu nu pot depind de secrete. De exemplu, pentru (i = 0; i < 32; i++) ... x[i] ...
e bine. Dar pentru (i = 0; i < m[0]; i++) ...
nu este bine dacă m[0]
ar trebui să fie secret și pentru (i = 0; i < m[0]; i++) ... tab[x[i]] ...
nu este bine dacă x[i]
ar trebui să fie secret.
În al doilea rând, încă poți construi aceste lucruri! Doar că este puțin mai complicat. De exemplu, să presupunem b
este un uint32_t care este fie 0, fie 1. Atunci b - 1
este fie -1 = 0xffffffff, fie, respectiv, 0, deci
x = ((b - 1) și z) | (~(b - 1) & y);
cauze x = y
dacă b
este 1 sau x = z
dacă b
este 0âmult asemănător x = (b ? y : z)
, dar fără ramură. Evident, acest lucru necesită calcul ambii y
și z
, deci există un anumit impact asupra performanței! În mod similar, puteți căuta un element al unui tabel privind în sus toate elemente ale tabelului și selectând-o pe cea dorită cu operații pe biți ca aceasta. Nu la fel de repede ca x[i]
, dar nici la fel de scurs.
În general, tu poate sa convertiți un program cu condiționale într-un circuit logic fără condiționale, chiar dacă nu o faceți vrei la din motive de performanţă. Există diverse alte trucuri similare pe care le puteți face. Puteți elabora o rutină de egalitate a memoriei în timp constant, cum ar fi crypto_verify_32
astfel, presupunând că x și y sunt matrice uint8_t:
uint32_t rezultat = 0;
pentru (i = 0; i < 32; i++)
rezultat |= x[i] ^ y[i];
return ((rezultat - 1) >> 8) & 1;
Exercițiu: Acesta returnează 0 pentru egal și 1 pentru inegal, sau 0 pentru inegal și 1 pentru egal?
Scrierea unor programe ca acesta... și adoptarea unor criptosisteme precum X25519 a incuraja implementări care arată așa, în loc de criptosisteme precum RSA sau AES care a incuraja implementările care implică ramuri dependente de secret sau căutări de tabel dependente de secret – este un bun început pentru conectarea canalelor laterale de sincronizare.
Dar, există o captură! Îți amintești când am spus că compilatorul C nu garantează timp constant? Un compilator C inteligent precum Clang/LLVM ar putea recunoaşte că isteţul crypto_verify_32
bucla de mai sus poate fi executată mai eficient făcându-l să avorteze devreme și ar putea anula munca grea pe care ați făcut-o pentru a-l rescrie ca un circuit logic care rulează în timp constant. (În alte circumstanțe, s-ar putea să vă ajute, de exemplu, prin conversie x = (b ? y : z);
într-o instrucțiune de mutare condiționată, CMOV, fără ramuri, dar de obicei nu vă puteți baza pe bunăvoința compilatorului C.)
Există câteva trucuri pe care le puteți face pentru a contracara acest lucru, cum ar fi un fragment de asamblare inline care face ca compilatorul să renunțe la aproximativ toate ipotezele pentru optimizare:
uint32_t rezultat = 0;
pentru (i = 0; i < 32; i++)
rezultat |= x[i] ^ y[i];
asm volatile ("" ::: "memorie");
return ((rezultat - 1) >> 8) & 1;
Acest lucru poate sau nu să funcționeze cu compilatorul dvs. Pentru a fi încrezător, trebuie să examinați codul mașină generat de compilator... și chiar și atunci, un compilator ar putea efectua optimizări la timp care rescrie codul mașinii conform analizei de profilare, în special în limbaje de nivel superior precum Java. Deci s-ar putea să vrei cu adevărat scrieți logica în asamblare (sau într-un limbaj de programare precum qhasm, care poate genera ansamblul reglat mai fiabil decât un compilator C) și numiți-l din C.
Poate într-o zi, compilatorii C vor adopta a secret
calificativ, ca const
sau volatil
, care forțează compilatorul să genereze numai instrucțiuni de mașină care sunt cunoscute – în anumite modele de CPU! – să ruleze în timp constant atunci când operează pe obiect și împiedică compilatorul să ia comenzi rapide, cum ar fi întreruperi anticipate dependente de secret de la O buclă. Dar acea zi nu este încă aici.
Există, de asemenea, o chestiune despre instrucțiunile de mașină care rulează de fapt în timp constant pe un procesor, care uneori este documentat și uneori de încredere. Deci, pe lângă a face Inginerie pentru a vă construi programele din circuite logice, trebuie să faceți ştiinţă pentru a afla ce operațiuni sunt de fapt sigure de utilizat pe CPU.
Acest lucru ne aduce înapoi la punctul inițial: chiar doriți să vă concentrați efortul de a menține acest lucru într-o rutină de bibliotecă, astfel încât fiecare programator să nu fie nevoit să țină evidența capriciilor compilatoarelor (și a design-urilor CPU!) în codul generat și sincronizare. pe cont propriu și pot lăsa în schimb ursul nostru prietenos de cartier.
Există și alte contramăsuri decât logica în timp constant? Cateodata da.
Ai putea injecta zgomot aleatoriu în logica ta, în speranța că va încurca măsurătorile atacatorului. Dar există deja zgomot în măsurătorile lor, cum ar fi programarea în sistemul de operare, așa că trebuie doar să ia mai multe mostre… și se dovedește că zgomotul este nu este o contramăsură foarte eficientă a canalului lateral.
Mai exact, zgomotul artificial crește costurile atacatorului cu cel mult aproximativ pătratul raportului dintre zgomotul artificial și zgomotul real, ceea ce este mult sub ceea ce este de obicei considerat un decalaj acceptabil pentru securitate în criptografie. Deci, în mare parte, te costă mult timp să nu faci nimic.
Ai putea folosi proprietățile algebrice ale criptosistemului pentru a-l randomiza, uneori numite „orbire”. De exemplu, în loc de calcul y^d mod n
Unde d
este un exponent secret în RSA, ați putea alege r
la întâmplare, calculează s := r^e mod n
Unde e*d â¡ 1 (mod (n))
, înmulțiți y
de s
a obține (y * r^e) mod n
, calculează (y * r^e)^d mod n = (r * y^d) mod n
, apoi împărțiți r
.
Multe implementări, cum ar fi OpenSSL, folosesc această abordare, deoarece este o modalitate ușoară de a moderniza o implementare existentă a unui criptosistem precum RSA care are structura algebrică necesară. Nu este o idee rea, așa cum este zgomotul aleatoriu, dar are costuri: trebuie să faceți munca suplimentară pentru randomizare, trebuie să aveți o diviziune modulară sau o logică de inversare, iar canalele laterale ar putea încă scurge informații despre r
și d
. De exemplu, chiar și exponentiația modulară orbită va scurge greutatea lui Hamming d
cu excepția cazului în care luați contramăsuri suplimentare, cum ar fi adăugarea unui multiplu aleatoriu de (n)
la d
primul... care poate expune canale laterale suplimentare, etc.
Pentru cazul specific de comparare a două șiruri de octeți pentru egalitate (de exemplu, două coduri de autentificare a mesajelor), o opțiune rezonabilă este să le distribuiți prin hash cu o familie de funcții pseudoaleatoare precum HMAC-SHA256 sub o cheie secretă unică. k
, și verificați dacă HMAC-SHA256_k(x) == HMAC-SHA256_k(y)
.
Probabilitatea unui fals pozitiv este 1/2256, care este o probabilitate mai mică decât trebuie să vă faceți griji vreodată. Puteți utiliza în siguranță egalitatea în timp variabil pentru HMAC, deoarece dacă X
este nu egal cu y
, apoi cantitatea de timp chiar și în naiv Rutina de egalitate a șirurilor de octeți (presupunând că nu se salvează la primul octet zero sau ceva stupid de genul acesta!) va fi independentă de valorile lui X
și y
: există o probabilitate de 255/256 să se oprească după o iterație, o probabilitate de 65535/65536 după două iterații, etc.
Desigur, acest lucru vă ajută cu adevărat doar dacă puteți implementa HMAC-SHA256 în timp constant! Din fericire, SHA-256 este proiectat pentru a fi implementat cu ușurință ca circuit logic în timp constant, deci implementările C tind pentru a fi suficient de rezistent la canalele laterale, dar, să zicem, Python vă va pune în probleme din cauza cache-ului întreg mic, dacă nu altceva.
* Terminologia este, din păcate, puțin confuză. Aici timp constant înseamnă că durata de timp nu variază în funcție de intrări, și nu este același cu asimptotic noțiunea de „timp constant” în informatică, adesea scrisă O(1), ceea ce înseamnă doar perioada de timp poate varia în funcție de intrări, dar este delimitată de o constantă. Îmi pare rău. Nu eu am inventat terminologia. S-ar putea să fi ales âfixed-timeâ vs. âtimp variabilâ, dar este prea târziu acumââtimp constantâ este înrădăcinat în literatură.