Хакер - Реверсинг .NET. Как искать JIT-компилятор в приложениях
hacker_frei
МВК
Содержание статьи
- Немного теории
- Ищем JIT-компилятор
- Проверка в бою
- Заключение
Во время декомпиляции и анализа .NET-приложений реверсеры сталкиваются с различными методами антиотладки. Один из них — сокрытие метаданных и IL-кода, которые восстанавливаются только при JIT-компиляции программы. Для борьбы с ними хакеры и реверс‑инженеры придумали целый комплекс специальных инструментов и методов, которые мы рассмотрим в сегодняшней статье.
INFO
О принципах взлома приложений, защищенных протектором Enigma, читай в статьях «Больше не энигма. Ломаем защиту приложений Enigma x64 актуальных версий» и «Триальный конь. Как сломать trial, защищенный Enigma Protector».
C легкой руки Microsoft одной из самых популярных платформ для программирования в настоящее время стала .NET. Огромное количество инструментов, библиотек и документации обеспечивают простоту вхождения даже для самых начинающих кодеров, а кросс‑платформенность и все более совершенная оптимизация кода делают ее одним из основных стандартов написания коммерческого софта. Как следствие, инструментов для взлома и реверс‑инжиниринга под эту платформу тоже успели создать немало. Среди них dnSpy, ILspy, ILdasm, Dile, SAE и многие другие, имя им — легион!
Задача для реверсеров упрощается тем, что по умолчанию скомпилированная программа фактически содержит свой исходник: имена символов хранятся в явном виде, а кросс‑платформенный IL-псевдокод легко восстанавливается до исходных синтаксических конструкций C# или VB, из которых он был получен при компиляции. Соответственно, взлом такой программы для начинающего хакера — одно удовольствие: достаточно загрузить ее в dnSpy, и вот она, на блюдечке в своих исходниках, для удобства даже окрашенных в приятные цвета. Отлаживай и правь как хочешь, как будто сам эту программу и написал!
НЕМНОГО ТЕОРИИ
Разумеется, производители софта мириться с подобным положением дел не могут, и на очередном витке конфронтации между хакерами и протекторами было разработано много инструментов, препятствующих восстановлению исходного кода из IL-сборки. Грубо говоря, все подобные инструменты используют три основных принципа:
- сокрытие (шифрование, компрессия и так далее) .NET-метаданных и IL-кода с восстановлением только в краткий миг JIT-компиляции;
- обфускация IL-кода, то есть преднамеренное запутывание его логики, борьба с читаемостью текстовых строк и имен символов, чтобы понять логику работы восстановленного IL-кода было сложнее;
- комбинация двух предыдущих категорий.
Сегодня мы поговорим о методах из первой категории. В принципе, наиболее простой и дубовый способ оградить программу от ILDasm — скомпилировать ее с атрибутом SupressIldasmAttribute. Понятное дело, это защита от честных людей, поскольку такая сборка превосходно детектируется как .NET-приложение, декомпилируется другими инструментами, а данный атрибут с полпинка снимается в CFFexplorer или, при изрядной сноровке, в простом HEX-редакторе. Более интересно «завернуть» метаданные в обычное нативное приложение, формирующее и запускающее .NET-сборку на лету.
В этом случае никакие детекторы не распознают в ней .NET, если их предварительно не обучили этому трюку, а декомпиляторы и отладчики, с ходу не увидевшие в программе метаданных, обломаются при загрузке. С помощью dnSpy можно попытаться исследовать такое приложение, однако при прерывании он навряд ли сможет восстановить и трассировать код дальше, что делает такую отладку бесполезной. Как быть в таком случае?
Самый простой способ — воспользоваться утилитой MegaDumper (или даже ее более продвинутой версией ExtremeDumper). Если .NET сформирован и запущен по всем правилам, то он корректно распознается упомянутыми утилитами именно как .NET-процесс, и при нажатии кнопочки .NET dump дампится как стандартное .NET-приложение. Правда, вовсе не факт, что оно будет запускаться. Чтобы привести его в запускаемый вид, придется проделать определенные телодвижения, в зависимости от продвинутости протектора. Тем не менее метаданные .NET и IL в такой сдампленной сборке будут доступны для декомпиляции и анализа. Можно убедиться в этом, открыв сборку, например, в CFFexplorer. Однако я специально сделал оговорку «если». Попробуем разобраться, почему подобное может не сработать.
Для этого постараюсь коротко в двух словах напомнить принцип функционирования .NET-приложения для тех, кто забыл матчасть. Несмотря на то что сборка состоит из метаданных и кросс‑платформенного IL-кода, при выполнении приложения он не интерпретируется, а компилируется в весьма оптимизированный нативный код целевого процессора и целевой операционной системы. Делается это непосредственно при загрузке блока кода один раз, впоследствии будет выполняться уже скомпилированный нативный код метода. Сам процесс называется JIT-компиляция (Just In Time, «временная компиляция на лету»). То есть если прервать программу в произвольный момент в отладчике типа x64dbg, то процесс будет остановлен именно во время исполнения такого временно скомпилированного нативного кода.
Трассировать, отлаживать и реверсировать его, конечно, можно, но целесообразность этого сомнительна. Нас интересует другой подход — поймать и сдампить уже восстановленный фрагмент IL-кода перед его JIT-компиляцией. Логика подсказывает, что, если мы хотим сделать это вручную, нам надо найти в отладчике изначальную точку входа в JIT-компилятор. Самое простое — отыскать метод SystemDomain::Execute в библиотеке clr.dll (или mscorwks.dll для более старых версий .NET). Обычно для подобных вещей рекомендуют использовать WinDbg и его расширение SOS, но я для примера покажу, как это делать в x64dbg.
ИЩЕМ JIT-КОМПИЛЯТОР
Итак, загрузив нужное приложение в отладчик, мы с неприятным удивлением обнаруживаем, что библиотека clr.dll отсутствует в списке отладочных символов. Значит, ее придется загрузить дополнительно, предварительно отыскав глубоко в недрах подкаталогов системной папки Windows. Найдя и загрузив clr.dll (попутно загрузится несколько библиотек), мы снова с раздражением обнаружим, что метод SystemDomain::Execute отсутствует в правом списке экспорта. Ну что ж, по счастью, x64dbg предоставляет прекрасную возможность загрузить отладочные символы прямо с майкрософтовского сервера — для этого нужно щелкнуть правой клавишей мыши на clr.dll и выбрать соответствующий пункт в контекстном меню.
Подождав некоторое время, мы увидим, что список в правой части окна отладчика изрядно увеличился и искомый метод SystemDomain::Execute в нем уже присутствует. Ставим на него точку останова и запускаем программу. В момент останова на этом методе дотнетовские метаданные чаще всего уже расшифрованы, распакованы и их можно дампить в файл хоть MegaDumper’ом, хоть Scylla из самого дебаггера. Однако этого тоже может оказаться недостаточно. Попробуем копнуть чуть глубже и выйти на исходный JIT-компилятор.
Для этого найдем и загрузим вышеописанным способом библиотеку clrjit.dll, а также отладочные символы к ней. Находим в них следующий метод:
private: virtual enum CorJitResult __stdcall CILJit::compileMethod(class ICorJitInfo *,struct CORINFO_METHOD_INFO *,unsigned int,unsigned char * *,unsigned long *)
Это и есть искомая точка входа JIT-компилятора, транслирующего IL-код в нативный машинозависимый. К сожалению (или к счастью), данный метод может быть подменен через функцию GetJit того же самого модуля clrjit.dll, чем и пользуются протекторы, инжектируя в компилятор собственный модуль расшифровки IL-кода. К нашей радости, совсем подменить компилятор на свой собственный они не могут, ибо тогда им придется фактически с нуля переписывать всю платформу .NET, с полной поддержкой разных операционных систем и процессоров. То есть в какой‑то момент расшифрованный код будет передан в найденный нами родной компилятор. Там мы его благополучно примем. Ставим точку останова на данный метод и запускаем программу.
После того как программа остановится, попробуем проанализировать параметры на стеке. Для этого снова вспомним теорию. В терминах языка С описание данного метода выглядит вот так:
CorJitResult (__stdcall * compileMethod) {
struct ICorJitCompiler *pThis, /* IN */
struct ICorJitInfo *comp, /* IN */
struct CORINFO_METHOD_INFO *info, /* IN */
unsigned /* CorJitFlag */ flags, /* IN */
BYTE **nativeEntry, /* OUT */
ULONG *nativeSizeOfCode /* OUT */
Третий сверху стека адрес (аккурат над двойным словом flags, которые обычно равны FFFFFFFF) — указатель на структуру CORINFO_METHOD_INFO. Эта структура содержит данные о блоке IL-кода, которым описывается компилируемый метод. Снова покурив мануалы, находим описание этой структуры:
struct CORINFO_METHOD_INFO {
CORINFO_METHOD_HANDLE ftn;
CORINFO_MODULE_HANDLE scope;
BYTE * ILCode;
unsigned ILCodeSize;
unsigned short maxStack;
unsigned short EHcount;
CorInfoOptions options;
CORINFO_SIG_INFO args;
CORINFO_SIG_INFO locals;
};
Перейдя в дампе по ссылке, мы обнаружим, что третье двойное слово с начала структуры и вправду указатель на IL-код метода, а четвертое — размер блока. Конечно, довольно напряжно вот так вот руками в отладчике расшифровывать каждый метод по одному. Однако мы теперь знаем, как это делается, и при желании можем реверсировать всю предшествующую последовательность действий, которую производит с блоком кода инжектированный в него протектор. В конце концов, можно инжектировать свой код между протектором и родным компилятором и реализовывать собственный дампер для каждой новой защиты.
ПРОВЕРКА В БОЮ
Попробуем использовать данный метод на случайном приложении, которое защищено распространенным и довольно мерзким протектором‑обфускатором Agile.Net. Приложение при загрузке в отладчики или декомпиляторы выдает практически у всех методов пустое тело, состоящее из одной команды ret или ldnull / ret. То же самое творится в секции Main, однако .cctor ссылается на вызов внешней DLL-ки, внутри которой при беглом осмотре обнаруживается упоминание AgileDotNetRT.dll. Собственно, die так и идентифицирует защиту, сомнений быть не может. Начинаем копать программу всеми доступными нам инструментами.
Деобфускаторы (включая родной Agile.NET-Deobfuscator-DNLIB) с ходу справиться с программой не могут, дамп с помощью MegaDumper и ExtreamDumper не добавляет данных, отображающихся в телах методов. Не помогает также ManagedJitter — словом, все инструменты, имеющиеся у нас под рукой, оказались бессильны. Забегая вперед, отмечу, что существует версия дампера специально для Agile: SimpleMSILDecryptorForAgile, которая основана именно на упомянутом выше принципе инъекции своего кода в clrjit, но мы попробуем дойти до этого своим путем.
Итак, испытав все способы, загружаем нашу программу в x64dbg и, как было описано выше, ставим бряк на CILJit::compileMethod. Несколько раз бряк срабатывает нормально, правда подаваемый на вход компилируемый код методов не отличается от исходного, который мы видели в декомпиляторе. И вдруг внезапно счастье заканчивается, программа молча завершается. Похоже, Agile оправдывает свою репутацию, активно борясь с отладчиком.
Мы тоже могли бы побороться с антиотладчиком, но сейчас у нас цель несколько другая, и мы не отвлекаемся на подобные мелочи. Временно отключаем точку останова и перезапускаем приложение — оно стартует нормально. Ну что ж, антиотладчику не нравятся только активные точки останова внутри clrjit, и это радует. Прерываем программу и опять активируем точку останова на compileMethod — по счастью, программа не кончает жизнь самоубийством. Значит, проверка идет не постоянно, а в некоторых ключевых точках, это тоже обнадеживает.
Смотрим более внимательно, где именно мы остановились. Ага, вызов clrjit::CompileMethod из той самой кастомной DLL-ки. Смотрим по стеку вызовов, откуда мы сюда попали. На наше счастье, всего в одном вложении выше идет вызов инжектированной функции протектора из clr.dll. Мы нашли вход и выход расшифровщика IL-кода. Cтавим на них две точки останова, благо антиотладчик борется только с бряками на оригинальный clrjit::CompileMethod, после чего перезапускаем программу. Попутно ставим сохранение в лог значений CORINFO_METHOD_INFO*info и BYTE*ILCode на входе и выходе из инъекции.
Поскольку все точки останова расположены вне clrjit, антиотладчик нам уже не мешает. С момента запуска инжектированного компилятора два раза он срабатывает вхолостую — исходный IL-код передается на оригинальную компиляцию без изменений. А вот на третий уже интересно: указатель на ILCode в структуре info подменен на новый блок памяти размером 0x100000, который, в принципе, уже можно дампить для исследования. Оставим его за рамками нашей статьи, остановимся только на паре моментов.
Прежде всего проверим, какой именно метод подменен протектором. На самом деле задача не такая простая, как может показаться. В структурах, подаваемых на вход CompileMethod, имеются только параметры компилируемого блока кода, но нет ни указателя на имя метода, ни даже его индекса в таблице методов. А ведь это придется делать, если мы хотим написать свой собственный дампер. Прямой способ — использовать следующий метод интерфейса ICorStaticInfo:
virtual const char* getMethodName(
CORINFO_METHOD_HANDLE ftn, /* IN */
const char **moduleName /* OUT */
) = 0;
Здесь задействован параметр comp и хендл метода ftn, однако из отладчика это сделать затруднительно, поэтому мы слегка схалтурим. Дело в том, что хендл ftn (первое двойное слово в структуре CORINFO_METHOD_INFO), если его использовать как указатель, указывает на одинарное слово — индекс метода в .NET-метадате EXE-модуля. В нашем примере это 0x23=35. Открываем CFFExplorer и находим метод — Main. В оригинале он занимает 1 байт ret, однако на выходе он поправился до 0x1A байт. Попутно мы нашли и развилку в коде, фильтрующую внешние методы, которые транзитом передаются в оригинальный компилятор без изменений, а также сам код преобразования и замены:
730B902E | mov ecx,dword ptr ds:[edx+C]
730B9031 | mov dword ptr ss:[ebp-190],ecx
730B9037 | cmp dword ptr ss:[ebp-19C],0
730B903E | je 730B90BF <-----------------------
730B9040 | mov eax,dword ptr ss:[ebp-18C]
730B9046 | push eax
730B9047 | mov ecx,dword ptr ss:[ebp-48]
730B904A | push ecx
730B904B | lea edx,dword ptr ss:[ebp-3C]
730B904E | push edx
730B904F | mov ecx,dword ptr ss:[ebp-4]
730B9052 | call 730B1361
730B9057 | push 1C
730B9059 | push 0
730B905B | call dword ptr ds:[<&GetProcessHeap>]
730B9061 | push eax
730B9062 | call dword ptr ds:[<&RtlAllocateHeap>]
730B9068 | mov dword ptr ss:[ebp-38],eax
730B906B | mov eax,dword ptr ss:[ebp-3C]
730B906E | push eax
730B906F | mov ecx,dword ptr ss:[ebp-38]
730B9072 | push ecx
730B9073 | call 730B1104
730B9078 | mov eax,dword ptr ss:[ebp+10]
730B907B | mov dword ptr ss:[ebp-1A0],eax
730B9081 | mov eax,dword ptr ss:[ebp+10]
730B9084 | mov ecx,dword ptr ss:[ebp-38]
735D9087 | mov edx,dword ptr ds:[ecx+C]
735D908A | mov dword ptr ds:[eax+8],edx <-- edx-указатель на новый IL-код метода Main
ЗАКЛЮЧЕНИЕ
И на закуску по традиции вспомним о нашей «Энигме», которой были посвящены две мои предыдущие статьи («Ломаем защиту приложений Enigma x64 актуальных версий» и «Как сломать trial, защищенный Enigma Protector»). Как я уже говорил, с ней совсем все плохо: дотнетовские метаданные упрятаны глубоко в упакованный и зашифрованный код. Соответственно, приложение не распознается как дотнетовское, не грузится нормально в отладчики и, понятное дело, не дампится. Причем антиотладчик там настолько серьезный, что не позволяет просто так взять и установить бряк на CompileMethod (да и вообще никуда не позволяет), а ведь нам для реализации описанного метода нужна точка останова не просто в этом месте, а именно в момент JIT-компиляции основного кода при загрузке приложения.
Все выглядит довольно‑таки страшно, однако попробуем зайти с другой стороны. В статье «Ломаем защиту приложений Enigma» я упоминал, что во время работы приложения расшифрованные секции присутствуют в памяти процесса. В дотнетовских программах для анализа обычно необходимы две секции: .text, где содержатся метаданные и IL-код, а также .rsrc с ресурсами. Попробуем поискать эти секции в памяти процесса.
За маску поиска секции .text возьмем, к примеру, имя потока "#Strings", содержащего в себе список строк со служебной информацией: названия классов, методов и атрибутов.
Таких вхождений обнаруживается немало (по числу загруженных .NET-библиотек). Фильтруем их по заголовкам .NET-метаданных и по Assembly.Name определяем имя модуля. Для поиска секции ресурсов можно использовать какую‑нибудь строку из манифеста, например <assembly xmlns=. Принадлежность найденной секции идентифицируем по ProductName.
Итак, у нас есть две расшифрованные жизненно важные секции. Прилепив к ним PE-заголовок и подправив в нем валидные размеры, мы получаем EXE-файл, который хоть и не запускается, но прекрасно загружается в декомпиляторы и деобфускаторы для последующего анализа кода. По идее, исполняемый файл можно даже сделать рабочим, запускаемым, воспользовавшись одним из инструментов, которые нетрудно отыскать в интернете. Но это уже тема для другого разговора.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei