Từ số 0 đến calc trong 6 ngày: Bên trong chuỗi khai thác Chrome hoàn chỉnh của XBOW

Phần mềm13 tháng 10, 2026·13 phút đọc

XBOW đã xây dựng chuỗi khai thác Chrome hoàn chỉnh đầu tiên thời đại LLM chỉ trong 6 ngày với chi phí khoảng 40.000 USD tiền API, kết hợp ba lỗ hổng: lỗi trình biên dịch Maglev của V8, lỗi bộ giải mã WebP động và lỗi an toàn bộ nhớ trong Crashpad. Google đã trao thưởng 250.000 USD theo chương trình VRP mới áp dụng từ tháng 4/2026.

Từ số 0 đến calc trong 6 ngày: Bên trong chuỗi khai thác Chrome hoàn chỉnh của XBOW

XBOW vừa công bố chi tiết về chuỗi khai thác Chrome hoàn chỉnh đầu tiên trong kỷ nguyên LLM, đạt được chỉ trong 6 ngày và tiêu tốn khoảng 40.000 USD tiền gọi API mô hình. Chuỗi tấn công này kết hợp ba lỗ hổng riêng biệt để đi từ việc thực thi JavaScript trong một tab Chrome thông thường đến thực thi mã ngoài sandbox của renderer — và Google đã trao giải thưởng 250.000 USD, mức thưởng full-chain đầu tiên theo quy định VRP mới có hiệu lực từ tháng 4/2026.

Điều đáng chú ý không nằm ở con số tiền thưởng, mà ở tốc độ. Trước kỷ nguyên LLM, một chuỗi khai thác Chrome hoàn chỉnh thường cần một nhóm bốn chuyên gia làm việc từ 6 đến 9 tháng. Lần nộp đầu tiên của XBOW mất 2 tháng. Còn chuỗi được trao thưởng lần này? Chỉ 6 ngày.

Ba lỗ hổng, ba ranh giới bị vượt qua

Chuỗi khai thác vượt qua hai lớp sandbox của Chrome thông qua ba giai đoạn lỗ hổng nối tiếp nhau:

  • Lỗi trình biên dịch Maglev (V8): tái sử dụng một số boxed có thể thay đổi trong quá trình deoptimization, dẫn đến quyền đọc và ghi trong toàn bộ vùng nhớ pointer-compression 4 GiB của V8.
  • Lỗi bộ giải mã WebP động (V8 sandbox escape): kết hợp hình học được cache với dữ liệu ảnh đã thay đổi, tạo ra một ghi native có kiểm soát ra ngoài vùng cage.
  • Lỗi an toàn bộ nhớ trong Crashpad (renderer sandbox escape): xử lý sai một lần đọc thất bại từ một tiến trình tưởng như đã bị đóng băng, dẫn đến thực thi mã trong trình xử lý crash không bị sandbox.

Điểm thú vị là cả ba lỗ hổng, một cách tình cờ, đều xoay quanh việc thay đổi thứ tưởng như bất biến: một HeapNumber trong giai đoạn 1, một buffer WebP đã được chuyển nhượng trong giai đoạn 2, và mức bảo vệ trang của một tiến trình được cho là đã đóng băng trong giai đoạn 3.

Giai đoạn 1: Biến cái bất biến thành có thể thay đổi

Lỗ hổng đầu tiên nằm trong Maglev, trình biên dịch tối ưu tầng trung của V8. Khi mã tối ưu hóa hoạt động dựa trên các giả định về giá trị và đối tượng, và một giả định không còn đúng, V8 phải tái tạo lại các đối tượng JavaScript thông thường — quá trình này gọi là deoptimization.

Vấn đề nằm ở chỗ: đường dẫn Maglev bị lỗi tạo ra một HeapNumber trong old-space cho giá trị ảo, rồi lưu chính đối tượng đó vào mảng literal của deoptimization. Trong khi đó, bộ materializer chung lại sao chép danh tính của đối tượng — chứ không chỉ giá trị số — vào từng chủ sở hữu được tái tạo.

Kết quả: V8 có thể tạo ra nhiều đối tượng JavaScript riêng biệt nhưng các trường double của chúng lại chia sẻ chung một HeapNumber có thể thay đổi. Điều này vi phạm một bất biến của trình biên dịch được ghi rõ trong mã nguồn V8: HeapNumberRef chỉ được tạo cho các HeapNumber bất biến.

