TẢN MẠN VỀ THIẾT LẬP KHAI THÁC CHROME Maglev Edition

Sơ lược về Chromium security architecture

Trước khi đi sâu vào chi tiết của quá trình render và V8 engine, chúng ta bắt đầu với cách thức hoạt động của kiến ​​trúc tổng thể của Chromium.

Chromium sử dụng kiến ​​trúc đa quy trình để phân tách các tác vụ khác nhau thành các quy trình khác nhau. Sự phân tách này làm tăng tính ổn định và  bảo mật của trình duyệt vì các vấn đề trong một quy trình (như tab bị sập) không ảnh hưởng đến các quy trình khác.

Hình ảnh Chromium Project bên dưới sẽ cung cấp tổng quan về màu sắc theo rủi ro của các thành phần của trình duyệt.

Kiến trúc bảo mật Chromium

Trong sơ đồ, chúng ta thấy một số loại quy trình riêng biệt:

  • Browser Process : Quản lý giao diện người dùng, đĩa và I/O mạng, và hoạt động như bộ điều phối trung tâm cho các quy trình khác. Nó hoạt động với đầy đủ đặc quyền của người dùng và quản lý hầu hết các tài nguyên hệ thống, bao gồm hồ sơ người dùng và các dữ liệu liên tục.
  • Renderer Processes : Xử lý việc render các trang web và thực thi JavaScript. Mỗi trang web hoặc ứng dụng web thường chạy trong quy trình renderer riêng, tách biệt với các quy trình khác. Các quy trình này phải tuân theo các biện pháp giảm thiểu nghiêm ngặt — chẳng hạn như sandbox — để đảm bảo render an toàn nội dung web.
  • GPU Process : Quy trình này xử lý các tác vụ GPU riêng biệt với việc hiển thị nội dung web, cải thiện hiệu suất và bảo mật. Nó được chạy với quyền tối thiểu, có vai trò xử lý các tác vụ đồ họa chuyên sâu một cách hiệu quả.
  • Utility Process: Đóng vai trò xử lý các hoạt động ngắn hạn và có thể chạy sandboxed hoặc unsandboxed tùy thuộc vào chức năng cụ thể.
  • PPAPI (Pepper Plugin API) Broker Process và NaCl (Native Client) Loader Process là hai thành phần trong kiến ​​trúc của Chromium, quản lý các khía cạnh khác nhau khi chạy code trong trình duyệt.

Đối với mỗi quy trình, Chromium áp dụng hai biện pháp bảo mật: hộp cát (sandbox)  và nguyên tắc đặc quyền tối thiểu (principle of least privilege).

Mỗi quy trình render và plugin hoạt động trong một sandbox, đây môi trường hạn chế giới hạn khả năng đọc và ghi vào đĩa, tương tác với hệ điều hành hoặc qua luồng giao tiếp của chúng. Chiến lược ngăn chặn này làm giảm các nguy cơ mã độc thoát khỏi trình duyệt và ảnh hưởng đến toàn bộ hệ thống của người dùng.

Ngoài ra, mỗi tiến trình hoạt động với đặc quyền tối thiểu cần thiết để thực hiện chức năng của nó. Ví dụ, các tiến trình render có quyền truy cập rất hạn chế vào tài nguyên hệ thống và bất kỳ hành động nào yêu cầu nhiều đặc quyền hơn (như lưu tệp) phải thông qua tiến trình trình duyệt, hoạt động như một người gác cổng bảo mật.

 

Bây giờ chúng ta đi sâu vào chủ đề chính của bài viết này !!!

0. Intro V8 engine

Đầu tiên ta sẽ xem xét kỹ hơn về quy trình render trong Chromium. Quy trình render này rất quan trọng đối với bảo mật của trình duyệt vì nó chịu trách nhiệm vẽ các trang web và thực thi mã JavaScript không đáng tin cậy, khiến nó trở thành mục tiêu chính cho các cuộc khai thác.

Điều này đưa chúng ta đến với key word mới là V8 engine. V8 được hiểu đơn giản chính là JavaScript và WebAssembly engine cung cấp cơ sở cho quy trình này, phân tích cú pháp và thực thi mã định nghĩa tương tác của người dùng và chức năng của trang web. Vì vậy, V8 ở đây có ảnh hưởng trực tiếp đến tính bảo mật và tính ổn định của quy trình renderer.

Hãy cùng khám phá chi tiết hơn các thành phần khác nhau giúp cho đường truyền V8 Chromium JIT trở nên hiệu quả và xem xét cách mà tính phức tạp vốn có của nó có thể dẫn đến lỗi nhầm lẫn kiểu (Type confusion bugs).

1. Các bước và yêu cầu thiết lập môi trường Testing V8

Từ Visual Studio Installer Visual Studio, Ta thiết lập 2 môi trường Workloads C++ và Python.

Individual Components của window pane ta chuẩn bị:

  • C++ MFC for Latest v143 Build Tools (x86 & x64)
  • C++ Clang Compiler for Windows (17.0.3)
  • MSBuild support for LLVM (clang-cl) toolset
  • C++ CMake tools for Windows
  • Git for Windows
  • Windows 11 SDK (10.0.22621.0)

Để thực hiện debug theo dõi phân tích bộ nhớ trên windows chắc không thể thiếu  Windows 11 SDK Debugging Tools. Chúng ta chỉ cần tải về và thiết lập bộ công cụ để sẵn sàng “chiến đấu” !!!

2. Testing V8 Environment

Khi các điều kiện yêu cầu bên trên đã được đáp ứng, Chúng ta có thể tạo một thư mục như C:\v8_dev chứa các cài đặt tất cả thư viện phụ thuộc và mã nguồn cần thiết cho V8.

 Từ dấu nhắc lệnh, điều hướng đến thư mục đó và tải xuống Depot Tools thông qua gitclone.

Tải xuống công cụ depot

 

Depot Tools là một tập hợp các script và công cụ được thiết kế để quản lý quy trình phát triển cho các dự án liên quan đến Chromium. Những công cụ này giúp trong việc lấy mã nguồn, quản lý các phụ thuộc, xây dựng dự án và chạy thử nghiệm. Một số công cụ trong đó bao gồm gclient để đồng bộ hóa các phụ thuộc, ninja để xây dựng, và gn để tạo dự án.

Sau đó, chúng ta có thể thêm thư mục công cụ depot vào biến hệ thống PATH cũng như hai biến người dùng DEPOT_TOOLS_WIN_TOOLCHAIN ​​và vs2022_install để dễ dàng truy cập sử dụng.

Chúng ta có thể thực hiện nhanh bằng cách dán và chạy các lệnh sau trên Command promt admin.

Thêm các biến môi trường cần thiết

 

Sau khi thiết lập các biến, chúng ta có thể di chuyển đến thư mục con depot tools và chạy lệnh gclient , và cùng đợi một lát để hoàn tất đồng bộ.

Chạy lệnh gclient

 

Lệnh gclient là một phần của Depot Tools và được sử dụng để quản lý và đồng bộ hóa các thư viện phụ thuộc cho Chromium và các dự án liên quan. Lệnh này lấy mã nguồn, kiểm tra các phiên bản thư viện phụ thuộc chính xác và thiết lập môi trường phát triển.

Nếu quá trình đồng bộ không gặp lỗi, mặc định công cụ Python 3 của depot sẽ được ưu tiên sử dụng.

 Thứ tự ưu tiên của đường dẫn Python3

 

Từ thư mục v8_dev, bây giờ chúng ta có thể tạo thư mục con v8 và lấy mã nguồn V8 bằng lệnh fetch.

Đang lấy mã nguồn v8

 

Sau khi lấy mã nguồn của V8, chúng ta sẽ dựng lại phiên bản tồn tại lỗ hổng, cụ thể trong bài viết này là CVE-2023-4069.

Theo báo cáo lỗi của Chromium đã ghi nhận , sự cố đã được thực hiện trong phiên bản 11.5.150.16 và được thử nghiệm trên commit 5315f073233429c5f5c2c794594499debda307bd . Vì vậy, chúng ta có thể dùng git checkout đến nó như sau từ thư mục v8 mới tạo.

Xây dựng lab V8 khớp version CVE đề cập

 

Tiếp theo, chúng ta cần chạy lệnh gclient sync -D để xác minh rằng mã nguồn đang ở trạng thái Clean, xóa mọi tệp không được kho lưu trữ theo dõi và đảm bảo rằng tất cả các thành phần phụ thuộc đều khớp với Commit đã chỉ định.

Cập nhật các phần thư viện phụ thuộc với gclient

 

Sau khi cập nhật đúng mã nguồn tồn tại lỗ hổng và các thành phần liên quan, giờ là lúc biên dịch V8. Chúng ta sẽ thực hiện thông qua công cụ đóng gói của Python gm.py và chỉ định loại kiến ​​trúc/bản dựng làm đối số. Vì trình biên dịch Maglev chỉ có trong phiên bản phát hành nên chúng ta phải chỉ định Flags: x64.release.

python3 tools\dev\gm.py x64.release

Compiling V8

 

Sau khi hoàn tất các bước trên, Chúng ta có thể thử khởi chạy V8 và xác minh version của nó là phiên bản d8 11.5.150.16 như hiển thị bên dưới.

C:\v8_dev\v8\v8\out\x64.release\d8.exe –maglev

⇒ Tuyệt!!! Chúng ta đã biên dịch và xây dựng được phiên bản d8 tồn tại lỗ hổng CVE-2023-4069. Bây giờ chúng ta hãy đi sâu hơn một chút vào nguyên nhân gốc rễ và cách khai thác lỗi.

 

3. Walk Through CVE-2023-4069

Vào tháng 10 năm 2023, Man Yue Mo từ nhóm bảo mật GitHub đã xuất bản một bài viết về lỗi CVE-2023-4069 Type Confusion trong V8.

Tóm lại, Nguyên nhân chính của lỗi này trong trình biên dịch Maglev là do việc khởi tạo đối tượng không hoàn chỉnh. Trong quá trình xây dựng đối tượng, V8 có thể tạo ra một đối tượng chưa được khởi tạo hoàn chỉnh. Maglev có thể sử dụng những đối tượng này trước khi tất cả các thuộc tính của chúng được thiết lập, dẫn đến việc truy cập vào các đối tượng chưa được khởi tạo đúng cách. Điều này có thể gây ra lỗi bộ nhớ và dẫn đến lỗ hổng bảo mật.

 

Trong JavaScript, từ khóa new” được sử dụng để hỗ trợ tính năng Lập trình Hướng đối tượng (OOP). Khi sử dụng “new”, JavaScript tạo ra một đối tượng mới, thiết lập prototype của nó, liên kết “this” với đối tượng mới, và trả về đối tượng đó (trừ khi hàm khởi tạo này trả về một đối tượng khác).

 

Ngoài ra, chúng ta cũng có một toán tử tương tự là new.target . Đây là special meta-property có sẵn trong các constructors. Nó cho phép chúng ta phát hiện xem một hàm hoặc hàm tạo có được gọi bằng toán tử new hay không . Nếu được gọi thông qua new, new.target sẽ tham chiếu đến constructor của function hoặc class đó. Nếu được gọi mà không có new , new.target sẽ trả về undefined.

 

Điều này giúp thực thi lệnh chỉ gọi các hàm tạo bằng new, ngăn chặn việc sử dụng sai mục đích khởi tạo đối tượng.

Để làm rõ hơn những khái niệm này, đoạn mã dưới đây minh họa cách new.target hoạt động kết hợp với toán tử new.

Ví dụ về toán tử ‘new’ và ‘new.target’ trong JS

 

Chúng ta có thể lưu lại đoạn code trên vào file new.js và thử load vào môi trường V8 để kiểm tra thực nghiệm như ảnh dưới đây.

Thử nghiệm trong môi trường test V8

 

⇒ Như chúng ta có thể dự đoán, dòng đầu tiên in ra lỗi vì new.target bắt được exception do đối tượng được tạo mà không có toán tử new. Mặt khác, dòng thứ hai in đúng tham số vì đối tượng đã được khởi tạo bằng the_new_constructor. Dòng cuối cùng có thể không cần quan tâm vì đây là do đoạn mã trên không trả về giá trị nào nên mặc định xử lý của trình duyệt sẽ hiển thị “undefined”.

 

Để tiếp cận sâu hơn nữa về lỗ hổng, chúng ta đi tiếp với reflect.construct nó có thể gián tiếp gọi new.target. Nó có cú pháp sau:

                                                                                Cú pháp Reflect.construct

 

Theo tài liệu MDN:

“Reflect.construct(target, argumentsList, newTarget) về mặt ngữ nghĩa tương đương với: new target(…argumentsList);”

Vậy chúng ta hãy đi qua ví dụ sau để hiểu rõ cơ chế và các rủi ro tiềm ẩn:

Reflect_construct.js

 

Và thật ngạc nhiên với output khi load đoạn code này để V8 xử lý:

Load Reflect_construct.js

 

Trong hai dòng đầu tiên, chúng ta nhận được output của từng Class như mong đợi. Tuy nhiên, dòng thứ ba cho thấy chỉ có B được in sau khi gọi Reflect.construct. Tại sao vậy???

 

