Автор: nopnopshellcode
Для форума: xss.pro
Привет форум и читатели, сегодня мы займемся реверсом и изучением внутреннего устройства приложений, собранных с использованием нативного AOT. Статья рассчитана в основном на новичков, хотя тем кто знаком с dotnet возможно тоже будет интересно.
Микрософт ще в дотнет 7 представил новую модель развертывания: "предварительную нативную компиляцию" (ahead of time, AOT) почитать подробней можно тут https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=windows,net8
Когда .NET-приложение компилируется с использованием native AOT, оно превращается в автономный нативный исполняемый файл с собственным минимальным рантаймом для управления выполнением кода.
Этот рантайм довольно мал, и в .NET 8 стало возможно собирать автономные C# - приложения размером менее 1 МБ. Для сравнения: размер native AOT Hello World на C# ближе к размеру аналогичной программы на Rust, чем на Golang, и примерно в шесть раз меньше, чем аналогичное приложение на Java
Это также первый случай, когда .NET-программы распространяются не в формате файлов, определённом стандартом ECMA-335 (т.е. инструкции и метаданные для виртуальной машины), а в виде нативного кода (в формате PE/ELF/Mach-O ), с нативными структурами данных, как например в C++. Это означает, что ни один из инструментов реверс-инжиниринга для .NET, созданных за последние 20 лет, не работает с native AOT
Вместе эти два аспекта (маленький размер, сложность реверс-инжиниринга) сделали native AOT популярным выбором среди создателей малвари, всяких стиллеров, и прочего вредоносного ПО, сильно усложняя жизнь аверам и малваре-аналитикам
Эта статья постарается немного углубиться в детали адаптации реверс инжиниринга к новой среде. Приготовьте свои нативные отладчики.
Native aot не использует форматы файлов CLR VM для хранения программы и её метаданных. Инструменты, читающие формат виртуальной машины, бесполезны для нативных AOT исполняемых файлов. Остаются толькo инструменты реверс инжиниринга произвольного нативного кода, такие как нативные отладчики (WinDBG / VS / x64dbg в Windows, lldb / gdb в системах Unix) и фреймворки для анализа нативного кода (Ghidra, IDA, Binary Ninja и прочие). Так как native AOT компилируется в единый исполняемый файл без зависимостей, объём доступных метаданных значительно уменьшается, но некоторая метаинформация всё же остаётся (как, например, в C++).
Первый взгляд на бинарь.
Если вы хотите повторить действия, установите .NET 8 SDK (или старше, у меня 9). Можно не устанавливать, а просто скачать ZIP и добавить путь к распакованной папке в переменную PATH.
Начну со сборки Hello World с native AOT:
Это создаст новую директорию MyAOTApp и разместит в ней консольный проект Hello World, настроенный на компиляцию с AOT.
После завершения публикации мы должны увидеть бинарный файл в папке /bin\Release\net8.0\win-x64\publish\ (в linux или mac будет аналогично). Размер бинарного файла около 1.2 МБ, рядом находится файл с отладочной информацией (PDB для Windows, DBG для Linux и что-то иное для Mac). Давайте посмотрим:
Результат:
Файл выглядит в основном стандартно. Секция .managed содержит управляемый код (в данном случае «нативный код, память которого управляется сборщиком мусора»). Секция hydrated неинициализирована, но заполняется в ранний момент запуска данными рантайма.
Остальные секции также типичны: .text содержит неуправляемый код, такой как сам сборщик мусора или другие части кода, слинкованные пользователем.
Запуск strings на этом исполняемом файле может показать интересные строки, например версию или хэш коммита из репозитория dotnet/runtime, с которого собран исполняемый файл — может быть полезно позже
Также найдутся строки вроде DivideByZeroException или get_CanWrite, дающие надежду, что можно восстановить информацию о типах и методах.
Отладка выделения памяти и виртуального вызова
Интересный эксперимент - пошагово пройти через короткий участок кода. Заменим Program.cs на:
Снова выполняем dotnet publish и запускаем под отладчиком. У нас есть отладочные символы. При анализе вредоносного ПО получить PDB/DBG почти невозможно. Ставим точку останова на строку Main и смотрим дизассемблирование:
Этот код выглядит стандартным. Избыточные манипуляции с регистрами и стеком из-за отключённой оптимизации. Символические имена видны только благодаря отладочной информации. Без неё TestApp_Program::vftable отображался бы как просто 07FF730BCC688h
Рассмотрим эти строки:
Здесь мы видим, как происходит выделение памяти — загружается адрес специальной структуры vftable, описывающей класс Program, и вызывается вспомогательная функция RhpNewFast, чтобы выделить экземпляр этого класса из кучи, управляемой сборщиком мусора (GC). Поскольку .NET является проектом с открытым исходным кодом, мы можем изучить детали реализации. Вкратце, эта функция считывает из vftable размер выделяемого объекта (в данном случае экземпляра класса Program), отрезает участок нулевой памяти (bump allocation) и записывает адрес vftable в первое поле нового экземпляра, тем самым придавая области памяти "личность". Если bump-аллокатор исчерпывает доступную память, используется медленный путь, но он здесь не представляет интереса.
Функция RhpNewFast написана на ассемблере и редко меняется, поэтому есть высокая вероятность распознать её даже без отладочных символов.
После выделения памяти вызывается конструктор экземпляра:
Поскольку у нас есть отладочные символы, мы видим имя символа (TestApp_Program___ctor). Без символов это выглядело бы просто как call 07FF730B8FDB0h.
После возвращения конструктора происходит виртуальный вызов ToString. Это ещё одна интересная часть:
Сначала происходит разыменование ссылки на объект. Как мы видели при выделении, это приведёт нас к адресу структуры vftable, который будет помещён в rax. Затем вызывается адрес, находящийся по смещению 0x18 от начала vftable. Вероятно, там хранится адрес метода Program.ToString.
Эта "волшебная" структура vftable — это таблица виртуальных методов (vtable), знакомая по C++. Она содержит все адреса виртуальных методов, которые реализует тип. Также она включает дополнительную метаинформацию, такую как размер экземпляра объекта, указание на то, является ли он структурой или классом и т. д. В мире .NET практически гарантировано, что первые три ячейки vtable будут содержать реализации методов object.ToString, object.GetHashCode и object.Equals (их порядок зависит от оптимизации на уровне всей программы).
В кодовой базе native AOT структура vtable называется MethodTable или EEType — эти названия используются как синонимы. Подробнее об этом можно узнать, изучив реализацию её записи и чтения. (Осторожно: в виртуальной машине CoreCLR также есть структура MethodTable, но её формат отличается.)
Хотя структура MethodTable содержит множество информации, крайне полезные данные, такие как имена типов, отсутствуют. Также недоступны:
-список всех методов (мы можем хотя бы получить адреса виртуальных, как при реверсе C++);
-список всех полей (однако информация GC, предшествующая MethodTable, позволяет определить, где в объекте размещены GC-указатели — лучше, чем ничего);
-сборка, содержащая тип;
-и т. д.
"Сухие" данные
Дополнительной проблемой является то, что структуры данных MethodTable размещаются в сегменте hydrated исполняемого файла, который по умолчанию нулевой (zero init). В начале пути выполнения небольшая часть кода наполняет этот сегмент актуальными данными. Поэтому статическим инструментам анализа будет сложно интерпретировать содержимое MethodTable, если оно не выгружено из памяти.
Дегидратация данных была добавлена в этом pull request (см. репозиторий) и объясняет процесс лучше, чем я мог бы здесь. По сути, данные хранятся в более компактной форме внутри формата файла и "раздуваются" во время выполнения. Теоретически можно попытаться воспроизвести этот процесс в статических инструментах анализа, начиная от заголовка RTR и анализируя blob с данными. Однако этот формат не является частью стабильного ABI, он может меняться ежегодно с новыми версиями .NET.
Структуры данных для рефлексии
Хотя информация об именах недоступна напрямую, она всё же присутствует — как мы видели в выводе strings. Рефлексия сохраняет имена всех типов, поскольку в .NET можно вызвать object.GetType() и запросить имя типа.
Blob, отображающий структуры MethodTable в дескрипторы метаданных, связан с заголовком RTR, как и сам blob метаданных. Теоретически можно использовать API для чтения метаданных, чтобы восстановить символические имена всех MethodTable в программе. Однако ни формат данных, ни API не предназначены для публичного использования и, скорее всего, будут меняться с каждой новой основной версией .NET.
Создатель вредоносного ПО может также скомпилировать приложение с параметром IlcDisableReflection=true, чтобы включить режим без рефлексии, при котором метаданные не генерируются. Этот режим не поддерживается и не документирован вне репозитория dotnet/runtime.
Структуры данных для трассировки стека
Аналогично, как мы видели в выводе strings, информация о названиях методов также присутствует. Единственная причина, по которой она есть — это генерация трассировки стека: при выбрасывании исключения разработчик может вызвать ToString() у исключения или получить свойство StackTrace, чтобы увидеть текстовый стек вызовов. Это реализовано через карту между нативными адресами методов и метаданными, позволяющими построить имя и сигнатуру метода. Это схоже с тем, как генерируется рефлексивная информация, и использует те же форматы файлов (также ссылаются на заголовок RTR). Попробуем:
(Мы изменили предыдущую программу: теперь ToString выбрасывает исключение, которое не обрабатывается.)
Обратите внимание: приложение смогло вывести имена и сигнатуры участвовавших методов. Это работает даже после удаления отладочной информации (файлов PDB/DBG).
Однако пользователь может задать свойство StackTraceSupport=false при публикации, чтобы отключить генерацию этих данных (по умолчанию включено). Тогда программа выведет, например:
Если приложение собрано таким образом, наши шансы восстановить имена или сигнатуры методов стремятся к нулю. Некоторые имена методов всё ещё могут быть доступны через метаданные рефлексии, но список таких методов, как правило, крайне мал — компилятор агрессивно удаляет их, если анализ обрезки (trimming analysis) не определил, что их нужно сохранить.
Подводя итог: анализ .NET-бинарников, собранных с native AOT, требует навыков, аналогичных тем, что используются при анализе C++-приложений. Некоторые данные всё ещё доступны (например, unwind-информация, ограниченные сведения о типах и т. д.), но про привычные удобства вроде разбора типов на поля и отслеживания доступа к ним можно забыть. Поля «растворяются» в инструкциях: можно догадываться, что что-то — это int, если оно читается как 4 байта. Имена методов исчезают, если отключена трассировка стека. А имена типов могут исчезнуть, если отключена рефлексия.
Для форума: xss.pro
Привет форум и читатели, сегодня мы займемся реверсом и изучением внутреннего устройства приложений, собранных с использованием нативного AOT. Статья рассчитана в основном на новичков, хотя тем кто знаком с dotnet возможно тоже будет интересно.
Микрософт ще в дотнет 7 представил новую модель развертывания: "предварительную нативную компиляцию" (ahead of time, AOT) почитать подробней можно тут https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=windows,net8
Когда .NET-приложение компилируется с использованием native AOT, оно превращается в автономный нативный исполняемый файл с собственным минимальным рантаймом для управления выполнением кода.
Этот рантайм довольно мал, и в .NET 8 стало возможно собирать автономные C# - приложения размером менее 1 МБ. Для сравнения: размер native AOT Hello World на C# ближе к размеру аналогичной программы на Rust, чем на Golang, и примерно в шесть раз меньше, чем аналогичное приложение на Java
Это также первый случай, когда .NET-программы распространяются не в формате файлов, определённом стандартом ECMA-335 (т.е. инструкции и метаданные для виртуальной машины), а в виде нативного кода (в формате PE/ELF/Mach-O ), с нативными структурами данных, как например в C++. Это означает, что ни один из инструментов реверс-инжиниринга для .NET, созданных за последние 20 лет, не работает с native AOT
Вместе эти два аспекта (маленький размер, сложность реверс-инжиниринга) сделали native AOT популярным выбором среди создателей малвари, всяких стиллеров, и прочего вредоносного ПО, сильно усложняя жизнь аверам и малваре-аналитикам
Эта статья постарается немного углубиться в детали адаптации реверс инжиниринга к новой среде. Приготовьте свои нативные отладчики.
Native aot не использует форматы файлов CLR VM для хранения программы и её метаданных. Инструменты, читающие формат виртуальной машины, бесполезны для нативных AOT исполняемых файлов. Остаются толькo инструменты реверс инжиниринга произвольного нативного кода, такие как нативные отладчики (WinDBG / VS / x64dbg в Windows, lldb / gdb в системах Unix) и фреймворки для анализа нативного кода (Ghidra, IDA, Binary Ninja и прочие). Так как native AOT компилируется в единый исполняемый файл без зависимостей, объём доступных метаданных значительно уменьшается, но некоторая метаинформация всё же остаётся (как, например, в C++).
Первый взгляд на бинарь.
Если вы хотите повторить действия, установите .NET 8 SDK (или старше, у меня 9). Можно не устанавливать, а просто скачать ZIP и добавить путь к распакованной папке в переменную PATH.
Начну со сборки Hello World с native AOT:
$ dotnet new console --aot -o MyAOTAppЭто создаст новую директорию MyAOTApp и разместит в ней консольный проект Hello World, настроенный на компиляцию с AOT.
$ cd MyAOTApp$ dotnet publishПосле завершения публикации мы должны увидеть бинарный файл в папке /bin\Release\net8.0\win-x64\publish\ (в linux или mac будет аналогично). Размер бинарного файла около 1.2 МБ, рядом находится файл с отладочной информацией (PDB для Windows, DBG для Linux и что-то иное для Mac). Давайте посмотрим:
"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.42.34433\bin\Hostx64\x64\dumpbin.exe" bin\Release\net9.0\win-x64\publish\MyAOTApp.exeРезультат:
Файл выглядит в основном стандартно. Секция .managed содержит управляемый код (в данном случае «нативный код, память которого управляется сборщиком мусора»). Секция hydrated неинициализирована, но заполняется в ранний момент запуска данными рантайма.
Остальные секции также типичны: .text содержит неуправляемый код, такой как сам сборщик мусора или другие части кода, слинкованные пользователем.
Запуск strings на этом исполняемом файле может показать интересные строки, например версию или хэш коммита из репозитория dotnet/runtime, с которого собран исполняемый файл — может быть полезно позже
Также найдутся строки вроде DivideByZeroException или get_CanWrite, дающие надежду, что можно восстановить информацию о типах и методах.
Отладка выделения памяти и виртуального вызова
Интересный эксперимент - пошагово пройти через короткий участок кода. Заменим Program.cs на:
C#:
using System;
using System.Runtime.CompilerServices;
class GreetingApp
{
[MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
static void Main()
{
var app = new GreetingApp();
Console.WriteLine(app.GenerateMessage());
}
public override string ToString() => "Greetings from GreetingApp!";
private string GenerateMessage() => ToString();
}
Снова выполняем dotnet publish и запускаем под отладчиком. У нас есть отладочные символы. При анализе вредоносного ПО получить PDB/DBG почти невозможно. Ставим точку останова на строку Main и смотрим дизассемблирование:
Этот код выглядит стандартным. Избыточные манипуляции с регистрами и стеком из-за отключённой оптимизации. Символические имена видны только благодаря отладочной информации. Без неё TestApp_Program::vftable отображался бы как просто 07FF730BCC688h
Рассмотрим эти строки:
Здесь мы видим, как происходит выделение памяти — загружается адрес специальной структуры vftable, описывающей класс Program, и вызывается вспомогательная функция RhpNewFast, чтобы выделить экземпляр этого класса из кучи, управляемой сборщиком мусора (GC). Поскольку .NET является проектом с открытым исходным кодом, мы можем изучить детали реализации. Вкратце, эта функция считывает из vftable размер выделяемого объекта (в данном случае экземпляра класса Program), отрезает участок нулевой памяти (bump allocation) и записывает адрес vftable в первое поле нового экземпляра, тем самым придавая области памяти "личность". Если bump-аллокатор исчерпывает доступную память, используется медленный путь, но он здесь не представляет интереса.
Функция RhpNewFast написана на ассемблере и редко меняется, поэтому есть высокая вероятность распознать её даже без отладочных символов.
После выделения памяти вызывается конструктор экземпляра:
Поскольку у нас есть отладочные символы, мы видим имя символа (TestApp_Program___ctor). Без символов это выглядело бы просто как call 07FF730B8FDB0h.
После возвращения конструктора происходит виртуальный вызов ToString. Это ещё одна интересная часть:
Сначала происходит разыменование ссылки на объект. Как мы видели при выделении, это приведёт нас к адресу структуры vftable, который будет помещён в rax. Затем вызывается адрес, находящийся по смещению 0x18 от начала vftable. Вероятно, там хранится адрес метода Program.ToString.
Эта "волшебная" структура vftable — это таблица виртуальных методов (vtable), знакомая по C++. Она содержит все адреса виртуальных методов, которые реализует тип. Также она включает дополнительную метаинформацию, такую как размер экземпляра объекта, указание на то, является ли он структурой или классом и т. д. В мире .NET практически гарантировано, что первые три ячейки vtable будут содержать реализации методов object.ToString, object.GetHashCode и object.Equals (их порядок зависит от оптимизации на уровне всей программы).
В кодовой базе native AOT структура vtable называется MethodTable или EEType — эти названия используются как синонимы. Подробнее об этом можно узнать, изучив реализацию её записи и чтения. (Осторожно: в виртуальной машине CoreCLR также есть структура MethodTable, но её формат отличается.)
Хотя структура MethodTable содержит множество информации, крайне полезные данные, такие как имена типов, отсутствуют. Также недоступны:
-список всех методов (мы можем хотя бы получить адреса виртуальных, как при реверсе C++);
-список всех полей (однако информация GC, предшествующая MethodTable, позволяет определить, где в объекте размещены GC-указатели — лучше, чем ничего);
-сборка, содержащая тип;
-и т. д.
"Сухие" данные
Дополнительной проблемой является то, что структуры данных MethodTable размещаются в сегменте hydrated исполняемого файла, который по умолчанию нулевой (zero init). В начале пути выполнения небольшая часть кода наполняет этот сегмент актуальными данными. Поэтому статическим инструментам анализа будет сложно интерпретировать содержимое MethodTable, если оно не выгружено из памяти.
Дегидратация данных была добавлена в этом pull request (см. репозиторий) и объясняет процесс лучше, чем я мог бы здесь. По сути, данные хранятся в более компактной форме внутри формата файла и "раздуваются" во время выполнения. Теоретически можно попытаться воспроизвести этот процесс в статических инструментах анализа, начиная от заголовка RTR и анализируя blob с данными. Однако этот формат не является частью стабильного ABI, он может меняться ежегодно с новыми версиями .NET.
Структуры данных для рефлексии
Хотя информация об именах недоступна напрямую, она всё же присутствует — как мы видели в выводе strings. Рефлексия сохраняет имена всех типов, поскольку в .NET можно вызвать object.GetType() и запросить имя типа.
Blob, отображающий структуры MethodTable в дескрипторы метаданных, связан с заголовком RTR, как и сам blob метаданных. Теоретически можно использовать API для чтения метаданных, чтобы восстановить символические имена всех MethodTable в программе. Однако ни формат данных, ни API не предназначены для публичного использования и, скорее всего, будут меняться с каждой новой основной версией .NET.
Создатель вредоносного ПО может также скомпилировать приложение с параметром IlcDisableReflection=true, чтобы включить режим без рефлексии, при котором метаданные не генерируются. Этот режим не поддерживается и не документирован вне репозитория dotnet/runtime.
Структуры данных для трассировки стека
Аналогично, как мы видели в выводе strings, информация о названиях методов также присутствует. Единственная причина, по которой она есть — это генерация трассировки стека: при выбрасывании исключения разработчик может вызвать ToString() у исключения или получить свойство StackTrace, чтобы увидеть текстовый стек вызовов. Это реализовано через карту между нативными адресами методов и метаданными, позволяющими построить имя и сигнатуру метода. Это схоже с тем, как генерируется рефлексивная информация, и использует те же форматы файлов (также ссылаются на заголовок RTR). Попробуем:
C#:
using System.Runtime.CompilerServices;
class Program
{
[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!<BaseAddress>+0x9dab4
at TestApp!<BaseAddress>+0x9da77
Если приложение собрано таким образом, наши шансы восстановить имена или сигнатуры методов стремятся к нулю. Некоторые имена методов всё ещё могут быть доступны через метаданные рефлексии, но список таких методов, как правило, крайне мал — компилятор агрессивно удаляет их, если анализ обрезки (trimming analysis) не определил, что их нужно сохранить.
Подводя итог: анализ .NET-бинарников, собранных с native AOT, требует навыков, аналогичных тем, что используются при анализе C++-приложений. Некоторые данные всё ещё доступны (например, unwind-информация, ограниченные сведения о типах и т. д.), но про привычные удобства вроде разбора типов на поля и отслеживания доступа к ним можно забыть. Поля «растворяются» в инструкциях: можно догадываться, что что-то — это int, если оно читается как 4 байта. Имена методов исчезают, если отключена трассировка стека. А имена типов могут исчезнуть, если отключена рефлексия.
Последнее редактирование модератором: