Khai thác “vượt thời gian” với Meltdown
Hôm nay lướt web thấy một bài giải thích rất rõ ràng về lỗ hổng Meltdown - lỗ hổng trên CPU của Intel, Apple mà báo chí đang đưa mấy tuần qua. Mình đã đọc paper về Meltdown nhưng thấy khó hiểu quá, may có bài này của bạn m4n0w4r (https://tradahacking.vn/@kienbigmummy) đỡ phải tìm hiểu tiếp
Nguồn: https://tradahacking.vn/khai-thác-vượt-thời-gian-với-meltdown-37f7fe2e30d2
Trong tuần vừa qua đã có rất nhiều các suy đoán liên quan tới hai lỗ hổng mới được công bố (https://googleprojectzero.blogspot.sg/2018/01/reading-privileged-memory-with-side.html) của bộ vi xử lý , các chuyên gia đặt tên cho hai lỗi này là Meltdown và Spectre (Hai cái tên mà tôi nghĩ với nhiều người, ngay cả tôi đọc cũng méo mồm và khó hiểu ). Trong bài này tôi sẽ nói về lỗi có tên Meltdown. Về lỗi Spectre sẽ trình bày ở một bài viết khác.
Trước tiên: Nếu bạn là những người sử dụng máy tính bình thường (nôm na là chỉ dùng vào việc gõ văn bản, chơi game, xem phim, nghe nhạc…) thì bạn chả cần phải làm gì đặc biệt cả. Chỉ việc cài đặt bản cập nhật cho hệ điều hành và phần mềm của bạn thì bạn sẽ ổn thôi. Bạn không nhất thiết phải sắm một máy tính mới hay vứt bỏ bộ vi xử lý đang dùng. Các chuyên gia có khuyến cáo rằng các bản vá lỗi có thể sẽ làm chậm máy tính của bạn nhưng chỉ một chút thôi, và vì bạn là người dùng bình thường nên sẽ không cảm nhận thấy được sự sụt giảm hiệu năng này . Túm váy lại là chỉ việc cập nhật OS và phần mềm, mọi thứ rồi sẽ ổn thôi…
Còn nếu bạn đang là nhân viên kĩ thuật của một công ty, các máy ảo của bạn sẽ cần phải khởi động lại ngay bây giờ. Bởi các hãng cung cấp giải pháp điện toán đám mây đang tiến hành việc sửa lỗi cho các hypervisors, nhưng tốt nhất để an toàn hơn bạn vẫn nên tiến hành cập nhật bản vá cho các hệ điều hành của các máy ảo mà bạn đang quản lý. Ví dụ như Azure đã thực hiện một số “điểu chỉnh” đặc biệt, cho nên ngay cả khi các guest OS của bạn chưa được cập nhật, bạn sẽ không bị ảnh hưởng bởi lỗi này, nhưng Amazon thì chắc chắn không, và nếu bạn không cài đặt các bản cập nhật bảo mật trên các máy ảo Azure thì bạn là một người điên và vấn đề chỉ là thời gian mà thôi, trước khi bạn thấy dữ liệu khách hàng của mình được phệt thẳng trên pastebin. Thế cho nên, hãy xách mông lên , kiểm tra và cài đặt các bản cập nhật đi...
Các chuyên gia nói với nhau đây là một lỗi rất thú vị ngay cả khi bạn không cần phải làm bất cứ điều gì đặc biệt để sửa nó. Vậy thì cách thức nó hoạt động ra sao?
Nếu bạn là người thích nghịch code hơn là muốn đọc những lời giải thích dông dài, thì xin lỗi, chắc bạn bị điên, tác giả bài viết (không phải tôi nhé!!) tặng các bạn mã khai thác (https://pastebin.com/raw/fDHrAEuZ) mà tác giả đã viết. Còn nếu bạn thích đọc giải thích hơn, xin tiếp tục…
“Du hành thời gian” với các bộ vi xử lý hiện đại
Các bộ vi xử lý hiện đại mà chúng ta đang dùng hàng ngày khá phức tạp. Chúng sử dụng một loạt các “tiểu xảo” nhằm để thử và ép đạt hiệu năng cao hơn vượt ra khỏi các ứng dụng phần mềm.
Đã từ rất lâu, các nhà thiết kế vi xử lý bắt đầu biến mã ứng dụng thành một “đường ống” hoạt động. Nếu bộ vi xử lý gặp một lệnh tốn khoảng thời gian dài để thực hiện —ví dụ, truy cập bộ nhớ từ RAM —ngay lập tức, để tránh thời gian nhàn rỗi phải chờ đợi để hoàn thành lệnh, nó có thể tiếp tục thực hiện các lệnh tiếp theo.
Vấn đề xảy ra nếu như câu lệnh đầu tin thực hiện sai điều gì đó, giả dụ như cố gắng truy cập vào một địa chỉ bộ nhớ không tồn tại, sau khi nó đã tiến hành thực thi câu lệnh phía sau.
Chuyện gì sẽ xảy ra nếu ta thực hiện các lệnh theo đúng trình tự? Điều này khá đơn giản. Nếu lệnh đầu tiên gặp “lỗi”, lệnh thứ hai sẽ không bao giờ thực thi, bộ vi xử lý sẽ thông báo cho hệ điều hành, để từ đó lựa chọn ra các phản ứng phù hợp.
Bỏ qua cái tên, những “lỗi” này là hành động bình thường. Nếu có lỗi xảy ra trong quá trình thực thi của một chương trình bình thường, hệ điều hành quan sát xem chương trình có khả năng xử lý lỗi không và nếu không thể, hệ điều hành sẽ khiến cho tiến trình “crash”. Việc gây crash có thể là sự kết thúc đối với các chương trình có lỗi, nhưng còn hệ điều hành và bộ xử lý thì vẫn tiếp tục làm việc. Đó là lý do tại sao bạn thấy chương trình của mình “bay hơi”, nhưng màn hình desktop vẫn hiển thị, thay vì chứng kiến cảnh máy tính phải khởi động lại.
Tuy nhiên, mọi thứ bắt đầu trở nên phức tạp hơn nếu như bộ vi xử lý thực hiện nhiều câu lệnh cùng lúc. Nếu bộ xử lý gặp một lệnh truy cập bộ nhớ, bắt đầu thực thi lệnh tiếp theo sau lệnh này và chỉ sau đó nó mới phát hiện ra rằng lệnh nạp bộ nhớ đầu tiên đã bị lỗi, thì điều gì sẽ xảy ra sau đó?
Câu trả lời là bộ vi xử lý phải “hủy” các lệnh đã thực hiện phía sau lệnh mà lỗi logic đã xảy ra. Bằng cách này, hệ điều hành “nhìn” thấy chương trình như thể mỗi lệnh đã thực hiện theo đúng trình tự. Cho tới khi mà hệ điều hành được xem là có liên quan trong việc lệnh đầu tiên được thực thi còn lệnh thứ hai thì không.
Các bộ vi xử lý hiện đại không chỉ thực hiện thêm một câu lệnh tại một thời điểm. Nó thực thi rất nhiều câu lệnh cùng lúc, mỗi lệnh ép hiệu năng bổ sung thay vì dừng lại và chờ đợi các lệnh trước hoàn thành tác vụ. Điều này có nghĩa là các bộ vi xử lý luôn thực hiện một số lượng lớn các lệnh trong một “đường ống” hoạt động và nó chỉ dừng lại khi phát hiện ra lỗi ở một trong các lệnh và sau đó phải “hủy” tất cả các lệnh đã thực hiện theo dự đoán ở phía sau lệnh gây lỗi.
Sau một thời gian, các nhà thiết kế vi xử lý đã phát hiện ra họ có thể sử dụng nguyên tắc này cho một “tiểu xảo” khéo léo khác để thu được hiệu năng cao hơn từ chương trình. Trình tự code xuất hiện trong các chuỗi lệnh nhỏ (được gọi là “các khối cơ bản”) sau đó là nhảy tới đoạn code khác. Kĩ thuật “đường ống” thực sự tốt khi thực thi các khối lệnh này. Nhưng điều gì sẽ xảy ra khi bộ xử lý thực hiện đến cuối khối và cần phải nhảy tới một nơi khác? Việc chờ đợi để “đường ống” hoàn thành rất tốn kém; chúng ta không thích phải chờ đợi.
Nếu đó là lệnh nhảy không điều kiện, bộ xử lý chỉ việc đi qua, nạp các lệnh mới từ khối tiếp theo và bổ sung chúng vào cuối “đường ống”.
Nhưng nếu là lệnh nhảy có điều kiện thì liệu nó vẫn tiếp tục làm việc theo cách trên thông qua “đường ống”? Trong một thế giới “có trật tự”, bộ xử lý sẽ đợi. Sau đó, nó xác định lệnh nhảy có điều kiện có được thực hiện hay là không, và căn cứ vào kết quả để nhảy tới nhánh hợp lệ . Nhưng “vùng đất” của các bộ vi xử lý không phải là một thế giới “trật tự”, việc phải chờ đợi trong nhàn rỗi là điều cấm kị.
Vậy thì bộ xử lý làm những gì? Nó sẽ “phỏng đoán”. Khi quá trình xử lý giá trị điều kiện cuối cùng được xác định trong “đường ống”, bộ xử lý có thể sau đó quay lại và hỏi liệu việc phỏng đoán có đúng không. Nếu đúng, thì xin chúc mừng! Chúng ta đã tránh được việc chờ đợi và đã ép thêm hiệu năng của bộ xử lý bằng cách làm như vậy. Nếu phỏng đoán sai, chỉ cần hủy tất cả các lệnh hiện tại đã thực hiện trong nhánh sai và sau đó lựa chọn nhánh lệnh đúng để thực hiện.
Như vậy, có thể thấy nếu việc phòng đoán là đúng, hiệu năng thực hiện của bộ vi xử lý được tăng lên. Nếu sai, thì nó chỉ lãng phí một khoảng thời gian mà lẽ ra nếu không làm trước thì sẽ phải chờ đợi.
Vậy thì một điều hiển nhiên, càng thường xuyên đoán đúng điều kiện càng “giành chiến thắng” nhiều hơn và có được hiệu năng cao hơn. Việc này cho thấy rằng khi tới dự đoán nhánh thực hiện, hiệu năng trong quá khứ là một chỉ số khá tin cậy của hiệu năng trong tương lai. Do vậy, các bộ vi xử lý hiện đại có một bộ “dự đoán nhánh”, có nhiệm vụ “tìm hiểu” các nhánh đã được thực hiện dựa trên dữ liệu thực nghiệm, và sử dụng thông tin đó để dự đoán tốt hơn các nhánh trong tương lai.
Quá tuyệt! Tới đây ta đã biết rõ hơn về cách hoạt động của bộ vi xử lý hiện đại qua đó để hiểu và khai thác lỗi Meltdown.
Meltdown
Lỗi “Meltdown” xảy ra nếu có một truy cập vào bộ nhớ không hợp lệ diễn ra trong khi một nhánh suy đoán (speculative path) phải bị hủy bỏ. Quan sát đoạn mã giả dưới đây:
Trước tiên, ta quan sát nếu hoạt động trong một thế giới có “trật tự” thì bộ xử lý thực thi từng lệnh một như thế nào?
Đầu tiên, bộ xử lý thực hiện biểu thức expr(), gán kết quả thu được cho [imath]A[/B]. Tiếp theo, bộ vi xử lý hỏi xem giá trị của [B][/imath]A có là 1 không. Nếu đúng, nó sẽ thực hiện các lệnh trong khối { }. Lệnh đầu tiên sẽ nạp một địa chỉ nào đó vào [imath]B[/B]. Lúc này, ở lệnh tiếp theo [B][/imath]B được xem như là một địa chỉ và được lấy từ bộ nhớ RAM gán cho $C.
Nếu địa chỉ không hợp lệ thì sao? Nếu vậy, trong trường hợp đó, bộ xử lý sẽ lỗi ở dòng lệnh thứ 4. Dòng lệnh thứ 5 sẽ không bao giờ thực hiện.
Nhưng như đã mô tả ở trên, ta không sống ở một thế giới “trật tự”, nơi mà các lệnh sẽ được thực hiện theo trình tự, và đây không phải là cách mà các bộ xử lý Intel hiện đại ngày nay thực thi đoạn code trên.
Các bộ vi xử lý hiện đại cũng bắt đầu thực hiện lệnh theo cùng cách ở trên, đó là thực hiện biểu thức expr() trước. Sau đó, tới quá trình đánh giá điều kiện tại dòng lệnh thứ 2, thế nhưng bộ xử lý không chờ cho việc thực hiện của expr() hoàn thành trước khi quyết định có hay không việc thực hiện nhánh điều kiện bên dưới. Nó đưa ra dự đoán, và dự đoán của nó dựa trên branch-prediction logic của bộ vi xử lý.
Nếu sự dự đoán là giả sử nhánh sẽ được thực hiện, bộ vi xử lý sau đó khai thác khối lệnh mới bằng cách nạp bộ nhớ cho địa chỉ tại dòng lệnh 4. Tiếp theo bộ xử lý nối luôn lệnh nạp bộ nhớ ở dòng thứ 5 vào đằng sau — nó tùy thuộc vào dòng lệnh thứ 4 vẫn đang hoàn thành. Quan trọng là, địa chỉ của lần đọc thứ hai lại căn cứ vào giá trị của bộ nhớ được tìm nạp trong lần đọc đầu tiên. (Lệnh 5 phụ thuộc vào lệnh 4).
Điều gì xảy ra nếu địa chỉ không hợp lệ? Vâng, trong trường hợp đó, chúng ta sẽ bị lỗi ở dòng 4. Nhưng đợi đã! Chúng ta thậm chí không biết liệu chúng ta đang thực hiện nhánh này chưa. Nếu cuối cùng kết quả của expr() được tính xong và ta phát hiện ra không bao giờ thực hiện được nhánh này, ta sẽ phải hủy cả hai lệnh tại dòng 5 và 4, bao gồm cả lỗi đã xảy ra tại dòng 4. Làm như vậy chẳng khác gì chúng ta quay ngược lại thời gian và do vậy lỗi sẽ không bao giờ xảy ra.
Trong thực tế, điều này có nghĩa là chúng ta queue luôn lỗi ở dòng 4 chờ cho đến khi có kết quả của expr() ở dòng 1.
Sự phức tạp tiếp theo là việc nạp bộ nhớ có thể không hợp lệ bởi một trong hai lý do. Hoặc địa chỉ đó thực sự không hợp lệ và bạn đang cố thử nạp bộ nhớ từ một địa chỉ không trỏ tới đâu cả, hoặc đó là địa chỉ hợp lệ, nhưng chỉ đơn giản là chương trình không có quyền đọc nó. Điều này thường xảy ra nếu bạn là một ứng dụng còn bộ nhớ lại thuộc sở hữu của hệ điều hành, hoặc bạn là một guest VM còn bộ nhớ lại thuộc sở hữu của hypervisor.
Với các bộ vi xử lý của Intel, nếu địa chỉ hợp lệ, nhưng phân quyền, bộ xử lý xếp hàng đợi một lỗi, nhưng engine thực thi vẫn tiếp tục thực hiện tiếp. Điều này không thành vấn đề; những lệnh vượt khỏi đặc quyền đọc không bao giờ được hiển thị. Sau cùng, bộ vi xử lý sẽ hoàn tác toàn bộ các lệnh này hoặc quay lại lỗi, hoặc quay trở lại nhánh nếu cả nhánh bị sai.
Dù không phải là vấn đề….nhưng bộ vi xử lý hoạt động như thế.
Khai thác với Meltdown
Giờ kết hợp toàn bộ các thông tin mô tả ở trên với nhau để hiểu được cách khai thác “Meltdown” nhằm đọc được kernel memory từ một ứng dụng, hoặc là đọc được hypervisor memory từ một guest VM.
Đầu tiên, chúng ta thực hiện một việc nhỏ trước để “huấn luyện” cho bộ dự báo nhánh (branch predictor) rằng một nhánh có điều kiện cụ thể sẽ luôn luôn được thực hiện. Sau đó, ta chạy một “rigged version” nơi mà nhánh không được thực hiện, nhưng sẽ được dự đoán sai để được thực thi bởi bộ xử lý. Chúng ta thiết kế nó để nhánh thực hiện một công việc hoàn toàn không hợp lệ trong khi đó việc thực hiện dự đoán sai làm rò rỉ thông tin mà chúng ta có thể thấy khi hủy các lệnh dự đoán sai.
Trong quá trình thực thi dự đoán sai, ta sắp đặt cho việc truy cập bộ nhớ của một địa chỉ hợp lệ, nhưng được phân quyền. Trong thực tế, việc này có nghĩa là truy cập vào bộ nhớ hypervisor từ guest VM hoặc truy cập bộ nhớ của hệ điều hành từ bên trong một ứng dụng. Nếu ta đã thử thực hiện viêc này bên ngoài việc thực thi dự đoán, nó sẽ bị lỗi. Nhưng bởi chúng ta đang thực thi dự đoán, bộ vi xử lý xếp hàng lỗi và việc thực thi dự đoán vẫn tiếp tục.
Tiếp theo, chúng ta bố trí để lấy bộ nhớ mà địa chỉ của nó phụ thuộc vào nội dung của bộ nhớ đã phân quyền mà ta đã nạp. Nếu ta cẩn thận và xóa bộ nhớ cache trước khi thực hiện việc này, lệnh đọc thứ hai này sẽ để lại dấu hiệu có thể phát hiện ra được giá trị gì đó trong bộ nhớ cache của bộ xử lý, và ta có thể phát hiện ra sau đó.
Bây giờ tất cả chúng ta cần làm là chờ đợi để túm được engine thực thi dự đoán. Cuối cùng nó sẽ hoàn tất việc tính toán expr và phát hiện ra rằng nhánh thực hiện bị dự đoán sai. Khi nó làm như vậy nó sẽ quay lại; đi ngược thời gian, hủy tất cả các lệnh nó đã thực hiện, bao gồm cả lỗi, và sau đó tiếp tục thực hiện vờ như không có chuyện gì xảy ra.
Bộ xử lý có thể trông như là nó đã đi ngược thời gian — chúng ta không thể thấy bất kỳ kết quả trực tiếp nào của quá trình thực thi dự đoán — nhưng chúng ta có thể đo lường các kết quả gián tiếp. Những gì chúng ta làm là thực hiện một loạt phép đo thời gian có độ chính xác rất cao của truy cập bộ nhớ để quan sát dòng cache nào đã được nạp trong lần truy cập bộ nhớ thứ hai. Điều này cho ta biết giá trị của bộ nhớ đặc quyền là gì trong quá trình thực thi dự đoán.
Bằng cách lặp lại việc này nhiều lần, dần dần ta có thể leak bất kỳ bộ nhớ nào từ hypervisor, hoặc từ kernel của hệ điều hành.
Nguồn: https://tradahacking.vn/khai-thác-vượt-thời-gian-với-meltdown-37f7fe2e30d2
Trong tuần vừa qua đã có rất nhiều các suy đoán liên quan tới hai lỗ hổng mới được công bố (https://googleprojectzero.blogspot.sg/2018/01/reading-privileged-memory-with-side.html) của bộ vi xử lý , các chuyên gia đặt tên cho hai lỗi này là Meltdown và Spectre (Hai cái tên mà tôi nghĩ với nhiều người, ngay cả tôi đọc cũng méo mồm và khó hiểu ). Trong bài này tôi sẽ nói về lỗi có tên Meltdown. Về lỗi Spectre sẽ trình bày ở một bài viết khác.
Trước tiên: Nếu bạn là những người sử dụng máy tính bình thường (nôm na là chỉ dùng vào việc gõ văn bản, chơi game, xem phim, nghe nhạc…) thì bạn chả cần phải làm gì đặc biệt cả. Chỉ việc cài đặt bản cập nhật cho hệ điều hành và phần mềm của bạn thì bạn sẽ ổn thôi. Bạn không nhất thiết phải sắm một máy tính mới hay vứt bỏ bộ vi xử lý đang dùng. Các chuyên gia có khuyến cáo rằng các bản vá lỗi có thể sẽ làm chậm máy tính của bạn nhưng chỉ một chút thôi, và vì bạn là người dùng bình thường nên sẽ không cảm nhận thấy được sự sụt giảm hiệu năng này . Túm váy lại là chỉ việc cập nhật OS và phần mềm, mọi thứ rồi sẽ ổn thôi…
Còn nếu bạn đang là nhân viên kĩ thuật của một công ty, các máy ảo của bạn sẽ cần phải khởi động lại ngay bây giờ. Bởi các hãng cung cấp giải pháp điện toán đám mây đang tiến hành việc sửa lỗi cho các hypervisors, nhưng tốt nhất để an toàn hơn bạn vẫn nên tiến hành cập nhật bản vá cho các hệ điều hành của các máy ảo mà bạn đang quản lý. Ví dụ như Azure đã thực hiện một số “điểu chỉnh” đặc biệt, cho nên ngay cả khi các guest OS của bạn chưa được cập nhật, bạn sẽ không bị ảnh hưởng bởi lỗi này, nhưng Amazon thì chắc chắn không, và nếu bạn không cài đặt các bản cập nhật bảo mật trên các máy ảo Azure thì bạn là một người điên và vấn đề chỉ là thời gian mà thôi, trước khi bạn thấy dữ liệu khách hàng của mình được phệt thẳng trên pastebin. Thế cho nên, hãy xách mông lên , kiểm tra và cài đặt các bản cập nhật đi...
Các chuyên gia nói với nhau đây là một lỗi rất thú vị ngay cả khi bạn không cần phải làm bất cứ điều gì đặc biệt để sửa nó. Vậy thì cách thức nó hoạt động ra sao?
Nếu bạn là người thích nghịch code hơn là muốn đọc những lời giải thích dông dài, thì xin lỗi, chắc bạn bị điên, tác giả bài viết (không phải tôi nhé!!) tặng các bạn mã khai thác (https://pastebin.com/raw/fDHrAEuZ) mà tác giả đã viết. Còn nếu bạn thích đọc giải thích hơn, xin tiếp tục…
“Du hành thời gian” với các bộ vi xử lý hiện đại
Các bộ vi xử lý hiện đại mà chúng ta đang dùng hàng ngày khá phức tạp. Chúng sử dụng một loạt các “tiểu xảo” nhằm để thử và ép đạt hiệu năng cao hơn vượt ra khỏi các ứng dụng phần mềm.
Đã từ rất lâu, các nhà thiết kế vi xử lý bắt đầu biến mã ứng dụng thành một “đường ống” hoạt động. Nếu bộ vi xử lý gặp một lệnh tốn khoảng thời gian dài để thực hiện —ví dụ, truy cập bộ nhớ từ RAM —ngay lập tức, để tránh thời gian nhàn rỗi phải chờ đợi để hoàn thành lệnh, nó có thể tiếp tục thực hiện các lệnh tiếp theo.
Vấn đề xảy ra nếu như câu lệnh đầu tin thực hiện sai điều gì đó, giả dụ như cố gắng truy cập vào một địa chỉ bộ nhớ không tồn tại, sau khi nó đã tiến hành thực thi câu lệnh phía sau.
Chuyện gì sẽ xảy ra nếu ta thực hiện các lệnh theo đúng trình tự? Điều này khá đơn giản. Nếu lệnh đầu tiên gặp “lỗi”, lệnh thứ hai sẽ không bao giờ thực thi, bộ vi xử lý sẽ thông báo cho hệ điều hành, để từ đó lựa chọn ra các phản ứng phù hợp.
Bỏ qua cái tên, những “lỗi” này là hành động bình thường. Nếu có lỗi xảy ra trong quá trình thực thi của một chương trình bình thường, hệ điều hành quan sát xem chương trình có khả năng xử lý lỗi không và nếu không thể, hệ điều hành sẽ khiến cho tiến trình “crash”. Việc gây crash có thể là sự kết thúc đối với các chương trình có lỗi, nhưng còn hệ điều hành và bộ xử lý thì vẫn tiếp tục làm việc. Đó là lý do tại sao bạn thấy chương trình của mình “bay hơi”, nhưng màn hình desktop vẫn hiển thị, thay vì chứng kiến cảnh máy tính phải khởi động lại.
Tuy nhiên, mọi thứ bắt đầu trở nên phức tạp hơn nếu như bộ vi xử lý thực hiện nhiều câu lệnh cùng lúc. Nếu bộ xử lý gặp một lệnh truy cập bộ nhớ, bắt đầu thực thi lệnh tiếp theo sau lệnh này và chỉ sau đó nó mới phát hiện ra rằng lệnh nạp bộ nhớ đầu tiên đã bị lỗi, thì điều gì sẽ xảy ra sau đó?
Câu trả lời là bộ vi xử lý phải “hủy” các lệnh đã thực hiện phía sau lệnh mà lỗi logic đã xảy ra. Bằng cách này, hệ điều hành “nhìn” thấy chương trình như thể mỗi lệnh đã thực hiện theo đúng trình tự. Cho tới khi mà hệ điều hành được xem là có liên quan trong việc lệnh đầu tiên được thực thi còn lệnh thứ hai thì không.
Các bộ vi xử lý hiện đại không chỉ thực hiện thêm một câu lệnh tại một thời điểm. Nó thực thi rất nhiều câu lệnh cùng lúc, mỗi lệnh ép hiệu năng bổ sung thay vì dừng lại và chờ đợi các lệnh trước hoàn thành tác vụ. Điều này có nghĩa là các bộ vi xử lý luôn thực hiện một số lượng lớn các lệnh trong một “đường ống” hoạt động và nó chỉ dừng lại khi phát hiện ra lỗi ở một trong các lệnh và sau đó phải “hủy” tất cả các lệnh đã thực hiện theo dự đoán ở phía sau lệnh gây lỗi.
Sau một thời gian, các nhà thiết kế vi xử lý đã phát hiện ra họ có thể sử dụng nguyên tắc này cho một “tiểu xảo” khéo léo khác để thu được hiệu năng cao hơn từ chương trình. Trình tự code xuất hiện trong các chuỗi lệnh nhỏ (được gọi là “các khối cơ bản”) sau đó là nhảy tới đoạn code khác. Kĩ thuật “đường ống” thực sự tốt khi thực thi các khối lệnh này. Nhưng điều gì sẽ xảy ra khi bộ xử lý thực hiện đến cuối khối và cần phải nhảy tới một nơi khác? Việc chờ đợi để “đường ống” hoàn thành rất tốn kém; chúng ta không thích phải chờ đợi.
Nếu đó là lệnh nhảy không điều kiện, bộ xử lý chỉ việc đi qua, nạp các lệnh mới từ khối tiếp theo và bổ sung chúng vào cuối “đường ống”.
Nhưng nếu là lệnh nhảy có điều kiện thì liệu nó vẫn tiếp tục làm việc theo cách trên thông qua “đường ống”? Trong một thế giới “có trật tự”, bộ xử lý sẽ đợi. Sau đó, nó xác định lệnh nhảy có điều kiện có được thực hiện hay là không, và căn cứ vào kết quả để nhảy tới nhánh hợp lệ . Nhưng “vùng đất” của các bộ vi xử lý không phải là một thế giới “trật tự”, việc phải chờ đợi trong nhàn rỗi là điều cấm kị.
Vậy thì bộ xử lý làm những gì? Nó sẽ “phỏng đoán”. Khi quá trình xử lý giá trị điều kiện cuối cùng được xác định trong “đường ống”, bộ xử lý có thể sau đó quay lại và hỏi liệu việc phỏng đoán có đúng không. Nếu đúng, thì xin chúc mừng! Chúng ta đã tránh được việc chờ đợi và đã ép thêm hiệu năng của bộ xử lý bằng cách làm như vậy. Nếu phỏng đoán sai, chỉ cần hủy tất cả các lệnh hiện tại đã thực hiện trong nhánh sai và sau đó lựa chọn nhánh lệnh đúng để thực hiện.
Như vậy, có thể thấy nếu việc phòng đoán là đúng, hiệu năng thực hiện của bộ vi xử lý được tăng lên. Nếu sai, thì nó chỉ lãng phí một khoảng thời gian mà lẽ ra nếu không làm trước thì sẽ phải chờ đợi.
Vậy thì một điều hiển nhiên, càng thường xuyên đoán đúng điều kiện càng “giành chiến thắng” nhiều hơn và có được hiệu năng cao hơn. Việc này cho thấy rằng khi tới dự đoán nhánh thực hiện, hiệu năng trong quá khứ là một chỉ số khá tin cậy của hiệu năng trong tương lai. Do vậy, các bộ vi xử lý hiện đại có một bộ “dự đoán nhánh”, có nhiệm vụ “tìm hiểu” các nhánh đã được thực hiện dựa trên dữ liệu thực nghiệm, và sử dụng thông tin đó để dự đoán tốt hơn các nhánh trong tương lai.
Quá tuyệt! Tới đây ta đã biết rõ hơn về cách hoạt động của bộ vi xử lý hiện đại qua đó để hiểu và khai thác lỗi Meltdown.
Meltdown
Lỗi “Meltdown” xảy ra nếu có một truy cập vào bộ nhớ không hợp lệ diễn ra trong khi một nhánh suy đoán (speculative path) phải bị hủy bỏ. Quan sát đoạn mã giả dưới đây:
Trước tiên, ta quan sát nếu hoạt động trong một thế giới có “trật tự” thì bộ xử lý thực thi từng lệnh một như thế nào?
Đầu tiên, bộ xử lý thực hiện biểu thức expr(), gán kết quả thu được cho [imath]A[/B]. Tiếp theo, bộ vi xử lý hỏi xem giá trị của [B][/imath]A có là 1 không. Nếu đúng, nó sẽ thực hiện các lệnh trong khối { }. Lệnh đầu tiên sẽ nạp một địa chỉ nào đó vào [imath]B[/B]. Lúc này, ở lệnh tiếp theo [B][/imath]B được xem như là một địa chỉ và được lấy từ bộ nhớ RAM gán cho $C.
Nếu địa chỉ không hợp lệ thì sao? Nếu vậy, trong trường hợp đó, bộ xử lý sẽ lỗi ở dòng lệnh thứ 4. Dòng lệnh thứ 5 sẽ không bao giờ thực hiện.
Nhưng như đã mô tả ở trên, ta không sống ở một thế giới “trật tự”, nơi mà các lệnh sẽ được thực hiện theo trình tự, và đây không phải là cách mà các bộ xử lý Intel hiện đại ngày nay thực thi đoạn code trên.
Các bộ vi xử lý hiện đại cũng bắt đầu thực hiện lệnh theo cùng cách ở trên, đó là thực hiện biểu thức expr() trước. Sau đó, tới quá trình đánh giá điều kiện tại dòng lệnh thứ 2, thế nhưng bộ xử lý không chờ cho việc thực hiện của expr() hoàn thành trước khi quyết định có hay không việc thực hiện nhánh điều kiện bên dưới. Nó đưa ra dự đoán, và dự đoán của nó dựa trên branch-prediction logic của bộ vi xử lý.
Nếu sự dự đoán là giả sử nhánh sẽ được thực hiện, bộ vi xử lý sau đó khai thác khối lệnh mới bằng cách nạp bộ nhớ cho địa chỉ tại dòng lệnh 4. Tiếp theo bộ xử lý nối luôn lệnh nạp bộ nhớ ở dòng thứ 5 vào đằng sau — nó tùy thuộc vào dòng lệnh thứ 4 vẫn đang hoàn thành. Quan trọng là, địa chỉ của lần đọc thứ hai lại căn cứ vào giá trị của bộ nhớ được tìm nạp trong lần đọc đầu tiên. (Lệnh 5 phụ thuộc vào lệnh 4).
Điều gì xảy ra nếu địa chỉ không hợp lệ? Vâng, trong trường hợp đó, chúng ta sẽ bị lỗi ở dòng 4. Nhưng đợi đã! Chúng ta thậm chí không biết liệu chúng ta đang thực hiện nhánh này chưa. Nếu cuối cùng kết quả của expr() được tính xong và ta phát hiện ra không bao giờ thực hiện được nhánh này, ta sẽ phải hủy cả hai lệnh tại dòng 5 và 4, bao gồm cả lỗi đã xảy ra tại dòng 4. Làm như vậy chẳng khác gì chúng ta quay ngược lại thời gian và do vậy lỗi sẽ không bao giờ xảy ra.
Trong thực tế, điều này có nghĩa là chúng ta queue luôn lỗi ở dòng 4 chờ cho đến khi có kết quả của expr() ở dòng 1.
Sự phức tạp tiếp theo là việc nạp bộ nhớ có thể không hợp lệ bởi một trong hai lý do. Hoặc địa chỉ đó thực sự không hợp lệ và bạn đang cố thử nạp bộ nhớ từ một địa chỉ không trỏ tới đâu cả, hoặc đó là địa chỉ hợp lệ, nhưng chỉ đơn giản là chương trình không có quyền đọc nó. Điều này thường xảy ra nếu bạn là một ứng dụng còn bộ nhớ lại thuộc sở hữu của hệ điều hành, hoặc bạn là một guest VM còn bộ nhớ lại thuộc sở hữu của hypervisor.
Với các bộ vi xử lý của Intel, nếu địa chỉ hợp lệ, nhưng phân quyền, bộ xử lý xếp hàng đợi một lỗi, nhưng engine thực thi vẫn tiếp tục thực hiện tiếp. Điều này không thành vấn đề; những lệnh vượt khỏi đặc quyền đọc không bao giờ được hiển thị. Sau cùng, bộ vi xử lý sẽ hoàn tác toàn bộ các lệnh này hoặc quay lại lỗi, hoặc quay trở lại nhánh nếu cả nhánh bị sai.
Dù không phải là vấn đề….nhưng bộ vi xử lý hoạt động như thế.
Khai thác với Meltdown
Giờ kết hợp toàn bộ các thông tin mô tả ở trên với nhau để hiểu được cách khai thác “Meltdown” nhằm đọc được kernel memory từ một ứng dụng, hoặc là đọc được hypervisor memory từ một guest VM.
Đầu tiên, chúng ta thực hiện một việc nhỏ trước để “huấn luyện” cho bộ dự báo nhánh (branch predictor) rằng một nhánh có điều kiện cụ thể sẽ luôn luôn được thực hiện. Sau đó, ta chạy một “rigged version” nơi mà nhánh không được thực hiện, nhưng sẽ được dự đoán sai để được thực thi bởi bộ xử lý. Chúng ta thiết kế nó để nhánh thực hiện một công việc hoàn toàn không hợp lệ trong khi đó việc thực hiện dự đoán sai làm rò rỉ thông tin mà chúng ta có thể thấy khi hủy các lệnh dự đoán sai.
Trong quá trình thực thi dự đoán sai, ta sắp đặt cho việc truy cập bộ nhớ của một địa chỉ hợp lệ, nhưng được phân quyền. Trong thực tế, việc này có nghĩa là truy cập vào bộ nhớ hypervisor từ guest VM hoặc truy cập bộ nhớ của hệ điều hành từ bên trong một ứng dụng. Nếu ta đã thử thực hiện viêc này bên ngoài việc thực thi dự đoán, nó sẽ bị lỗi. Nhưng bởi chúng ta đang thực thi dự đoán, bộ vi xử lý xếp hàng lỗi và việc thực thi dự đoán vẫn tiếp tục.
Tiếp theo, chúng ta bố trí để lấy bộ nhớ mà địa chỉ của nó phụ thuộc vào nội dung của bộ nhớ đã phân quyền mà ta đã nạp. Nếu ta cẩn thận và xóa bộ nhớ cache trước khi thực hiện việc này, lệnh đọc thứ hai này sẽ để lại dấu hiệu có thể phát hiện ra được giá trị gì đó trong bộ nhớ cache của bộ xử lý, và ta có thể phát hiện ra sau đó.
Bây giờ tất cả chúng ta cần làm là chờ đợi để túm được engine thực thi dự đoán. Cuối cùng nó sẽ hoàn tất việc tính toán expr và phát hiện ra rằng nhánh thực hiện bị dự đoán sai. Khi nó làm như vậy nó sẽ quay lại; đi ngược thời gian, hủy tất cả các lệnh nó đã thực hiện, bao gồm cả lỗi, và sau đó tiếp tục thực hiện vờ như không có chuyện gì xảy ra.
Bộ xử lý có thể trông như là nó đã đi ngược thời gian — chúng ta không thể thấy bất kỳ kết quả trực tiếp nào của quá trình thực thi dự đoán — nhưng chúng ta có thể đo lường các kết quả gián tiếp. Những gì chúng ta làm là thực hiện một loạt phép đo thời gian có độ chính xác rất cao của truy cập bộ nhớ để quan sát dòng cache nào đã được nạp trong lần truy cập bộ nhớ thứ hai. Điều này cho ta biết giá trị của bộ nhớ đặc quyền là gì trong quá trình thực thi dự đoán.
Bằng cách lặp lại việc này nhiều lần, dần dần ta có thể leak bất kỳ bộ nhớ nào từ hypervisor, hoặc từ kernel của hệ điều hành.
Chỉnh sửa lần cuối bởi người điều hành: