Lỗi Meltdown và Spectre hoạt động thế nào?


Một trong những thông tin nổi bật gần đây là hai lỗi có trong các bộ xử lý hiện đại, với cái tên Meltdown và Spectre, hai lỗi này xuất hiện trong rất nhiều thế hệ vi xử lý, và nguy hiểm nhất là nó cho phép các chương trình có thể truy xuất tùy ý các vùng bộ nhớ được bảo vệ bởi hệ điều hành. Trong bài viết này mình sẽ cố gắng giải thích một cách dễ hiểu nhất về cách hoạt động của hai lỗi này.

Bài viết này dựa trên thông tin có trong trang https://www.raspberrypi.org/blog/why-raspberry-pi-isnt-vulnerable-to-spectre-or-meltdown/https://spectreattack.com, đặc biệt các ví dụ sẽ mượn từ bài viết trên trang RaspberryPi.

Trước tiên chúng ta lần lượt đi qua một số khái niệm và cơ chế hoạt động của các bộ xử lý (một số từ chuyên môn sẽ không được dịch vì rất khó tìm từ phù hợp), chúng ta cũng sẽ dùng ví dụ sau để minh họa cho các khái niệm có trong bài viết này:

t = a+b
u = c+d
v = e+f
w = v+g
x = h+i
y = j+k

1. Bộ xử lý (processor – CPU) scalar (vô hướng) là gì?

Đây là dạng vi xử lý có khả năng xử lý tuần tự một câu lệnh trên mỗi nhịp đồng hồ, với ví dụ trên một scalar processor sẽ mất 6 nhịp đồng hồ để tính toán.

Vài ví dụ cho loại vi xử lý này là Intel 486 và ARM1176 được dùng trong Raspberry 1 và Raspberry Zero.

2. Bộ xử lý superscalar (siêu hướng) là gì?

Rõ ràng với bộ xử lý vô hướng, chỉ có một cách duy nhất làm tăng tốc độ tính toán là tăng tốc độ đồng hồ, tuy nhiên chúng ta sẽ sớm đạt đến ngưỡng xử lý của các cổng logic bên trong bộ xử lý, do vậy các nhà thiết kế bắt đầu nghĩ đến việc cho phép thực hiện các câu lệnh cùng lúc.

Một bộ xử lý siêu hướng có thứ tự (in-order superscalar processor) sẽ khảo sát các câu lệnh đang chờ và cố gắng thực thi cùng lúc nhiều hơn một câu lệnh, bằng cách xem xét sự phụ thuộc giữa các câu lệnh với nhau, nó có thể biết được liệu các câu lệnh cho trước có thể được ghép lại (thành hai hay nhiều hơn hai) luồng xử lý song song. Sự phụ thuộc này rất quan trọng, bởi ta không thể chỉ đơn giản ghép từng cặp câu lệnh và yêu cầu hai luồng xử lý đồng thời như ví dụ sau:

t, u = a+b, c+d
v, w = e+f, v+g
x, y = h+i, j+k

Việc ghép cặp lại một cách đơn giản như trên là vô nghĩa, bởi phải tính v trước rồi mới tính giá trị của w, hay nói cách khác w phụ thuộc v.

Do vậy, các bộ xử lý vô hướng sẽ phân tích và tạo ra hai luồng xử lý đồng thời các phép tính như sau:

t, u = a+b, c+d
v    = e+f                   # luồng xử lý thứ hai không làm gì trong nhịp đồng hồ này
w, x = v+g, h+i
y    = j+k

Các bộ xử lý vô hướng tiêu biểu có thể kể đến là Intel Pentium, ARM-Cortex A7, ARM-Cortex A53.

Raspberry 3 với bộ xử lý ARM-Cortex A53 có xung nhịp cao hơn Raspberry 2 (ARM-Cortex A7) 33%, nhưng lại có hiệu năng cao hơn gần gấp đôi là nhờ khả năng phân tích và song song hóa được nhiều câu lệnh hơn ARM-Cortex A7.

3. Bộ xử lý out-of-order (không theo thứ tự) là gì?

Trở lại với ví dụ của chúng ta, mặc dù có sự phụ thuộc giữa v và w, ta có thể thấy những câu lệnh khác hoàn toàn có thể được thế vào luồng trống để tận dụng hết xung nhịp thứ hai.

Một out-of-order processor có thể hoán đổi vị trí các câu lệnh tính toán w và x như sau:

t = a+b
u = c+d
v = e+f
x = h+i
w = v+g
y = j+k

Các mã lệnh này sau đó có thể được thực thi song song.

Các bộ xử lý out-of-order có thể kể đến là Intel Pentium 2 và gần như toàn bộ những bộ xử lý sau đó, cũng như nhiều bộ xử lý ARM hiện đại sau này như Cortex-A9, -A15, -A17, và -A57.

