[Перевод] Реверс-инжиниринг нативно скомпилированных .NET-приложений
Изучение внутреннего устройства приложений, созданных с использованием нативной опережающей компиляции (AOT).
На платформе .NET 7 впервые была представлена новая модель развертывания: опережающая нативная компиляция. Когда приложение .NET компилируется нативно по методу AOT, оно превращается в автономный нативный исполняемый файл, оснащённый собственной минимальной средой исполнения для управления выполнением кода.
Время выполнения весьма небольшое и в .NET 8 можно создавать автономные приложения на C# размером менее 1 МБ. Для сравнения: размер нативного приложения AOT Hello World на C# ближе к размеру аналогичного приложения в Rust, чем в Golang, при этом вшестеро меньше аналогичного приложение на Java.
Кроме того, впервые в истории программы .NET распространяются в формате файла, отличном от того, что определён в ECMA-335 (т. е. в виде инструкций и метаданных для виртуальной машины), а именно, распространяются в виде нативного кода (формат файла PE/ELF/Mach-O) с нативными структурами данных, точно как, например, в С++. Это означает, что ни один из инструментов реверс-инжиниринга для .NET, созданных за последние 20 лет, не работает с нативной опережающей компиляцией.
К сожалению, из-за этих двух аспектов (компактность и сложность реверс-инжиниринга) нативная AOT-компиляция популярна среди авторов вредоносного ПО, о чем свидетельствуют, например, эти статьи:
Здесь попытаемся немного рассказать о том, как адаптировать реверс-инжиниринг к новым условиям.
❯ Готовим Ghidra и нативные дебаггеры
Повторю мысль из введения: нативная AOT-компиляция не работает с теми форматами файлов, что применяются в виртуальных машинах CLR для хранения программы и ее метаданных. Инструменты для считывания формата файлов VM бесполезны при работе с нативными исполняемыми файлами для AOT. Остаётся использовать инструменты, предназначенные для реверс-инжиниринга произвольного нативного кода, в частности, нативные отладчики (WinDBG/VS/x64dbg в Windows, lldb/gdb в Unix-подобных системах) и фреймворки для анализа кода (Ghidra, IDA, Binary Ninja и т. д.).
Поскольку в нативном AOT-режиме программа компилируется в один исполняемый файл без зависимостей, количество доступных метаданных значительно сокращается, однако некоторые метаданные всё-таки остаются (как, например, в C++).
❯ Рассмотрим двоичный файл
Если вы хотите углубиться в тему, установите .NET 8 SDK (я использую версию RC1, последнюю доступную на момент написания этой статьи). Вы можете пропустить установку и просто загрузить ZIP-архив и указать местоположение распакованных файлов у себя в PATH.
Начнем с приложения Hello World с нативной AOT:
$ dotnet new console --aot -o TestApp
Создаем новый каталог TestApp и помещаем туда проект консольного приложения Hello World, настроенный для опережающей компиляции.
$ cd TestApp
$ dotnet publish
После завершения процесса публикации вы должны увидеть двоичный файл в папке bin\Release\net8.0\win-x64\publish (я выполнил эту операцию в Windows, но она будет работать и в Linux/Mac). Размер двоичного файла составляет около 1,2 МБ, и рядом с ним находится файл с нативной отладочной информацией (PDB в Windows, DBG в Linux и что-то там еще в Mac). Давайте взглянем, что у нас получилось.
$ dumpbin bin\Release\net8.0\win-x64\publish\TestApp.exe
Microsoft (R) COFF/PE Dumper Version 14.37.32824.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file bin\Release\net8.0\win-x64\publish\TestApp.exe
File Type: EXECUTABLE IMAGE
Summary
D000 .data
5E000 .managed
B000 .pdata
60000 .rdata
1000 .reloc
1000 .rsrc
64000 .text
1000 _RDATA
31000 hydrated
На вид ничего необычного. Раздел .managed
содержит управляемый код (в данном случае «нативный код, памятью которого управляет сборщик мусора»). Секция hydrated
не инициализирована, но она заполняется на ранних этапах запуска структурами данных времени выполнения.
Остальные разделы тоже выглядят довольно стандартно: .text
содержит неуправляемый код, в частности, сам сборщик мусора, или другой нативный код, который пользователь сам связал с исполняемым файлом.
Запуск команды strings
в исполняемом файле подводит нас к интересным вещам, в частности:
8.0.23.41904v8.0.0-rc.1.23419.4+92959931a32a37a19d8e1b1684edc6db0857d7de
(Версия хеша коммита из репозитория dotnet/runtime, того самого, из которого был получен исполняемый файл, может пригодиться нам позже.)
Обратите внимание и на такие строки, как DivideByZeroException
или get_CanWrite
, если повезёт, на их основе мы сможем восстановить полезную информацию о типах и методах.
❯ Отладка выделения памяти и виртуального вызова
Интересный эксперимент, позволяющий понять, как все работает — выполнить небольшой фрагмент кода. Давайте заменим Program.cs следующим листингом:
using System.Runtime.CompilerServices;
class Program
{
// Отметим NoOpt/NoInline, чтобы не произошло девиртуализации,
// или втягивания в управляемый код запуска.
[MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
static void Main() => Console.WriteLine(new Program().ToString());
public override string ToString() => "Hello World!";
}
Мы снова выполняем dotnet publish
и запускаем программу под отладчиком. Здесь нам очень пригодится такая роскошь как отладочные символы в приложении. При исследовании вредоносного ПО шансы заполучить PDB/DBG очень невелики. Установим точку останова в строке Main и посмотрим, что нам даст дизассемблирование:
00007FF730B8FD50 push rbp
00007FF730B8FD51 sub rsp,30h
00007FF730B8FD55 lea rbp,[rsp+30h]
00007FF730B8FD5A xor eax,eax
00007FF730B8FD5C mov qword ptr [rbp-8],rax
00007FF730B8FD60 lea rcx,[TestApp_Program::`vftable' (07FF730BCC688h)]
00007FF730B8FD67 call RhpNewFast (07FF730AF1DE0h)
00007FF730B8FD6C mov qword ptr [rbp-8],rax
00007FF730B8FD70 mov rcx,qword ptr [rbp-8]
00007FF730B8FD74 call TestApp_Program___ctor (07FF730B8FDB0h)
00007FF730B8FD79 mov rcx,qword ptr [rbp-8]
00007FF730B8FD7D mov rax,qword ptr [rbp-8]
00007FF730B8FD81 mov rax,qword ptr [rax]
00007FF730B8FD84 call qword ptr [rax+18h]
00007FF730B8FD87 mov rcx,rax
00007FF730B8FD8A call System_Console_System_Console__WriteLine_12 (07FF730B56190h)
00007FF730B8FD8F nop
00007FF730B8FD90 add rsp,30h
00007FF730B8FD94 pop rbp
00007FF730B8FD95 ret
Код выглядит довольно стандартно. Произошли дополнительные перестановки регистров/стеков, поскольку для наглядности мы отключили оптимизацию. Символьные имена видны только потому, что у нас была отладочная информация. Если бы у нас ее не было, TestApp_Program::vftable'
мог бы иметь единственное значение 07FF730BCC688h.
Давайте разберём подробнее:
00007FF730B8FD60 lea rcx,[TestApp_Program::`vftable' (07FF730BCC688h)]
00007FF730B8FD67 call RhpNewFast (07FF730AF1DE0h)
Здесь видно, как организовано выделение памяти: мы загружаем адрес структуры vftable
, описывающей класс Program
, и вызываем помощник RhpNewFast
для выделения экземпляра этой структуры из кучи сборщика мусора. Поскольку .NET имеет открытый исходный код, можно рассмотреть детали, но по сути мы считываем поле из структуры vftable
, чтобы определить размер выделяемой памяти (размер экземпляра класса Program). Мы вырезаем кусок памяти, обнуляемую при выделении («выталкивающее выделение», bump allocation) и записывает адрес vftable
в первое поле вновь выделенного экземпляра, «идентифицируя» таким образом фрагмент памяти. Если в буферном распределителе заканчивается память, то всё можно сделать немного медленнее, но этот вариант не так интересен.
Код RhpNewFast
написан на ассемблере и редко меняется, поэтому вполне вероятно, что вы сами его отыщете, даже не прибегая к отладочным символам.
После выделения нового экземпляра объекта вызывается конструктор экземпляра:
00007FF730B8FD70 mov rcx,qword ptr [rbp-8]
00007FF730B8FD74 call TestApp_Program___ctor (07FF730B8FDB0h)
Поскольку у нас есть символы отладки, мы видим имя символа (TestApp_Program___ctor)
. Если бы у нас не было символов, это был бы вызов 07FF730B8FDB0h
.
После завершения работы конструктора мы выполняем виртуальный вызов ToString
. Это еще одна интересная деталь:
00007FF730B8FD81 mov rax,qword ptr [rax]
00007FF730B8FD84 call qword ptr [rax+18h]
Сначала мы разыменуем ссылку на объект. Как мы убедились во время выделения, у нас останется адрес структуры vftable
в rax
. Затем мы вызываем адрес 0×18 байт в структуру vftable
. Предположительно, именно здесь хранится адрес метода Program.ToString
.
Структура vftable
представляет собой таблицу виртуальных методов, знакомую нам по C++. В ней перечислены все адреса виртуальных методов, реализуемых типом. Она также содержит дополнительные метаданные, в частности, размер экземпляра объекта, является ли он структурой или классом и т. д. В мире .NET почти всегда первые три слота vftable
являются реализациями object.ToString
, object.GetHashCode
и object.Equals
(однако порядок этих трех сущностей зависит от оптимизации всей программы).
Нативная AOT вызывает структуру vtable MethodTable
или EEType
, причём, одна может заменять другую. О ней можно узнать подробнее, просмотрев реализацию для записи или для чтения. (Имейте в виду, что в виртуальной машине CoreCLR также есть функция MethodTable, но ее структура другая.)
Хотя структура данных MethodTable
информативна, наиболее полезная информация, в частности, имена типов, не всегда доступна. Другие вещи, которыми мы также не располагаем:
- Список всех методов (мы можем, по крайней мере, перечислить адреса виртуальных методов, как при реверс-инжиниринге C++)
- Список всех полей (однако информация сборщика мусора, приведённая перед
MethodTable
, позволяет судить, по каким смещениям внутри экземпляра объекта находятся указатели сборщика мусора, а это все же это лучше, чем ничего) - Содержание сборки типа
- И т. д.
❯ Дегидрированные данные
Дополнительное осложнение заключается в том, что структуры данных MethodTable размещаются в сегменте hydrated исполняемого файла, который обнуляется при инициализации (zero init). В начале пусковой последовательностьи есть небольшой фрагмент кода, который заполняет этот сегмент фактическими данными. Поэтому статическим аналитическим инструментам будет ещё сложнее интерпретировать содержимое MethodTables, если только оно не будет выгружено из памяти.
Дегидрирование данных обсуждалось здесь, и в этом пул-реквесте происходящее описано даже лучше, чем мог бы описать я в этой статье. В сущности, эти данные хранятся в более компактной форме в формате файла и увеличиваются во время выполнения. Возможно, такое явление можно было бы симулировать в статических аналитических инструментах, определяя, в каком именно большом двоичном объекте (блобе) содержатся релевантные данные, начиная с заголовка RTR. Однако этот файловый формат не соответствует какому-либо ABI, он может измениться и, возможно, его придется обновлять каждый год для новых версий .NET.
❯ Отражение структур данных
Хотя информация об именах достаётся не так легко, она все равно присутствует в дампе строк, как мы могли убедиться. При отражении отслеживаются все имена типов, поскольку в .NET можно просто вызвать object.GetType
для любого объекта и выяснить его имя.
Блоб данных, который сопоставляет структуры данных MethodTable с дескрипторами метаданных, связан с заголовком RTR, как и сам блок метаданных. В теории можно использовать API-интерфейсы чтения метаданных, чтобы восстановить символьные имена для всех MethodTable в программе. Однако ни один из этих форматов или API не предназначен для свободного использования и, скорее всего, будет меняться с каждым крупным выпуском .NET.
Например, опытный автор вредоносного ПО также может опубликовать свое приложение со свойством IlcDisableReflection
, установленным в значение true, что позволит ему отключить рефлексию и не генерировать никаких метаданных об отражении. Этот режим не поддерживается и не документирован за пределами репозитория dotnet/runtime.
❯ Структуры данных стектрейса
Аналогично, как и в случае с дампом strings
, нам нужна информация об именах методов. Единственная причина, по которой она есть в доступе — эта информация нужна, чтобы сгенерировать обратную трассировку стека. При выбросе исключения разработчик может проверить его при помощи ToString
или получить доступ к свойству StackTrace
, чтобы вывести текстовую трассировку стека. Для этого постоянно сопоставляются адреса нативных методов и метаданных, так можно создавать имена и сигнатуры. Примерно таким же образом генерируются данные отражения, и форматы файлов здесь те же (на них также ссылаются в заголовке RTR). Давайте попробуем:
using System.Runtime.CompilerServices;
class Program
{
// Отметим NoOpt/NoInline, чтобы весь этот код не девиртуализировался
// или не втягивался в управляемый код запуска.
[MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
static void Main() => Console.WriteLine(new Program().ToString());
public override string ToString() => throw new Exception();
}
(Мы обновили предыдущую программу, разрешив ToString
выбрасывать исключение и при этом оставлять его необработанным.)
Unhandled Exception: System.Exception: Exception of type 'System.Exception' was thrown.
at Program.ToString() + 0x24
at Program.Main() + 0x37
Обратите внимание, что приложение смогло вывести имена и сигнатуры задействованных методов. Этот подход будет работать, даже если мы избавимся от отладочной информации, удалив файл PDB/DBG.
Однако пользователь может установить для свойства StackTraceSupport значение false на момент публикации своего приложения, чтобы отключить генерацию этих данных (генерация данных трассировки стека включена по умолчанию). Тогда программа вместо этого выведет:
Unhandled Exception: System.Exception: Exception of type 'System.Exception' was thrown.
at TestApp!+0x9dab4
at TestApp!+0x9da77
Если приложение было собрано именно таким образом, наши шансы восстановить имена или сигнатуры методов почти нулевые. Некоторые имена методов все еще могут быть доступны в метаданных отражения, но методов видимых через отражение, обычно очень мало — компилятор агрессивно удаляет их, если при анализе возможностей усечения (trimming) не рекомендуется обратное.
❯ Заключение
Подводя итог скажу, что анализ бинарных файлов .NET, скомпилированных нативно с использованием AOT, требует тех же навыков, что и анализ С++, например. Некоторая информация находится легко (например, о размотке стека, некоторая информация о типах и т. д.), но мы можем забыть о такой роскоши, как возможность разбивать типы на отдельные поля и контролировать доступ к ним. Поля в основном растворяются в инструкциях доступа (мы можем догадаться, что что-то может быть init, если полн читается как 4-байтовое). Имена методов исчезнут, если данные трассировки стека отключены. Имена типов также могут исчезнуть, если отключить отражение.
Возможно, захочется почитать и это:
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