Murasaki
-
Автор темы
- #1
Шалом. Давно я не выпускал публичные статьи для форума, уйдя полностью в бусти.
Особо контента не было на публику, но тут мне напомнили об легендарной Overdroch 2, чьи импорты я уже разбирал прошлым летом, почитать можно здесь: https://yougame.biz/threads/300963/
Мне поведали о том, что Blizzard перетерпела множество изменений, в особенности во время инициализации важных данных на самом старте. И поскольку год назад я не анализировал её инициализацию и анти-дебаг, зациклившись на импортах, то на сегодняшний день, учитывая мою инактивность на форуме, я решил подарить вам, хоть и не так много, но всё же контента.
Вся инициализация игры в основном состоит из дешифрования и шифрования разных нужных ему данных в последующем, будь-то для вызова, для чтения и т.д., укромно спрятав это под изначально зашифрованный код в памяти.
Статья не будет такой огромной и содержательной, но вы держитесь.
TLS-Callback Spoofing
Перед тем, как перейти к исключениям, я заранее скажу, что на самом старте программы (а именно первый TLS-Callback) идет проверка флага NtGlobalFlag:
Такая проверка тут нужна просто чтобы удостовериться, что во время дешифрования второго TLS-каллбека не подключен отладчик.
Но если всё гуд, то идем дальше.
Игра вызывает VirtualProtect по своему первому TLS-каллбеку:
Теперь к самим каллбекам.
Overdroch 2 использовал VirtualProtect по адресу, где расположены указатели на все TLS-каллбеки. По началу кажется, что все эти каллбеки просто не расшифрованы, и к ним еще вернуться для декрипьа, но после вызова VirtualProtect, он заменяет указатель на второй каллбек своим адресом, где есть валидный код, и передает ему управление. Опять же, если вы провалили проверку на NtGlobalFlag, то спуфа не произойдет, и вас кинет на второй оригинальный каллбек, где сидят случайные байты.
Вообще, в этом и кроется весь смысл первого каллбека, при старте программы можно самому направить на спуфнутый адрес, и это никак не повлияет на дальнейшее выполнение кода.
Любовь к исключениям
Overdroch 2 активно спамит исключениями, даже во время самой игры в отдельных потоках, делает он это по разным причина, и, ебать, его, в, рот, как же их много будет во время исполнения, игра буквально не может жить без этого.
На самом старте программы ставится пока что только обычный Veh.
После установки обработчика исключений, Overdroch триггерит первое исключение, а именно - C0000096, что в простонародье означает попытку выполнить привилегированную инструкцию (EXCEPTION_PRIV_INSTRUCTION), делает он это через инструкцию hlt.
Сразу скажу, что обработчик Overdroch 2 служит для нескольких целей:
В коде куча мертвого кода и обфускации констант, так что я буду показывать псевдо-код того, что происходит в обработчике при разных обстоятельствах (К обфускации мы еще вернемся).
После 19 полученных исключений от hlt, разработчики добавили новую инструкцию, которая триггерит тот же самый эксепшн, а именно: wbindv.
К моменту, когда мы дойдем до этого исключения, обработчик будет иметь при себе в арсенале большое количество глобальных счетчиков, тут мы остановимся и рассмотрим детальнее псевдокод:
Каждое исключение, вызванное инструкциями HLT или WBINVD, не только обновляет глобальные счетчики, но и выполняет сложную серию проверок:
Single-step я не показываю по той причине, что он делает абсолютно всё тоже самое. К слову, все single-step исключения можно обойти либо добавив их в исключения отладчику, либо из инструкции
сделать
Либо же просто скипнуть их, запатчив обработчик)0, но это сработает лишь частично, чуть позже объясню почему.
Исходя из текущей логики обработчика, на данном этапе он выглядит примерно так, без учета SINGLE_STEP исключений:
Я "попытался" примерно реализовать подобную шизу с автоматическим добавлением глобальных счетчиков при EXCEPTION_BREAKPOINT, в целом, отработало все нормально, кусочек кода из обработчика (
Имплементация Self-Remap у Blizzard
Шо ж, впервые я такую технику увидел еще давным-давно, но в этой игре Blizzard максимально облегчили себе задачу и сделали вот какой алгоритм. Сама суть техник заключается в защите от патча кода засчёт изменения прав региона до маппинга.
Что делает Overdroch:
Снова исключения. SEH + KiUserExceptionDispatcher hook
Overdroch 2 удаляет обработчик исключений, и ставит новый. Но вместо простой установки VEH, он перехватывает KiUserExceptionDIspatcher, и теперь в качестве исключений использует такие инструкции, как ud2 и int 0x2c.
После удаления обработчика, он вызывает ud2. Но к этому моменту, ни обработчика, ни хука нет. Overdroch 2 использует альтернативный вариант хендла исключений в виде SEH (Structured Exception Handling).
Но как вообще код передается в __except блок?
Чуть-чуть теории.
Когда в программе происходит исключение (например, деление на ноль или обращение к недопустимому региону памяти), процессор генерирует прерывание. Это как сигнал тревоги в системе. Windows перехватывает этот сигнал в ядре и передает управление функции под названием KiUserExceptionDispatcher в юзермоде.
KiUserExceptionDispatcher, получив управление, вызывает функцию RtlDispatchException. Эта функция отвечает за поиск подходящего обработчика для нашего исключения.
RtlDispatchException, найдя подходящий обработчик, передает управление функции RtlpExecuteHandlerForException. Эта функция отвечает за подготовку и выполнение найденного обработчика.
Наконец, RtlpExecuteHandlerForException вызывает __C_specific_handler. Это специальный обработчик, который является ключевым компонентом в реализации конструкций
Но в случае с Overdroch, роль Seh слегка отличается от простого хендлинга исключений, она помимо обработки еще и вызывает нужные ей функции в __except блоке, заранее передав ей аргументы (
Внутри обработчика игра постоянно взаимодействует с текущим TEB, и использует его адрес, как значение для последующего декрипта данных в стеке.
И проверки ради я решил изменить один из получившихся байтов, и ожидаемо мне дали пизды, поскольку оказалось, что они служат в последующем для декрипта адреса функции, которую им нужно вызывать в будущем.
В хуке KiUserExceptionDIspatcher мы видим следующую картину:
Опять манипуляция с данными какой-то шизы по адресу [00007FF7DE8BC270] во время обработки разных исключений, которые выбрасывает игра. Назвать это чем-то важным не особо язык поворачивается, поскольку это значение никаким потоком не читается в дальнейшем (кроме самим хендлером в самом начале).
Как вы поняли, изменение этого значения вообще никак не влияет на работоспособность игры в будущем, так что это выглядит как песок в глаза.
Также пока я снимал трассировку, то краем глаза обнаружил забавный проверки целостности возвращаемого адреса. Эта инструкция проверяет первый байт релативного вызова, который его вызвал, в случае несовпадения отправляет в бесконечный цикл, пока стек не умрет:
Эта последовательность вычисляет адрес возврата из вызывающей инструкции CALL и сравнивает его с ожидаемым адресом (7FF6A00B6FD0). Это проверка того, что функция была вызвана из ожидаемого места в коде:
А эти инструкции выполняют проверку диапазона адресов:
Они проверяют, что адрес вызова (rcx) находится в определенном диапазоне адресов, заданном значениями в стеке.
И все они, в случае несовпадения, отправляют погулять RIP в бесконечный цикл, вызывая этим шеллом исключение STATUS_RESOURCE_NOT_OWNED:
Печалька в анти-отладке
Анти-отладка, тут, к сожалению, оказалась не очень.
Фактически, все функции, связанные с анти-дебагом, вызываются в ммапнутом ntdll, чтобы убить Anti-anti-debug плагины.
Игра аллоцирует через NtAllocateVirtualMemory выделенную память для ntdll и через NtReadFile читает файл с диска, поскольку CreateFileMapping/MapViewOfFile шлют на все четыре стороны, если задать флаг выполнения.
Примерная моя реализация была такой (
Возвращаясь к ммапед ntdll, первым что он там выполняет это NtProtectVirtualMemory:
Указывая на DbgBreakPoint и DbgUserBreakPoint, запатчив обоих на RET, но я года два тому назад ещё говорил в одной из своих статей, что это никак не влияет на работу x64dbg.
Затем происходит создание двух поток из ntdll через NtCreateThreadEx с флагом HideThreadFromDebugger, эти потоки в свою очередь, создают еще пару потоков.
Созданные потоки начинают друг за другом вызывать NtSetInformationThread, чтобы скрывать свои потоки. Но непонятно что мешало их сразу создать со скрытым флагом, как это и делал изначально Overdroch.
Далее один из потоков, в том же ммапнутом ntdll, вызывает NtQueryInformationProcess с флагом 0x1F - (ProcessDebugFlags), если вас детектит, то вы улетаете в нуллптр. Ии, на этом всё. Весь антидебаг в ините именно в этом и крылся.
Складывается ощущение, что разрабы просто зашли на сайт
Но это во время инита, а что у нас происходит во время запущенной игры?
Игра, находясь в главном меню, просто спамит из нескольких потоков чтение поля BeingDebugged + вызывая дефолтные анти-отладочные трюки по несколько раз, приправив все это флудом исключений.
Сисколлы кстати никуда не делись, они также продолжают выполняться внутри ммапед ntdll:
Да и когда вы захотите аттачнуться, вероятно, вам прилетит по лбу, поскольку все эти потоки уже были скрыты и вас просто дропнет с отладчика.
Как подобное контрить? Если бы не ммапед ntdll, который оказался исполняемым, то со всем этим спокойно справлялись популярные usermode-плагины вроде ScyllaHide и SharpOD, и в ScyllaHide даже намек есть на Nirvana hook, но в нашей плачебной ситуации нас выручит TitanHide (
После того, как у вас благодаря EfiGuard отключен PatchGuard + DSE, основной проблемой становится попытка аттачнуться к процессу, у которого абсолютно все потоки скрыты, ведь нас по прежнему будет дропать с отладчика.
Почему так?
В ядре при ThreadHideFromDebugger в ETHREAD->CrossThreadFlags задается флаг PS_CROSS_THREAD_FLAGS_HIDEFROMDBG. В дальнейшем во функции "DbgkForwardExceptionPattern" мы встретим следующую проверку:
Т.е. при обнаружении флага, DebugPort отлаживаемого процесса просто зануляется. Те, кто читал мою статью про защиту процесса из кернела (
Оригинальный источник с ресерчем: (
Поскольку у нас в арсенале есть EfiGuard и PG нам не страшен, то мы можем пропатчить ядро, сделав так, чтобы наша проверка никогда не передала управление коду, который убьет DebugPort.
Это уже писал один человек два года тому назад - https://yougame.biz/threads/243341/
Я воспользовался этим драйвером, изменив там паттерн и метод получения адреса Ntoskrnl (
И, как вы уже заметили, меня перестало кикать с отладчика в скрытых потоках :)
Всего этого вполне хватает, чтобы беспрепятственно аттачнуться к игре и вытворять свои махинации.
Бонус: Пишем деобфускатор
Во время анализа трейса, я неоднократно сталкивался с обфускацией констант, анти-дизасмом и фейк JCC.
Анти-дизасм и фейк JCC устроены таким образом, чтобы фейк JCC никогда не гарантировал выполнение куска кода, коим и является анти-дизасм, сигнатурой такой техники является то, что фейк JCC всегда прыгает на NOP инструкцию:
Забавно, что год тому назад я правильно интерпретировал работу этого анти-дизасма, но облажался, показав неверный пример на графе, в котором нет кода из самой игры:
Теперь перейдем к обфускатору констант. Для тех, кто в танке, и не знает вообще что такое обфускатор констант, это - метод скрытия истинных значений в коде. Он работает путем применения серии математических операций к начальному зашифрованному значению, чтобы в итоге получить желаемую константу. Вот как это работает:
В этом примере происходит всё то, что было описано мною раннее, это самый обычный и лёгкий обфускатор: инициализация, серия операция, промежуточные сохранения и финальное значение.
Абсолютно та же картина происходящего. Такой обфускатор очень прост в реализации, используя compile-time (
Результат:
Учитывая, как мало контента я завез в статью, то напоследок я решил написать деобфускатор, используя Triton. Скрипт будет оптимизировать подобный код, и показывать в регистрах/стеке деобфусцированные значения.
Основная идея такая:
Тут мы инициализируем контекст Triton, загружаем байты нашей обфусцированной функции в эмулируемую память и устанавливаем начальное значение RIP.
Теперь переходим к деобфускации:
Этот метод - сердце нашего деобфускатора. Мы начинаем с начального адреса и эмулируем выполнение инструкций одну за другой. Для каждой инструкции мы:
Теперь посмотрим на process_instruction:
Здесь мы обрабатываем каждую инструкцию и обновляем final_state - словарь, хранящий текущие значения регистров. Почему мы фокусируемся только на MOV, XOR, ADD и CMP? Потому что это основные инструкции, используемые в обфускации констант в Overwatch.
"CMP" здесь проверяется, поскольку обфускация констант часто встречалась в обработчиках исключений, где происходит много проверок на такое-то значение эксепшена.
Если operand XOR самого себя, это обнуляет значение, казалось бы, юзлесс проверка. Но она была добавлена, поскольку она попалась в одном из обфусцированных констант.
Наконец, generate_optimized_code:
Этот метод генерирует оптимизированный код на основе final_state. Мы создаем простые инструкции MOV для каждого регистра и его финального значения.
Тут я сильно наговнокодил (снова), особенно с аргументами в CMP, где вместо регистров подставлял получившиеся IMM-значения, просто потому что мне было так удобнее, такие дела :)
Давайте проверим деобфускатор на пару примерчиков, первой жертвой станет наш код, который мы запилили для примера, закидываем ему код, и получаем такой вывод:
Поскольку в рантайме наш код действительно сохраняет это значение еще и в стеке, то это тоже логгируется, но можно было это сократить до простого
Но меня устраивает и текущий результат, так что, давайте теперь проверим на обфускаторе Overdroch 2, мы скормим скрипту вот такой код:
И получаем следующий код:
Наш деобфускатор справился на ура. Но, по факту вся наша деобфускация состояла из простого выполнения кода с мониторингом регистров, поскольку я сам новичок в использовании Triton и смотря на другие примеры его использования, то понимаю, что есть моменты, где можно было не костылить проверками, а пойти по иному и более легкому пути.
Сам исходный код деобфускатора лежит здесь:
Конец
Спасибо за чтение этой статьи. Я немного задержался с выходом этой статьи, поскольку в очередной N-ый раз умудрился заболеть. Мне будет очень приятно, если вы поддержите меня и поднимите мотивацию для выхода статей на моем Boosty:
Всего доброго, пока!
Особо контента не было на публику, но тут мне напомнили об легендарной Overdroch 2, чьи импорты я уже разбирал прошлым летом, почитать можно здесь: https://yougame.biz/threads/300963/
Мне поведали о том, что Blizzard перетерпела множество изменений, в особенности во время инициализации важных данных на самом старте. И поскольку год назад я не анализировал её инициализацию и анти-дебаг, зациклившись на импортах, то на сегодняшний день, учитывая мою инактивность на форуме, я решил подарить вам, хоть и не так много, но всё же контента.
Вся инициализация игры в основном состоит из дешифрования и шифрования разных нужных ему данных в последующем, будь-то для вызова, для чтения и т.д., укромно спрятав это под изначально зашифрованный код в памяти.
Статья не будет такой огромной и содержательной, но вы держитесь.
TLS-Callback Spoofing
Перед тем, как перейти к исключениям, я заранее скажу, что на самом старте программы (а именно первый TLS-Callback) идет проверка флага NtGlobalFlag:
C++:
MOV EAX, DWORD PTR DS:[RCX + BC]
AND EAX, 70
CMP AL, 70
JE 7FF68D935B8C
Но если всё гуд, то идем дальше.
Игра вызывает VirtualProtect по своему первому TLS-каллбеку:
C++:
mov [rsp + unknown*0x1:64 bv[63..0] + 0x20], 0x110
mov rax, 0x4
mov rcx, 0x7ff6906fb240
mov rdx, 0x110
mov [rsp + unknown*0x1:64 bv[63..0] + 0x28], 0x4
mov r8, 0x4 ; r8=0x4
call rbx
Overdroch 2 использовал VirtualProtect по адресу, где расположены указатели на все TLS-каллбеки. По началу кажется, что все эти каллбеки просто не расшифрованы, и к ним еще вернуться для декрипьа, но после вызова VirtualProtect, он заменяет указатель на второй каллбек своим адресом, где есть валидный код, и передает ему управление. Опять же, если вы провалили проверку на NtGlobalFlag, то спуфа не произойдет, и вас кинет на второй оригинальный каллбек, где сидят случайные байты.
Вообще, в этом и кроется весь смысл первого каллбека, при старте программы можно самому направить на спуфнутый адрес, и это никак не повлияет на дальнейшее выполнение кода.
Любовь к исключениям
Overdroch 2 активно спамит исключениями, даже во время самой игры в отдельных потоках, делает он это по разным причина, и, ебать, его, в, рот, как же их много будет во время исполнения, игра буквально не может жить без этого.
На самом старте программы ставится пока что только обычный Veh.
После установки обработчика исключений, Overdroch триггерит первое исключение, а именно - C0000096, что в простонародье означает попытку выполнить привилегированную инструкцию (EXCEPTION_PRIV_INSTRUCTION), делает он это через инструкцию hlt.
Сразу скажу, что обработчик Overdroch 2 служит для нескольких целей:
- Обнаружение DR7 на самом старте.
- Исполнение глобальных счетчиков, связанных с чеком времени исполнения кода за определенный промежуток, которые добавляются со временем, которые мы рассмотрим чуть позже.
- Манипуляция с кодом, шифрование уже выполненного кода, и дешифрование следующего кода перед исполнением.
В коде куча мертвого кода и обфускации констант, так что я буду показывать псевдо-код того, что происходит в обработчике при разных обстоятельствах (К обфускации мы еще вернемся).
После 19 полученных исключений от hlt, разработчики добавили новую инструкцию, которая триггерит тот же самый эксепшн, а именно: wbindv.
К моменту, когда мы дойдем до этого исключения, обработчик будет иметь при себе в арсенале большое количество глобальных счетчиков, тут мы остановимся и рассмотрим детальнее псевдокод:
C++:
LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS ExceptionInfo)
{
if (ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_PRIV_INSTRUCTION)
{
PCONTEXT ctx = ExceptionInfo->ContextRecord;
BYTE* instruction = (BYTE*)ctx->Rip;
if (*instruction == 0xF4)
{
ctx->Rip++;
InterlockedIncrement((LONG*)0x7FF7D7225290);
ULONGLONG timing = __rdtsc() & 0x3FF;
if (timing == 0)
{
InterlockedIncrement((LONG*)0x7FF7D7225294);
}
if (CheckTimingConditions())
{
// Set trap flag for single-stepping
ctx->EFlags |= 0x100;
}
return EXCEPTION_CONTINUE_EXECUTION;
}
else if (*instruction == 0x0F && *(instruction + 1) == 0x09)
{
ctx->Rip += 2;
UpdateCountersAndPerformChecks();
return EXCEPTION_CONTINUE_EXECUTION;
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
- Счетчик 1 (0x7FF7D7225290): Это основной счетчик исключений. Он увеличивается с каждым исключением и используется для отслеживания общего прогресса выполнения защитного кода.
- Счетчик 2 (0x7FF7D7225294): Этот счетчик увеличивается только когда результат RDTSC & 0x3FF равен нулю. Он служит для внесения элемента случайности в механизм.
- Счетчик 3 (0x7FF7D72252A0): Увеличивается при определенных условиях, связанных с timing_value. Может использоваться для обнаружения аномалий во времени выполнения.
- Счетчик 4 (0x7FF7D72252A8): Еще один счетчик, зависящий от timing_value. Используется для дополнительной проверки времени.
- Счетчик 5 (0x7FF7D72252AC): Последний из основных счетчиков в нашем примере, по сути его роль остаются такой же, как и у остальных.
Single-step я не показываю по той причине, что он делает абсолютно всё тоже самое. К слову, все single-step исключения можно обойти либо добавив их в исключения отладчику, либо из инструкции
C:
OR EDX, ECX
C:
MOV EDX, ECX
Исходя из текущей логики обработчика, на данном этапе он выглядит примерно так, без учета SINGLE_STEP исключений:
Я "попытался" примерно реализовать подобную шизу с автоматическим добавлением глобальных счетчиков при EXCEPTION_BREAKPOINT, в целом, отработало все нормально, кусочек кода из обработчика (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):
C++:
std::uint32_t exception_handler( EXCEPTION_POINTERS* exception )
{
const auto code = exception->ExceptionRecord->ExceptionCode;
if ( code == EXCEPTION_SINGLE_STEP || code == EXCEPTION_BREAKPOINT || code == EXCEPTION_PRIV_INSTRUCTION )
{
if ( !is_address_in_module( (void*) exception->ContextRecord->Rip ) )
{
exception->ContextRecord->EFlags &= 0x100;
return EXCEPTION_CONTINUE_EXECUTION;
}
if ( exception->ContextRecord->Dr7 != 0 )
{
exception->ContextRecord->Rsp ^= 0xdeadc0de;
return EXCEPTION_CONTINUE_SEARCH;
}
bool all_checks_passed = true;
for ( size_t i = 0; i < counters.size( ); ++i )
{
if ( !check_time( counters ) )
{
all_checks_passed = false;
break;
}
}
if ( all_checks_passed )
{
exception->ContextRecord->EFlags |= 0x100;
std::uint8_t offset;
switch ( code )
{
case EXCEPTION_BREAKPOINT:
offset = 1;
if ( counters.size( ) < 10 )
{
counters.push_back( { 0, 0x200 + (std::uint32_t) counters.size( ) * 0x100, 0x7E0 } );
}
break;
case EXCEPTION_SINGLE_STEP:
offset = 0;
break;
case EXCEPTION_PRIV_INSTRUCTION:
offset = 2;
break;
default:
return EXCEPTION_CONTINUE_SEARCH;
}
exception->ContextRecord->Rip += offset;
return EXCEPTION_CONTINUE_EXECUTION;
}
else
{
return EXCEPTION_CONTINUE_SEARCH;
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
Шо ж, впервые я такую технику увидел еще давным-давно, но в этой игре Blizzard максимально облегчили себе задачу и сделали вот какой алгоритм. Сама суть техник заключается в защите от патча кода засчёт изменения прав региона до маппинга.
Что делает Overdroch:
- Игра выделяет новый блок памяти, размер которого равен размеру всего образа игры.
- Все содержимое оригинального образа копируется в новый блок памяти.
- Отмапливание оригинального образа с помощью UnmapViewOfFile.
- Создание нового отображения файла с помощью CreateFileMapping.
- Отображение нового файлового отображения на место оригинального образа с помощью MapViewOfFileEx.
- Копирование содержимого из нового блока памяти в только что созданное отображение.
Пожалуйста, авторизуйтесь для просмотра ссылки.
)):
C++:
namespace warden
{
namespace remap
{
using RemapFunction = void ( * )( void* original_base, std::size_t size, void* new_base );
void __declspec( noinline ) do_remap( void* original_base, std::size_t size, void* new_base )
{
const auto file =
CreateFileMappingA( INVALID_HANDLE_VALUE, nullptr, PAGE_EXECUTE_READWRITE, 0, static_cast<DWORD>( size ), nullptr );
if ( !file )
{
std::printf( "Failed to create file mapping." );
return;
}
if ( !UnmapViewOfFile( original_base ) )
{
std::printf( "Failed to unmap original view." );
CloseHandle( file );
return;
}
// Map the view of the file at the original base address
void* mapped_base = MapViewOfFileEx( file, FILE_MAP_ALL_ACCESS | FILE_MAP_EXECUTE, 0, 0, size, original_base );
if ( !mapped_base )
{
std::printf( "Failed to map view of file at original address." );
CloseHandle( file );
return;
}
// Copy the content from our new allocation to the mapped view
std::memcpy( mapped_base, new_base, size );
std::printf( "Remap successful.\n" );
CloseHandle( file );
}
void __declspec( noinline ) remap_trampoline( )
{
auto base = GetModuleHandle( nullptr );
MODULEINFO module_info;
if ( !GetModuleInformation( GetCurrentProcess( ), base, &module_info, sizeof( module_info ) ) )
{
std::printf( "Failed to get module information." );
return;
}
void* new_base = VirtualAlloc( nullptr, module_info.SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE );
if ( !new_base )
{
std::printf( "Failed to allocate memory for remapping." );
return;
}
// Copy the module to the new location
std::memcpy( new_base, module_info.lpBaseOfDll, module_info.SizeOfImage );
// Calculate the offset to the do_remap function in the new allocation
auto offset = reinterpret_cast<uintptr_t>( do_remap ) - reinterpret_cast<uintptr_t>( base );
auto remap_func = reinterpret_cast<RemapFunction>( reinterpret_cast<uintptr_t>( new_base ) + offset );
// Call the remapping function from the new allocation
remap_func( module_info.lpBaseOfDll, module_info.SizeOfImage, new_base );
VirtualFree( new_base, 0, MEM_RELEASE );
}
}
}
Overdroch 2 удаляет обработчик исключений, и ставит новый. Но вместо простой установки VEH, он перехватывает KiUserExceptionDIspatcher, и теперь в качестве исключений использует такие инструкции, как ud2 и int 0x2c.
После удаления обработчика, он вызывает ud2. Но к этому моменту, ни обработчика, ни хука нет. Overdroch 2 использует альтернативный вариант хендла исключений в виде SEH (Structured Exception Handling).
Но как вообще код передается в __except блок?
Чуть-чуть теории.
Когда в программе происходит исключение (например, деление на ноль или обращение к недопустимому региону памяти), процессор генерирует прерывание. Это как сигнал тревоги в системе. Windows перехватывает этот сигнал в ядре и передает управление функции под названием KiUserExceptionDispatcher в юзермоде.
KiUserExceptionDispatcher, получив управление, вызывает функцию RtlDispatchException. Эта функция отвечает за поиск подходящего обработчика для нашего исключения.
RtlDispatchException, найдя подходящий обработчик, передает управление функции RtlpExecuteHandlerForException. Эта функция отвечает за подготовку и выполнение найденного обработчика.
Наконец, RtlpExecuteHandlerForException вызывает __C_specific_handler. Это специальный обработчик, который является ключевым компонентом в реализации конструкций
__try
/__except
/__finally
в Windows.Но в случае с Overdroch, роль Seh слегка отличается от простого хендлинга исключений, она помимо обработки еще и вызывает нужные ей функции в __except блоке, заранее передав ей аргументы (
Пожалуйста, авторизуйтесь для просмотра ссылки.
)):
C++:
template <typename Func, typename... Args>
auto call( Func&& func, Args&&... args ) -> decltype( func( std::forward<Args>( args )... ) )
{
using ReturnType = decltype( func( std::forward<Args>( args )... ) );
ReturnType result{ };
__try
{
__debugbreak( );
}
__except ( EXCEPTION_EXECUTE_HANDLER )
{
if constexpr ( std::is_same_v<ReturnType, void> )
{
func( std::forward<Args>( args )... );
}
else
{
result = func( std::forward<Args>( args )... );
}
}
return result;
}
И проверки ради я решил изменить один из получившихся байтов, и ожидаемо мне дали пизды, поскольку оказалось, что они служат в последующем для декрипта адреса функции, которую им нужно вызывать в будущем.
C:
CMP BYTE PTR DS:[RCX - 5], E8
JNE 7FF6A00B7130
MOVSXD RAX, DWORD PTR DS:[RCX - 4]
LEA RDI, QWORD PTR DS:[7FF6A00B6FD0]
ADD RAX, RCX
CMP RAX, RDI
JNE 7FF6A00B7130
MOV RAX, QWORD PTR SS:[RSP + 10]
CMP RCX, RAX
JB 7FF6A00B7130
C++:
if (*(uint32_t*)0x7FF7DE8BB100 != 0) {
if (rcx->ExceptionCode == 0xC0000096) {
if (rcx->ExceptionCode == 0x80000001) {
if (rcx->ExceptionCode == 0x80000004) { {
uint64_t r8 = *(uint64_t*)(0x7FF7DE8BB100 + 0x1170);
const uint64_t r14 = 0xFDE7AEEDD0E3D097;
const uint64_t r15 = 0xB400444ACE64BD4B;
uint32_t r9 = (uint32_t)r8;
r8 = (r8 & 0xFFFFFFFF00000000) | ((r9 << 20) ^ (0x4B7AC120B16F545C ^ r9));
r9 = ((uint32_t)(r8 - 0x5C28264) << 20) ^ (r8 & 0xFFFFFFFF00000000) | (r8 & 0xFFFFFFFF);
r9 ^= r15;
uint64_t rdtsc_value = __rdtsc();
uint32_t r10 = rdtsc_value & 0x3FF;
uint32_t rbx = 1 + r10;
r8 = ((rbx * 2 - rbx) & 0xFFFFFFFF);
r8 = (r8 << 20) | rbx;
r10 = ((uint32_t)(r8 - 0x5C28264) << 20) | (r8 & 0xFFFFFFFF);
r10 ^= r15;
r8 = ~(uint32_t)r10;
uint32_t rdx = (uint32_t)r10;
uint32_t rax = r14 & 0xFFF;
r10 &= 0xFFFFFFFF00000000;
uint64_t rcx = 0x5A45527EE176371F;
rcx >>= 0x20;
rcx = _rotr(rcx, 0xB);
r8 ^= rcx;
r8 = (r8 << 20) ^ r10 | rdx;
rcx = (uint32_t)r8;
r9 = r14 >> 0x34;
rdx = (uint32_t)r8;
r8 &= 0xFFFFFFFF00000000;
rcx -= *(uint32_t*)(r9 + 0x380B520);
rcx = (rcx << 20) ^ r8 | rdx;
*(uint64_t*)(0x7FF7DE8BB100 + 0x1170) = rcx;
}
}
}
}
Как вы поняли, изменение этого значения вообще никак не влияет на работоспособность игры в будущем, так что это выглядит как песок в глаза.
Также пока я снимал трассировку, то краем глаза обнаружил забавный проверки целостности возвращаемого адреса. Эта инструкция проверяет первый байт релативного вызова, который его вызвал, в случае несовпадения отправляет в бесконечный цикл, пока стек не умрет:
C++:
cmp byte ptr ds:[rcx-5],E8
C++:
movsxd rax,dword ptr ds:[rcx-4]
lea rdi,qword ptr ds:[7FF6A00B6FD0]
add rax,rcx
cmp rax,rdi
C++:
mov rax,qword ptr ss:[rsp+10]
cmp rcx,rax
jb 7FF6A00B7130
mov rax,qword ptr ss:[rsp+18]
cmp rcx,rax
jae 7FF6A00B7130
И все они, в случае несовпадения, отправляют погулять RIP в бесконечный цикл, вызывая этим шеллом исключение STATUS_RESOURCE_NOT_OWNED:
C++:
MOV RAX, QWORD PTR GS:[60]
MOV QWORD PTR SS:[RSP + 20], RAX
MOV RDI, QWORD PTR DS:[RAX + 18]
LEA RSI, QWORD PTR SS:[RSP + 20]
MOV ECX, 1000
REP MOVSB
JMP 7FF6A00B7190
Анти-отладка, тут, к сожалению, оказалась не очень.
Фактически, все функции, связанные с анти-дебагом, вызываются в ммапнутом ntdll, чтобы убить Anti-anti-debug плагины.
Игра аллоцирует через NtAllocateVirtualMemory выделенную память для ntdll и через NtReadFile читает файл с диска, поскольку CreateFileMapping/MapViewOfFile шлют на все четыре стороны, если задать флаг выполнения.
Примерная моя реализация была такой (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):
C++:
void init( )
{
// Hardcoded path to ntdll.dll
constexpr std::wstring_view file_path = L"C:\\Windows\\System32\\ntdll.dll";
HANDLE file_handle = CreateFileW( file_path.data( ), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr );
if ( file_handle == INVALID_HANDLE_VALUE )
{
return;
}
LARGE_INTEGER file_size = { };
if ( !GetFileSizeEx( file_handle, &file_size ) )
{
CloseHandle( file_handle );
return;
}
LPVOID buffer =
VirtualAlloc( nullptr, static_cast<SIZE_T>( file_size.QuadPart ), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE );
if ( !buffer )
{
CloseHandle( file_handle );
return;
}
DWORD bytes_read = 0;
if ( !ReadFile( file_handle, buffer, static_cast<DWORD>( file_size.QuadPart ), &bytes_read, nullptr ) )
{
VirtualFree( buffer, 0, MEM_RELEASE );
CloseHandle( file_handle );
return;
}
if ( bytes_read != file_size.QuadPart )
{
VirtualFree( buffer, 0, MEM_RELEASE );
CloseHandle( file_handle );
return;
}
CloseHandle( file_handle );
auto* dos_header = static_cast<IMAGE_DOS_HEADER*>( buffer );
if ( dos_header->e_magic != IMAGE_DOS_SIGNATURE )
{
VirtualFree( buffer, 0, MEM_RELEASE );
return;
}
auto* nt_headers = reinterpret_cast<IMAGE_NT_HEADERS*>( reinterpret_cast<uint8_t*>( buffer ) + dos_header->e_lfanew );
if ( nt_headers->Signature != IMAGE_NT_SIGNATURE )
{
VirtualFree( buffer, 0, MEM_RELEASE );
return;
}
ntdll = reinterpret_cast<std::uint64_t>( buffer );
printf( "Ntdll loaded at: 0x%p.\n", reinterpret_cast<void*>( ntdll ) );
}
Код:
[!] Syscall executed in 0x0000021D7D840B32 with 0x50 number
NtProtectVirtualMemory(ProcessHandle: 0xFFFFFFFFFFFFFFFF, BaseAddress: 0x00007FFC8AF93E30, RegionSize: 0x4, NewProtect: 0x40, OldProtect: 0x00007FF725EC1000) call
Затем происходит создание двух поток из ntdll через NtCreateThreadEx с флагом HideThreadFromDebugger, эти потоки в свою очередь, создают еще пару потоков.
Созданные потоки начинают друг за другом вызывать NtSetInformationThread, чтобы скрывать свои потоки. Но непонятно что мешало их сразу создать со скрытым флагом, как это и делал изначально Overdroch.
Далее один из потоков, в том же ммапнутом ntdll, вызывает NtQueryInformationProcess с флагом 0x1F - (ProcessDebugFlags), если вас детектит, то вы улетаете в нуллптр. Ии, на этом всё. Весь антидебаг в ините именно в этом и крылся.
Складывается ощущение, что разрабы просто зашли на сайт
Пожалуйста, авторизуйтесь для просмотра ссылки.
, перепастили оттуда пару трюков, которым по 10+ лет, запихнули их в heartbeat-потоки для постоянного наблюдения и пошли попивать чаек.Но это во время инита, а что у нас происходит во время запущенной игры?
Код:
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
SystemKernelDebuggerInformation by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
SystemKernelDebuggerInformation by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
Сисколлы кстати никуда не делись, они также продолжают выполняться внутри ммапед ntdll:
Да и когда вы захотите аттачнуться, вероятно, вам прилетит по лбу, поскольку все эти потоки уже были скрыты и вас просто дропнет с отладчика.
Как подобное контрить? Если бы не ммапед ntdll, который оказался исполняемым, то со всем этим спокойно справлялись популярные usermode-плагины вроде ScyllaHide и SharpOD, и в ScyllaHide даже намек есть на Nirvana hook, но в нашей плачебной ситуации нас выручит TitanHide (
Пожалуйста, авторизуйтесь для просмотра ссылки.
) + EfiGuard (
Пожалуйста, авторизуйтесь для просмотра ссылки.
). Т.к. все, что вызывается в качестве анти-отладке спокойно покрывается титанхайдом.После того, как у вас благодаря EfiGuard отключен PatchGuard + DSE, основной проблемой становится попытка аттачнуться к процессу, у которого абсолютно все потоки скрыты, ведь нас по прежнему будет дропать с отладчика.
Почему так?
В ядре при ThreadHideFromDebugger в ETHREAD->CrossThreadFlags задается флаг PS_CROSS_THREAD_FLAGS_HIDEFROMDBG. В дальнейшем во функции "DbgkForwardExceptionPattern" мы встретим следующую проверку:
C++:
if (PsGetCurrentThread( )->CrossThreadFlags & PS_CROSS_THREAD_FLAGS_HIDEFROMDBG) {
Port = NULL;
} else {
Port = Process->DebugPort;
}
Пожалуйста, авторизуйтесь для просмотра ссылки.
) уже видели, что будет с отладчиком, если вот так в наглую изменить DebugPort. Именно по этой причине отладка дальше невозможна и в случае x64dbg, дизассемблер визуально исчезает, а приложение завершается с исключением 0x80000003/0x80000004.Оригинальный источник с ресерчем: (
Пожалуйста, авторизуйтесь для просмотра ссылки.
)Поскольку у нас в арсенале есть EfiGuard и PG нам не страшен, то мы можем пропатчить ядро, сделав так, чтобы наша проверка никогда не передала управление коду, который убьет DebugPort.
Это уже писал один человек два года тому назад - https://yougame.biz/threads/243341/
Я воспользовался этим драйвером, изменив там паттерн и метод получения адреса Ntoskrnl (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):И, как вы уже заметили, меня перестало кикать с отладчика в скрытых потоках :)
Всего этого вполне хватает, чтобы беспрепятственно аттачнуться к игре и вытворять свои махинации.
Бонус: Пишем деобфускатор
Во время анализа трейса, я неоднократно сталкивался с обфускацией констант, анти-дизасмом и фейк JCC.
Анти-дизасм и фейк JCC устроены таким образом, чтобы фейк JCC никогда не гарантировал выполнение куска кода, коим и является анти-дизасм, сигнатурой такой техники является то, что фейк JCC всегда прыгает на NOP инструкцию:
Забавно, что год тому назад я правильно интерпретировал работу этого анти-дизасма, но облажался, показав неверный пример на графе, в котором нет кода из самой игры:
Теперь перейдем к обфускатору констант. Для тех, кто в танке, и не знает вообще что такое обфускатор констант, это - метод скрытия истинных значений в коде. Он работает путем применения серии математических операций к начальному зашифрованному значению, чтобы в итоге получить желаемую константу. Вот как это работает:
- Инициализация: Начальное значение помещается в память (обычно на стек).
- Серия операций: Выполняется последовательность математических операций (сложение, вычитание, XOR, ROL, и т.д.) над этим значением.
- Промежуточные сохранения: После каждой операции результат сохраняется обратно в память.
- Финальное значение: В конце серии операций в памяти оказывается нужная константа.
C++:
MOV QWORD PTR SS:[RSP + 198], FFFFFFFFFFFFFD67
MOV RAX, QWORD PTR SS:[RSP + 198]
XOR RAX, B79
MOV QWORD PTR SS:[RSP + 198], RAX
MOV RAX, QWORD PTR SS:[RSP + 198]
ADD RAX, 6B4
MOV QWORD PTR SS:[RSP + 198], RAX
MOV RAX, QWORD PTR SS:[RSP + 198]
ADD RAX, 32E
MOV QWORD PTR SS:[RSP + 198], RAX
MOV RAX, QWORD PTR SS:[RSP + 198]
C++:
MOV QWORD PTR SS:[RSP + 20], 1F22
MOV RAX, QWORD PTR SS:[RSP + 20]
ADD RAX, FFFFFFFFFFFFECE8
MOV QWORD PTR SS:[RSP + 20], RAX
MOV RAX, QWORD PTR SS:[RSP + 20]
MOV QWORD PTR SS:[RSP + 20], RAX
MOV RAX, QWORD PTR SS:[RSP + 20]
MOV QWORD PTR SS:[RSP + 20], RAX
MOV RAX, QWORD PTR SS:[RSP + 20]
ADD RAX, FFFFFFFFFFFFF44B
MOV QWORD PTR SS:[RSP + 20], RAX
MOV RAX, QWORD PTR SS:[RSP + 20]
Пожалуйста, авторизуйтесь для просмотра ссылки.
):
C++:
template <uint64_t X, uint64_t key = random( compile_time_seed( ) )>
struct obfuscated_constant
{
static constexpr uint64_t random1 = random( X ^ key );
static constexpr uint64_t random2 = random( random1 );
static constexpr uint64_t random3 = random( random2 );
static constexpr uint64_t crypted_val = ( ( ( X ^ random1 ) + random2 ) ^ random3 ) + key;
__forceinline static uint64_t decrypt( )
{
uint64_t v = crypted_val;
v -= key;
v ^= random3;
v -= random2;
v ^= random1;
return v;
}
};
template <uint64_t X, uint64_t key = random( compile_time_seed( ) )>
uint64_t obfuscate( )
{
return obfuscated_constant<X, key>::decrypt( );
}
C++:
MOV RAX, 0xADFF6DF3F3D2B8EF
MOV QWORD PTR SS:[RSP], RAX
MOV RAX, 0xF68ADEBB419C580
MOV RCX, QWORD PTR SS:[RSP]
SUB RCX, RAX
MOV RAX, RCX
MOV QWORD PTR SS:[RSP], RAX
MOV RAX, 0xA7D3B9F5B60612C0
MOV RCX, QWORD PTR SS:[RSP]
XOR RCX, RAX
MOV RAX, RCX
MOV QWORD PTR SS:[RSP], RAX
MOV RAX, 0x4D03B8AFB7C07D40
MOV RCX, QWORD PTR SS:[RSP]
ADD RCX, RAX
MOV RAX, RCX
MOV QWORD PTR SS:[RSP], RAX
MOV RAX, 0x58E48C429FD2E000
MOV RCX, QWORD PTR SS:[RSP]
XOR RCX, RAX
MOV RAX, RCX
MOV QWORD PTR SS:[RSP], RAX
MOV RAX, QWORD PTR SS:[RSP]
MOV QWORD PTR SS:[RSP + 8], RAX
MOV RAX, QWORD PTR SS:[RSP + 8]
Основная идея такая:
- Загружаем обфусцированный код в эмулятор Triton
- Эмулируем выполнение инструкций одну за другой
- На каждом шаге отслеживаем изменения в регистрах
- В конце смотрим на финальные значения регистров
Python:
class Deobfuscator:
def __init__(self, bin_path):
self.bin_path = bin_path
self.ctx = triton.TritonContext()
self.ctx.setArchitecture(triton.ARCH.X86_64)
self.start_address = 0x1000
def initialize(self):
with open(self.bin_path, 'rb') as f:
function_bytes = f.read()
self.ctx.setConcreteMemoryAreaValue(self.start_address, function_bytes)
self.ctx.setConcreteRegisterValue(self.ctx.registers.rip, self.start_address)
Теперь переходим к деобфускации:
Python:
def deobfuscate(self):
pc = self.start_address
final_state = {}
while True:
opcode = self.ctx.getConcreteMemoryAreaValue(pc, 16)
instruction = triton.Instruction(pc, opcode)
self.ctx.processing(instruction)
if instruction.getType() == triton.OPCODE.X86.ADD and \
instruction.getOperands()[0].getType() == triton.OPERAND.MEM and \
instruction.getOperands()[0].getBaseRegister().getName() == 'rax' and \
instruction.getOperands()[1].getName() == 'al':
break
self.process_instruction(instruction, final_state)
pc = instruction.getNextAddress()
return self.generate_optimized_code(final_state)
- Получаем ее опкод из эмулируемой памяти
- Создаем объект инструкции
- Обрабатываем инструкцию в контексте Triton
Теперь посмотрим на process_instruction:
Python:
def process_instruction(self, instruction, final_state):
disasm = instruction.getDisassembly()
if disasm.startswith(('mov', 'xor', 'add')):
dest, src = instruction.getOperands()
dest_name = self.get_operand_name(dest)
src_value = self.get_value(src)
if disasm.startswith('xor') and dest_name == self.get_operand_name(src):
final_state[dest_name] = 0
elif disasm.startswith('add'):
dest_value = self.get_value(dest)
final_state[dest_name] = (dest_value + src_value) & 0xFFFFFFFFFFFFFFFF
else:
final_state[dest_name] = src_value
elif disasm.startswith('cmp'):
left, right = instruction.getOperands()
final_state['cmp'] = (self.get_value(left), self.get_value(right))
"CMP" здесь проверяется, поскольку обфускация констант часто встречалась в обработчиках исключений, где происходит много проверок на такое-то значение эксепшена.
Если operand XOR самого себя, это обнуляет значение, казалось бы, юзлесс проверка. Но она была добавлена, поскольку она попалась в одном из обфусцированных констант.
Наконец, generate_optimized_code:
Python:
def generate_optimized_code(self, final_state):
optimized = []
for reg, value in final_state.items():
if reg != 'cmp':
optimized.append(f"mov {reg}, {hex(value)}")
if 'cmp' in final_state:
left, right = final_state['cmp']
optimized.append(f"cmp {hex(left)}, {hex(right)}")
return optimized
Тут я сильно наговнокодил (снова), особенно с аргументами в CMP, где вместо регистров подставлял получившиеся IMM-значения, просто потому что мне было так удобнее, такие дела :)
Давайте проверим деобфускатор на пару примерчиков, первой жертвой станет наш код, который мы запилили для примера, закидываем ему код, и получаем такой вывод:
C++:
Optimized code:
mov rax, 0xdeadbeefdeadbeef
mov [rsp + unknown*0x1:64 bv[63..0] + 0x0], 0xdeadbeefdeadbeef
mov rcx, 0x58e48c429fd2e000
mov [rsp + unknown*0x1:64 bv[63..0] + 0x8], 0xdeadbeefdeadbeef
C++:
mov rax, 0xdeadbeefdeadbeef
C++:
MOV QWORD PTR SS:[RSP + 50], 17A9
LEA RDX, QWORD PTR SS:[RSP + 150]
MOV RAX, QWORD PTR SS:[RSP + 50]
LEA RCX, QWORD PTR SS:[RSP + 1C8]
ADD RAX, 2D1
MOV QWORD PTR SS:[RSP + 50], RAX
MOV RAX, QWORD PTR SS:[RSP + 50]
ADD RAX, FFFFFFFFFFFFF45C
MOV QWORD PTR SS:[RSP + 50], RAX
MOV RAX, QWORD PTR SS:[RSP + 50]
ADD RAX, FFFFFFFFFFFFF12B
MOV QWORD PTR SS:[RSP + 50], RAX
MOV RAX, QWORD PTR SS:[RSP + 50]
C++:
Optimized code:
mov [rsp + unknown*0x1:64 bv[63..0] + 0x50], 0x1
mov rax, 0x1
Сам исходный код деобфускатора лежит здесь:
Пожалуйста, авторизуйтесь для просмотра ссылки.
Конец
Спасибо за чтение этой статьи. Я немного задержался с выходом этой статьи, поскольку в очередной N-ый раз умудрился заболеть. Мне будет очень приятно, если вы поддержите меня и поднимите мотивацию для выхода статей на моем Boosty:
Пожалуйста, авторизуйтесь для просмотра ссылки.
Всего доброго, пока!