4. Bộ dự đoán rẽ nhánh (branch predictor) là gì?

Các ví dụ phía trên minh họa một chuỗi lệnh tuần tự, nhưng trong thực tế các luồng xử lý lại không giống như vậy, chúng thường có nhiều nhánh khác nhau và dựa vào một điều kiện nào đó để xác định tiếp tục thực thi nhánh nào (mệnh đề if).

Để tránh việc chờ đợi đến khi có kết quả, các vi xử lý hiện đại sẽ có một bộ dự đoán rẽ nhánh để đoán trước xem chương trình sẽ tiếp tục thực hiện trên nhánh nào, nó làm được điều này dựa vào các thống kê trên những lần rẽ nhánh trước đó (các nhánh nào thường xuyên được chọn).

5. Speculation (suy đoán) là gì?

Thay đổi thứ tự thực hiện các mã lệnh là một cách rất tốt để song song hóa việc thực thi, nhưng với sự cải tiến của các bộ xử lý ngày nay, với số luồng xử lý ngày càng nhiều hơn, việc giữ cho các luồng xử lý luôn bận rộn (để tận dụng thời gian) là rất khó. Do vậy các bộ xử lý sau này đã được thêm vào khả năng speculation: xử lý cả những câu lệnh vốn có thể không được thực thi (thay vì chỉ xử lý trước luồng (dự đoán) có thể được thực thi thì nay sẽ xử lý cả 2 nhánh của if), điều này tận dụng năng lực xử lý của tất cả các luồng xử lý, nếu một nhánh nào đó không được thực thi, bộ xử lý chỉ đơn giản không dùng đến kết quả của nó.

Việc thực thi toàn bộ như vậy có thể sẽ làm tăng năng lượng tiêu thụ, nhưng mang hiệu quả xứng đáng.

Để minh họa tính năng speculation, bạn có thể xem ví dụ dưới đây:

t = a+b
u = t+c
v = u+d
if v:
   w = e+f
   x = w+g
   y = x+h

Giờ chúng ta có sự phụ thuộc từ t đến u đến v, và từ w đến x đến y, do vậy một bộ xử lý không có tính năng suy đoán speculation sẽ không biết cách nào thực thi trên luồng thứ hai. Và nếu mệnh đề if chiếm một nhịp đồng hồ, đoạn lệnh trên sẽ mất 4 hoặc 7 nhịp tùy thuộc vào v có khác 0 hay không.

Tuy nhiên, một bộ xử lý có speculation sẽ phân tích đoạn lệnh trên thành:

t = a+b
u = t+c
v = u+d
w_ = e+f
x_ = w_+g
y_ = x_+h
if v:
   w, x, y = w_, x_, y_

Và khi chuyển thành song song, nó sẽ tương tự như sau:

t, w_ = a+b, e+f
u, x_ = t+c, w_+g
v, y_ = u+d, x_+h
if v:
   w, x, y = w_, x_, y_

6. Bộ nhớ đệm (cache) là gì?

Do thời gian truy xuất bộ nhớ chính rất chậm so với khả năng xử lý của CPU nên các lệnh đọc ghi chiếm thời gian rất lớn, ví dụ với Cortex-A53, thời gian thực thi một mã lệnh chỉ khoảng 0.5ns, trong khi mất tới 100ns để truy xuất vào bộ nhớ, chính vì vậy trong các bộ xử lý thường có thêm vùng nhớ đệm nằm ngay bên cạnh bộ xử lý giúp tăng tốc quá trình này lên.

Thông thường khi truy cập vào một địa chỉ nào đó, vùng nhớ đó và các vùng nhớ lân cận sẽ được đọc vào trong bộ nhớ đệm, các câu lệnh tiếp theo nếu truy xuất vào vùng nhớ đã có sẵn trong cache sẽ nhanh hơn rất nhiều, trong ví dụ sau, tổng thời gian thực thi sẽ chỉ lớn hơn 100ns một chút:

a = mem[0]    # độ trễ 100ns, sao chép toàn bộ mem[0:15] vào bộ nhớ đệm
b = mem[1]    # mem[1] đã có sẵn trong bộ nhớ đệm

Dựa vào thời gian truy xuất này, người ta có thể biết được liệu một vùng nhớ có vừa được truy cập hay không (nếu hơn 100ns có nghĩa vùng nhớ này chưa được truy cập), đây là một trong những cơ sở tạo nên các lỗi Meltdown và Spectre.

7. Side channel:

