Aceasta a început ca o modificare a răspunsului lui fgrieu, dar a fost foarte lung, așa că am decis să îl postez ca răspuns separat.
Sunt de acord cu tot ce a scris fgrieu în răspunsul său
Voi cita răspunsul lui fgrieu aici pentru a pune mai mult accent:
Singura soluție aproape de încredere pentru execuția în timp constant într-un limbaj de nivel înalt, fără indicații despre mediul țintă, este: Asigurați-vă că calea de execuție a codului și tiparele de acces la memorie sunt independente de date.
Dacă nu luăm în considerare modelele de acces la memorie, deoarece nu este în domeniul de aplicare al întrebării. Citatul poate numai teoretic poate fi realizat cu timp constant de acces la memorie și execuție constantă în timp a fiecărei instrucțiuni. Desigur, acestea vin cu costul ciclurilor de ceas suplimentare și al acceselor suplimentare la memorie.
Modelele de acces la memorie se referă la un alt tip de atac de canal lateral care necesită Oblivious Random Access Memory (ORAM) pentru a fi securizat împotriva atacurilor de canal lateral.
Dacă funcția_1 și funcția_2 sunt timp constant și durează același
timpul pentru a le calcula, este încă vulnerabil pentru o astfel de ramificare
atacuri pe canale laterale?
Cel mai frecvent da, dacă/altfel este vulnerabil la atacul de sincronizare, deoarece atunci când utilizați o instrucțiune if/else vă puteți aștepta cu ușurință de la compilator sau de la mediul de execuție să execute o ramură. În orice caz, în general, încercați să o evitați.
Selectarea funcției de apelat printr-un index de matrice, ca în răspunsul mentallurg în Python, de exemplu, nu este o abordare atât de proastă, are timp constant de acces la memorie și codul care este executat de Python pentru a efectua apelul funcției este timpul constant.
Înainte să încep.
- În cazul accesărilor la memorie, rețineți că, deoarece ne interesează atacurile de sincronizare, ne interesează doar accesul la memorie timp si nu modele care ar putea scurge informații, deoarece acest lucru ne-ar complica puțin situația.
- Consider cache-urile doar la sfârșit, deocamdată nu luăm în considerare cache-urile, deoarece toată lumea știe ca-urile CPU pot fi cel mai rău coșmar al unui criptograf în astfel de situații.
- Presupun că fiecare instrucțiune care nu include un acces la memorie necesită aceleași cicluri de ceas pentru a se executa.
Analiza răspunsului mentallurg:
Să luăm de exemplu acest cod Python simplu
def f1(x):
returnează x + 1
def f2(x):
returnează x + 2
def main():
funcții = [f1, f2]
x = int(input("Introduceți valoarea:"))
i = x % 2
rezultat = funcții[i](x)
imprimare (rezultat)
if __name__=='__main__':
principal()
Acesta este compilat la următorul bytecode Python (ieșirea pycdas pentru .pyc compilat cu Python 3.10):
[Cod]
Nume fișier: test.py
Nume obiect: principal
Număr de argumente: 0
Număr Arg Numai Poz: 0
Număr KW Doar Arg: 0
Localnici: 4
Dimensiunea stivei: 3
Indicatori: 0x00000043 (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE)
[Nume]
'f1'
'f2'
'int'
'intrare'
'imprimare'
[Nume Var]
'functii'
'X'
'eu'
'rezultat'
[Varie gratuite]
[Cell Vars]
[Constante]
Nici unul
„Introduceți valoarea:”
2
[Dezasamblare]
0 LOAD_GLOBAL 0: f1
2 LOAD_GLOBAL 1: f2
4 BUILD_LIST 2
6 STORE_FAST 0: funcții
8 LOAD_GLOBAL 2: int
10 LOAD_GLOBAL 3: intrare
12 LOAD_CONST 1: „Introduceți valoarea:”
14 CALL_FUNCTION 1
16 CALL_FUNCTION 1
18 STORE_FAST 1: x
20 LOAD_FAST 1: x
22 LOAD_CONST 2: 2
24 BINARY_MODULO
26 STORE_FAST 2: i
28 LOAD_FAST 0: funcții
30 LOAD_FAST 2: i
32 BINARY_SUBSCR
34 LOAD_FAST 1: x
36 CALL_FUNCTION 1
38 STORE_FAST 3: rezultat
40 LOAD_GLOBAL 4: imprimare
42 LOAD_FAST 3: rezultat
44 CALL_FUNCTION 1
46 POP_TOP
48 LOAD_CONST 0: Nici unul
50 RETURN_VALUE
Căutarea și execuția liniei rezultat = funcții[i](x)
se face de la liniile octeți 26-36. Să aruncăm o privire la BINARY_SUBSCR
operator. Este nevoie de două argumente, o listă (poate fi un dict sau un tuplu, dar să nu ne concentrăm asupra acestui lucru) ca primul și un index din stivă (cele care au fost încărcate anterior cu LOAD_FAST
), returnează valoarea la acel indice și scade stiva cu 1.
Acum, să aruncăm o privire cum BINARY_SUBSCR
este implementat în CPython. Implementarea poate fi găsită Aici si este urmatoarea:
ȚINTĂ(BINARY_SUBSCR) {
PREVIZIT(SUBSCR_BINAR);
PyObject *sub = POP();
PyObject *container = TOP();
PyObject *res = PyObject_GetItem(container, sub);
Py_DECREF(container);
Py_DECREF(sub);
SET_TOP(rez);
dacă (res == NULL)
merge la eroare;
JUMPBY(INLINE_CACHE_ENTRIES_BINARY_SUBSCR);
EXPEDIERE();
}
Acum se poate concentra întreaga analiză PyObject *res = PyObject_GetItem(container, sub);
. Aceasta este o metodă generică și, până când elementul este preluat, sunt numite alte metode intermediare. Desigur, ne putem aștepta $O(1)$ complexitate. Rămâne de verificat. La sfarsit PyList_GetItem
se numeste care este urmatorul:
PyObject *
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
dacă (!PyList_Check(op)) {
PyErr_BadInternalCall();
returnează NULL;
}
dacă (!valid_index(i, Py_SIZE(op))) {
_Py_DECLARE_STR(list_err, „indexul listei în afara intervalului”);
PyErr_SetObject(PyExc_IndexError, &_Py_STR(list_err));
returnează NULL;
}
return ((PyListObject *)op) -> ob_item[i];
}
După cum putem vedea pe ultima linie. Are $O(1)$ complexitate.Desigur, datorită complexității limbajelor de nivel înalt, acestea nu sunt niciodată folosite în practică pentru astfel de aplicații. Deci, să încercăm acest cod într-un limbaj de nivel inferior, cum ar fi C, pentru a vedea ce produce.
#include <stdio.h>
int f1(int x) {
întoarce x + 1;
}
int f2(int x) {
întoarce x + 2;
}
int main() {
int x;
scanf("%d", &x);
int (*(funcții[2]))(int) = {f1, f2};
int i;
i = x %2;
int rezultat = functions[i](x);
}
Acesta este x86_64 de la Godbolt cu cel mai recent GCC fără optimizări:
f1:
adiu $sp,$sp,-8
sw $fp,4($sp)
mutare $fp,$sp
sw $4,8($fp)
lw $2,8($fp)
nup
adiu $2,$2,1
mutare $sp,$fp
lw $fp,4($sp)
adiu $sp,$sp,8
jr 31 USD
nup
f2:
adiu $sp,$sp,-8
sw $fp,4($sp)
mutare $fp,$sp
sw $4,8($fp)
lw $2,8($fp)
nup
adiu $2,$2,2
mutare $sp,$fp
lw $fp,4($sp)
adiu $sp,$sp,8
jr 31 USD
nup
$LC0:
.ascii „%d\000”
principal:
adiu $sp,$sp, -56
sw $31,52($sp)
sw $fp, 48($sp)
muta $fp,$sp
adiu $2,$fp, 32
muta $5,2 dolari
lui $2,% salut(0 USD)
adiu $4,$2,%lo($LC0)
jal __isoc99_scanf
nup
lui $2,%hi(f1)
adiu $2,$2,%lo(f1)
sw $2,36($fp)
lui $2,%hi(f2)
adiu $2,$2,%lo(f2)
sw $2,40($fp)
lw $3,32($fp)
li $2,-2147483648 # 0xffffffff80000000
ori $2,$2,0x1
și $2,$3,$2
bgez $2,$L6
nup
adiu $2,$2,-1
li $3,-2 # 0xfffffffffffffffe
sau $2,$2,$3
adiu $2,$2,1
$L6:
sw $2,24($fp)
lw $2,24($fp)
nup
sll $2,2,2 dolari
adiu $3,$fp, 24
addu $2,$3,$2
lw $2,12($2)
lw $3,32($fp)
nup
mutare $4,$3
mutare $25,$2
25 USD
nup
sw $2,28($fp)
mutare $2,$0
mutare $sp,$fp
lw $31,52($sp)
lw $fp,48($sp)
adiu $sp,$sp,56
jr 31 USD
nup
Mai precis, ne interesează aceste instrucțiuni în care se face apelarea la funcția corespunzătoare:
lw $2,24($fp)
nup
sll $2,$2,2
adiu $3,$fp, 24
addu $2,$3,2 dolari
lw $2,12(2 USD)
lw $3,32($fp)
nup
muta $4,3 dolari
muta $25,2 dolari
jalr $25
nup
sw $2,28($fp)
mutare $2,$0
După cum putem vedea, nu se fac ramuri, cu excepția jalr care apelează funcția corespunzătoare.
Analiza raspunsului lui fgrieu
Desigur, este ușor de observat că acesta este un timp constant:
#include <stdio.h>
int f1(int x) {
întoarce x + 1;
}
int f2(int x) {
întoarce x + 2;
}
int main() {
int x;
scanf("%d", &x);
int (*(funcții[2]))(int) = {f1, f2};
int m = -(x&1); // masca variabila m trebuie să aibă același tip ca x
x = (funcția_1(x) și m) | (funcția_2(x) & ~m);
printf(x);
}
Din nou, Goldbolt cu aceleași opțiuni:
f1:
împinge rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
adăugați eax, 1
pop rbp
ret
f2:
împinge rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
adăugați eax, 2
pop rbp
ret
.LC0:
.string „%d”
principal:
împinge rbp
mov rbp, rsp
împinge rbx
sub RSP, 40
lea rax, [rbp-24]
mov rsi, rax
mov edi, OFFSET FLAT:.LC0
mutare eax, 0
apelați __isoc99_scanf
mov QWORD PTR [rbp-48], OFFSET FLAT:f1
mov QWORD PTR [rbp-40], OFFSET FLAT:f2
mov eax, DWORD PTR [rbp-24]
și eax, 1
neg eax
mov DWORD PTR [rbp-20], eax
mov eax, DWORD PTR [rbp-24]
mov edi, eax
mutare eax, 0
funcția de apelare_1
și eax, DWORD PTR [rbp-20]
mov ebx, eax
mov eax, DWORD PTR [rbp-24]
mov edi, eax
mutare eax, 0
apel funcția_2
mov edx, DWORD PTR [rbp-20]
nu edx
și eax, edx
sau eax, ebx
mov DWORD PTR [rbp-24], eax
mov eax, DWORD PTR [rbp-24]
cdqe
mov rdi, rax
mutare eax, 0
apelați printf
mutare eax, 0
mov rbx, QWORD PTR [rbp-8]
părăsi
ret
Ne concentrăm pe această parte în care se face atribuirea lui m și ramificarea inline:
mov eax, DWORD PTR [rbp-24]
și eax, 1
neg eax
mov DWORD PTR [rbp-20], ea
mov eax, DWORD PTR [rbp-24]
mov edi, eax
mutare eax, 0
funcția de apel_1
și eax, DWORD PTR [rbp-20]
mov ebx, eax
mov eax, DWORD PTR [rbp-24]
mov edi, eax
mutare eax, 0
apel funcția_2
mov edx, DWORD PTR [rbp-20]
nu edx
și eax, edx
sau eax, ebx
mov DWORD PTR [rbp-24], eax
Putem vedea de fapt execuția în timp constant.
Deci, care este diferența dintre aceste soluții:
Ambele satisfac măsurile de analiză anti-timing, dar a doua ascunde și tiparele de acces. Apelează întotdeauna ambele funcții. Deoarece nu am menționat cache-urile până acum, în prezența cache-urilor, cel de-al doilea pare mai sigur împotriva atacurilor de canal lateral.Pur și simplu pentru că are nevoie ca codul ambelor funcții să fie în cache pentru a executa instrucțiunea. În al doilea caz, doar cel care este apelat este stocat în cache. Dacă presupunem că pentru o anumită perioadă de timp f1
a fost chemat şi f2
a fost evacuat din cache, ar exista o diferență de timp în care f2
va fi sunat din nou.
Pentru alte soluții și citiri suplimentare pe această temă puteți lua în considerare [1] și [2].