Изучение внутреннего устройства приложений, созданных с использованием нативной опережающей компиляции (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. Затем мы вызываем адрес 0x18 байт в структуру 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!<BaseAddress>+0x9dab4
   at TestApp!<BaseAddress>+0x9da77

Если приложение было собрано именно таким образом, наши шансы восстановить имена или сигнатуры методов почти нулевые. Некоторые имена методов все еще могут быть доступны в метаданных отражения, но методов видимых через отражение, обычно очень мало — компилятор агрессивно удаляет их, если при анализе возможностей усечения (trimming) не рекомендуется обратное.

Заключение


Подводя итог скажу, что анализ бинарных файлов .NET, скомпилированных нативно с использованием AOT, требует тех же навыков, что и анализ С++, например. Некоторая информация находится легко (например, о размотке стека, некоторая информация о типах и т. д.), но мы можем забыть о такой роскоши, как возможность разбивать типы на отдельные поля и контролировать доступ к ним. Поля в основном растворяются в инструкциях доступа (мы можем догадаться, что что-то может быть init, если поле читается как 4-байтовое). Имена методов исчезнут, если данные трассировки стека отключены. Имена типов также могут исчезнуть, если отключить отражение.



Возможно, захочется почитать и это:


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Комментарии (3)