Khai thác lợi dụng sự mâu thuẫn này nhắm vào Array.prototype.at(). Một quan sát của trình biên dịch thấy chỉ số an toàn và phê duyệt truy cập trong biên; một quan sát khác của cùng hằng số lại cung cấp offset tải thực tế. Chỉ cần thay đổi trường double của một đối tượng là có thể thay đổi offset mà mã tối ưu sử dụng mà không vô hiệu hóa lý luận về biên trước đó.

Ba hàm tải tối ưu hóa là đủ để rò rỉ các con trỏ V8 lân cận, khôi phục bố cục của mảng packed-double, và có được tham chiếu đến một mảng do kẻ tấn công mô tả. Mảng giả mạo đó sau đó được dùng làm bàn đạp để ghi lại siêu dữ liệu của một Uint8Array thật, mở rộng quyền truy cập chỉ mục thông thường trên toàn bộ cage 4 GiB của V8.

V8 đã gỡ bỏ trường hợp đặc biệt literal-object. Mỗi lần deoptimization giờ đây tạo ra một box mới, và Maglev ngăn chặn việc khử trùng lặp chúng. Bản vá đầy đủ là thay đổi V8 8255554.

Giai đoạn 2: Hai file WebP rẽ lối trong cage V8

Khi tìm kiếm lỗ hổng thoát khỏi sandbox V8, nhóm XBOW quyết định không tấn công chính sandbox mà tìm những thứ được phép nhưng có khả năng gây vấn đề. Họ tập trung vào các nghĩa vụ mà mô hình mối đe dọa của sandbox V8 áp đặt lên các bên tiêu thụ hạ nguồn.

Lỗ hổng được tìm thấy nằm ở WebCodecs: nó có thể đưa một ArrayBuffer cho ImageDecoder bằng cách chuyển nhượng. Theo ngữ nghĩa JavaScript thông thường, việc chuyển nhượng sẽ tách buffer cũ và các view của nó. Nhưng sandbox V8 có mô hình mối đe dọa khác: một khi kẻ tấn công kiểm soát bộ nhớ trong cage, V8 không hứa sẽ giữ một ArrayBuffer đã tách bất biến về mặt ngữ nghĩa.

Khai thác sử dụng một ghi trong cage từ giai đoạn 1 để xóa một view Uint8Array đang sống khỏi chuỗi thông báo tách của JSArrayBuffer. Khi ImageDecoder chuyển nhượng buffer, V8 tách các view mà nó còn biết. Nhưng các ghi tối ưu hóa qua view bị bỏ sót vẫn dùng được. Blink tin rằng nó sở hữu độc quyền các byte đó; kẻ tấn công vẫn có thể thay đổi chúng.

Giả định sở hữu bị phá vỡ này biểu hiện trong giải mã WebP dưới dạng lỗ hổng TOCTOU (time of check / time of use). Khai thác đặt hai file WebP động có cùng độ dài, đều hợp lệ độc lập, vào cùng một backing store:

  • WebP A mô tả canvas 64×64, khung đầu rộng 48 pixel, bắt đầu tại x = 16, cao 64 pixel.
  • ImageDecoder chuyển nhượng buffer, bộ demuxer liên tục cache hình chữ nhật khung và vị trí payload của A.
  • View còn sống thay thế các byte bằng WebP B, có payload hiện tại giải mã khung 64×64 đầy đủ.
  • Bộ giải mã đọc dữ liệu nén từ B nhưng mô tả buffer đầu ra bằng hình học khung được cache từ A.

WEBPImageDecoder::DecodeSingleFrame() khởi tạo stride và kích thước của buffer ngoài từ hình chữ nhật được cache, rồi tiến con trỏ đầu ra độc lập đến pixel trên cùng bên trái của hình chữ nhật đó. Trong bản tái hiện ASAN, bitmap có stride 256 byte và tổng cấp phát là 16.384 byte. GetAddr(16, 0) tiến con trỏ đầu ra 64 byte, để lại 16.320 byte — vừa đủ cho hình chữ nhật được cache của A. Nhưng Blink vẫn quảng cáo toàn bộ 16.384 byte, nên payload rộng 64 pixel của B vượt qua kiểm tra kích thước buffer ngoài của libwebp.

Kết quả: WebPIUpdate() ghi các pixel vượt quá 64 byte khỏi cấp phát SkBitmap native. ASAN phát hiện một ghi chuyển đổi 256 byte bắt đầu từ byte đầu tiên vượt ngoài cấp phát.