Vâng, khi Reflect.construct(A, [], B) được sử dụng, nó tạo ra new instance của A , nhưng nó đặt constructors new.target thành B. Điều này có nghĩa là trong constructor của A, new.target trỏ đến B mà không phải A. Vì vậy, mặc dù constructor của A được thực thi, new.target khiến nó in B thay vì A.

 

Có một bật mí rất thú vị về Reflect.construct là nó thể hiện hành vi khác nhau tùy thuộc vào việc hàm được gọi có trả về giá trị hay không. Chúng ta hãy so sánh thông qua ví dụ của 2 đoạn code sau:

Đầu tiên, chúng ta tạo một mục tiêu hàm trả về một mảng gồm ba số nguyên nhỏ và gọi nó thông qua Reflect.construct. Ta Load code trên vào V8 đi kèm flags:-allow-natives-syntax;cho phép ta xem chi tiết về các đối tượng trong JS.

reflect.js

 

Output sau khi load reflect.js

 

    Như dự đoán, hàm trả về một JSArray chứa 3 giá trị trên. Vậy điều gì sẽ xảy ra nếu chúng ta gọi một hàm mục tiêu và nó không trả về kết quả gì ?

rc2.js

 

    Output khi load vào V8 như hình bên dưới:

Output sau khi load rc2.js

 

Theo logic thông thường, khi chạy code trên chúng ta sẽ mong muốn chương trình trả về “undefined” từ console/debugger. Nhưng ở trong ví dụ trên, nó trả về type: JS_OBJECT_TYPE.

 

Trong V8, khi tạo một đối tượng mới thông qua FastNewObject, một đối tượng mặc định (default receiver) cũng được tạo ra. Nếu hàm mục tiêu trả về một đối tượng khác, đối tượng mặc định sẽ bị bỏ qua và đối tượng được trả về sẽ được sử dụng; nếu không, đối tượng mặc định sẽ được trả về.

 

Mỗi hàm JavaScript đều có một initial_map, đây là một đối tượng Map xác định loại và cấu trúc bộ nhớ của đối tượng nhận (receiver object). Như đã thảo luận trước đó, map đóng vai trò quan trọng để định nghĩa kiểu ẩn của một đối tượng, cấu trúc bộ nhớ và cách lưu trữ các trường.

Cụ thể hơn:

  1. initial_map: Đây là bản đồ (map) ban đầu của hàm, xác định kiểu ẩn và cách bố trí bộ nhớ cho các đối tượng được tạo từ hàm đó.
  2. FastNewObject: Khi hàm này được gọi để tạo một đối tượng mới, nó sử dụng initial_map của new.target để xác định cách tạo và bố trí bộ nhớ cho đối tượng mới.
  3. new.target: Đây là một meta-property trong JavaScript, cho biết hàm nào đã được sử dụng với từ khóa new. FastNewObject sử dụng initial_map của new.target để thiết lập đối tượng mặc định.

FastNewObject function

 

Nếu FastNewObject thất bại, nó sẽ gọi đường dẫn runtime JSObject::New:

JSObject::New function

 

GetDerivedMap có thể gọi FastInitializeDerivedMap để tạo initial_map trong new_target:

FastInitializeDerivedMap function

 

initial_map ở đây là bản sao của initial_map của target nhưng với prototype được đặt thành prototype của new_targetconstructor được đặt thành target. Nói cách khác, mục đích của FastNewObject là kiểm tra rằng đối tượng nhận mặc định được tạo ra đúng cách và hiệu quả bằng cách sử dụng initial_map đúng.

Khi tạo các đối tượng từ các Class với các constructor mặc định không hoạt động (no-op constructor), V8 tối ưu bằng cách bỏ qua các constructor này. Ví dụ:

Ở đây, việc gọi new B() bỏ qua constructor mặc định A để tối ưu hóa hiệu suất. Tối ưu hóa FindNonDefaultConstructorOrConstruct của trình biên dịch bỏ qua các constructor không hoạt động.

 

Nhìn nhận kĩ hơn về phần hoạt động của nó, ta có thể xem dạng byte code của đoạn mã trên, bằng cách sử dụng flags: –print-bytecode khi chạy d8.

Từ kết quả hiển thị trên,  nếu FindNonDefaultConstructorOrConstruct là đúng, nó sẽ lấy câu lệnh JumpIfTrue , nhảy đến 0000002A50019AA6D và do đó trả về default receiver.

 

