Nghiên cứu bởi: Jiri Vinopal tại Check Point Research (CPR)

Điểm chính

  • Check Point Research (CPR) giới thiệu kỹ thuật mới gọi là R2R stomping, nhằm giấu đoạn code khai thác trong file thực thi .NET compiled với định dang ReadyToRun (R2R).
  • CPR giải thích chi tiết, từ bản chất đến cách thức triển khai kỹ thuật R2R stomping.
  • Các thách thức và trở ngại đối với kỹ sư dịch ngược và nhà nghiên cứu bảo mật khi ứng dụng sử dụng kỹ thuật R2R stomping.
  • CPR diễn giải các kỹ thuật và công cụ có thể sử dụng để dịch ngược assembly của các ứng dụng sử dụng kỹ thuật R2R stomped và một vài cách khả thi để phát hiện các ứng dụng này.
  • CPR chưa tìm thấy bất cứ bằng chứng nào cho thấy kỹ thuật R2R stomping đã được sử dụng trong thực tế, tuy nhiên cũng không thể loại trừ khả năng kỹ thuật này đã được ứng dụng từ trước bài nghiên cứu.

Tóm tắt

Đôi khi, thực tế mà bạn nhìn thấy bằng chính đôi mắt của mình không phải lúc nào cũng giống như vẻ ngoài của nó. Điều này hoàn toàn có thể xảy ra: đoạn mã .NET mà bạn đang dõi theo thực thi trong các trình quản lý gỡ lỗi, như dnSpy/dnSpyEx, không nhất thiết phải là cùng một đoạn mã hoạt động khi bạn mở ứng dụng trong thực tế.

Thời gian khởi động và độ trễ của ứng dụng .NET có thể cải thiện bằng cách biên dịch assembly của chúng dưới dạng tệp tin ReadyToRun (R2R), là một dạng biên dịch ahead-of-time (AOT). Các file thực thi biên dịch dưới dạng này chứa đoạn mã native tương tự như biên dịch với JIT (just-in-time), nhưng chúng có kích cỡ lớn hơn bởi chứa cả đoạn mã intermediate language (IL) và phiên bản native của “đoạn mã này”. Ít nhất, đó là những gì mà tài liệu kỹ thuật của Microsoft mô tả.

Bài nghiên cứu này giới thiệu một kỹ thuật mới cho phép chạy đoạn mã khai thác ẩn trong các file thực thi .NET compiled với định dạng R2R. Kỹ thuật này tập trung vào khả năng thay đổi các file thực thi dạng R2R sao cho đoạn mã IL của assembly khác hẳn với đoạn mã native, một thành phần trong file đã được pre-compiled. Tuân theo quy trình tối ưu hóa của .NET, đoạn mã native được biên dịch sẵn sẽ được ưu tiên và thực thi, bỏ qua đoạn mã IL gốc.

Hơn nữa, để có lợi hơn cho trải nghiệm debugging, các cấu hình tối ưu mặc định của các trình quản lý gỡ lỗi như dnSpy/dnSpyEx không thực sự giống .NET, dẫn tới sự khác biệt giữa việc thực thi tệp R2R bị thay đổi trong môi trường thực và trong phạm vi của trình quản lỹ gỡ lỗi.

Bài nghiên cứu sẽ làm rõ các vấn đề sau:

  • Giới thiệu kỹ thuật R2R stoping
  • Giải thích chi tiết từ bản chất đến cách thức triển khai kỹ thuật R2R
  • Các thách thức và trở ngại đối với kỹ sư dịch ngược
  • Các kỹ thuật và công cụ để dịch ngược phần assembly của các tệp thực thi bị R2R stomped
  • Các dấu hiện nhận biết tệp thực thi áp dụng kỹ thuật R2R stomping.

Giới thiệu về R2R stomping

Trước khi đi sâu vào hình thức biên dịch ReadyToRun ở các ứng dụng dotnet, cần điểm qua về công nghệ .NET nói chung.

