Хакер - Ангард! Реверсим приложение, защищенное DNGuard
hacker_frei
МВК
Фантазия разработчиков, желающих защитить свою программу, порой не знает границ. Частенько им мало одного навешенного на софтину протектора, и они, как монашка из известного анекдота, натягивают их несколько штук последовательно. Порой тем же самым грешат и сами разработчики средств защиты. Случай подобного симбиоза IL-обфускатора с VMProtect мы и рассмотрим в сегодняшней статье.
.NET-обфускатор DNGuard HVM существует довольно долгое время, за которое он успел обзавестись изрядным количеством фич (антиотладка и антидамп, тотальное шифрование всей информации в модуле и весьма успешное противодействие статическому и динамическому анализу). Несмотря на широкую известность и неплохую изученность (в свое время CodeCracker запилил множество распаковщиков под разные версии DNGuard и сопутствующих фреймворков), последние несколько лет средства анализа и распаковки для актуальных версий этого обфускатора напрочь отсутствуют. Вплоть до того, что популярные инструменты (и даже Detect It Easy) до сих пор не научились их даже детектировать. Поэтому, надеюсь, сегодняшняя статья поможет читателю разобраться, как распознать эту заразу и бороться с ней подручными средствами.
WARNING
Статья имеет ознакомительный характер и предназначена для специалистов по безопасности, проводящих тестирование в рамках контракта. Автор и редакция не несут ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону.
Итак, предположим, что в твои загребущие руки попала сборка, защищенная DNGuard. Есть два варианта. Первый: нам повезло, и это одна из старых изученных версий, тогда все ясно и можно смело пользоваться одним из дамперов DNGuard_HVM_Unpacker от CodeCracker. Возможно, я когда‑нибудь и расскажу об особенностях и даже принципах работы этих утилит. Однако DNGuard совершенствуется стремительно, а проект, судя по всему, давно заброшен: последняя версия поддерживаемого фреймворка была 4.2, а физическая привязка к конкретным файлам библиотек настолько сильна, что делает использование данных наработок малополезным в деобфускации новых версий.
Поэтому сразу переходим к более грустному варианту. Как я уже писал выше, хоть актуальные версии и не детектятся, однако сильно и не скрываются. В рабочем каталоге программы сразу бросается в глаза библиотека runtime.dll (или со схожим названием), с навешенным на ней VMProtect на минималках.

Внутри библиотеки обнаруживаются две экспортируемые функции: VMRuntime и GetUserString (или ResolveString).

Еще одна характерная черта — VMProtect’овская секция hvm0, из‑за которой этот обфускатор, насколько я понимаю, и получил такое название.

Впрочем, наличие этой библиотеки в каталоге в явном виде вовсе необязательно, самые последние версии научились держать ее в зашифрованном виде в теле основной программы, рожая ее при загрузке в системные временные каталоги типа Temp или ProgramData. Cама программа, защищенная этим обфускатором, тоже выглядит достаточно характерным образом при загрузке в отладчик вроде dnSpy.


