Những trở ngại đối với quá trình dịch ngược
Thông thường, khi phân tích assembly dotnet, đa số các nhà nghiên cứu bảo mật sẽ tập trung ở tầng mã IL hoặc mã C# biên dịch ngược. Thành thật mà nói, chẳng ai nghĩ tới một công cụ khác ngoài dnSpy/dnySpyEx.
Tuy nhiên, khi cần dịch ngược assembly sử dụng kỹ thuật R2R stomping, cần phân tích ở tầng sâu hơn. Như chúng ta đã thấy ở phần triển khai, thủ thuật nằm ở tầng mã native.
Những vấn đề chính chúng ta phải đối diện có thể tóm gọn như sau:
- Chúng ta nhìn thấy đoạn mã khác với cái thực sự gì đã thực thi (phân tích tĩnh)
- Chúng ta debug đoạn mã khác với cái thực sự đã thực thi trong môi trường trình quản lý gỡ rối (phân tích động)
- Những hình thức biên dịch khác cũng có thể được áp dụng, gây khó khăn cho quá trình phân tích (tăng độ phức tạp của quá trình phân tích)
Để làm rõ các vấn đề ảnh hưởng đến việc dịch ngược, chúng ta sẽ sử dụng các ứng dụng sử dụng kỹ thuật R2R tạo ở đề mục “Triển khai kỹ thuật R2R stomping” ở bài viết trước đó làm ví dụ.
Những trở ngại đối với quá trình phân tích tĩnh
Khi thử kiểm tra mã IL hoặc đoạn mã C# biên dịch ngược của assembly sử dụng kỹ thuật R2R stomping, chúng ta sẽ không thấy bất cứ sự khác thường nào qua đánh giá sơ bộ.
Ví dụ, chương trình sử dụng kỹ thuật R2R stomping theo kiểu thay thế hoặc thay đổi đoạn mã IL và giữ nguyên phần mã biên dịch sẵn (ở mục “Biên dịch mã khai thác - Thay thế bằng mã IL giả”), sẽ được hiển thị trong dnSpyEx như sau:
Một người bất kỳ sẽ cho rằng các chỉ dẫn nops ở đây thật khả nghi, tuy nhiên điều quan trọng cần chú ý là các chỉ dẫn nops đều có thể được xóa bỏ hoàn toàn.
Những ai đã quen thuộc với các thành phần bên trong của dotnet sẽ nhận thấy rằng metadata dotnet về kiểu tham chiếu đang chứa những kiểu tham chiếu không hề được sử dụng bởi mã IL (trên thực tế chúng được sử dụng bởi mã native biên dịch sẵn được giữ nguyên).
Tuy đây là một ý hay, nhưng trong chương trình phức tạp hơn, khi chỉ một trong số nhiều method là đối tượng được áp dụng kỹ thuật R2R stomping, thì các kiểu tham chiếu không được sử dụng sẽ dễ bị bỏ qua.
Ngoài ra, đối với trường hợp nhắc trong đề mục “Biên dịch mã giả - Thay thế bằng mã native khai thác” thì sao? Trong trường hợp này, đoạn mã IL code ban đầu giữ nguyên, còn đoạn mã native biên dịch sẵn bị thay thế bằng shellcode, do đó metadata về kiểu tham chiếu là đúng với thực tế.
Những trở ngại đối với quá trình phân tích động - debugging
Khi nhắc đến debugging một assembly dotnet, thật khó có thể tưởng tượng việc sử dụng một công cụ khác ngoài dnspy/dnSpyEx.
Khi chạy/debug ứng dụng bị sửa với kỹ thuật ReadyToRun trong dnSpyEx, quá trình thực thi diễn ra hoàn toàn khác so quá trình thực thi thông thường. Đó là bởi vì các thiết đặt mặc định của dnSpyEx đã chặn cơ chế tối ưu JIT (để đảm bảo trải nghiệm debugging), cưỡng chế thực hiện biên dịch JIT (Just-In-Time) đối với đoạn mã IL hiện tại, và bỏ qua việc thực thi đoạn mã native biên dịch sẵn.
Có thể nhận thấy, khi thử debug/chạy ứng dụng sử dụng kỹ thuật R2R stomping tạo trong đề mục “Biên dịch mã giả - Thay thế bằng mã native khai thác” (đoạn mã IL gốc giữ nguyên, đoạn mã native biên dịch sẵn được thay thế bằng shellcode) trong dnSpyEx thì tiến trình calc.exe
không chạy.
Nhưng một khi chạy ứng dụng ngoài môi trường của trình gỡ lỗi (thực thi như bình thường), có thể thấy rằng nhờ quá trình tối ưu hóa của .NET, mà shellcode (nhúng thay thế vị trí của đoạn mã native biên dịch sẵn) được ưu tiên và thực thi.
Để nâng cao trải nghiệm debugging thì thiết đặt chặn tối ưu hóa JIT là hoàn toàn hợp lý. Ngoài lề thì hoàn toàn có thể tái tạo hành vi của dnSpyEx với các thiết đặt mặc định khi thực thi ứng dụng ở môi trường thông thường, bằng cách tắt cơ chế tối ưu AOT. Điều này có thể đạt được bằng cách đặt thêm biến môi trường COMPlus_ReadyToRun=0
cho tiến trình.
Dưới đây là sự so sánh khi thực thi ứng dụng khi không có (bên trái) và khi có (bên phải) biến môi trường COMPlus_ReadyToRun=0
.
Làm rối quá trình phân tích
Để làm rối quá trình phân tích assembly áp dụng kỹ thuật R2R stomping, có thể sinh ra nhiều hình thức ứng dụng ReadyToRun khác nhau bằng cách tùy chỉnh các thiết đặt cho trình biên dịch.
Một ví dụ điển hình cho ý tưởng trên là sự kết hợp các thiết đặt định dạng tệp tin bundle dotnet (single-file) và tự chứa assembly (self-contained).
Những thiết đặt này đều chỉ sinh ra một tệp thực thi native (nhờ thiết đặt biên dịch single-file), chứa cả các assembly dotnet trong overlay của nó. Ngoài module chính của ứng dụng, thì một phần lớn các assembly dotnet cũng đại diện cho runtime dotnet mục tiêu được đóng gói ở định dạng single-file (nhờ thiết đặt tự chứa assembly (self-contained)).
Khi gặp phải một chương trình dạng này, khó khăn nằm ở chỗ không chỉ vừa phải giải quyết các vấn đề đã đề cập trên, mà còn phải xác định hình thức biên dịch và phân tách các assembly ra khỏi overlay của bundle dotnet (single-file).
Tuy các hình thức biên dịch này nằm ngoài phạm vi nghiên cứu trong bài viết (vì nó không trực tiếp liên quan đến kỹ thuật R2R stomping), nhưng việc phân tách các assembly dotnet ra khỏi overlay của bundle dotnet (single-file) hoàn toàn khả thi khi sử dụng các công cụ hỗ trợ sâu đối với các tệp bundle dotnet, có thể là các công cụ có giao diện như ILSpy, dotPeek hoặc tiếp cận qua phương pháp lập trình sử dụng AsmResolver.
Các kỹ thuật và công cụ sử dụng để dịch ngược các assembly R2R stomping
Việc phân tích và dịch ngược các assembly áp dụng kỹ thuật R2R stomping đòi hỏi cách tiếp cận khác hơn so với những thủ thuật áp dụng trên các assembly dotnet thông thường. Cần bộ công cụ khác để phân tích các thành phần của assembly ReadyToRun liên quan đến biên dịch AOT và kết quả sinh ra từ quá trình này. Không may thay, hiện giờ không có giải pháp toàn diện nào cho việc này. Tuy nhiên, lại có một vài công cụ giúp ích cho từng nhóm công việc liên quan.
Một cách tổng quan, các công việc cần thực hiện có thể chia nhóm như sau:
- Phân tích chuyển đổi cấu trúc assembly ReadyToRun (R2RDump, dotPeek)
- Hiển thị đoạn mã IL và đoạn mã C# được biên dịch ngược (ILSpy, dnSpyEx, dotPeek)
- Xác định vị trí và dịch ngược đoạn mã native được biên dịch sẵn (R2RDump, ILSpy)
Để minh họa việc sử dụng từng công cụ cho từng công việc, ứng dụng R2R stomping tạo ở đề mục “Biên dịch mã giả - Thay thế bằng mã native khai thác” (thay thế đoạn mã native biên dịch sẵn, giữ lại đoạn mã IL ban đầu) được chọn để đối chiếu.
Phân tích chuyển đổi cấu trúc assembly ReadyToRun
Chuyển đổi chính xác cách hiển thị cấu trúc assembly R2R rất quan trọng, bởi đây là nơi chứa đựng thông tin quan trọng phục vụ quá trình phân tích và dịch ngược. Một ví dụ có thể kể đến, là danh sách các method được biên dịch sẵn ở dạng native kèm theo các chi tiết về vị trí, cũng như kích thước.
R2RDump và dotPeek là một trong số các công cụ hàng đầu tường tận về cấu trúc assembly R2R, phân tích chuyển đổi và hiển thị thông tin kể trên một cách trực quan.
*R2RDump là công cụ command-line, là một phần của mã nguồn của runtime dotnet trên repository GitHub tương ứng. Công cụ này không nằm trong bản cài đặt runtime dotnet, nên nếu cần sử dụng, cần phải tự biên dịch. Công cụ được duy trì và cập nhật thường xuyên, vì vậy nó cung cấp thông tin toàn diện nhất về các assembly ReadyToRun. Các lựa chọn chạy công cụ hiển thị trong ảnh dưới đây.*
Ví dụ, sử dụng R2RDump để xuất ra thông tin về phần header R2R và nội dung của từng section:
Nếu thích công cụ có giao diện, có thể sử dụng dotPeek. Mặc dù nó không thể cung câp thông tin chi tiết như R2RDump, tuy nhiên là một sự thay thế phù hợp.
Hiển thị đoạn mã IL và đoạn mã C# biên dịch ngược
Như đã mô tả trước đó, lợi dụng kỹ thuật R2R stomping, một đoạn mã IL hoặc một đoạn mã native biên dịch sẵn nhất định bị thay đổi. Vì vậy, việc hiển thị đoạn mã IL của các method bị thay đổi là một giai đoạn quan trọng trong quá trình phân tích.
Đa số các nhà nghiên cứu đều biết những công cụ như dnSpyEx, ILSpy, hay dotPeek, có thể phục vụ cho việc hiển thị đoạn mã IL và đoạn mã C# biên dịch ngược tương ứng với nó. Đây là có lẽ là thao tác chung duy nhất trong quá trình phân tích bất cứ assembly dotnet điển hình.
Engine ILSpy chạy trong công cụ dnSpyEx nhằm tái cấu trúc cả đoạn mã IL và đoạn mã C# biên dịch ngược. Dưới đây là ví dụ đối chiếu việc hiển thị này.
Xác định vị trí và dịch ngược đoạn mã native biên dịch sẵn
Bước cuối cùng và quan trọng nhất của quá trình phân tích các assembly áp dụng kỹ thuật R2R stomping là xác định vị trí và thấy phần dịch ngược của các method được biên dịch sẵn ở dạng native.
Đến bước này, chỉ có số lượng hữu hạn các công cụ có thể sử dụng. Những công cụ này cần hiểu cấu trúc của assembly R2R và phải chuyển đổi phù hợp thông tin sử dụng cho việc xác định và xử lý đoạn mã native biên dịch sẵn để dịch ngược, cũng như hiển thị đoạn mã này. Các công cụ hữu ích nhất để thực hiện công việc này là R2RDump và ILSpy.
Công cụ R2RDump đã được đề cập ở trên, nhưng chưa được nhắc tới khả năng phục dựng và hiển thị phần dịch ngược của các method được biên dịch sẵn ở dạng native. Ví dụ dưới đây minh họa khả năng này. (hiển thị phần dịch ngược của assembly R2R stomping, method Main
)
Công cụ ILSpy là một trong số các công cụ đột phá cho việc phân tích dotnet. Công cụ này có thể hiểu định dạng assembly R2R ở mức độ đủ để đọc từng dòng mã dịch ngược của các method biên dịch sẵn. Bằng cách chọn method biên dịch sẵn ở dạng mã native và chuyển sang chế độ hiển thị tên “ReadyToRun”, có thể xem đoạn mã dịch ngược của method vừa chọn.
Phát hiện R2R stomping
Trước khi đưa ra các cách phát hiện kỹ thuật R2R stomping, thì cần tìm cách phát hiện chung cho dạng biên dịch ReadyToRun. Đây là một công việc dễ dàng, có thể thực hiện cả bằng phương pháp thủ công hoặc tự động.
Với các tiếp cận thủ công, có thể nghĩ tới ngay các công cụ như dotPeek hay ILSpy để phát hiện định dạng R2R. Các công cụ này thậm chí còn hiểu định dạng tệp bundle dotnet, vì vậy kể cả khi ứng dụng R2R stomping bị làm rối với thiết đặt này, chúng vẫn có thể hỗ trợ tốt (tức là trích xuất nội dụng trong bundle).
Các tệp nhị phân được biên dịch ở dạng ReadyToRun là một tệp CLU có thêm trường “ManagedNativeHeader” trỏ tới một “READYTORUN_HEADER” cụ thể. Trường nhận diện “READYTORUN_HEADER” luôn có giá trị 0x00525452 (tham chiếu theo bảng mã ASCII nghĩa là “RTR”). Địa chỉ RVA và kích cỡ “ManagedNativeHeader” là một phần của .NET Directory. Giá trị của tất cả những thành phần này có thể sử dụng để tạo một rule Yara, tự động hóa việc phát hiện định dạng dotnet ReadyToRun. Đoạn mã dưới đây minh họa rule Yara.
import "pe"
rule r2r_assembly
{
meta:
author = "jiriv"
description = "Detects dotnet binary compiled as ReadyToRun - form of ahead-of-time (AOT) compilation"
condition:
// check if valid PE
uint16(0) == 0x5a4d and uint16(uint32(0x3c)) == 0x4550 and
// check if dotnet -> .NET Directory is present
pe.data_directories[pe.IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR].virtual_address != 0 and
// check if ManagedNativeHeader exists -> ManagedNativeHeader RVA is not 0 inside .NET Directory
uint32(pe.rva_to_offset(pe.data_directories[pe.IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR].virtual_address) + 0x40) != 0 and
// check if it is R2R -> RTR magic signature is present (0x00525452 == "RTR" in ascii)
uint32(pe.rva_to_offset(uint32(pe.rva_to_offset(pe.data_directories[pe.IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR].virtual_address) + 0x40))) == 0x00525452
}
Thông thường, phương pháp phát hiện R2R stomping thủ công dựa vào việc phân tích sự khác biệt giữa đoạn mã IL của method và đoạn mã native biên soạn sẵn thật sự của nó.
Như đề cập từ trước, chưa tồn tại một công cụ nào được coi là giải pháp tích hợp toàn diện cho việc phân tích và phát hiện R2R stomping, tuy nhiên ILSpy có gần đạt tới mức đó. ILSpy hiểu định dạng R2R và có khả năng hiển thị đoạn mã IL, đoạn mã C# biên dịch ngược, và thậm chí dạng dịch ngược của đoạn mã native biên dịch sẵn. Hơn thế, công cụ này có thể xử lý các định dạng biên dịch khác của dotnet như bundle (single-file) hoặc tự chứa assembly (self-contained). Với tất cả những khả năng này, nó trở thành công cụ chính sử dụng cho quá trình phát hiện và phân tích thủ công ứng dụng R2R stomping. Cần lưu ý, tuy engine của ILSpy được chạy ẩn trong dnSpyEx, tuy nhiên tất cả các tính năng vừa đề cập lại không được khai thác ở dnSpyEx.
Ví dụ phát hiện thủ công R2R stomping thông qua ILSpy được minh họa ở ảnh dưới, ở đây sử dụng ứng dụng trong đề mục ”Biên dịch mã giả - Thay thế bằng mã native khai thác” (thay thế đoạn mã native biên dịch sẵn, để lại đoạn mã IL gốc) – nhúng shellcode.
Với chế độ nhìn đối chiếu giữa các khung hiển thị, có thể nhận thấy đoạn mã native code biên dịch sẵn của method Main
rất có vấn đề. Khó có thể tưởng tượng một tình huống thực tế cho phép đoạn mã native biên dịch sẵn thiếu đoạn khởi tạo hàm thông thường và thậm chí còn thao túng cấu trúc PEB (Process Environment Block). Trạng thái dịch ngược kỳ vọng thường sẽ giống ảnh sau (tệp gốc, không áp dụng kỹ thuật R2R stomping).
Khi sử dụng phương pháp phát hiện thủ công R2R stomping đối với ứng dụng thứ hai trong đề mục “Biên dịch mã khai thác – Thay thế bằng mã IL giả” (thay thế đoạn mã IL đã biên dịch, để lại đoạn mã native biên dịch sẵn gốc), có thể dễ dàng phát hiện thiếu reference cho method Process.Start()
trong khung hiển thị mã IL và C#.
Đương nhiên, các ứng dụng càng phức tạp thì càng khó để bóc trần kỹ thuật R2R stomping được sử dụng. Lối tiếp cận thủ công luôn mất thời gian hơn, nhưng trong đa số các trường hợp thì đây là cách tốt nhất để phát hiện các assembly áp dụng kỹ thuật R2R stomping.
Nếu cố tự động hóa việc phát hiện R2R stomping, thì không thể tìm ra một giải pháp đơn giản và có độ chính xác cao ở thời điểm hiện tại. Như đã thấy, logic của việc phát hiện R2R stomping nằm ở việc bao quát hết toàn bộ các viễn cảnh có thể xảy ra. Trong bài viết này chỉ đưa ra các trường hợp nhúng shellcode và thay đổi đoạn mã IL giả so với đoạn mã biên dịch trước, nhưng luôn có thể tồn tại những cách khác để triển khai kỹ thuật R2R stomping.
Như vậy, rất khó để có thể triển khai logic phát hiện R2R stomping thông qua những giải pháp dựa trên tập dấu hiệu nhận biết (signature-based), ví dụ Yara.
Giải pháp nghe chừng khả thi nhất là tiếp cận bằng phương pháp lập trình, thông qua sự hỗ trợ của các thư viện hiểu cấu trúc, metadata của assembly dotnet, mã IL và cũng có khả năng dịch ngược đoạn mã native được biên dịch sẵn (như dnlib, AsmResolver, iced). Độ tin cậy phụ thuộc vào việc logic triển khai có định dạng được đoạn mã biên dịch sẵn của các method hay không, và chúng trông như thế nào trên các nền tảng và kiến trúc khác nhau.
Giải pháp đáng tin cậy và dễ triển khai. Nếu giá trị băm tính trước của đoạn mã IL và đoạn mã biên dịch sẵn từ đoạn mã IL này được thêm vào cấu trúc assembly R2R và được xác thực trước mỗi lần thực thi trong runtime dotnet, thì kỹ thuật R2R stomping sẽ không thể triển khai (cho tới khi người ta nghiên cứu ra kỹ thuật R2R Hash stomping).
Kết luận
Nghiên cứu này chỉ ra phương pháp mới để chạy đoạn mã nhúng ẩn trong các tệp nhị phân .NET được biên dịch ở định dạng ReadyToRun (R2R). Nội dung bài viết đã làm rõ chi tiết cách thức triển khai, tập trung chỉ ra các quá trình xử lý bên trong runtime dotnet và các hệ quả khiến cho quá trình dịch ngược trở nên khó hơn.
Trong những đề mục cuối cùng, bài viết đưa ra một số công cụ và kỹ thuật hiệu quả và hữu ích cho quá trình phân tích các ứng dụng áp dụng kỹ thuật R2R stomping và cách sử dụng chúng cho quá trình phát hiện.
Mặc dù không sẵn có cơ chế phát hiện tĩnh và tự động, đối với các trường hợp nhúng shellcode sử dụng kỹ thuật R2R stomping, thì cơ chế phát hiện dựa trên hành vi vẫn có thể áp dụng. R2R stomping có thể ảnh hưởng tới quá trình nghiên cứu bảo mật, nhưng nó không hẳn là kỹ thuật “lẩn tránh” (evasion technique). Cho tới thời điểm này, chưa có bằng chứng về việc sử dụng rộng rãi kỹ thuật R2R stomping, nhưng cũng không thể loại trừ việc kỹ thuật này đã được sử dụng trước thời điểm ra bài nghiên cứu.
Chủ đề của bài nghiên cứu này đã được báo cho MSRC (Microsoft Security Response Center) vào tháng 6 năm 2023. Tuy nhiên, vì nó không vi phạm bất cứ các nguyên tắc nào của MSRC về spoofing hay bypass tính năng bảo mật, nên sẽ không có bản vá nào chống kỹ thuật R2R stomping.
Tài liệu tham khảo
- ReadyToRun File Format / Định dạng tệp ReadyToRun: https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/readytorun-format.md
- ReadyToRun Overview / Tóm lược về ReadyToRun: https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/readytorun-overview.md
- ReadyToRun Compilation / Biên dịch ứng dụng ReadyToRun: https://learn.microsoft.com/en-us/dotnet/core/deploying/ready-to-run
- Single-file deployment / Triển khai single-file: https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/
- ILSpy: https://github.com/icsharpcode/ILSpy
- DnSpy/dnSpyEx: https://github.com/dnSpyEx/dnSpy
- R2RDump: https://github.com/dotnet/runtime/tree/main/src/coreclr/tools/r2rdump
- DotPeek: https://www.jetbrains.com/decompiler/
- NGEN: https://learn.microsoft.com/en-us/dotnet/framework/tools/ngen-exe-native-image-generator
- Native AOT: https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=net7
- ECMA-335: https://www.ecma-international.org/publications-and-standards/standards/ecma-335/
- Visual Studio IDE: https://visualstudio.microsoft.com/
- AsmResolver: https://github.com/Washi1337/AsmResolver
- Dnlib: https://github.com/0xd4d/dnlib
- MsfVenom: https://docs.metasploit.com/docs/using-metasploit/basics/how-to-use-msfvenom.html
- IDA (Hex-Rays): https://hex-rays.com/ida-pro/
- YARA: https://github.com/VirusTotal/yara
- Iced: https://github.com/icedland/iced