Гайд [Reverse-Engineering] Denuvo Anti-Tamper

Зимний шалом! Наконец-то выпускаю эту статью, меня часто просили проанализировать Denuvo, нашлось время, и я решил покопаться "чуть-чуть" и разобраться как устроена защита у этой шайтан-машины :).

Перед чтением статьи просьба подписаться сюды: А ещё у нас есть крутой Discord сервер!!
Приятного чтения!

Немного истории

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 оба любят часто его внедрять в виртуальну машину.
C++:
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 код будет выглядеть таким образом:
C++:
int main() {
  int y = 400;
  printf("The value of y is: %d\n", y);
  return 0;
}

Constant Propagation - это оптимизация, которая заменяет выражения константами, в нашем случае тулза должна узнать значение, которое должно получиться на выходе.
C++:
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 и заменить его константой. Оптимизированный код будет выглядеть так:
C++:
int fee() {
  int z = 200;
  int w = 0;

  if (z % 2 == 0) {
    w = 100;
  } else {
    w = 66;
  }

  return w;
}

Но Constant Propagation может пойти ещё дальше, и зная, что 200 это четное число - возвращать сразу 100.
C++:
int fee() {
    return 100;
}

Strength Reduction - это оптимизация, которая заменяет сложные операции более простыми операциями.

Например,
y = x / 8 он может оптимизировать как y = x >> 3
y = x * 64 как y = x << 6

Собственно, сами компиляторы выдают такой же результат, но тут надо понимать, что если им заведомо известно значение множителя, то компилятор попробует схитрить и оптимизировать её по своему, как на этом скриншоте, где вместо инструкции imul компилятор clang использовал побитовой сдвиг вправо.
Pasted-image-20231222191009.png


Суммируя всё вместе, Deadcode elimination может помочь удалить мертвый код. Constant propagation может помочь убрать ненужные операции в коде и использовать сразу нужную константу. Strength reduction может помочь выявить сложные операции, которые можно заменить более простыми.

Мой Denuvo Tracer выдавал по итогу такой код:
Perl:
[!] 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 вход выглядит так:
Perl:
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.
Perl:
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.
Perl:
000000015B284057  | FF15 CB38A1E6                      | call qword ptr ds:[141C97928]                  | ; MsgBoxA

Чо там по виртуализации?

После слухов об использовании виртуальной машины VMProtect хотелось проверить, какие изменения затронули её в самой Denuvo.

Изначально виртуальная машина VMProtect (до 3.6) использовала следующую последовательность инструкций для входа в секцию с виртуализацией:
Perl:
push crypted_next_handler_addr
call vmentry

Однако Denuvo эту последовательность, удалив инструкцию push. Теперь вход в секцию с виртуализацией выглядит как простой колл.

Несмотря на то, что VMProtect обычно высчитывал адрес следующего хендлера во время исполнения текущего хендлера, то Denuvo решила откровенно на это забить, и вырезала декрипт хендлеров. И в итоге мы получаем следующее:
Perl:
lea rdx, [15431F164]
xchg [rsp], rdx
ret

Perl:
mov r10, 0x1493AAB89
xchg [rsp], r10
ret

Perl:
mov rdx, [rdi+A1C3174]
xchg [rsp], rdx
ret

Perl:
lea r14, [15431F0CF]
push r9
lea r9, [15431F0C1]
lea rsi, [15431F0CD]
xchg [rsp], r9
ret

Perl:
mov [rsp], r14
lea r14, [15431F351]
jmp r14

Мёртвый код

Denuvo решила минимизировать использование мертвого кода в виртуальной машине, для примера я взял семпл с VMProtect 3.1.2

VMProtect 3.1.2:
Perl:
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 видоизменённый мёртвый код так же присутствует, но в малых количествах, встретить их можно в каждом уголке виртуалки
Perl:
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.

Пример:
Perl:
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:
Perl:
lea rsp,qword ptr ss:[rsp-8]
mov qword ptr ss:[rsp],r14

Эквивалентно push r14

POP:
Perl:
mov r10,qword ptr ss:[rsp]
lea rsp,qword ptr ss:[rsp+8]

Эквивалентно pop r10

Я не буду расписывать детально про банальную мутацию с инструкциями, как я это делал в статье про VMProtect, лишь опишу вкратце работу некоторых инструкции, которые реверсер по началу может неправильно воспринять. Как и в случае с VMProtect, чтобы мутировать sub инструкцию -> Denuvo инвертирует второй операнд, и вместо sub инструкции будет стоять add.
Perl:
sub rdi, 0x98815628 - > add rdi, 0xFFFFFFFFF5E3CE8C
Тоже самое будет касаться и add инструкции.

С MOV инструкцией всё ещё легче, и фактически всё осталось без изменений.
Perl:
push r11
pop r10

Эквивалентен mov r10, r11

Если же в первом операнде просто ноль, то Denuvo может его крутить и вертеть как душе угодно, применяя самые разные операции
Perl:
xor eax, 0xFFFF0 ; if eax == 0 -> eax = 0xFFFF0
or eax, 0xFFFF0 ; if eax == 0 -> eax = 0xFFFF0