Một phương pháp tấn công dạng side channel dựa trên thông tin có được từ các hiệu ứng phụ trong quá trình thực thi phương pháp bảo mật, chứ không phải do cách vét cạn (brute-force) hay do điểm yếu của phương pháp mã hóa. Những thông tin đó có thể là thời gian, lượng điện tiêu thụ, âm thanh/từ tính phát ra trong quá trình mã hóa/giải mã…

Spectre và Meltdown là các phương pháp tấn công dạng side channel do nó dùng yếu tố thời gian để xác định giá trị của một thực thể trong bộ nhớ (nếu thời gian truy cập một vùng nhớ là nhanh, có nghĩa nó có trong cache, đồng nghĩa với việc địa chỉ ô nhớ truy cập trước đó là một giá trị xác định trước).

8. Kết nối tất cả lại với nhau:

Giờ chúng ta xem thử bằng cách nào speculation và caching có thể kết hợp lại để tạo nên những lỗ hổng như Meltdown và Spectre.

Cho ví dụ sau:

t = a+b
u = t+c
v = u+d
if v:
   w = kern_mem[address]   # nếu thực thi câu lệnh này sẽ phát sinh lỗi (vì vùng bộ nhớ kernel là được bảo vệ)
   x = w&0x100
   y = user_mem[x]

Giờ chúng ta sẽ bằng cách nào đó làm cho bộ dự đoán rẽ nhánh nghĩ rằng lần truy cập vào v kế tiếp sẽ mang lại giá trị <> 0  (xem lại phần 4, nếu hầu hết các lần kiểm tra giá trị của v trước đây đều <> 0, bộ dự đoán rẽ nhánh sẽ coi nó là <> 0 trong lần này).

t, w_ = a+b, kern_mem[address]
u, x_ = t+c, w_&0x100
v, y_ = u+d, user_mem[x_]

if v:
   # fault
   w, x, y = w_, x_, y_      # chúng ta không bao giờ đi đến đây

Dù rằng bộ xử lý suy đoán rằng sẽ đọc từ bộ nhớ của hệ điều hành (dich từ kernel memory – không chính xác lắm nhưng dịch tạm cho dễ hiểu), nhưng việc đọc này vẫn được coi là an toàn vì:

  • Nếu v = 0, luồng xử lý sẽ không bao giờ gặp câu lệnh trong if và kết quả đơn giản là bị hủy bỏ.
  • Nếu v <> 0, một lỗi sẽ phát ra ngay trước khi các giá trị đã đọc được gán vào cho w.

Tuy nhiên giả sử chúng ta đã dọn bộ nhớ cache trước khi thực thi, và sắp xếp các giá trị của a, b, c, d sao cho v = 0, khi đó, phụ thuộc vào giá trị có trong w_, câu lệnh thứ 3 sẽ truy cập vào vùng nhớ 0x000 hoặc 0x100.

v, y_ = u+d, user_mem[x_]

Vì v = 0 nên các giá trị sẽ đơn giản bị bỏ đi mà không đưa vào w, x và y (cũng không phát sinh lỗi truy cập vì phần thân if không được thực thi).

Xin chúc mừng bạn vừa đọc được một bit từ phần bộ nhớ của hệ điều hành.

Để tạo nên lỗi Meltdown trong thực tế phức tạp hơn đáng kể, tuy nhiên về cơ bản là hoàn toàn giống như trên. Spectre có cùng cách tiếp cận với mục đích vượt qua các kiểm tra giới hạn các mảng (array boundary checks, ví dụ truy cập vào m[20] của một mảng m với chỉ 10 phần tử).

Meltdown và Specture là hai lỗi liên quan thiết kế hệ thống và xuất hiện trong rất nhiều thế hệ CPU hiện đại, mình cũng chưa rõ các bản vá hoạt động như thế nào (các bản vá cho Windows và MacOS là mã nguồn đóng), tuy nhiên theo thông tin tại https://lwn.net/Articles/738975/, bản vá cho Linux có tên Kaiser sẽ hoạt động bằng cách duy trì hai page table, một page table đầy đủ như trước giờ với tất cả ánh xạ địa chỉ ảo vào bộ nhớ thực – bao gồm vùng bộ nhớ user và cả kernel, một page table rút gọn trong đó vùng ánh xạ vào kernel chỉ chứa các địa chỉ dùng cho các lời gọi hệ thống. Page table đầy đủ chỉ được kích hoạt khi hoạt động trong kernel mode và ngược lại.

9. Tổng kết

Bằng cách duy trì hai page table như vậy, khi ở user mode, ứng dụng không có cách nào truy cập vào các vùng nhớ trong kernel mode (giống như một căn nhà không có cửa), tuy nhiên việc này cũng làm giảm hiệu năng hệ thống.

Bạn có thể xem demo về việc dùng Meltdown để đọc dữ liệu trong vùng nhớ kernel:

 

Leave a comment