Framework Dotnet, tạo ra bởi Microsoft, là môi trường cross-platform mã nguồn mở, hỗ trợ xây dựng nhiều loại ứng dụng khác nhau. Framework này hoạt động dựa vào bộ các ngôn ngữ lập trình và ngôn ngữ kịch bản, bao gồm C#, F#, VB.NET và PowerShell. Dotnet giới thiệu lần đầu vào năm 2002 với tên gọi “.NET Framework” và ở thời điểm đó, chỉ phục vụ nền tảng Windows và không công khai mã nguồn. Hai năm sau (2004), phiên bản mã nguồn mở cross-platform đầu tiên của “.NET Framework” ra mắt, với tên gọi “Mono Project”. Phải mất khá nhiều thời gian, Microsoft mới chính thức tung ra .NET Core (2016) – phiên bản mã nguồn mở hỗ trợ cross-platform của “.NET Framework”. Sau đó, giải pháp này được phát triển và thống nhất với tên gọi .NET (.NET 5 - 2020). Vì hình thức biên dịch dotnet ReadyToRun đã xuất hiện từ .NET Core 3.0, kỹ thuật R2R stomping giới thiệu trong bài viết này có thể áp dụng trên tất cả các phiên bản từ .NET Core 3.0.NET 5 trở lên.

Thường thì, phần assembly của một ứng dụng .NET cơ bản chỉ chứa đoạn mã Intermediate Language (hay còn gọi là mã IL, MSIL, CIL). Đoạn mã này cần được biên dịch và phiên dịch thành dạng mã native bởi trình biên dịch JIT ngay khi ứng dụng bắt đầu chạy. Môi trường dotnet ngày càng trở nên phổ biến bởi khả năng xây dựng nhiều loại hình ứng dụng khác nhau, bởi vậy các nhà phát triển nên chú trọng đến cải thiện độ trễ và tốc độ khởi chạy ứng dụng của JIT.

Quá trình biên dịch JIT là nguyên nhân chính khiến tốc độ khởi chạy và thực thi chậm, nên nhìn chung về mặt logic, cách giải quyết vấn đề này là giảm lượng code cần được biên dịch bởi JIT hoặc hạn chế tối đa việc sử dụng JIT. Các giải pháp đề xuất đã được triển khai bằng các hình thức biên dịch khác nhau cho assembly dotnet, và một cách tổng quan, chúng đều là dạng biên dịch ahead-of-time (AOT).

Các hình thức biên dịch AOT chính bao gồm:

  • *NGEN – dành riêng cho .NET Framework, được cho là một giải pháp không thực sự toàn diện
  • *ReadyToRun – từ .NET Core 3.0 trở lên, giảm nhu cầu biên dịch JIT bằng cách pre-compilation
  • *Native AOT – từ .NET 7 trở lên, định dạng full native (PE + mã máy CPU), không cần cài đặt .NET runtime, không sử dụng JIT, không có mã IL hay metadata của .NET trong ứng dụng.

Khi các assembly của ứng dụng được biên dịch ở định dạng ReadyToRun (R2R), một dạng AOT, các tệp thực thi thường sẽ lớn hơn, bởi chúng chứa thêm cả đoạn mã native mà JIT sẽ sinh ra, ngoài đoạn mã IL tương ứng. Bởi định dạng này vẫn phụ thuộc vào metadata dotnet gốc của assembly, nên phần mã native cũng là một thành phần của tệp thực thi được tạo ra.

Vì vậy, nhìn chung, những tệp thực thi này sẽ tuân theo định dạng CLI, mô tả ở ECMA-335, nhưng có thêm phần “ManagedNativeHeader” trở tới “READYTORUN_HEADER” đặc thù, đi kèm với các cấu trúc cần thiết khác để có thể thực thi được đoạn mã native đã được pre-compiled. Dấu hiệu nhận biết “READYTORUN_HEADER” là đoạn hex 0x00525452 (cũng chính là mã ASCII của “RTR”). Dấu hiệu này cũng có thể dùng để phân biệt các tệp thực thi ReadyToRun với các tệp thực thi CLI chỉ có “ManagedNativeHeader” (ví dụ, các tệp thực thi NGen).

Ảnh 1: Cấu trúc một header ReadyToRun hiển thị qua công cụ dotPeek

Kỹ thuật “R2R stomping” tập trung vào khả năng thay đổi các file thực thi dạng R2R sao cho đoạn mã IL của assembly khác hẳn với đoạn mã native, một thành phần trong file đã được biên dịch từ trước. Tuân theo quy trình tối ưu hóa của .NET, đoạn mã native được biên dịch sẵn sẽ được ưu tiên và thực thi, bỏ qua đoạn mã IL gốc của assembly.