Trong khai thác production, hình học được chọn cho PartitionAlloc: một khung hai pixel bắt đầu tại x = 124 trên canvas 126 pixel. Cấp phát pixel 504 byte và một WebPIDecoder 496 byte nằm ở các slot liền kề trong bucket 512 byte. Ghi pixel thay thế các trường native của bộ giải mã đang sống bằng con trỏ đến hai bản ghi WebPDecBuffer đã được dàn dựng. Khi giải mã hoàn tất, WebPCopyDecBufferPixels() thực sự đi theo mô tả nguồn và đích giả, sao chép một giá trị 64-bit được chọn đến một địa chỉ native được chọn. Chính xác một ghi đó là toàn bộ giao diện giữa khai thác ảnh và giai đoạn tiếp theo.

Bàn giao WASM / WCP

Giai đoạn 2 tạo ra một ghi native được chọn ra ngoài cage V8. Chuỗi vẫn cần biến ghi đó thành thực thi mã native tùy ý mà không sửa đổi một mục con trỏ mã được bảo vệ.

Một hàm WebAssembly nóng giải quyết mục tiêu của nó thông qua bảng Wasm Code Pointer toàn tiến trình của V8. Khai thác thay đổi base có thể ghi dùng để định vị bảng đó và cung cấp một mục thay thế có đúng kiểu.

Nhóm đã phỏng theo kỹ thuật "nhảy đảo" (island hopping) từ chuỗi khai thác hoàn chỉnh trước đó. Lần này, họ dùng 13 giá trị tức thời 64-bit trong mã Wasm do Liftoff sinh ra và ABI trình tải Windows. Sau khi base bảng Wasm Code Pointer bị thay đổi, lệnh gọi Wasm tiếp theo vượt qua kiểm tra chữ ký và giải quyết mục thay thế, trỏ đến lệnh jmp rcx trong chrome.dll. Mỗi đảo đóng góp tối đa sáu byte của loader, theo sau là một bước nhảy ngắn qua các lệnh do trình biên dịch sinh ra. Cùng lại, 13 đảo tạo thành loader khoảng 70 byte — cấp phát bộ nhớ ghi được, sao chép payload Crashpad, đổi ánh xạ sang thực thi được, và gọi nó.

Giai đoạn 3: Renderer từ chối bình tĩnh

Trình xử lý Crashpad của Chrome chạy ngoài sandbox renderer. Khi nhận yêu cầu snapshot, nó tạm dừng renderer và đọc cấu trúc dữ liệu từ không gian địa chỉ của renderer. Handler tạo ScopedProcessSuspend, gọi NtSuspendProcess(), trước khi thông báo cho bộ máy snapshot rằng mục tiêu đã bị tạm dừng.

Trong phiên bản ngay trước bản vá, một trong các trợ giúp bộ nhớ từ xa của Crashpad, ProcessMemoryWin::ReadUpTo(), chứa đường dẫn phục hồi sau:

if (GetLastError() == ERROR_PARTIAL_COPY) {
  size_t short_read =
      base::GetPageSize() - (address & (base::GetPageSize() - 1));
  success = ReadProcessMemory(handle_, ..., short_read, &size_out);
  if (success)
    return base::checked_cast(size_out);
}

Thoạt nhìn, lỗi có vẻ đơn giản là short_read không bị giới hạn bởi size. Nhưng phép tính lại an toàn dưới giả định mà Crashpad tin là đã thiết lập: không gian địa chỉ của tiến trình bị tạm dừng không thể thay đổi.

Giả định đó sụp đổ vì Windows cho phép một luồng được tạo với cờ THREAD_CREATE_FLAGS_BYPASS_PROCESS_FREEZE tiếp tục chạy xuyên qua NtSuspendProcess(). Trước khi yêu cầu snapshot, payload renderer native tạo một luồng như vậy với cờ 0x42. Trong khi Crashpad coi renderer đã bị tạm dừng, luồng đó liên tục đổi các trang nguồn do kẻ tấn công kiểm soát giữa PAGE_NOACCESS và PAGE_READWRITE.

Trình tự thắng cuộc:

  1. Crashpad phát hành một đọc nhỏ nằm hoàn toàn trong một trang nguồn.
  2. Trang không truy cập được, nên ReadProcessMemory() trả về ERROR_PARTIAL_COPY.
  3. Crashpad hiểu sai thất bại đó là bằng chứng rằng yêu cầu đã vượt qua ranh giới trang.
  4. Luồng renderer miễn đóng băng làm trang trở nên đọc được.
  5. Crashpad thử lại đến hết trang, sao chép nhiều dữ liệu hơn so với yêu cầu của bên gọi.

