
Предисловие
Этот пост предназначен исключительно для образовательных целей. Denuvo считается одним из самых успешных решений для управления цифровыми правами, поэтому оно многим интересно. В этом посте представлен большой объём моих личных заметок и переписки с другими реверс-инженерами (см. раздел «Благодарности»), содержащий информацию о последних версиях Denuvo; многое из этого раньше не публиковалось.
Я не стремлюсь нанести какой-либо ущерб Irdeto, поэтому часть информации была вырезана из поста.
Denuvo
Denuvo — это система для защиты от вмешательства и для управления цифровыми правами (digital rights management system, DRM). В основном она используется для защиты от пиратства и реверс-инжиниринга цифровых медиа наподобие видеоигр. В отличие от традиционных DRM-систем, Denuvo использует широкий спектр уникальных методик и проверок для подтверждения и целостности игры, и лицензированности пользователя.
Общий принцип работы
В идее, лежащей в основе Denuvo, нет ничего нового. По причинам, которые вскоре станут понятны, её можно описать как полуонлайн-DRM. Общая схема выглядит так:
Пользователь запускает program.exe в первый раз.
Перед исполнением кода игры Denuvo собирает информацию для идентификации оборудования текущей системы и подготавливает её для отправки через Интернет.
Затем program.exe отправляет эту информацию об оборудовании на сервер Denuvo. Разумеется, мы не знаем, что происходит на сервере, но, скорее всего, там применятся обратимые математические функции, чтобы скомбинировать «заимствованные константы» (stolen constants) (подробнее о них ниже) с информацией об оборудовании, переданной program.exe. Далее сервер отправляет эту смешанную информацию, которую я в дальнейшем буду называть «файл лицензии», обратно program.exe.
После получения program.exe файла лицензии создаётся его локальная копия, к которой может обращаться program.exe при последующих запусках, благодаря чему дополнительные онлайн-запросы не требуются (отсюда и «полуонлайновость», о которой говорилось выше).
program.exe перенаправляется на исходную точку входа (original entry point, OEP) и начинает исполнять сам код игры. В процессе исполнения program.exe собирает информацию об оборудовании и пытается расшифровать заимствованные константы из файла лицензии. Эти уже расшифрованные константы затем применяются для исполнения «исходных команд игры».
То есть игра, по сути, выполняет проверки целостности пользователя. Если собранная во время исполнения информация об оборудовании не будет равна той, которая использовалась для создания файла лицензии на сервере Denuvo, то будет расшифрована неверная заимствованная константа, и из-за этого пострадает игра (чаще всего это приводит напрямую к вылету).
Более техническое объяснение
В этом разделе мы подробнее объясним каждый механизм защиты и проверку целостности пользователя. Помните, что в Denuvo есть гораздо больше, чем здесь описано.
Возвращаемся к общему принципу работы
Файл лицензии
Когда Denuvo впервые добавляется к двоичному файлу, в игре выбираются определённые функции, которые становятся «защищёнными». Это означает, что сама функция будет исполняться внутри виртуальной машины, а выбранные части определённых команд будут полностью удалены из двоичного файла. Файл лицензии — это просто все эти удалённые байты, соединённые вместе и скомбинированные с идентификацией оборудования пользователя при помощи обратимых математических функций. Здесь важно, что все применяемые операции обратимы: в противном случае клиент не смог бы расшифровать данные и получить исходную константу.
DWORD лицензии
Поскольку заимствованных команд много, до передачи исполнения точке входа OEP Denuvo записывает избранные части файла лицензии в DWORD, разбросанные по разделу .vm (.vm — это раздел PE, содержащий код VM). Каждое DWORD, которые я буду называть «DWORD лицензии» — это, по сути, одна команда, извлечённая из двоичного файла и скомбинированная с идентификационной информацией об оборудовании пользователя.
Пример зашифрованной константы/удалённой команды
Я покажу на конкретном примере, как команды «удаляются» из двоичного файла. Допустим, у нас есть следующая функция:
add(int, int): push rbp mov rbp, rsp mov DWORD PTR [rbp-4], edi mov DWORD PTR [rbp-8], esi mov edx, DWORD PTR [rbp-4] mov eax, DWORD PTR [rbp-8] add eax, edx pop rbp ret
Легко увидеть, что после компиляции некоторые части команд не меняются. Например:
mov DWORD PTR [rbp-4], edi
Здесь мы записываем содержимое 32-битного регистра EDI в [RBP-4]. В этом случае Denuvo вырежет из двоичного файла константу -4 и сохранит её на сервере. Теперь единственный способ получить доступ к этой константе, которая будет необходима для успешного выполнения add(int, int) — это запросить файл лицензии у Denuvo, потому что он будет содержать DWORD лицензии, в которых находится зашифрованная константа -4 (напомню, что файл лицензии содержит константы, перемешанные с идентификацией оборудования). Более того: Denuvo преобразует всю функцию add(int, int) в байт-код, который может понимать только её виртуальная машина. В этом байт-коде присутствует код, действующий в качестве обёртки вокруг удалённой функции. Эта обёртка отвечает за следующее:
Сбор соответствующей информации об оборудовании во время исполнения (конкретной информации об оборудовании, которая была смешана с константой).
Чтение соответствующего DWORD лицензии, содержащего зашифрованную константу для этой конкретной функции.
Выполнение последовательности математических операций с использованием DWORD лицензии и идентификации оборудования, собранной во время исполнения, для получения значения
-4. Это должны быть операции, обратные тем, что выполнял сервер.Исполнение исходной команды с уже расшифрованной константой.
В предыдущем разделе я говорил, что если собранная во время исполнения информация об оборудовании не согласуется с той, что использовалась на сервере Denuvo для шифрования константы, то пункт (3), скорее всего, вернёт результат, не равный -4, и это вызовет неопределённое поведение.
Проверки целостности пользователя
Ниже я перечислю все векторы, которые Denuvo использует для определения целостности системы, исполняющей защищённый двоичный файл. По самой природе такой защиты, при запросе файла лицензии как минимум один пример каждой проверки должен отправляться на сервер.
Проверки до OEP
Прочитав предыдущие разделы, вы могли задаться вопросом: а что произойдёт, если идентификационная информация оборудования каким-то образом изменится (например, обновится Windows, будет установлен новый CPU и так далее)? Denuvo учитывает это при помощи специальных проверок, исполняемых непосредственно перед передачей управления OEP. Они просто выполняют расшифровку каких-то констант, но вместо того, чтобы применить константу для исполнения команды, они проверяют, равна ли она нужному значению (это единственные проверки, которые выполняют такие действия, все остальные предполагают, что расшифрованная константа верна, и действуют соответствующим образом). Если результат не совпал с ожидаемым, Denuvo удаляет локально сохранённый файл лицензии и запрашивает с сервера Denuvo новый; по сути, повторяется процесс, описанный в разделе «Общий принцип работы».
KUSER_SHARED_DATA
KUSER_SHARED_DATA — это одна страница памяти только для чтения (4096 байт), отражаемая в каждый процесс, работающий на машине с Windows. Она содержит информацию, которая может понадобиться процессам, например Windows Version, Windows Build Number, SystemTime и так далее. Большую часть содержащейся в ней информации можно использовать для идентификации машины, а потому Denuvo активно пользуется ею в своих целях.
Denuvo использует следующие поля:
0x026C : ULONG NtMajorVersion
0x02E8 : ULONG NumberOfPhysicalPages
0x02D0 : ULONG SuiteMask
0x0260 : ULONG NtBuildNumber
0x0264 : NT_PRODUCT_TYPE NtProductType
0x0268 : BOOLEAN ProductTypeIsValid
0x0270 : ULONG NtMinorVersion
0x0274 : BOOLEAN ProcessorFeatures [0x40]
0x026A : USHORT NativeProcessorArchitecture
0x03C0 : ULONG volatile ActiveProcessorCount
Примечание: смещения указаны для 64-битных машин.
CPUID
Команда CPUID используется для извлечения подробностей о процессоре. Наверно, это самый популярный способ сбора информации оборудования, используемый Denuvo. И как мы покажем ниже, разработчиками приложены огромные усилия для защиты от вмешательства в её исполнение.
Denuvo использует следующие параметры:
EAX=0x1 : Processor Info and Feature Bits
EAX=0x80000001 : Extended Processor Info and Feature Bits
EAX=0x80000002, 0x80000003, 0x80000004 : Processor Brand String
SYSCALL
Команда SYSCALL вызывает обработчик системных вызовов операционной системы с уровнем привилегий 0. Можно воспринимать его как способ, позволяющий программам пользовательского режима общаться и запрашивать у ядра сервисы.
Denuvo использует всего один параметр:
0x36 : NtQuerySystemInformation
Проверки NTDLL
ntdll.dll — это «интерфейс ядра Windows для пользовательского режима». По сути, он предоставляет многофункциональный API, который приложения пользовательского режима могут использовать, чтобы просить ядро выполнять действия от их имени. Windows Loader загружает ntdll.dll практически во все процессы Windows; обычно эта библиотека меняется с обновлением Windows, поэтому идеально подходит для Denuvo.
Проверки функций NTDLL
Этот аспект я изучал не так глубоко, как он того стоил. Похоже, Denuvo идентифицирует пользователя на основании расположения в ntdll.dll байт определённых функций и их относительного виртуального адреса (RVA).
Папка данных образа NTDLL
Как говорилось выше, ntdll.dll обычно немного меняется с каждым обновлением/версией Windows, поэтому вполне логично, что Denuvo изучает её Image Data Directory. Если конкретнее, то она получает доступ к следующим полям:
Export Directory RVA
Export Directory Size
Import Directory RVA
Import Directory Size
Resource Directory RVA
Resource Directory Size
Exception Directory RVA
Exception Directory Size
Relocation Directory RVA
Relocation Directory Size
Блок окружения процесса (Process Environment Block, PEB)
Process Environment Block (PEB) похож на KUSER_SHARED_DATA в том смысле, что в них обоих содержится информация. Однако в PEB содержится меньше «глобальной» и больше «локальной» информации. Кроме того, у каждого процесса в системе есть свой уникальный PEB. Ещё одно важное различие заключается в том, что приложение может свободно переписывать значения в PEB, поэтому он не столь идеально подходит для проверки информации об оборудовании, но Denuvo всё равно им пользуется.
Denuvo использует следующие поля:
0x0118 : ULONG OSMajorVersion
0x011C : ULONG OSMinorVersion
0x012C : ULONG ImageSubsystemMajorVersion
0x0130 : ULONG ImageSubsystemMinorVersion
Примечания: указаны смещения для 64-битных машин.
XGETBV
XGETBV считывает extended-control-register (XCR). Я не знаю особых подробностей о ней, это очень маленькая и уникальная с точки зрения исполнения команда, которую можно использовать для определения тонких особенностей CPU.
GetWindowsDirectoryW
GetWindowsDirectoryW получает путь к папке Windows.
GetVolumeInformationW
GetVolumeInformationW получает информацию о файловой системе и о томе, связанном с конкретным корневым каталогом.
GetComputerNameW
GetComputerNameW получает имя NetBIOS локального компьютера.
GetUsernameW
GetUsernameW получает имя пользователя, связанного с текущим потоком. В нашем случае это будет имя пользователя, пытающегося запустить защищённый Denuvo двоичный файл.
Проверки целостности кода
Cyclic Redundancy Check (CRC)
CRC VM
Как и можно ожидать, Denuvo выполняет сканирование важных обработчиков (например, CPUID, SYSCALL и так далее) и, вероятно, другого кода, чтобы удостовериться в отсутствии перехвата/вмешательства. К сожалению, это всё, что я могу сказать об этих проверках.
Как будто случайная проверка .VM
Часто Denuvo собирает константу, считывая кажущееся случайным количество байт из раздела .VM. Затем эта константа используется для выполнения вычислений, которые поломаются в случае изменения константы. Например, рассмотрим следующий обработчик:
mov edx, dword ptr ds:[rax+0x03] ; считывание индекса следующего обработчика movsx r13, word ptr ds:[0x00000001467FEE8D] ; здесь Denuvo считывает "случайное" слово из кода .VM add r13, 0xFFFFFFFFFFFFDBAB ; расшифровка слова add rax,r13 ; обновление vip mov qword ptr ds:[rcx+418],rax ; сохранение vip lea rax,qword ptr ds:[0x14E2FD140] ; копирование адреса таблицы обработчика в rax ; вычисление следующего обработчика и переход к нему mov r12,qword ptr ds:[rax+rdx*8] xchg qword ptr ss:[rsp],r12 ret
Если пользователь установит точку останова/перехватчик или попробует изменить слово, хранящееся по адресу 0x00000001467FEE8D (если я правильно помню, это CPUID), то VM, скорее всего, в результате исполнит случайный обработчик, из-за чего получившееся значение в R13 будет отличаться, вызывая неопределённое поведение.
Разное
Виртуальная машина (VM)
О виртуальной машине я знаю не особо много. Кажется, существуют разные их виды. Иногда она кажется простой (например, таблица обработчиков, отсутствие динамического ключа и так далее). Возможно, стоит рассмотреть её в будущем посте?
Битовый вектор
Наверно, больше всего мне нравится в Denuvo то, что, в отличие от традиционных VM (например, VMP и Themida), Denuvo не хранит значения в сплошной памяти. Её разработчики решили хранить, например, значения регистров, разбросав их байты/биты повсюду. Это крайне усложняет анализ, особенно когда с этими значениями выполняются операции. Вероятно, вот лучший пример, который я могу привести о побитовой записи Denuvo значения:
; извлечение бита 0x7 EDI mov eax, edi shr rax, 0x7 and eax, 0x1 mov qword ptr ss:[rsp+0x48], rax ; извлечение бита 0x8 EDI mov eax, edi shr rax, 0x8 and eax, 0x1 mov qword ptr ss:[rsp+0xB0], rax ; Извлечение бита 0x9 EDI mov eax, edi shr rax, 0x9 and eax, 0x1 mov qword ptr ss:[rsp+0x40], rax ; извлечение бита 0xC EDI mov eax, edi shr rax, 0xC and eax, 0x1 mov qword ptr ss:[rsp+0xB8], rax ...
Случайность
Случайность — краеугольный камень защиты. Без неё проверки патчинга были бы крайне тривиальны. В отличие от других схем защиты, Denuvo не использует никакие API и команду RDRAND x86. Вместо этого Denuvo применяет значения из нативных регистров. Это гениальное решение, ведь входные данные, по сути, гарантированно будут меняться, будь то из-за релокации базы образа или из-за того, что персонаж игрока потерял здоровье.
Один из способов, используемых Denuvo, и, вероятно, единственный, заключается в генерации случайности на основании нативного значения регистра игры при помощи модульной арифметики. Вот реальный пример из защищённого исполняемого файла Denuvo:
Примечание: я не могу предоставить ассемблерный код, потому что он чрезвычайно обфусцирован и нечитаем, но этого демо на C должно быть достаточно.
if (VCTX[0] % 9 == 0) // VCTX -> VM Context { CPUID_A(); // обработчик cpuid } else { CPUID_B(); // обработчик cpuid }
В этом примере CPUID_A и CPUID_B семантически идентичны. Нет никакой разницы, какой из них вы решите исполнить.
Mixed-Boolean-Arithmetic (MBA)
Mixed-Boolean Arithmetic (MBA) — это способ трансляции выражений в сложное для понимания и анализа представление с сохранением семантики исходного выражения. Он заменяет выражение арифметическими и булевыми операциями (то есть ^, |, +, -, ~, &).
Примеры:
x + y = (x & y) + (x | y)
x | y = x + y + 1 + (~x | ~y)
x - y = (x ^ -y) + 2*(x & -y) = ((x ^ -y) & 2*(x & -y)) + ((x ^ -y) | 2*(x & -y)) = ((x ^ -y) & 2*(x & -y)) + ((x ^ -y) + 2*(x & -y) + 1 + (~(x ^ -y) | ~2*(x & -y)))
Примечание: эквивалентность этих выражений можно доказать инструментом автоматического доказательства теорем, например, Z3.
Если приглядеться, то можно понять, что для получения (3) мы просто многократно подставляем тождества x | y и x + y в x - y. Это распространённый и простой способ генерации MBA-выражений. Другие, возможно, более «совершенные» способы генерации MBA выходят за рамки нашей статьи; например, в них используются линейная и абстрактная алгебра. Но если вам любопытно, изучите следующие источники:
Примечание: в моей статье будут приведены только высокоуровневые объяснения концепций и идей, но для читателей, желающих строгих математических выкладок, приведены ссылки на вышеупомянутые теоремы.
В Denuvo они активно применяются для MBA. В частности, в них используются результаты zhou2007:
(zhou2007, теорема 2) Пусть e — побитовое выражение, тогда e имеет нетривиальное линейное выражение MBA.
(zhou2007, доказательство 1) Любую операцию в BA-алгебре (можно считать их булевыми и арифметическими операторами, например, ^, |, +, -, ~, >, <, &, …) можно представить как полиномиальное MBA-выражение высокой степени.
Примечание: повторюсь, все строгие формулировки здесь опущены. Более подробную информацию см. в описанных выше статьях.
Из этих результатов, по сути, следует, что можно переписать большинство команд x86 в виде MBA-выражений. Например, возьмём следующую команду x86:
mov rax, rbx
Перепишем её:
; y = ((~x)&(x))|y push rax not rax and qword ptr [rsp], rax pop rax or rbx
Согласно zhou2007 (теорема 2), мы можем применить к представленным в переписанном виде командам BA-алгебры дальнейшие MBA-преобразования, ещё больше усложнив выражение. Этот пример был намеренно упрощён; вот сырой код VM Denuvo:
mov r8b,byte ptr ds:[rcx+2BA] and r11d,r8d mov al,byte ptr ds:[rcx+65] shld r11d,r8d,18 lea rbx,qword ptr ds:[rcx+2BD] ror r8d,8 or r8d,r11d lea rbx,qword ptr ds:[rbx+564C320C] shl eax,18 mov dl,byte ptr ds:[rbx-564C320C] ror eax,18 and eax,FF rcr r8d,18 mov r9b,byte ptr ds:[rcx+14A] ror edx,8 and r8d,FF sar edx,18 sub ebx,ebx mov r10d,FF or ebx,r9d shr r9d,8 and edx,FF and ebx,r10d rcl ebx,18 sub r10d,r10d sub r11d,r11d xor r9d,ebx mov r10b,byte ptr ds:[rcx+AD] lea rbx,qword ptr ds:[rcx-5DF0648A] shr r9d,18 mov r11b,byte ptr ds:[rcx+39D] push rsi not rsi or rsi,FFFFFFFFFFFFFF00 and qword ptr ss:[rsp],rsi pop rsi or sil,byte ptr ds:[rcx+C7] push rdi not rdi and byte ptr ss:[rsp],dil pop rdi rol esi,18 or dil,byte ptr ds:[rbx+5DF0669F] mov dil,dil mov rbx,FF shl edi,18 shr edi,18 shr esi,18 and rdi,rbx pushfq push r15 mov r15,FFFFFFFFFFFF0000 shl r15,20 add r15,0 mov rbx,r15 pop r15 popfq push rax
Здесь уже не всё так просто. Среди прочего, к способам применения MBA относятся Software Watermarking и Constant Hiding, которые можно найти в zhou2007 (Section 4, Protection Methods). Но я не знаю, использовались ли они в Denuvo.
Дешифруемый + повторно шифруемый на лету CPUID
Иногда вместо стандартного исполнения обработчика CPUID в VM Denuvo дешифрует CPUID в разделе VM, исполняет его, а затем быстро шифрует его заново. Думаю, это делается, чтобы помешать взломщикам сопоставлять паттерны каждой команды CPUID, хоть это и не было бы особо для них полезно. Использование шифрования и дешифровки в реальном времени имеет любопытные последствия.
У VM есть общие обработчики с разными потоками исполнения. Что, если два потока попытаются одновременно исполнить один и тот же зашифрованный CPUID? Чтобы потоки не вызвали неопределённое поведение, требуется спин-блокировка. Однако спин-блокировки должны быть быстрыми, потому что в противном случае мы будем исполнять уже обфусцированный код, и делать это уже в цикле. Для решения этой проблемы разработчики Denuvo полностью убрали основную логику спин-блокировки из обфускации. Следовательно, взломщики могут выполнять сканирование паттернов в поисках спин-блокировок, что, в свою очередь, сообщит им, где находится зашифрованный CPUID (более или менее). Как Denuvo решила эту проблему? Зашифровав спин-блокировку, для чего требуется ещё одна спин-блокировка.
Я не знаю, зашифровала ли она спин-блокировку, отслеживающую зашифрованную спин-блокировку, которая отслеживает зашифрованную команду CPUID, но с большой долей вероятности это так.
Паттерн спин-блокировок Denuvo:
push r0 push r1 mov r1, 0x1 xor r0, r0 spinlock_entry: lock cmpxchg dword ptr ds:[SPINLOCK_BOOL], r1 ; SPINLOCK_BOOL - это байт переключения je spinlock_exit pause jmp spinlock_entry spinlock_exit: pop r1 pop r0 ... ; рано или поздно выполнит jmp к дешированному коду
Защита от перехвата на основе исключений
Атаки на ранние версии Denuvo в основном выполнялись патчингом всех проверок информации об оборудовании, благодаря чему возвращалась правильная информация, требуемая для дальнейшего вычисления правильной константы. Часто использовался способ перехвата команд CPUID и SYSCALL через хук на основе исключения. Однако при помощи Windows API можно легко зарегистрировать vector exception handler. Основной подход заключался в том, чтобы заменять каждую команду CPUID и SYSCALL на команду UD2 для запуска и INVALID_OPCODE_EXCEPTION с перехватом KiUserExceptionDispatcher для загрузки в нужные регистры нужной информации об оборудовании.
Этот подход хорошо работал, потому что CPUID и SYSCALL имеют двухбайтную длину, а значит, для их перехвата достаточно пропатчить один байт. Однако Denuvo реализовала гениальный патч. Перед исполнением обработчика CPUID Denuvo записывает важные значения в верхнюю часть «неиспользуемого» пространства стека. В дальнейшем она извлекает это значение, чтобы выполнять важные вычисления, что в противном случае вызовет неопределённое поведение. Это позволило защититься от всех способов перехвата на основе исключений, потому что чаще всего при срабатывании исключения Windows записывает EXCEPTION_RECORD в верхнюю часть неиспользуемого пространства стека. Наверно, вы уже поняли, к чему всё идёт. Теперь в случае перехвата CPUID при помощи исключения это важное значение переписывается EXCEPTION_RECORD, в дальнейшем вызывая неопределённое поведение. Наверно, эту защиту можно обойти, если подключить к процессу отладчики и задать определённые флаги при обработке исключений, но из-за случайности этот способ патчинга всех проверок оборудования всё равно довольно неудобен.
Взлом
Патчинг проверок ID оборудования
При попытке обхода этой защиты первым делом стоит попробовать вручную пропатчить каждую проверку идентификации оборудования, благодаря чему каждый раз будет возвращаться правильная информация об оборудовании (под «правильностью» здесь имеется в виду, что оборудование будет дешифровать нужную константу). Однако, как сказано в предыдущих разделах, это будет крайне сложно сделать. Взломщику придётся столкнуться не только со сложными CRC, но и со случайностью, из-за чего одному человеку будет практически невозможно обнаружить все проверки, не говоря уже об их патчинге.
Патчинг дешифрования констант
Аналогично патчингу всех проверок информации об оборудовании, можно нацелиться на подпрограммы дешифрования констант и возвращать нужную константу вместо расшифрованной ошибочно из-за неправильной информации об оборудовании. Более того, это решение гораздо разумнее, чем патчинг всех проверок информации об оборудовании, поскольку пока в этих подпрограммах нет CRC и случайности. Однако найти одно дешифрование константы в трассировке примерно десяти миллионов команд x86 не так просто.
Полное восстановление binary.exe
Из одного названия этого решения можно понять, насколько сложно это будет сделать. Для этого понадобится исправление/развиртуализация, вероятно, тысяч команд. Тем не менее, мне известен один пример полного восстановления защищённого Denuvo двоичного файла (наверно, это лучший взлом из известных мне).
Гипервизор
Чуть более продвинутое решение заключается в использовании гипервизора для спуфинга всей необходимой информации об оборудовании. Разумеется, это проще сказать, чем сделать. Однако и AMD, и Intel поддерживают возможность перехвата команд наподобие CPUID и XGETBV, а перехват SYSCALL с уровня гипервизора реализовать тоже не очень сложно. Думаю, единственным сложным моментом будет патчинг проверок NTDLL и KUSER так, чтобы не поломать все остальные приложения на компьютере. На самом деле, я удивлён, что до сих пор не существует решения на основе гипервизора peer2peer (p2p).
В заключение
Можно с уверенностью сказать, что Denuvo чертовски хорошо справляется со своей работой. Она многократно демонстрировала свою способность защищать игры в течение месяцев, а иногда и лет. Непонятно, в чём причина, в лени или в некомпетентности хакеров, но Denuvo определённо выходит победительницей. Мне кажется, эта технология будет с нами ещё очень долго.
Благодарности
Благодарю за помощь всех этих замечательных людей:
Sp********
Ma****
Mk***
Az****
AlexSandrov
Denuvo основана на VMProtect, о чём можно почитать интересную историю тут: https://rsdn.org/forum/shareware/6733631.flat.1