Hơn nữa, các cấu hình tối ưu mặc định của các trì nh quản lý gỡ lỗi như dnSpy/dnSpyEx không thực sự giống .NET (loại bỏ quy trình tối ưu JIT), dẫn tới sự khác biệt giữa việc thực thi tệp R2R bị thay đổi trong môi trường thực và trong phạm vi của trình quản lỹ gỡ lỗi.

Nhìn chung, ý tưởng về kỹ thuật R2R stomping tương đồng vớ kỹ thuật VBA stomping, kỹ thuật gây ảnh hưởng đến đoạn mã VBA trong các sản phẩm MS Office và đã được sử dụng bởi kẻ tấn công trong khoảng thời gian dài.

Triển khai kỹ thuật R2R stomping

Như đã đề cập ở trên, ý tưởng chính để triển khai R2R stomping là thay đổi đoạn mã gốc của assembly được biên dịch sao cho chức năng và hành vi của các method trong mã IL khác với đoạn mã native đã được pre-compiled.

Việc thay đổi có thể thực hiện theo hai cách:

  • Biên dịch mã khai thác - Thay thế bằng mã giả, tức là thực hiện thay thế mã IL đã biên dịch, sử dụng đoạn mã native pre-compiled ban đầu
  • Biên dịch mã giả - Thay thế bằng mã khai thác, tức là thực hiện thay thế đoạn mã native đã pre-compiled, để lại đoạn mã IL ban đầu

Khi triển khai R2R stomping, cần ghi nhớ việc chọn giữ lại đoạn mã IL gốc hay đoạn mã native được pre-compiled phụ thuộc vào metadata gốc của assembly dotnet. Nói cách khác, cần thận trọng khi thay đổi các yếu tố ở metadata, bởi kết quả của các thay đổi này có thể khiến cho chương trình không thực thi được.

Tuy môi trường thử nghiệm trong bài nghiên cứu giới hạn trên Windows OS, kiến trúc x64 với .NET 6, CPR đã thành công triển khai kỹ thuật R2R stomping trên đa dạng các runtime dotnet (có hỗ trợ ReadyToRun), từ .NET Core tới .NET 7 trên nhiều kiến trúc và nền tảng OS khác nhau (Windows, Linux, MacOSS).

Điều đáng chú ý là R2R stomping có thể kết hợp thêm với các cấu hình biên dịch khác nhau, như chế độ cho phép tạo bundle dotnet (single-file) hoặc tự chứa assembly. Đối với ví dụ trong bài nghiên cứu, các định dạng biên dịch nói trên đều được loại bỏ để có thể giải thích kỹ thuật R2R stomping một cách dễ hiểu nhất. Một khi áp dụng các cấu hình biên dịch này, quá trình phân tích tệp thực thi áp dụng kỹ thuật R2R stomping sẽ trở nên phức tạp hơn.

Biên dịch mã khai thác - Thay thế bằng mã IL giả

Với cách triển khai này, đoạn mã mục tiêu cần thay thế chính là mã IL cho assembly được sinh ra, còn mã native biên dịch sẵn được giữ nguyên. Đầu tiên, cần tạo một project mới trong Visual Studio IDE, sau đó chọn C#, Console App, và xây dựng ứng dụng trên framework .NET (ví dụ trong bài viết này sử dụng .NET 6).

Hình 2: Visual Studio IDE – Tạo mới một project C#, Console App, .NET 6

Để dựng một ứng dụng ReadyToRun tắt chế độ tự chứa assembly, cần trực tiếp sử dụng flag “PublishReadyToRun” trong lệnh dotnet publish:

dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true --self-contained false.
Hình 3: Dựng ứng dụng ReadyToRun bằng lệnh dotnet publish

Để minh họa việc thay đổi đoạn mã IL, có thể thay thế lời gọi method Process.Start("calc") bằng các chỉ dẫn nop trong mã IL. Có thể sử dụng công cụ có giao diện như dnSpyEx hoặc lập trình với các thư viện AsmResolver hay dnlib. Dù tiếp cận theo hướng nào, thì mục tiêu vẫn là hạn chế thay đổi metadata và cấu trúc PE gốc, để không xóa mất phần native code đã pre-compiled cho các module dotnet.