Với vị trí gọi mà khai thác sử dụng: yêu cầu 0x0b8 byte, offset nguồn trong trang 0x0e98, độ dài thử lại 0x168 byte, slot heap đích 0x0c0 — ghi đè sang slot kế tiếp 0x0a8 byte. Để đảm bảo độ tin cậy của cuộc đua, payload cung cấp 48 lần đọc ứng viên trong một yêu cầu snapshot.

Việc thử lại quá khổ chỉ có thể làm hỏng cấp phát ngay sau slot buffer đọc 0xc0. Payload đã sắp xếp để cấp phát đó chứa một SnapshotMinidumpMemoryWriter đang sống. Ghi đè viết lại 0xa8 byte đầu của writer đang sống: ghi lại vtable của nó, đặt trạng thái sang writable, và thay vtable của MemorySnapshot::Delegate bằng một vtable CopyingOutputStreamAdaptor bị dịch chuyển.

Sau đó Crashpad gọi WriteObject() qua vtable đầu tiên, gọi Read() qua con trỏ snapshot được giữ nguyên, và callback qua vtable thứ hai sao chép các byte đến một địa chỉ ghi được trong chrome.exe. Các byte được sao chép dựng nên cả một output stream một phương thức lẫn một đối tượng fallback-launcher. Khi adaptor xả bộ đệm, Write() ảo của stream đi vào FallbackUnhandledExceptionFilter() của Chrome. Bộ lọc gọi FallbackCrashHandlerLauncher::LaunchAndWaitForHandler(), truyền cmd /k calc.exe cho CreateProcessW.

Một chi tiết quan trọng cho người dùng Việt Nam quan tâm đến bảo mật: vì Windows ASLR ngẫu nhiên hóa module một lần thay vì cho từng tiến trình, chrome.exe được ánh xạ tại cùng địa chỉ trong cả renderer lẫn handler. Điều này cho phép payload renderer suy ra địa chỉ handler từ image base trong tiến trình của chính nó.

Bài học từ chuỗi khai thác thời đại LLM

Nhóm XBOW chia sẻ một số suy ngẫm đáng chú ý:

  • Cả ba lỗ hổng đều liên quan đến việc thay đổi thứ tưởng như bất biến. Đây có thể chính là nơi LLM tỏa sáng: chúng có thể áp dụng kiến thức bách khoa về API hệ điều hành và mã nguồn mở để nghĩ ra các tình huống bất biến thất bại.
  • Va chạm lỗ hổng là rủi ro lớn. Khi tất cả mọi người — kể cả Google — đều hướng LLM vào các codebase như Chrome, việc hai nhóm tìm ra cùng một lỗi là chuyện thường. Chuỗi đầu tiên của XBOW chết vì giai đoạn cuối trùng với một lỗi Google đã tìm thấy nội bộ từ nhiều tháng trước. Họ phải thay thế giai đoạn V8 sandbox escape bằng lỗi WebP động chỉ trong vài giờ căng thẳng.
  • Chất lượng ví dụ cho agent rất quan trọng. Dù LLM làm phần lớn công việc, agent thành công hơn khi được cung cấp ví dụ chất lượng cao — trong trường hợp này là kỹ thuật "nhảy đảo" do đội Native của XBOW phát triển.

Sáu ngày và 40.000 USD không phải là nhiều thời gian và tiền bạc khi phần thưởng là một chuỗi khai thác Chrome! Điều này không có nghĩa ai cũng có thể đạt được chuỗi Chrome với ngần ấy thời gian và tiền — chúng tôi đã áp dụng khá nhiều chuyên môn và phán đoán của con người. Nhưng nó có nghĩa là ngưỡng đã thấp hơn đáng kể. Hãy phòng thủ cho phù hợp.

Đối với các nhóm bảo mật và lập trình viên tại Việt Nam đang theo dõi xu hướng này, thông điệp rất rõ ràng: chi phí tìm kiếm lỗ hổng đang giảm nhanh chóng, và các bất biến ngầm trong mã nguồn — những thứ mà con người thường bỏ qua vì mặc định chúng đúng — đang trở thành mục tiêu ưu tiên. Việc rà soát lại các giả định tưởng như hiển nhiên trong codebase của bạn có thể là khoản đầu tư bảo mật đáng giá nhất trong thời gian tới.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