Как видно, для стартап‑кода программы характерно наличие класса с методами CheckRuntime, CheckString, GetUserString и так далее и импортом упомянутых выше функций из библиотеки Runtime.dll. Так же как и во всех серьезных обфускаторах названия остальных классов, методов, строк и ресурсов жестко пошифрованы, а тела методов пусты или содержат вызов исключения «Error, DNGuard Runtime library not loaded!» (причем даже эта строка может быть зашифрована).
Собственно, если попробовать запустить такое приложение из отладчика, максимум, что мы сможем увидеть, — это инициализация стартап‑кода DNGuard до нативного вызова VMRuntime, после чего тот перехватывает на себя JIT-компилятор и мы прощаемся с отладчиком, словив подобное исключение.
Попытка приаттачиться к запущенному процессу тоже не дает полезного результата, так же как и дамп его всеми известными дамперами. Поэтому закрываем dnSpy и начинаем вспоминать матчасть, в том числе информацию, изложенную в моей статье «Реверсинг .NET. Как искать JIT-компилятор в приложениях».
Для тех, кто не читал статью, напомню ее суть в двух словах. Как известно, любая .NET-сборка устроена следующим образом: кросс‑платформенный IL-код хранится в специальных метаданных, из которых подгружается по мере исполнения каждого метода и компилируется в нативный код специальной функцией. Указатель на нее возвращает функция GetGit библиотеки clrjit.dll, однако фреймворк любезно предоставляет пользователю самому устанавливать адрес на компилятор, чем беззастенчиво пользуются создатели всевозможных обфускаторов, подменяя ее своими процедурами расшифровки IL-кода.
Таким же образом можно менять на свои функции загрузки строк, ресурсов и так далее. В частности, функцию Module::ResolveStringRef(unsigned long,class BaseDomain *,bool), которая возвращает указатель на запрашиваемый строковый литерал. По счастью, ни один даже самый продвинутый обфускатор не может полностью подменить собой .NET-фреймворк с JIT-компиляцией, хотя бы из соображений системной совместимости. Поэтому, несмотря на все подмены, управление все равно в итоге передается на оригинальные обработчики. Более того, библиотека mscorlib.ni.dll предоставляет массу возможностей для работы с низкоуровневыми объектами (файлы, даты, строки, словари и прочее), через которые можно отслеживать логику работы программы уже из откомпилированного исполняемого нативного кода.
Все это избавляет нас от необходимости прошибать головой железобетонную стену девиртуализации VMProtect, анализируя «изнутри» в нашем любимом x64dbg уже расшифрованные библиотекой runtime.dll код и строки. Попробуем рассмотреть использование описанного метода на примерах.
Допустим, нам для начала интересно определить, какие строковые константы были загружены и использованы на определенном участке работы программы. Загружаем программу в отладчик x32dbg и идем на вкладку «Отладочные символы». В списке загруженных библиотек справа находим clr.dll, жмем на правую кнопку мыши и загружаем отладочные символы для этого модуля.
В списке отладочных символов находим символ public: struct OBJECTHANDLE__ * __thiscall Module::ResolveStringRef(unsigned long,class BaseDomain *,bool) и ставим на него точку останова. Запускаем программу и тормозимся на этой точке. Пока ничего интересного, она вызвана из изгаженного VMProtect’oвской виртуализацией кода runtime.dll. Запускаем выполнение до возврата из библиотеки. На выходе уже интереснее — в EAX появилась ссылка на указатель на расшифрованную строку.

Убираем точку останова на входе ResolveStringRef и переносим ее на выход, добавив печать возвращаемой строки.