Sử dụng DnSpyEx:

Mở assembly ReadyToRun đã biên dịch trong dnSpyEx.

Hình 4: DnSpyEx – mở assembly ReadyToRun

Thay thế các chỉ dẫn IL liên quan đến lời gọi method Process.Start("calc") bằng các chỉ dẫn nop.

Hình 5: Thay đổi các chỉ dẫn IL trong dnSpyEx

Lưu module đã chỉnh sửa - bảo toàn bản gốc nhiều nhất có thể và đảm bảo chế độ “Mixed-Mode Module” được chọn.

Hình 6: Lưu module đã hỉnh sửa trong dnSpyEx

Assembly ReadyToRun sử dụng kỹ thuật ReadyTo vừa tạo sẽ không để lại bất cứ dấu vết nào về đoạn mã tạo tiến trình calc trong cả giao diện IL lẫn giao diện biên dịch ngược mã C#.

Hình 7: Giao diện C# và giao diện IL của assembly ReadyToRun bị stomped

Thế nhưng, khi chạy assembly ReadyToRun bị thay đổi như ứng dụng thông thường, hoặc thông qua tệp thực thi CompileReal_ReplaceDecoy_IL.exe trong cùng thư mục, hoặc thông qua lệnh dotnet CompileReal_ReplaceDecoy_IL.dl từ command prompt, đoạn mã native được biên dịch sẵn sẽ thực thi, bỏ qua đoạn mã IL bị thay đổi (tiến trình calc.exe đã khởi chạy).

Hình 8: Đoạn mã native pre-compiled được thực thi

Lập trình với dnlib:

Về mặt ý tưởng thì cách lập trình cũng sửa lại assembly ReadyToRun như cách sử dụng dnSpyEx ở trên. Sử dụng dnlib là giải pháp hiệu quả nhất để vừa bảo toàn được metadata dotnet lẫn mã native biên dịch sẵn và các thành phần cấu trúc trong PE liên quan tới nó. Dnlib cung cấp một native writer và các lựa chọn phù hợp, cho phép bảo toàn tất cả những thứ cần thiết.

Ví dụ sử dụng dnlib (qua PowerShell) để sửa lại ứng dụng ReadyToRun ban đầu:

[Reflection.Assembly]::LoadFrom("C:\dnlib.dll") | Out-Null
$original = "C:\CompileReal_ReplaceDecoy_IL.dll"

$moduleDef = [dnlib.DotNet.ModuleDefMD]::Load($original)
$mainMethod = $moduleDef.Types.Methods.Where{$_.Name -like "Main"}[0]
$inst = $mainMethod.MethodBody.Instructions.Where{$_.Operand.FullName -like "*Process::Start*"}[0]
$instIndex = $mainMethod.MethodBody.Instructions.IndexOf($inst)
$nopInst = [dnlib.DotNet.Emit.Instruction]::Create([dnlib.DotNet.Emit.OpCodes]::Nop)

$mainMethod.MethodBody.Instructions[$instIndex-1] = $nopInst
$mainMethod.MethodBody.Instructions[$instIndex] = $nopInst
$mainMethod.MethodBody.Instructions[$instIndex+1] = $nopInst

$nativeModuleWriterOptions = [dnlib.DotNet.Writer.NativeModuleWriterOptions]::new($moduleDef, $true)
$nativeModuleWriterOptions.MetadataOptions.Flags = [dnlib.DotNet.Writer.MetadataFlags]::PreserveAll
$moduleDef.NativeWrite($original + "_patched.dll", $nativeModuleWriterOptions)

Biên dịch mã giả - Thay thế bằng mã native khai thác

Với cách triển khai này, đoạn mã mục tiêu cần thay thế chính là mã native pre-compiled cho assembly được sinh ra, còn mã IL được giữ nguyên. Đầu tiên, cần tạo một project mới trong Visual Studio IDE, sau đó chọn C#, Console App, và xây dựng ứng dụng trên framework .NET (ví dụ trong bài viết này sử dụng .NET 6).

Hình 9: Visual Studio IDE – Tạo mới một project C#, Console App, .NET 6

