Зимний шалом! Наконец-то выпускаю эту статью, меня часто просили проанализировать Denuvo, нашлось время, и я решил покопаться "чуть-чуть" и разобраться как устроена защита у этой шайтан-машины :). Перед чтением статьи просьба подписаться сюды: https://t.me/colby5engineering А ещё у нас есть крутой Discord сервер!! https://discord.gg/h7hb9Hr5A4 Приятного чтения! Немного истории Denuvo обычно рассматривается геймерами и реверс-инженерами как технология защиты от копирования в играх. Она является преемником SecuROM, которая была популярна в эпоху CD/DVD. В 2017 году появилось предположение, что исполняемые файлы, защищенные Denuvo, используют архитектуру виртуальной машины, аналогичную той, что применяется в VMProtect. Это предположение подтвердил администратор форума VMProtect спустя некоторое время. Лично я также неоднократно замечал схожесть этих технологий во время реверс-инжиниринга двух игр. Однако Denuvo немного модифицировала некоторые части виртуальной машины. Предисловие Используемая в статье версия Denuvo не очень-то и актуальная по меркам текущего времени, в играх Metro Exodus и Just Cause 4 используется 5.3 версия, которую крэкеры отломали менее чем за сутки. Забросил традицию выписывать список тулз, которые я использовал во время анализа, решил исправить эту ситуацию, и вот сам список: Scylla (x64) — утилита для реконструкции импортов. Visual Studio 2022 — для написания своей тулзы, которая анализировала трейс вирты. Denuvo Tracer - Непосредственно сам инструмент, помогающий мне анализировать трейс вирты, нестабильный, но всё же умел в Deadcode Elimination, Constant Propagation, и в целом деобфускацию некоторых инструкции. Небольшое разъяснение терминологий Т.к. я упомянул главную тулзу, с которой анализ мне давался в несколько раз легче, то будет не лишним объяснить, что она делает с кодом, который ей предоставляют: Deadcode Elimination - удаление мертвого кода, который никак не относится к контексту выполнения виртуальной машины, то есть, если их удалить, то это никак не повлияет на работу самой VM. В случае Denuvo и VMProtect оба любят часто его внедрять в виртуальну машину. int main() { int x = 1; int y = 0; if (x < 0) { y = 100; y = 200; printf("This statement will never be executed.\n"); printf("This statement will also never be executed.\n"); for (int i = 0; i < 10; i++) { printf("The value of i is: %d\n", i); } } else if (x == 0) { y = 300; printf("This statement will never be executed.\n"); } else { y = 400; printf("The value of y is: %d\n", y); } return 0; } При Deadcode Elimination код будет выглядеть таким образом: int main() { int y = 400; printf("The value of y is: %d\n", y); return 0; } Constant Propagation - это оптимизация, которая заменяет выражения константами, в нашем случае тулза должна узнать значение, которое должно получиться на выходе. int main() { int x = 10; int y = 20; int w = 0; int z = x * y; // z = 200 if (z % 2 == 0) { w = z / 2; // w = 100 } else { w = z / 3; // w = 66 } return w; } В этом коде Constant Propagation может вычислить значение переменной w и заменить его константой. Оптимизированный код будет выглядеть так: int fee() { int z = 200; int w = 0; if (z % 2 == 0) { w = 100; } else { w = 66; } return w; } Но Constant Propagation может пойти ещё дальше, и зная, что 200 это четное число - возвращать сразу 100. int fee() { return 100; } Strength Reduction - это оптимизация, которая заменяет сложные операции более простыми операциями. Например, y = x / 8 он может оптимизировать как y = x >> 3 y = x * 64 как y = x << 6 Собственно, сами компиляторы выдают такой же результат, но тут надо понимать, что если им заведомо известно значение множителя, то компилятор попробует схитрить и оптимизировать её по своему, как на этом скриншоте, где вместо инструкции imul компилятор clang использовал побитовой сдвиг вправо. Суммируя всё вместе, Deadcode elimination может помочь удалить мертвый код. Constant propagation может помочь убрать ненужные операции в коде и использовать сразу нужную константу. Strength reduction может помочь выявить сложные операции, которые можно заменить более простыми. Мой Denuvo Tracer выдавал по итогу такой код: [!] Prepare for loading... [+] Trace loaded. Size: 1000 [!] Trace before deobfuscation: 0x3E9: pop rax 0x3EA: push rbp 0x3ED: not rbp 0x3F1: and [rsp], rbp 0x3F2: pop rbp 0x3F6: xor rbp, [rsp] 0x3F7: push rdi 0x3FC: mov rdi, [rsp+0x08] 0x3FF: mov rdi, r11 0x404: xchg [rsp+0x08], rdi 0x405: pop rdi 0x406: push rdx 0x40B: mov edx, 0xCF6F41B4 0x411: mov r11d, 0xD5DBD084 0x413: push r9 0x416: add edx, r11d 0x419: mov r11d, edx 0x420: xor r11d, 0xC1B2523F 0x425: mov edx, 0x71757C90 0x428: mov r9d, r11d 0x42B: not r11d 0x42E: sub r11d, edx 0x431: and r9d, edx 0x434: add r11d, r9d 0x438: xor r11d, 0xFFFFFFFF 0x43B: xor edx, r11d 0x442: and r11d, 0x2EC8F0CE 0x449: adc r11d, 0x9BE32D91 0x44B: pop r9 0x452: and r11d, 0xC8D52F92 0x459: add r11d, 0xAF991BCD 0x45B: push r11 0x45C: push rdx 0x463: lea r11, [0x00000001430BC123] 0x46A: lea rdx, [0x00000001430BC096] 0x46E: cmovs r11, rdx 0x46F: pop rdx 0x473: xchg [rsp], r11 0x474: ret [!] Function end! [!] Start deobfuscation... 0x3E9: mov rbp, rsp 0x3EC: mov [rsp+0x08], rdi 0x400: mov r11d, 0x6FDA495F 0x45B: push r11 0x463: lea r11, [0x00000001430BC123] 0x474: jmp r11 [!] Function end! Тулза +- справляется со своей задачей, но тут много моментов не учитываются, по типу рестора регистров после использования другими выражениями, из-за чего этот код не будет никак работать в денуве хд. Но чтобы пытаться понять логику кода этого вполне хватает. Как работает система привязки к компьютеру Denuvo? Система привязки к компьютеру Denuvo основана на использовании нескольких сотен игровых констант, которые шифруются на удаленном сервере с помощью уникальных данных системы пользователя. Какие данные берет Denuvo? CPUID, чтобы максимально выжать информацию из процессора, оно будет брать разные параметры. Хэши ntdll, kernel32 и kernelbase, которые генерируются на основе информации из Image data directory. Хэш полей структуры Process Enivornment Block Хэш полей структуры KUSER_SHARED_DATA Denuvo v5 и v4 используют одинаковый подход к парсингу информации железа, лицензия Denuvo загружается один раз, и только в случае отсутствия или несоответствия аппаратного обеспечения. Игровые функции, которые полагаются на эти константы, обычно исполняются под виртуальной машиной Denuvo. Если игра запускается на другом компьютере, она не сможет расшифровать константы, поскольку у него будут другие уникальные системные данные. В результате игра просто уйдет в краш из-за неправильной дешифровки. Как заводят дамп с Denuvo? В случае Denuvo её дампят еще до того, как она дойдет до OEP. Чтобы создать валидный в последующем дамп Denuvo, необходимо понять, какую информацию по железу она собирает. И на ПК, не имеющем лицензии, спуфать данные в тот момент, когда Denuvo начинает их сбор. Либо же пойти по тактике CPY, сразу вшивать эти константы через свой патчер стим клиента. После того, как у реверсера появятся все нужные константы, то тот дамп можно будет использовать для запуска игры на любом компьютере. Но не стоит забывать про виртуальную машину, в которой происходит сбор информации, критические проверки и прочее. Исполняемый файл переходит в секцию с виртуальной машиной и остается там, чтобы инициализировать всё нужное, чтобы игра запустилась (Steam API, константы, и т.д.). Для простого пользователя это не играет никакой роли. Однако для тех, кто пытается обойти Denuvo, это означает, что необходимо преодолеть множество чеков, прежде чем будет достигнута OEP. Вход и создание IAT Denuvo, что в Just Cause 4, что в Metro: Exodus, предпочитает выделять место в секции для своей новой IAT, и заполнять её, чтобы использовать в последующем в самой игре, старая же IAT в последующем больше никак не фигурирует. В Just Cause 4 вход выглядит так: 000000015B284020 | 50 | push rax | 000000015B284021 | 53 | push rbx | 000000015B284022 | 51 | push rcx | 000000015B284023 | 52 | push rdx | 000000015B284024 | 55 | push rbp | 000000015B284025 | 54 | push rsp | 000000015B284026 | 56 | push rsi | 000000015B284027 | 57 | push rdi | 000000015B284028 | 41:50 | push r8 | 000000015B28402A | 41:51 | push r9 | 000000015B28402C | 41:52 | push r10 | 000000015B28402E | 41:53 | push r11 | 000000015B284030 | 41:54 | push r12 | 000000015B284032 | 41:55 | push r13 | 000000015B284034 | 41:56 | push r14 | 000000015B284036 | 41:57 | push r15 | 000000015B284038 | 48:81EC 48200000 | sub rsp,2048 | 000000015B28403F | E8 BC1FFFFF | call <justcause4.CreateNewImportAddrTable> | 000000015B284044 | 31C9 | xor ecx,ecx | 000000015B284046 | 49:89C9 | mov r9,rcx | 000000015B284049 | 48:8D15 E3000000 | lea rdx,qword ptr ds:[15B284133] | 000000015B284133:"ATTACH" 000000015B284050 | 4C:8D05 DC000000 | lea r8,qword ptr ds:[15B284133] | 000000015B284133:"ATTACH" 000000015B284057 | FF15 CB38A1E6 | call qword ptr ds:[141C97928] | ; MsgBoxA 000000015B28405D | 90 | nop | 000000015B28405E | 48:81C4 48200000 | add rsp,2048 | 000000015B284065 | 41:5F | pop r15 | 000000015B284067 | 41:5E | pop r14 | 000000015B284069 | 41:5D | pop r13 | 000000015B28406B | 41:5C | pop r12 | 000000015B28406D | 41:5B | pop r11 | 000000015B28406F | 41:5A | pop r10 | 000000015B284071 | 41:59 | pop r9 | 000000015B284073 | 41:58 | pop r8 | 000000015B284075 | 5F | pop rdi | 000000015B284076 | 5E | pop rsi | 000000015B284077 | 5C | pop rsp | 000000015B284078 | 5D | pop rbp | 000000015B284079 | 5A | pop rdx | 000000015B28407A | 59 | pop rcx | 000000015B28407B | 5B | pop rbx | 000000015B28407C | 58 | pop rax | Внутри CreateNewImportAddrTable он начинает заполнять специально выделенное место в секции указателями на функции, которые он получает от GetProcAddress. 000000015B27602E | FF15 ACF90000 | call qword ptr ds:[<VirtualProtect>] | 000000015B276034 | 48:8D0D E0B70000 | lea rcx,qword ptr ds:[15B28181B] | 000000015B28181B:"ADVAPI32.dll" 000000015B27603B | FF15 B7F80000 | call qword ptr ds:[<LoadLibraryA>] | 000000015B276041 | 48:894424 28 | mov qword ptr ss:[rsp+28],rax | 000000015B276046 | 48:8D15 C1B70000 | lea rdx,qword ptr ds:[15B28180E] | 000000015B28180E:"GetUserNameA" 000000015B27604D | 48:8B4C24 28 | mov rcx,qword ptr ss:[rsp+28] | 000000015B276052 | 48:89D2 | mov rdx,rdx | 000000015B276055 | FF15 FDF70000 | call qword ptr ds:[<GetProcAddress>] | 000000015B27605B | 48:8905 9E0FA2E6 | mov qword ptr ds:[141C97000],rax | Здесь видно, как на последней инструкции полученное значение из RAX, он копирует в адрес 141C97000, в дальнейшем все вызовы будут выполняться, обращаясь по этому адресу. Фиксить FF 15 call тоже не будет, поскольку все коллы уже изначально будут подготовлены к вызовам, обращаясь к новой IAT. 000000015B284057 | FF15 CB38A1E6 | call qword ptr ds:[141C97928] | ; MsgBoxA Чо там по виртуализации? После слухов об использовании виртуальной машины VMProtect хотелось проверить, какие изменения затронули её в самой Denuvo. Изначально виртуальная машина VMProtect (до 3.6) использовала следующую последовательность инструкций для входа в секцию с виртуализацией: push crypted_next_handler_addr call vmentry Однако Denuvo эту последовательность, удалив инструкцию push. Теперь вход в секцию с виртуализацией выглядит как простой колл. Несмотря на то, что VMProtect обычно высчитывал адрес следующего хендлера во время исполнения текущего хендлера, то Denuvo решила откровенно на это забить, и вырезала декрипт хендлеров. И в итоге мы получаем следующее: lea rdx, [15431F164] xchg [rsp], rdx ret mov r10, 0x1493AAB89 xchg [rsp], r10 ret mov rdx, [rdi+A1C3174] xchg [rsp], rdx ret lea r14, [15431F0CF] push r9 lea r9, [15431F0C1] lea rsi, [15431F0CD] xchg [rsp], r9 ret mov [rsp], r14 lea r14, [15431F351] jmp r14 Мёртвый код Denuvo решила минимизировать использование мертвого кода в виртуальной машине, для примера я взял семпл с VMProtect 3.1.2 VMProtect 3.1.2: push rbp setl bpl ; junk push r15 mov rbp,r12 ; junk mov r15,9480B69 ; junk setb bpl ; junk push rax movsx r15d,bp ; junk push rdi bswap bp ; junk push rsi mov bp,6D30 ; junk push r12 movsxd rsi,r15d ; junk movsx r15d,r15w ; junk pushfq push r8 not bp ; junk setl r12b ; junk ror sil,cl ; junk push rcx movsx bp,dl ; junk movsx rbp,ax ; junk push rbx push r11 push r14 btr r15w,2F ; junk movsxd rsi,ebp ; junk push r9 push r13 movsx r15w,spl ; junk movzx ebp,ax ; junk setns sil ; junk push r10 push rdx mov r15,7FF4EAFE0000 push r15 mov sil,sil ; junk movsx rbp,r10w ; junk movsx r9d,r13w ; junk mov rsi,qword ptr ss:[rsp+90] rol r12w,19 ; junk not r9b ; junk bts ebp,edi ; junk rol esi,1 mov ebp,6D302DD ; junk mov bpl,r11b ; junk movzx ecx,ax ; junk inc esi clc ; junk not r12 ; junk add bp,dx ; junk xor esi,741A1413 cmp r11,rax ; junk rcl ch,22 ; junk add rsi,r15 shr r12b,cl ; junk mov cl,53 ; junk bsr cx,r12w ; junk mov rcx,100000000 ; junk adc bpl,F0 ; junk add rsi,rcx and r9b,r12b ; junk ror bp,cl ; junk shld r9w,r8w,66 ; junk mov rbp,rsp neg r12b ; junk xchg r9b,r12b ; junk cmp r13w,ax ; junk sub rsp,180 add r12w,di ; junk sbb r12b,dil ; junk and rsp,FFFFFFFFFFFFFFF0 adc r12,r11 ; junk inc r9b ; junk bswap r9w ; junk lea r12,qword ptr ds:[7FF721C87F48] bts r9w,ax ; junk movzx r9d,byte ptr ds:[rsi] cmp dil,6E ; junk test ax,2B46 ; junk add rsi,1 jmp qword ptr ds:[r12+r9*8] В общем в асм листинге 83 инструкции, из них 52 помечены как "джанк-код". Получается, что мёртвый код в VMProtect примерно составляет 62,65% от общего числа инструкций. А какая ситуация у Denuvo? Несмотря на то, что в Denuvo видоизменённый мёртвый код так же присутствует, но в малых количествах, встретить их можно в каждом уголке виртуалки pushfq push qword ptr ss:[rsp] push rdi push r12 mov r12,0 ; junk shl r12,20 ; junk mov edi,100 ; junk xor rdi,r12 ; junk pop r12 ; junk push rbp lea rbp,qword ptr ss:[rsp+10] sub rbp,FFFFFFFFCA93A88A and rdi,qword ptr ss:[rbp-356C5776] pop rbp push 0 sub qword ptr ss:[rsp],rdi pop rdi push rdi pop r8 ; junk mov r8,rdx pushfq sub rdx,rdx xor rdx,0 ; junk popfq push rdi ; junk mov rdi,qword ptr ss:[rsp+10] ; junk mov rdi,r8 setb dl ; junk xchg qword ptr ss:[rsp+10],rdi pop rdi mov r8,qword ptr ss:[rsp] mov qword ptr ss:[rsp],r10 mov r10,metroexodus.15156DD98 push rdi lea rdi,qword ptr ds:[r10+rdx*8] add rdi,FFFFFFFFF5E3CE8C mov rdx,qword ptr ds:[rdi+A1C3174] pop rdi mov r10,qword ptr ss:[rsp] lea rsp,qword ptr ss:[rsp+8] xchg qword ptr ss:[rsp],rdx ret lea rsp,qword ptr ss:[rsp-8] mov qword ptr ss:[rsp],r14 push r11 lea r11,qword ptr ss:[rsp+10] mov r14,100 and r14,qword ptr ds:[r11] pop r11 rcl r14,38 ; junk push r11 lea r11,qword ptr ss:[rsp+8] mov r14,qword ptr ds:[r11] pop r11 push r14 lea r14,qword ptr ss:[rsp+8] mov qword ptr ds:[r14],rsi mov r14,qword ptr ss:[rsp] mov qword ptr ss:[rsp],r14 lea r14,qword ptr ds:[15431F0CF] push r9 lea r9,qword ptr ds:[15431F0C1] lea rsi,qword ptr ds:[15431F0CD] xchg qword ptr ss:[rsp],r9 ret В VMEntry приблизительно 71 инструкция, 11 из которых являются мертвым кодом, т.е. фактически составляет 15,49%. Про измененный мертвый код можно добавить следующее, VMP добавлял много лишних инструкции, которые бросались реверсеру сразу в глаза, в Denuvo же ситуация слегка изменилась, всё благодаря тому, что мёртвым кодом теперь выступают вычисляющие инструкции, хранящие во втором операнде ноль, под такой список попали: add, sub, xor, or, and. Пример: xor rdx,qword ptr ss:[rsp] ; RDX = 0 mov qword ptr ss:[rsp],FFFFFFFFFFFFFFFF ; SP = -1 xor qword ptr ss:[rsp],rdx ; SP по прежнему будет -1, из-за того что второй операнд был нулем И таких моментов в виртуальной машине довольно много. Понять Denuvo в минимизации использования мертвого кода вполне можно, их давно обвиняют в низкой производительности во время игры, порче скорости SSD (шиза) и прочим моментам, связанных с лагами. Мутация инструкции Denuvo так же слегка мутировала некоторые инструкции PUSH: lea rsp,qword ptr ss:[rsp-8] mov qword ptr ss:[rsp],r14 Эквивалентно push r14 POP: mov r10,qword ptr ss:[rsp] lea rsp,qword ptr ss:[rsp+8] Эквивалентно pop r10 Я не буду расписывать детально про банальную мутацию с инструкциями, как я это делал в статье про VMProtect, лишь опишу вкратце работу некоторых инструкции, которые реверсер по началу может неправильно воспринять. Как и в случае с VMProtect, чтобы мутировать sub инструкцию -> Denuvo инвертирует второй операнд, и вместо sub инструкции будет стоять add. sub rdi, 0x98815628 - > add rdi, 0xFFFFFFFFF5E3CE8C Тоже самое будет касаться и add инструкции. С MOV инструкцией всё ещё легче, и фактически всё осталось без изменений. push r11 pop r10 Эквивалентен mov r10, r11 Если же в первом операнде просто ноль, то Denuvo может его крутить и вертеть как душе угодно, применяя самые разные операции xor eax, 0xFFFF0 ; if eax == 0 -> eax = 0xFFFF0 or eax, 0xFFFF0 ; if eax == 0 -> eax = 0xFFFF0 Denuvo не всегда что-то декриптит, некоторые константы он может вшивать сразу и использовать их в дальнейшем mov eax, 0x7FFE0000 ; KUSER_SHARED_DATA address Так по паттерну "00 00 FE 7F" были найдены все места, где Denuvo обращалась к KUSER_SHARED_DATA. Если с KUSER_SHARED_DATA Denuvo решила ничего мудрить, то для PEB он мутирует инструкцию mov в or, и вместе с этим мемно его декриптит)00 lea r15,qword ptr ds:[10] ; Эквивалентен mov r15, 10 add r15,7C7D63B8 or rdx,qword ptr gs:[r15-7C7D63B8] ; Эквивалентен mov rdx, gs:[10] Обычно Denuvo линейно предпочитает линейно декриптить нужное ему значение push rsi mov esi, r9d and esi, [r10] not r9d sub r9d, [r10] add r9d,esi pop rsi push rbx mov ebx, 494D020A push r8 xor r9d, FFFFFFFF mov r8d, 76704D5E add r8d, 19AB30B2 or r8d, ebx stc adc r8d, 6C58D72B xor r8d, ebx shld r8d, ebx, 8 Или выполнять декрипт мемно, как тут))0 sub r15,CA62AA8 push qword ptr ds:[r15+CA62AA8] Борьба с CPUID Выше я затронул тот факт, что Denuvo пытается выжать максимум информации, которую предоставляет инструкция cpuid. Все нужные вызовы для Denuvo она откладывает в секции с виртуализированным кодом, но собсна для реверсеров она оставляет важный паттерн, по которому можно найти и другие вызовы cpuid. По паттерну "0F A2 C3" я нашел все места и всем поставил бряк, чтобы отследить какой параметр она будет заносить в регистр EAX. До того как она вызовет OEP инструкция cpuid будет вызвана всего лишь 1 раз. После OEP вызовов будет более 15 вызовов, + во время загрузки главного экрана они так же будут вызываться. Пока я трассировал, я параллельно с этим записывал, по какому место выполняется cpuid и какой параметр в EAX записан. До OEP: 0000000151AC3BBC - cpuid v1 После OEP: 0000000149D74CB8 - cpuid v1 000000014A7E98A8 - cpuid v1 0000000151B8CF68 - cpuid v1 000000014B1CC170 - cpuid v1 000000014E8F1C6D - cpuid v2 000000014E8F1C6D - cpuid v3 000000014E8F1C6D - cpuid v4 000000014750A5C8 - cpuid v1 0000000147DFC2F4 - cpuid v1 0000000150F8D1AC - cpuid v1 0000000151A529B4 - cpuid v2 0000000151A529B4 - cpuid v3 0000000151A529B4 - cpuid v4 000000014B62EE7C - cpuid v1 000000014DFFECCF - int 0x3 (CRASH) Почему же произошел краш? Дело в том, что Denuvo находит свободное место для кода, переносит уже имеющийся код с cpuid и выполняет его там, но т.к. на всех местах стоит мой бряк, то Denuvo копирует вместе с ним и бряк, который дебаггер уже не сможет захендлить нормально. Но для нашего спуфера это лишь очередной плюс, поскольку он в любом случае поймает исключение и обработает его. Наш спуфер будет ставить ud2 на все места вызовов cpuid, но перед началом он поставит хук на KiUserExceptionDispatcher (или же поставить свой VEH, тут уже решаете вы), чтобы отлавливать исключения и спуфать на нужные данные системной среды. Вот как пример реализации подобного хука: void NTAPI hkKiUserExceptionDispatcher(PEXCEPTION_RECORD pExceptionRecord, PCONTEXT pContextFrame) { if (pExceptionRecord->ExceptionCode == STATUS_ILLEGAL_INSTRUCTION) { switch (pContextFrame->Rax) { // // Random data ... case 1: { pContextFrame->Rax = 0x12374728; pContextFrame->Rbx = 0x64587385; pContextFrame->Rcx = 0x34857233; pContextFrame->Rdx = 0x12345678; break; } case 2: { pContextFrame->Rax = 0x14567465; pContextFrame->Rbx = 0x64567535; pContextFrame->Rcx = 0x34561633; pContextFrame->Rdx = 0x15444678; break; } case 3: { pContextFrame->Rax = 0x23984577; pContextFrame->Rbx = 0x24856748; pContextFrame->Rcx = 0x52355133; pContextFrame->Rdx = 0x65658578; break; } case 4: { pContextFrame->Rax = 0x34796296; pContextFrame->Rbx = 0x92476509; pContextFrame->Rcx = 0x00000001; pContextFrame->Rdx = 0; break; } default: { printf("Unknown EAX arg: 0x%X\n", pContextFrame->Rax); break; } } // // Skip ud2 instruction ... } } Полиморфный код и KUSER_SHARED_DATA В ходе анализа защиты я обнаружил полиморфический код (код, который динамически изменяет свою структуру, сохраняя при этом свою логику) каждый запуск генерировал новую функцию для чтения данных из KUSER_SHARED_DATA. Здесь функция читает по разным оффсетам данные из KUSER_SHARED_DATA. Вот как функция выглядит при первом запуске: 000000014C4D0E40 | 49:C7C2 7802FE7F | mov r10,7FFE0278 | 000000014C4D0E47 | 41:8B0A | mov ecx,dword ptr ds:[r10] | 000000014C4D0E4A | 6641:83C2 FC | add r10w,FFFC | 000000014C4D0E4F | 41:330A | xor ecx,dword ptr ds:[r10] | 000000014C4D0E52 | 6641:81F2 FC00 | xor r10w,FC | 000000014C4D0E58 | 41:330A | xor ecx,dword ptr ds:[r10] | 000000014C4D0E5B | 6641:83C2 E4 | add r10w,FFE4 | 000000014C4D0E60 | 41:2B0A | sub ecx,dword ptr ds:[r10] | 000000014C4D0E63 | 6641:81F2 EC00 | xor r10w,EC | 000000014C4D0E69 | 41:330A | xor ecx,dword ptr ds:[r10] | 000000014C4D0E6C | 6641:83EA FC | sub r10w,FFFC | 000000014C4D0E71 | 41:330A | xor ecx,dword ptr ds:[r10] | 000000014C4D0E74 | 6641:83C2 EC | add r10w,FFEC | 000000014C4D0E79 | 41:330A | xor ecx,dword ptr ds:[r10] | 000000014C4D0E7C | 6641:83C2 0C | add r10w,C | 000000014C4D0E81 | 41:330A | xor ecx,dword ptr ds:[r10] | 000000014C4D0E84 | 81C1 FEBBB4B1 | add ecx,B1B4BBFE | 000000014C4D0E8A | C3 | ret | И вот как будет выглядеть в следующем запуске: 0000000147146580 | 48:C7C7 7802FE7F | mov rdi,7FFE0278 | 0000000147146587 | 44:8B0F | mov r9d,dword ptr ds:[rdi] | 000000014714658A | 66:83F7 0C | xor di,C | 000000014714658E | 44:330F | xor r9d,dword ptr ds:[rdi] | 0000000147146591 | 66:83EF EC | sub di,FFEC | 0000000147146595 | 44:330F | xor r9d,dword ptr ds:[rdi] | 0000000147146598 | 66:83C7 E4 | add di,FFE4 | 000000014714659C | 44:2B0F | sub r9d,dword ptr ds:[rdi] | 000000014714659F | 66:81F7 EC00 | xor di,EC | 00000001471465A4 | 44:330F | xor r9d,dword ptr ds:[rdi] | 00000001471465A7 | 66:83EF FC | sub di,FFFC | 00000001471465AB | 44:330F | xor r9d,dword ptr ds:[rdi] | 00000001471465AE | 66:83C7 EC | add di,FFEC | 00000001471465B2 | 44:330F | xor r9d,dword ptr ds:[rdi] | 00000001471465B5 | 66:83F7 0C | xor di,C | 00000001471465B9 | 44:330F | xor r9d,dword ptr ds:[rdi] | 00000001471465BC | 41:81E9 808D8339 | sub r9d,39838D80 | 00000001471465C3 | C3 | ret | Функция может выполниться один или несколько раз, может быть вызвана абсолютно в разных местах, и тем не менее, результат в регистре всегда будет разным. Это связано с тем, что константа на предпоследней инструкции также подвергается рандомизации. Если попытаться изменить результат, то игра выдаст ошибку инициализации. После выполнения функции она через некоторое время затиралась случайными байтами. В секции виртуальной машины Denuvo есть много мест с случайными байтами. Эти места предназначены для записи новых виртуализированных функций. В зависимости от необходимости функция либо перезаписывалась на случайные байты, либо оставалась без изменений. Тайм-чеки Не обошлось без ебли в плане тайм-чеков, они берутся из той же самой структуры, по оффсету (+0x008 struct InterruptTime), и вызываются довольно часто в одном и том же месте. 000000015225B900 | 4C:8D1A | lea r11,qword ptr ds:[rdx] | Read InterruptTime 000000015225B903 | 41:8B13 | mov edx,dword ptr ds:[r11] | 000000015225B906 | 41:50 | push r8 | 000000015225B908 | 4C:8D05 EFEFF3F0 | lea r8,qword ptr ds:[14319A8FE] | 000000015225B90F | 4C:870424 | xchg qword ptr ss:[rsp],r8 | 000000015225B913 | C3 | ret | В этом месте чтение InterruptTime происходило около 200 раз, и по мере результата сравнивания времени оно могло включать дополнительные проверки, ситуация идентичная тому, что была с той полиморфной функцией, поскольку она тоже вызывалась не каждый запуск. Больше данных для железа Пока я трассировал, я так же обнаружил функцию, через которую Denuvo читала важные данные для привязки к железу: 000000014E473DD8 | 49:81EB BCCF79AF | sub r11,FFFFFFFFAF79CFBC | ; DECRYPT PTR 000000014E473DDF | 45:8BBB BCCF79AF | mov r15d,dword ptr ds:[r11-50863044] | ; READ DATA 000000014E473DE6 | 55 | push rbp | 000000014E473DE7 | 48:8D2D DB9509FC | lea rbp,qword ptr ds:[14A50D3C9] | 000000014E473DEE | 48:872C24 | xchg qword ptr ss:[rsp],rbp | 000000014E473DF2 | C3 | ret | Первым он читает данные из DataDirectory файлов ntdll, kernel32 и kernelbase. Из этих файлов она берет исключительно размер Debug Directory (таблицы отладочной информации) и Resource Directory (таблицы информации о ресурсах исполняемого файла). Ntdll Debug Directory Size (70) Ntdll Resource Directory Size (73300) Kernel32 Debug Directory Size (70) Kernel32 Resource Directory Size (520) Kernelbase Debug Directory Size (70) Kernelbase Resource Directory Size (548) Затем Denuvo переходит в PEB, она возьмёт значения переменных ImageSubsystemMajorVersion и OSMinorVersion, расположенных по адресам (peb + 0x12c) и (peb + 0x11c) соответственно. Затем она сгенерирует хэш из этих значений. Кроме того, через эту функцию программа также читала данные из секции .rdata в файле steam_api64.dll. Но это не единственная функция, которая пропалилась, вместе с этим трейсер обнаружил место, где происходил декриптит адреса, находящийся в регионе KUSER_SHARED_DATA и читался. mov [rsp], rax lea rax, [r9] add rax, FFFFFFFFDE6AB462 neg edi add edi, [rax+7910ACEE]; [rax+7910ACEE] == 7FFE026C (0A 00 00 00) pop rax mov r9, [rsp] push rbp mov rbp, [rsp+8] mov rbp, r15 xchg [rsp+8], rbp neg edi pop rbp push r14 jmp [1492B5A7C] В моем случае программа читала значение переменной, расположенной по адресу [rax+7910ACEE]. Значение переменной было равно 7FFE026C. Это означало, что программа читала переменную NtMajorVersion, которая имеет оффсет +26C. Т.к. Denuvo может при каждом запуске вызывать рандомно какую-либо важную функцию или другие её аналоги, то мне приходилось перезапускать приложение по 2-3 раза, чтобы я смог её трассировать, и самым мемным в этой ситуации был декрипт адреса 7FFE0270 (NtMinorVersion), чей декрипт выглядел так, как я описывал еще в разделе с виртуализацией, во всех функциях, где фигурирует этот адрес, всегда происходит именно такой тип декрипта: 000000014FA804A9 | 49:81C3 68C89C74 | add r11,749CC868 | 000000014FA804B0 | 41:23AB 9837638B | and ebp,dword ptr ds:[r11-749CC868] | 000000014A74EC57 | 49:81EF A82AA60C | sub r15,CA62AA8 | 000000014A74EC5E | 41:FFB7 A82AA60C | push qword ptr ds:[r15+CA62AA8] | sub r8,65D6D919 lea rdi,qword ptr ds:[r8+65D6D919] Ну и стоит еще упомянуть, что последним что он берет из структуры, так это +0x274 ProcessorFeatures. Есть ещё одно место, где Denuvo до OEP берёт некоторые важные для неё данные. mov ebx, [rbx] push rsi add rsi, 48CA1B03 mov [rsi-48CA1B03], r12 ; BRUH OBFUSCATION jmp qword ptr ds:[149ED1C9E] lea rsi, [rdx] lea rsi, [rsi-5201E190] mov edx, [rsi+5201E190] ; BRUH OBFUSCATION jmp [150977AF7] В этих функциях выполняется декрипт адресов, и затем чтение таблиц, а именно: Ntdll Debug Directory Address (135148) Ntdll Exception Directory Address (17E000) Kernel32 Debug Directory Address (85AD4) Kernel32 Exception Directory Address (B4000) Kernelbase Debug Directory Address (27EB98) Kernelbase Exception Directory Address (32C000) Чтение из структур также не обошло стороной эти функции, под них попали: KUSER_SHARED_DATA+0x020 - TimeZoneBias PEB+0xB8 - char StaticUnicodeBuffer[0x105] Все эти данные он также хэширует, и использует в последующем. Steam DRM В добавок к этому, хотелось бы отметить, что в Denuvo всегда интегрируется работа со Steam или Origin. Все сингл-плеер игры, работающие под Steam DRM будут вызывать функцию SteamAPI_RestartAppIfNecessary, но своими методами. В Metro Exodus эта функция вызывалась под виртой у Denuvo, но в играх без Denuvo обычно всё было по разному. Кто-то вызывал банально, кто-то генерировал целые шеллкоды в выделенной памяти, но по итогу один патч на экспорт SteamAPI_RestartAppIfNecessary, и DRM стима уже прекратит функционировать. Заключение Благодарю за прочтение данной статьи, по традиции как и со всеми прошлыми статьями я по началу её ленился делать, но когда начали сроки поджимать, то сидел до 4 утра с пеной у рта. Насчёт Denuvo, опять же, это версия уже устаревшая, если такая морока происходила во времена 18-19 года, то что происходит в v16? Конечно, мне было бы интересно и её посмотреть тоже, но думаю ещё не скоро доберусь до неё. С наступающим новым годом!