Запускаем программу и — бинго! — имеем в журнале отладчика полный список загруженных и расшифрованных в процессе работы приложения строковых констант. Теперь нам надо найти сравнение, после которого выполнение программы идет по нужной нам ветке. Для этого точно так же загружаем отладочные символы уже для библиотеки mscorlib.ni.dll. Мы обнаруживаем удивительное множество нативных методов для низкоуровневой работы со строками. Например, самые полезные из них.
Сравнить две строки:
mscorlib.ni.dll.System.String.Compare(System.String, System.String, Boolean)
Cклеить две строки:
mscorlib.ni.dll.System.String.Concat(System.String, System.String)
Загрузить строку из символьного массива:
mscorlib.ni.dll.System.String.CtorCharArray(Char[])
Загрузить строку по указателю на символьный массив:
mscorlib.ni.dll.System.String.CtorCharPtr(Char*)
Сравнить две строки:
mscorlib.ni.dll.System.String.Equals(System.String, System.String)
Присвоить строку:
mscorlib.ni.dll.System.String.Intern(System.String)
Вернуть подстроку:
mscorlib.ni.dll.System.String.Substring(Int32, Int32)
Загрузить символьный массив из строки:
mscorlib.ni.dll.System.String.ToCharArray()
Преобразовать строку в нижний регистр:
mscorlib.ni.dll.System.String.ToLower()
Ставя условные точки останова на эти функции с промежуточными печатями параметров, мы получаем в журнале совершенно прозрачный лог работы программы со строками, в котором без труда находим искомые проверки. Таким же методом можно воспользоваться, например, чтобы отучить программу от триала, ведь низкоуровневые операции с классом DateTime сидят в этом же модуле.
Сравнить временные промежутки:
mscorlib.ni.dll.System.DateTimeOffset.CompareTo(System.DateTimeOffset)
Сложить временные промежутки:
mscorlib.ni.dll.System.DateTimeOffset.op_Addition(System.DateTimeOffset, System.TimeSpan)
Вычесть из времени:
mscorlib.ni.dll.System.DateTime.Subtract(System.TimeSpan)
Добавить ко времени:
mscorlib.ni.dll.System.DateTime.op_Addition(System.DateTime, System.TimeSpan)
Позже?
mscorlib.ni.dll.System.DateTime.op_GreaterThan(System.DateTime, System.DateTime)
Раньше?
mscorlib.ni.dll.System.DateTime.op_LessThan(System.DateTime, System.DateTime)
Одновременно?
mscorlib.ni.dll.System.DateTime.op_Equality(System.DateTime, System.DateTime)
Отследив нужное сравнение и подобрав для него подходящее условие, можно даже не заботиться о том, как именно и куда скомпилировался IL код, — при работе в отладчике управление все равно придет в это место и остановится в нужный момент.
Итак, мы разобрались, какую проверку и на что нужно менять, но что делать дальше? Мы же не можем каждый раз запускать программу в отладчике, чтобы руками подсказывать ей, как дальше работать. По сути, мы не знаем даже названия класса и метода, в котором происходит проверка. Да даже если бы и знали, толку все равно мало — код по‑взрослому зашифрован, а декриптор виртуализирован самим VMProtect’ом, пускай на минималках.
В подобных случаях, как я уже писал, можно сурово идти до конца, как делал в свое время CodeCracer, и, полностью или частично пробившись сквозь виртуализированный код, написать свой деобфускатор. Или же пойти по более легкому пути, создав лоадер, патчащий IL-код в момент, когда он уже расшифрован и подается на вход JIT-компилятора. Суть этих двух способов я объяснял в статье, поэтому не буду повторяться, а в заключение просто приведу несколько советов и предложений, как реализовать эти идеи.
В принципе, можно взять за основу восстановленный код дампера от DNGuard_HVM_Unpacker, но мы не будем этого делать, потому что этот дампер сильно привязан к изрядно устаревшим версиям фреймворка, о чем я уже говорил. Поэтому читаем статью Даниеля Пистелли, в которой матчасть описана гораздо толковее, чем у меня. И берем за основу используемые в ней примеры готового кода.
Не буду их дублировать, чтобы не перегружать собственный текст, просто обрисую принцип в двух словах. Есть загрузчик обфусцированной программы на C# (rbloader), который делает инъекцию кода, содержащегося в нативной библиотеке. К статье прилагаются примеры проектов инъекторов, которые демонстрируют реальные имена компилируемых классов и методов исполняемой программы, и даже выполняется дизассемблирование их расшифрованного IL-кода.
Конечно, эта статья тоже устарела, и приведенный там код требует допиливания под современные фреймворки. К примеру, используемая для получения реального адреса JIT-компилятора функция getJit сейчас содержится не в упоминаемой там библиотеке mscorjit.dll, а в библиотеке clrjit.dll. Но это мелочи, вполне преодолимые, если не замахиваться сразу на создание полного декомпилятора или дампера, а начать с простых, но полезных вещей.
Например, можно решить проблему привязки исполняемого в данный момент откомпилированного нативного кода к конкретному классу и методу. Скажем, в качестве идентификации метода можно использовать токен ftn (первое слово структуры CORINFO_METHOD_INFO, подаваемой на вход JIT- компилятору, — это указатель на него CORINFO_METHOD_HANDLE). Не знаю, задокументировано это или нет, но CORINFO_METHOD_HANDLE находится непосредственно перед скомпилированным в натив методом.

Поставив условную точку останова на JIT-компилятор c логированием, можно через ftn привязать расшифрованный IL-код к откомпилированному нативному коду метода, однако его имя и имя его класса на момент компиляции из отладчика получить затруднительно. Халтурный способ, предложенный мной в предыдущей статье, здесь не работает.
Вот тут нам и пригодится загрузчик с инъектором Даниеля Пистелли. Из кода, инъектируемого в CompileMethod, получить имя компилируемого метода и его класса не просто, а очень просто. Более того, прямо в тело обработчика можно встроить автопатч свежерасшифрованного IL-кода метода.
Хочу добавить напоследок, что все описанное выше работает не только для DNGuard, но и для любого другого зловредного инструмента, использующего подобные принципы обфускации. Надеюсь, мне представится возможность продемонстрировать это в одной из следующих статей.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei