Поскольку JIT-компиляция по сути является формой динамической компиляции, она позволяет использовать такие технологии, как адаптивная оптимизация и динамическая перекомпиляция. Это позволяет JIT-компиляции работать лучше, чем статическая. Интерпретация и JIT-компиляция особенно подходят для динамических языков программирования, в то время как среда выполнения обрабатывает привязку позднего типа и гарантирует безопасность выполнения.
С легкой руки Microsoft .NET стала сегодня одной из самых популярных платформ программирования. Большое количество инструментов, библиотек и документации обеспечивает легкость входа даже для самых начинающих программистов, а наиболее продвинутая кроссплатформенная оптимизация делает его одним из основных стандартов для написания коммерческого программного обеспечения. В результате для этой платформы было создано множество инструментов взлома и обратного проектирования. Среди них dnSpy, ILspy, ILdasm, Dile, SAE и многие другие.
Задача для реверсоров упрощается тем, что по умолчанию скомпилированная программа фактически содержит свой источник: имена символов сохраняются явно, а кроссплатформенный IL-псевдокод легко восстанавливается до исходных синтаксических конструкций C # или VB, из которых он был получен. во время компиляции. Соответственно, взломать такую программу для начинающего хакера — одно удовольствие: достаточно загрузить ее в dnSpy, и вот она, на блюдечке в исходном коде, даже раскрашенная для удобства в приятные цвета. Отлаживайте и редактируйте как хотите, как будто вы сами написали эту программу!
Теория
Конечно, производители программного обеспечения терпеть не могут такое положение вещей, и в следующем раунде противостояния хакеров и протекторов было разработано множество инструментов для предотвращения восстановления исходного кода из сборки IL. Грубо говоря, все эти инструменты используют три основных принципа:
- сокрытие (шифрование, компрессия и так далее) .NET-метаданных и IL-кода с восстановлением только в краткий миг JIT-компиляции;
- обфускация IL-кода, то есть преднамеренное запутывание его логики, борьба с читаемостью текстовых строк и имен символов, чтобы понять логику работы восстановленного IL-кода было сложнее;
- комбинация двух предыдущих категорий.
Сегодняшняя беседа будет о методах первой категории. По сути, самый простой и наиболее требовательный способ защитить программу от ILDasm — это скомпилировать ее с атрибутом SupressIldasmAttribute
. Конечно, это защита от честных людей, так как такой набор отлично определяется как .NET-приложение, декомпилированное другими инструментами, и этот атрибут убирается из полутона в CFF Explorer или, при большом умении, в простом HEX-редакторе. Более интересно поместить метаданные в обычное собственное приложение, которое на лету генерирует и запускает сборку .NET.
В данном случае никакие детекторы не распознают в ней .NET, если предварительно не обучены этому трюку, а декомпиляторы и отладчики, которые не сразу увидели метаданные в программе, остановятся при загрузке. Вы можете попробовать исследовать такое приложение с помощью dnSpy, но если его прервать, то вряд ли удастся получить и отследить код дальше, что делает такую отладку бесполезной. Что делать в этом случае?
Простейший способ — использовать утилиту MegaDumper (или даже ее более продвинутую версию ExtremeDumper). Если .NET сформирован и запущен по всем правилам, то он правильно распознается упомянутыми утилитами как .NET-процесс, и при нажатии кнопки дампа .NET он загружается как стандартное .NET-приложение. Правда, далеко не факт, что он будет запущен. Чтобы привести его в метательную форму, вам нужно будет выполнить определенные движения тела в зависимости от продвинутости протектора. Однако метаданные .NET и IL в такой сборке будут доступны для декомпиляции и анализа. Проверить это можно, открыв сборку, например, в CFF Explorer. Однако я специально сделал оговорку «если». Попробуем разобраться, почему это может не сработать.
Я постараюсь в двух словах вкратце напомнить принцип работы .NET-приложения для тех, кто забыл про железо. Хотя сборка состоит из метаданных и кросс-платформенного кода IL, она не интерпретируется при запуске приложения, а скорее компилируется в высокооптимизированный собственный целевой процессор и код целевой операционной системы. Это делается непосредственно при однократной загрузке блока кода, после чего будет выполнен уже скомпилированный нативный код метода. Сам процесс называется JIT-компиляцией. То есть, если вы завершите программу в произвольное время в отладчике, таком как x64dbg, то процесс будет завершен именно тогда, когда выполняется этот временно скомпилированный машинный код.
Конечно, его можно отследить, отладить и отменить, но целесообразность этого вызывает сомнения. Нас интересует другой подход — захват и выгрузка уже восстановленного фрагмента IL перед его компиляцией в JIT. Логика заключается в том, что если мы хотим сделать это вручную, мы должны найти исходную точку входа JIT-компилятора в отладчике. Самый простой способ — найти метод SystemDomain :: Execute
в clr.dll
(или mscorwks.dll
для более старых версий .NET). Обычно для таких вещей рекомендуется использовать WinDbg и его расширение SOS, но в качестве примера я покажу вам, как это сделать в x64dbg.
Поиск JIT-компилятора
Таким образом, после загрузки необходимого приложения в отладчик мы неприятно удивлены, обнаружив, что библиотека clr.dll отсутствует в списке отладочных символов. Это значит, что вам нужно будет скачать его дополнительно, найдя глубоко в недрах подкаталогов системной папки Windows. После нахождения и загрузки clr.dll (по пути будут загружены несколько библиотек) мы снова с раздражением обнаруживаем, что метод SystemDomain :: Execute не находится в правильном списке экспорта. Что ж, к счастью, x64dbg предлагает отличную возможность загрузить символы отладки прямо с сервера Microsoft — для этого вам нужно щелкнуть правой кнопкой мыши на clr.dll и выбрать соответствующий пункт из контекстного меню.
Обождав определенное время, мы увидим, что список в правой части окна отладчика значительно увеличился и в нем уже присутствует необходимый метод SystemDomain :: Execute. Ставим на него точку останова и запускаем программу. На момент остановки этого метода метаданные dotnet
обычно уже расшифрованы, разархивированы и могут быть выгружены в файл даже с помощью MegaDumper или Scylla из самого отладчика. Однако и этого может быть недостаточно. Попробуем копнуть немного глубже и перейдем к исходному JIT-компилятору.
Для этого найдем и загрузим вышеописанным способом библиотеку clrjit.
, а также отладочные символы к ней. Находим в них следующий метод:
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-код метода, а четвертое — это размер блока. Конечно, расшифровывать каждый метод в отладчике таким образом своими руками довольно сложно. Однако теперь мы знаем, как это делается, и, если хотите, мы можем отменить всю предыдущую последовательность действий, которую протектор внедрил в нее с помощью блока кода. В конце концов, вы можете вставить свой код между протектором и собственным компилятором и реализовать свой собственный дампер для каждой новой защиты.
Проверка на практике
Попытаемся применить данный метод для случайного приложения. Когда приложение загружается в отладчик или декомпилятор, оно предоставляет почти всем методам пустое тело, состоящее из команды ret
или ldnull / ret
. То же самое происходит в секции Main
, но .cctor
относится к вызову внешней DLL, где быстрая проверка показывает упоминание AgileDotNetRT.dll. Действительно, в определении защиты таким образом не может быть никаких сомнений. Начинаем копаться в программе со всеми имеющимися у нас инструментами.
Деобфускаторы не могут справиться с программой на лету, дамп с использованием MegaDumper и ExtreamDumper не добавляет данные, отображаемые в теле метода. ManagedJitter тоже не помогает — словом, все инструменты под рукой оказались бессильны. Забегая вперед, я замечаю, что существует версия дампера специально для Agile: SimpleMSILDecryptorForAgile, которая основана на принципе внедрения собственного кода в clrjit
, упомянутом выше, но мы постараемся добраться до нее по-своему.
После тестирования всех методов загружаем нашу программу в x64dbg и, как описано выше, устанавливаем точку останова на CILJit :: compileMethod. Часто точка останова работает нормально, хотя скомпилированный код методов, представленных на входе, ничем не отличается от исходного, который мы видели в декомпиляторе. И вдруг, счастье заканчивается, программа молча завершается. Кажется, что Agile оправдывает свою репутацию, активно борясь с отладчиком.
Можно было бы побороться и с анти-отладчиком, но сейчас наша задача несколько иная, и мы не отвлекаемся на такие мелочи. Временно отключите точку останова и перезапустите приложение — оно запускается нормально. Что ж, анти-отладчику нравятся только активные точки останова внутри clrjit
, что не может не радовать. Мы прерываем программу и повторно включаем точку останова в методе compileMethod — к счастью, программа не совершает самоубийства. Значит, проверка идет не постоянно, а в некоторых ключевых точках, это тоже обнадеживает.
Рассмотрим подробнее, на чем именно мы остановились. Да, вызов clrjit :: CompileMethod из той же пользовательской библиотеки DLL. Мы смотрим на стек вызовов, откуда мы пришли. К счастью для нас, только одно вложение выше — это вызов функции защиты, внедренной из clr.dll.
Мы нашли вход и выход дешифратора кода IL. Давайте установим на них две точки останова, потому что анти-отладчик борется только с вырезками из исходного clrjit :: CompileMethod, после чего мы перезапускаем программу. Попутно заносим в журнал значения CORINFO_METHOD_INFO * info
и BYTE * ILCode
при входе и выходе из инъекции.
Так как все точки останова находятся вне clrjit
, анти-отладчик нас больше не беспокоит. С момента запуска внедренного компилятора он дважды простаивает — исходный IL-код переносится в исходную компиляцию без изменений. А вот третий уже интересен: указатель на ILCode в информационной структуре заменен новым блоком памяти размером 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.
Откройте CFF Explorer и найдите метод 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
Заключение
Во время выполнения приложения расшифрованные разделы присутствуют в памяти процесса. Программы Dotnet обычно нуждаются в двух секциях для анализа: .text,
который содержит метаданные и код IL, и .rsrc
с ресурсами. Попробуем найти эти участки в памяти процесса. В качестве маски поиска в разделе .text
возьмем, например, имя потока «#Strings
», содержащего список строк со служебной информацией: имена классов, методы и атрибуты. Есть много экземпляров этого типа (по количеству загруженных библиотек .NET). Мы фильтруем их по заголовкам метаданных .NET и по Assembly.Name определяем имя модуля. Вы можете использовать строку манифеста для поиска раздела ресурсов, например <assembly xmlns =
. Мы определяем членство в разделе, найденном по ProductName.
Таким образом, у нас есть два расшифрованных жизненно важных раздела. Приклеивая к ним PE-заголовок и настраивая в нем допустимые размеры, мы получаем EXE-файл, который, хотя и не запускается, отлично загружается в декомпиляторы и деобфускаторы для дальнейшего анализа кода. Теоретически исполняемый файл можно даже заставить работать, запускать с помощью одного из инструментов, которые легко найти в Интернете.