Tuy phần mã được biên dịch sẵn của ứng dụng ReadyToRun là native, nhưng nó vẫn phụ thuộc vào metadata của assembly dotnet, phần sẽ được đọc trước khi mã bắt đầu thực thi.

Lần này, phần cần thay thế là mã native code được biên dịch sẵn, nên một trong những giải pháp phù hợp nhất là thế chỗ nó bằng một shellcode độc lập với memory, tùy thuộc vào nền tảng OS và kiến trúc mục tiêu.

Shellcode native sử dụng để khai thác cần đảm bảo không thay đổi metadata của assembly dotnet mục tiêu nằm ngoài tầm kiểm soát. Để minh họa một cách dễ hiểu và rõ ràng, có thể tạo một đoạn mã C# giả khiến cho phần mã native biên dịch sẵn đủ lớn, tạo thuận lợi cho việc chèn shellcode. Đoạn mã IL giả tương ứng, là một thành phần trong assembly R2R sinh ra, cũng có thể sửa đổi và thay thế được (chỉ cần tạo chỗ chứa shellcode khai thác thay thế cho mã native pre-compiled).

Hình 10: Mã C# giả

Để dựng một ứng dụng ReadyToRun tắt chế độ tự chứa assembly, cần trực tiếp sử dụng flag “PublishReadyToRun” trong lệnh dotnet publish: dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true --self-contained false.

Khi đã dựng assembly ReadyToRun, cần xác định ví trí đoạn mã native của method Main() , là một thành phần trong assembly, và xác định kích cỡ của method này. Có nhiều các để thực hiện, nhưng cách đơn giản nhát là sử dụng công cụ R2RDump (chi tiết về công cụ này sẽ được đề cập sau).

Hình 11: Các cấu trúc trong assembly ReadyToRun hiển thị qua công cụ R2RDump

Dễ dàng nhận thấy, ở ví dụ này, đoạn mã biên dịch sẵn của method Main() nằm ở địa chỉ RVA 0x00001890 với kích thước 282 bytes.

Một trình dịch ngược native như IDA có thể sử dụng để tìm và trích xuất 282 bytes opcode của đoạn mã native pre-compiled ở địa chỉ RVA 0x00001890. Những byte opcode này dùng để tìm kiếm vị trí sửa đổi tệp tin thực thi.

Hình 12: Trình dịch ngược IDA được sử dụng để trích xuất các byte opcode của đoạn mã native pre-compiled

MsfVenom (công cụ tạo mã khai thác độc lập với Metasploit) rất hữu ích cho việc tạo mẫu shellcode độc lập với memory, có thể dùng thay thế đoạn mã native biên dịch sẵn trong assembly R2R. Chạy lệnh dưới đây để tạo ra 282 bytes shellcode khởi tạo tiến trình calc.exe trên Windows 64-bit.

.\msfvenom.bat -p windows/x64/exec CMD=calc.exe -f raw --smallest --nopsled 6 -o calc.sc

Một khi đã có cả các byte opcode của đoạn mã native biên dịch sẵn trong assembly và shellcode, có thể sử dụng tool bất kỳ để tìm kiếm đoạn mã native, rồi thực hiện sửa đổi dữ liệu thô của tệp thực thi. Ở bài viết này, công cụ 010 Editor được sử dụng để thực hiện khâu này.

Hình 13: Sửa đổi tệp thực thi sử dụng 010 Editor

Nếu chạy thử ứng dụng ReadyToRun đã bị stomped, hoặc thông qua tệp thực thi tương ứng CompileDecoy_ReplaceReal_SC.exe trong cùng thư mục, hoặc thông qua lệnh dotnet CompileDecoy_ReplaceReal_SC.dll ở command prompt, có thể thấy shellcode khai thác thay thế đoạn mã native pre-compiled gốc được thực thi, bất chấp sự khác biệt với đoạn mã IL giả ban đầu (tiến trình calc.exe đã khởi chạy).

Hình 14: Shellcode khai thác ghi đè trong tệp tin được thực thi

Tuy bài viết đang hướng dẫn cách triển khai này một cách thủ công, nhưng trên thực tế, hầu hết các bước trên đây có thể tiếp cận bằng cách lập trình tự động hóa.

(Còn tiếp)

Theo Check Point Research.

Dịch bởi chuyên gia WhiteHub Hà Minh Châu.

Chia sẻ bài viết này