Denuvo не всегда что-то декриптит, некоторые константы он может вшивать сразу и использовать их в дальнейшем
Perl:
mov eax, 0x7FFE0000 ; KUSER_SHARED_DATA address

Так по паттерну "00 00 FE 7F" были найдены все места, где Denuvo обращалась к KUSER_SHARED_DATA.
Если с KUSER_SHARED_DATA Denuvo решила ничего мудрить, то для PEB он мутирует инструкцию mov в or, и вместе с этим мемно его декриптит)00
Perl:
lea r15,qword ptr ds:[10] ; Эквивалентен mov r15, 10
add r15,7C7D63B8
or rdx,qword ptr gs:[r15-7C7D63B8] ; Эквивалентен mov rdx, gs:[10]

Обычно Denuvo линейно предпочитает линейно декриптить нужное ему значение
Perl:
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
Perl:
sub r15,CA62AA8
push qword ptr ds:[r15+CA62AA8]

Борьба с CPUID

Выше я затронул тот факт, что Denuvo пытается выжать максимум информации, которую предоставляет инструкция cpuid. Все нужные вызовы для Denuvo она откладывает в секции с виртуализированным кодом, но собсна для реверсеров она оставляет важный паттерн, по которому можно найти и другие вызовы cpuid.

По паттерну "0F A2 C3" я нашел все места и всем поставил бряк, чтобы отследить какой параметр она будет заносить в регистр EAX.
Pasted-image-20231224233104.png


До того как она вызовет OEP инструкция cpuid будет вызвана всего лишь 1 раз. После OEP вызовов будет более 15 вызовов, + во время загрузки главного экрана они так же будут вызываться.

Пока я трассировал, я параллельно с этим записывал, по какому место выполняется cpuid и какой параметр в EAX записан.

Perl:
До 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 копирует вместе с ним и бряк, который дебаггер уже не сможет захендлить нормально.
Pasted-image-20231225000320.png


Но для нашего спуфера это лишь очередной плюс, поскольку он в любом случае поймает исключение и обработает его.

Наш спуфер будет ставить ud2 на все места вызовов cpuid, но перед началом он поставит хук на KiUserExceptionDispatcher (или же поставить свой VEH, тут уже решаете вы), чтобы отлавливать исключения и спуфать на нужные данные системной среды.

Вот как пример реализации подобного хука:
C++:
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.

Вот как функция выглядит при первом запуске:
Perl:
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                                            |

И вот как будет выглядеть в следующем запуске:
Perl:
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), и вызываются довольно часто в одном и том же месте.
Perl:
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 читала важные данные для привязки к железу:
Perl:
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)
Pasted-image-20231227214213.png


Затем Denuvo переходит в PEB, она возьмёт значения переменных ImageSubsystemMajorVersion и OSMinorVersion, расположенных по адресам (peb + 0x12c) и (peb + 0x11c) соответственно. Затем она сгенерирует хэш из этих значений.

Кроме того, через эту функцию программа также читала данные из секции .rdata в файле steam_api64.dll.

Но это не единственная функция, которая пропалилась, вместе с этим трейсер обнаружил место, где происходил декриптит адреса, находящийся в регионе KUSER_SHARED_DATA и читался.
Perl:
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), чей декрипт выглядел так, как я описывал еще в разделе с виртуализацией, во всех функциях, где фигурирует этот адрес, всегда происходит именно такой тип декрипта:
Perl:
000000014FA804A9  | 49:81C3 68C89C74                   | add r11,749CC868                               |
000000014FA804B0  | 41:23AB 9837638B                   | and ebp,dword ptr ds:[r11-749CC868]            |
Perl:
000000014A74EC57  | 49:81EF A82AA60C                   | sub r15,CA62AA8                                |
000000014A74EC5E  | 41:FFB7 A82AA60C                   | push qword ptr ds:[r15+CA62AA8]                |
Perl:
sub r8,65D6D919
lea rdi,qword ptr ds:[r8+65D6D919]

Ну и стоит еще упомянуть, что последним что он берет из структуры, так это +0x274 ProcessorFeatures.

Есть ещё одно место, где Denuvo до OEP берёт некоторые важные для неё данные.
Perl:
mov ebx, [rbx]
push rsi
add rsi, 48CA1B03
mov [rsi-48CA1B03], r12 ; BRUH OBFUSCATION
jmp qword ptr ds:[149ED1C9E]
Perl:
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? Конечно, мне было бы интересно и её посмотреть тоже, но думаю ещё не скоро доберусь до неё.

С наступающим новым годом!
 
Последнее редактирование:

kin4stat

mq-team
Всефорумный модератор
2,730
4,710
одобряем

В ходе анализа защиты я обнаружил полиморфический код (код, который динамически изменяет свою структуру, сохраняя при этом свою логику) каждый запуск генерировал новую функцию для чтения данных из KUSER_SHARED_DATA.

Типа такого?
 

colby57

Активный
Автор темы
12
147
у денуво это слегка в другой форме выглядит, тут на гифке всё по простому
тот код, который попадает под полиморфный движок денувы всегда имеет разный адрес при запуске, поэтому отловить почти нереально