Điều này bắt đầu như một bản chỉnh sửa cho câu trả lời của fgrieu, nhưng nó rất dài, vì vậy tôi quyết định đăng nó dưới dạng một câu trả lời riêng.
Tôi đồng ý với tất cả những gì fgrieu đã viết trong câu trả lời của anh ấy
Tôi sẽ trích dẫn câu trả lời của fgrieu ở đây để nhấn mạnh hơn:
Giải pháp đáng tin cậy tiếp theo duy nhất để thực thi thời gian liên tục bằng ngôn ngữ cấp cao, thiếu dấu hiệu về môi trường đích, là: Đảm bảo đường dẫn thực thi mã và các mẫu truy cập bộ nhớ độc lập với dữ liệu.
Nếu chúng tôi không xem xét các mẫu truy cập bộ nhớ, vì nó không thuộc phạm vi của câu hỏi. các trích dẫn có thể chỉ có về mặt lý thuyết có thể đạt được với thời gian truy cập bộ nhớ không đổi và thời gian thực hiện liên tục của mọi lệnh. Tất nhiên những điều này đi kèm với chi phí của các chu kỳ xung nhịp bổ sung và truy cập bộ nhớ bổ sung.
Các mẫu truy cập bộ nhớ đề cập đến một kiểu tấn công kênh bên khác yêu cầu Bộ nhớ truy cập ngẫu nhiên không rõ ràng (ORAM) để được bảo mật trước các cuộc tấn công kênh bên.
Nếu hàm_1 và hàm_2 là thời gian không đổi và nó mất như nhau
thời gian để tính toán chúng, liệu sự phân nhánh như vậy có còn dễ bị tổn thương đối với
tấn công kênh phụ?
Thông thường nhất là có, nếu/khác dễ bị tấn công theo thời gian, bởi vì khi bạn sử dụng câu lệnh if/else, bạn có thể dễ dàng mong đợi từ trình biên dịch hoặc môi trường thời gian chạy để thực thi một nhánh. Trong mọi trường hợp, bạn thường cố gắng tránh nó.
Việc chọn hàm để gọi theo chỉ mục mảng, chẳng hạn như trong câu trả lời của psyllurg trong Python không phải là cách tiếp cận tồi, nó có thời gian truy cập bộ nhớ không đổi và mã được Python thực thi để thực hiện lệnh gọi hàm là thời gian liên tục.
Trước khi tôi bắt đầu.
- Trong trường hợp truy cập bộ nhớ, lưu ý rằng vì chúng tôi quan tâm đến các cuộc tấn công theo thời gian, nên chúng tôi chỉ quan tâm đến quyền truy cập bộ nhớ thời gian và không hoa văn điều đó có thể làm rò rỉ thông tin vì điều này sẽ làm cho tình hình của chúng ta phức tạp hơn một chút.
- Cuối cùng, tôi chỉ xem xét bộ đệm, hiện tại chúng tôi không xem xét bộ đệm, vì mọi người đều biết bộ đệm CPU có thể là cơn ác mộng tồi tệ nhất đối với người viết mật mã trong những tình huống như vậy.
- Tôi giả sử rằng mỗi lệnh không bao gồm quyền truy cập bộ nhớ sẽ thực hiện cùng một chu kỳ đồng hồ để thực thi.
Phân tích câu trả lời của psychurg:
Hãy lấy mã Python đơn giản này làm ví dụ
xác định f1(x):
trả lại x + 1
xác định f2(x):
trả lại x + 2
chắc chắn chính():
chức năng = [f1, f2]
x = int(input("Nhập giá trị:"))
i = x % 2
kết quả = chức năng [i] (x)
in (kết quả)
nếu __name__=='__main__':
chủ yếu()
Điều này được biên dịch thành mã byte Python sau (đầu ra của pycdas cho .pyc được biên dịch bằng Python 3.10):
[Mã số]
Tên tệp: test.py
Tên đối tượng: chính
Số đối số: 0
Số lượng đối số chỉ Pos: 0
KW Chỉ Arg Đếm: 0
Người dân địa phương: 4
Kích thước ngăn xếp: 3
Cờ: 0x00000043 (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE)
[Tên]
'f1'
'f2'
'int'
'đầu vào'
'in'
[Tên biến]
'chức năng'
'x'
'tôi'
'kết quả'
[Vac miễn phí]
[Tế bào Vars]
[Hằng số]
Không có
'Nhập giá trị:'
2
[Tháo gỡ]
0 LOAD_GLOBAL 0: f1
2 LOAD_GLOBAL 1: f2
4 BUILD_LIST 2
6 CỬA HÀNG_FAST 0: chức năng
8 LOAD_GLOBAL 2: int
10 LOAD_GLOBAL 3: đầu vào
12 LOAD_CONST 1: 'Nhập giá trị:'
14 GỌI_CHỨC NĂNG 1
16 GỌI_FUNCTION 1
18 CỬA HÀNG_FAST 1: x
20 LOAD_FAST 1: x
22 LOAD_CONST 2: 2
24 BINARY_MODULO
26 CỬA HÀNG_FAST 2: tôi
28 LOAD_FAST 0: chức năng
30 LOAD_FAST 2: tôi
32 BINARY_SUBSCR
34 LOAD_FAST 1: x
36 GỌI_FUNCTION 1
38 STORE_FAST 3: kết quả
40 LOAD_GLOBAL 4: in
42 LOAD_FAST 3: kết quả
44 GỌI_FUNCTION 1
46 POP_TOP
48 LOAD_CONST 0: Không
50 RETURN_VALUE
Việc tra cứu và thực hiện của dòng kết quả = chức năng [i] (x)
được thực hiện từ byte dòng 26-36. Chúng ta hãy nhìn vào BINARY_SUBSCR
nhà điều hành. Nó nhận hai đối số, một danh sách (nó có thể là một lệnh hoặc bộ nhưng chúng ta đừng tập trung vào điều này) làm đối số đầu tiên và một chỉ mục từ ngăn xếp (những đối số đã được tải trước đó bằng LOAD_FAST
), trả về giá trị tại chỉ mục đó và giảm ngăn xếp đi 1.
Bây giờ, chúng ta hãy xem làm thế nào BINARY_SUBSCR
được triển khai trong CPython. Việc thực hiện có thể được tìm thấy đây và là như sau:
MỤC TIÊU(BINARY_SUBSCR) {
PREDICTED(BINARY_SUBSCR);
PyObject *sub = POP();
PyObject * container = TOP();
PyObject *res = PyObject_GetItem(container, sub);
Py_DECREF(vùng chứa);
Py_DECREF(phụ);
SET_TOP(độ phân giải);
nếu (độ phân giải == NULL)
lỗi goto;
JUMPBY(INLINE_CACHE_ENTRIES_BINARY_SUBSCR);
GỬI ĐI();
}
Bây giờ toàn bộ phân tích có thể được tập trung vào PyObject *res = PyObject_GetItem(container, sub);
. Đây là một phương thức chung và cho đến khi mục được truy xuất, nhiều phương thức khác được gọi trong phương thức trung gian. Tất nhiên chúng ta có thể mong đợi $O(1)$ sự phức tạp. Nó vẫn còn để kiểm tra nó. Cuối cùng PyList_GetItem
được gọi là như sau:
PyObject *
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
nếu (!PyList_Check(op)) {
PyErr_BadInternalCall();
trả về NULL;
}
if (!valid_index(i, Py_SIZE(op))) {
_Py_DECLARE_STR(list_err, "danh sách chỉ mục nằm ngoài phạm vi");
PyErr_SetObject(PyExc_IndexError, &_Py_STR(list_err));
trả về NULL;
}
return ((PyListObject *)op) -> ob_item[i];
}
Như chúng ta có thể thấy ở dòng cuối cùng. Nó có $O(1)$ sự phức tạp.Tất nhiên, do sự phức tạp của các ngôn ngữ cấp cao, chúng không bao giờ được sử dụng trong thực tế cho các ứng dụng như vậy. Vì vậy, hãy thử mã này bằng ngôn ngữ cấp thấp hơn, chẳng hạn như C, để xem nó tạo ra kết quả gì.
#include <stdio.h>
int f1(int x) {
trả về x + 1;
}
int f2(int x) {
trả về x + 2;
}
int main() {
intx;
scanf("%d", &x);
int (*(hàm[2]))(int) = {f1, f2};
int tôi;
tôi = x %2;
kết quả int = hàm [i] (x);
}
Đây là x86_64 từ Godbolt với GCC mới nhất mà không cần tối ưu hóa:
F1:
thêm vào $sp,$sp,-8
sw $fp,4($sp)
di chuyển $fp,$sp
sw $4,8($fp)
tôi $2,8($fp)
ngủ gật
thêm vào $2,$2,1
di chuyển $sp,$fp
tôi $fp,4($sp)
thêm vào $sp,$sp,8
$31
ngủ gật
f2:
thêm vào $sp,$sp,-8
sw $fp,4($sp)
di chuyển $fp,$sp
sw $4,8($fp)
tôi $2,8($fp)
ngủ gật
thêm vào $2,$2,2
di chuyển $sp,$fp
tôi $fp,4($sp)
thêm vào $sp,$sp,8
$31
ngủ gật
$LC0:
.ascii "%d\000"
chủ yếu:
thêm $sp,$sp,-56
sw $31,52($sp)
sw $fp,48($sp)
di chuyển $fp,$sp
thêm $2,$fp,32
di chuyển $5,$2
lui $2,% hi($LC0)
thêm $4,$2,%lo($LC0)
jal __isoc99_scanf
ngủ gật
lui $2,%hi(f1)
thêm $2,$2,%lo(f1)
sw $2,36($fp)
lui $2,%hi(f2)
thêm vào $2,$2,%lo(f2)
sw $2,40($fp)
tôi $3,32($fp)
li $2,-2147483648 # 0xffffffff80000000
giá trị $2,$2,0x1
và $2,$3,$2
bgez $2,$L6
ngủ gật
thêm vào $2,$2,-1
li $3,-2 # 0xffffffffffffffffe
hoặc $2,$2,$3
thêm vào $2,$2,1
$L6:
sw $2,24($fp)
tôi $2,24($fp)
ngủ gật
sll $2,$2,2
thêm $3,$fp,24
thêm $2,$3,$2
tôi $2,12($2)
tôi $3,32($fp)
ngủ gật
di chuyển $4,$3
di chuyển $25,$2
jalr $25
ngủ gật
sw $2,28($fp)
di chuyển $2,$0
di chuyển $sp,$fp
tôi $31,52($sp)
tôi $fp,48($sp)
thêm vào $sp,$sp,56
$31
ngủ gật
Cụ thể hơn, chúng tôi quan tâm đến các hướng dẫn này trong đó lệnh gọi được thực hiện cho chức năng thích hợp:
tôi $2,24($fp)
ngủ gật
sll $2,$2,2
thêm vào $3,$fp,24
thêm bạn $2,$3,$2
tôi $2,12($2)
tôi $3,32($fp)
ngủ gật
di chuyển $4,$3
di chuyển $25,$2
jalr $25
ngủ gật
sw $2,28($fp)
di chuyển $2,$0
Như chúng ta có thể thấy không có nhánh nào được tạo, ngoại trừ từ jalr gọi hàm thích hợp.
Phân tích câu trả lời của fgrieu
Tất nhiên, dễ dàng thấy rằng đây là thời gian không đổi:
#include <stdio.h>
int f1(int x) {
trả về x + 1;
}
int f2(int x) {
trả về x + 2;
}
int main() {
intx;
scanf("%d", &x);
int (*(hàm[2]))(int) = {f1, f2};
int m = -(x&1); // mặt nạ biến m phải cùng kiểu với x
x = (hàm_1(x) & m) | (hàm_2(x) & ~m);
printf(x);
}
Một lần nữa, Goldbolt với các tùy chọn tương tự:
f1:
đẩy rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
thêm eax, 1
nhạc pop
rút lui
f2:
đẩy rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
thêm eax, 2
nhạc pop
rút lui
.LC0:
.chuỗi "%d"
chủ yếu:
đẩy rbp
mov rbp, rsp
đẩy rbx
rsp phụ, 40
lea rax, [rbp-24]
mov rsi, rax
mov edi, BÙM PHẲNG:.LC0
di chuyển eax, 0
gọi __isoc99_scanf
mov QWORD PTR [rbp-48], BÙM PHẲNG: f1
mov QWORD PTR [rbp-40], BÙM PHẲNG: f2
mov eax, DWORD PTR [rbp-24]
và eax, 1
tiêu cực
mov DWORD PTR [rbp-20], eax
mov eax, DWORD PTR [rbp-24]
di chuyển edi, eax
di chuyển eax, 0
chức năng gọi_1
và eax, DWORD PTR [rbp-20]
di chuyển ebx, eax
mov eax, DWORD PTR [rbp-24]
di chuyển edi, eax
di chuyển eax, 0
chức năng gọi_2
mov edx, DWORD PTR [rbp-20]
không phải edx
và eax, edx
hoặc eax, ebx
mov DWORD PTR [rbp-24], eax
mov eax, DWORD PTR [rbp-24]
cdqe
mov rdi, rax
di chuyển eax, 0
gọi printf
di chuyển eax, 0
mov rbx, QWORD PTR [rbp-8]
rời khỏi
rút lui
Chúng tôi tập trung vào phần này khi việc gán cho m được thực hiện và phân nhánh nội tuyến:
mov eax, DWORD PTR [rbp-24]
và eax, 1
tiêu cực
mov DWORD PTR [rbp-20], ea
mov eax, DWORD PTR [rbp-24]
di chuyển edi, eax
di chuyển eax, 0
chức năng gọi_1
và eax, DWORD PTR [rbp-20]
di chuyển ebx, eax
mov eax, DWORD PTR [rbp-24]
di chuyển edi, eax
di chuyển eax, 0
chức năng gọi_2
mov edx, DWORD PTR [rbp-20]
không phải edx
và eax, edx
hoặc eax, ebx
mov DWORD PTR [rbp-24], eax
Chúng ta thực sự có thể thấy việc thực hiện thời gian liên tục.
Vì vậy, sự khác biệt giữa các giải pháp này là gì:
Cả hai đều đáp ứng các biện pháp phân tích chống thời gian nhưng biện pháp thứ hai cũng ẩn các mẫu truy cập. Nó luôn gọi cả hai chức năng. Vì chúng tôi chưa đề cập đến bộ đệm cho đến nay, nên với sự hiện diện của bộ đệm, bộ đệm thứ hai có vẻ an toàn hơn trước các cuộc tấn công kênh phụ theo thời gian.Đơn giản vì nó cần mã của cả hai chức năng trong bộ đệm để thực thi lệnh. Trong trường hợp thứ hai, chỉ cái đang được gọi được lưu vào bộ đệm. Nếu chúng ta giả sử rằng trong một khoảng thời gian cụ thể f1
đã được gọi và f2
bị xóa khỏi bộ đệm, sẽ có sự khác biệt về thời gian khi f2
sẽ được gọi lại.
Đối với các giải pháp khác và đọc thêm về vấn đề này, bạn có thể xem xét [1] và [2].