⇒ Tóm lại, lỗ hổng CVE-2023-4069 xảy ra do cách Maglev xử lý việc tối ưu hóa khi tạo đối tượng (FindNonDefaultConstructorOrConstruct).

 

Khi tối ưu hóa, Maglev bỏ qua các hàm khởi tạo không cần thiết. Nếu nó phải sử dụng hàm khởi tạo cơ bản, nó sẽ dùng BuildAllocateFastObject thay vì FastNewObject để tạo đối tượng. Vấn đề là BuildAllocateFastObject không kiểm tra đúng cách các trường trong hàm khởi tạo của đối tượng, dẫn đến việc tạo ra các đối tượng với các trường chưa được khởi tạo đúng cách. Nếu new_targettarget khác nhau, điều này có thể gây ra lỗi type confusion.

 

Nhưng có 1 đặc điểm ta phải chú ý ở đây, VisitFindNonDefaultConstructorOrConstruct sẽ kiểm tra xem new_target có phải là hằng số hay không trước khi gọi FastObject

VisitFindNonDefaultConstructorOrConstruct

 

Hàm chịu trách nhiệm kiểm tra xem new_taget có phải là hằng số hay không chính là TryGetConstant. Ta hoàn toàn có thể bắt buộc new_target trở thành một hằng số khá đơn giản như đoạn code bên dưới.

Trong đoạn mã trên, chúng ta tạo một class cơ sở A và một class con B kế thừa từ A. Trong hàm khởi tạo của B, new.target được lưu vào biến toàn cục x.Khi gọi Reflect.construct với B làm targetx là new_target, chúng ta đã đi vào đoạn code có lỗ hổng, dẫn đến việc sử dụng FastObject.

 

Điều thực sự đang xảy ra là Reflect.construct tạo ra một instance của B nhưng đặt new.target thành Array , nghĩa là x trở thành một object kiểu Array. ARRAY cần có trường độ dài(Length) khởi tạo đúng cách. Nếu B khởi tạo Mảng này mà không thiết lập độ dài có thể dẫn đến vấn đề về memory corruption.

V8 thường sẽ kiểm tra để đảm bảo rằng constructor của new.target khớp với loại mong đợi để tránh lỗi phát sinh. Tuy nhiên, Maglev lại bỏ qua kiểm tra này, dẫn đến lỗ hổng. Khi Maglev tối ưu hóa B, Reflect.construct có thể tạo mảng với độ dài là 0 – do bộ nhớ ban đầu chứa giá trị 0 mặc định.

 

Tuy nhiên, bằng cách liên tục tạo mới và xóa đối tượng, cùng với kích hoạt cơ chế garbage collection, chúng ta có thể thao túng bộ nhớ. Điều này khiến mảng chưa được khởi tạo có thể có độ dài tùy ý, dẫn đến việc đọc ngoài phạm vi (OOB) và làm hỏng các địa chỉ quan trọng như con trỏ đối tượng.

Corrupting the Array After Triggering Garbage Collection

 

Sau khi trình tối ưu hóa Maglev được trigger thông qua việc ta dùng vòng lặp for , chúng ta in mảng thông qua câu lệnh debug. Sau đó, chúng ta kích hoạt garbage collection hai lần và in ra giá trị của mảng.

Ở đây, Giá trị 0x3fe00000 (khoảng 1 GB) là giá trị phù hợp  để trigger garbage collection vì nó đủ lớn để phân bổ bộ nhớ, lấp đầy một phần lớn bộ nhớ heap V8 là 4 GB và sẽ buộc công cụ JS thực hiện garbage collection.

 

Chạy đoạn mã trên trong d8 sẽ hiển thị các thuộc tính mảng sau:

 

⇒ Trong lần chạy đầu tiên, mảng corruptedArr có độ dài(Length)0, như dự đoán sau khi load đoạn mã trên. Tuy nhiên, do Corrupt mem ở garbage collection, chúng ta có thể thấy độ dài(Length) mới là 463333. Đây là một giá trị lớn có thể dẫn đến object corruption.

 

Như vậy, đến đây ta đã phân tích và chứng minh được rootcause của CVE này. Để tiếp tục khai thác sâu hơn về lỗ hổng này, tiếp theo chúng ta đến với quá trình Maglev Compiler Just-in-time (JIT) để xây dựng hướng exploit và các case thực tế.

Bài viết đến đây cũng khá dài, hẹn các bạn ở bài viết tiếp theo chúng ta sẽ đi sâu hơn về quá trình khai thác CVE-2023-4069 nhé !!!

 

212 lượt xem