Как известно, надёжность какой-либо системы в целом определяется надёжностью её самого слабого звена. Сейчас мы рассмотрим защиту от копирования одной популярной выпущенной на днях для OS X игрушки и способ её обхода. И на этом примере увидим на практике, как неуделение должного внимания (по непонятной мне причине) каждой части реализации защиты сводит на нет солидность применённых решений в целом. Ну и просто посмотрим на один из вариантов реализации защиты от копирования. Разумеется, исследование проведено в целях исследования и от нечего делать, хорошие программы и игры следует покупать и всё такое.
Акт I
Запускаем игру, видим окно регистрации или покупки. Регистрация осуществляется или онлайн вводом серийника, или вручную вводом имени и ключа в соответствии с отображаемым идентификатором конкретного компьютера. Выглядит серьёзно, запускаем gdb. И сразу получаем Program exited with code 055.
Понятно. В OS X существует возможность запретить отладку процесса, вызвав в нём ptrace(PT_DENY_ATTACH, 0, 0, 0);
Дело не хитрое, радостно запускаем для дизассемблирования нашего бинарника otool или замечательную программку otx, которая записывает в выходной файл ещё некоторую полезную информацию, и получаем 68 мегабайт ассемблерного кода. Ищем в них вызов ptrace()
, но к нашему удивлению не находим — это уже интересно. Ну да не страшно, ставим брейкпоинт на вызов этой функции, запускаем, видим следующее:
Breakpoint 1, 0x98ce6a18 in ptrace () (gdb) bt #0 0x98ce6a18 in ptrace () #1 0x00e3867f in StartupLicensing () #2 0x00e380d9 in Protect () #3 0x00cefdcd in dyld_stub_write () #4 0x8fe11203 in __dyld__ZN16ImageLoaderMachO18doModInitFunctionsERKN11ImageLoader11LinkContextE () #5 0x8fe10d68 in __dyld__ZN16ImageLoaderMachO16doInitializationERKN11ImageLoader11LinkContextE () #6 0x8fe0e2c8 in __dyld__ZN11ImageLoader23recursiveInitializationERKNS_11LinkContextEjRNS_21InitializerTimingListE () #7 0x8fe0f268 in __dyld__ZN11ImageLoader15runInitializersERKNS_11LinkContextERNS_21InitializerTimingListE () #8 0x8fe03694 in __dyld__ZN4dyld24initializeMainExecutableEv () #9 0x000288c2 in _start () #10 0x00028838 in start ()
То есть у нас вызываются функции с говорящими названиями, которые, очевидно, нам и интересны, но вызываются хитрым способом, отчего этот кусок кода не дизассемблировался. В функции _start
это выглядит следующим образом:
+110 call 0x00c60323 ___keymgr_dwarf2_register_sections +115 leal 0xe0(%ebp),%eax +118 movl %eax,0x04(%esp) +122 movl $0x004ebe64,(%esp) __dyld_make_delayed_module_initializer_calls +129 calll __dyld_func_lookup +134 call *0xe0(%ebp)
Соображаем, что, значит, где-то должен быть прописан адрес некоторой функции инициализации, которая находится судя по всему в другом сегменте/секции и вызывается таким нетривиальным способом. Если бы мы сразу посмотрели вывод otool -l
, то, разобравшись в куче команд загрузки, нашли бы первую:
Load command 6 cmd LC_SEGMENT cmdsize 124 segname __BOOKKEEPING vmaddr 0x00cef000 vmsize 0x0004ae5a fileoff 6656000 filesize 306778 maxprot 0x00000007 initprot 0x00000003 nsects 1 flags 0x4 Section sectname __bk segname __BOOKKEEPING addr 0x00cef000 size 0x00000004 offset 6656000 align 2^2 (4) reloff 0 nreloc 0 flags 0x00000000 reserved1 0 reserved2 0
и вторую
Section sectname __mod_init_func segname __DATA addr 0x0057f008 size 0x00000150 offset 5758984 align 2^2 (4) reloff 0 nreloc 0 flags 0x00000009 reserved1 0 reserved2 0
Но мне это было делать лениво, тем более, что мы уже знаем, куда попадаем, имеем красивые имена функций, осталось только найти их в бинарнике. Прямо в бинарнике и находим запрятанным там ещё один полноценный исполняемый модуль, дизассемблируем его и получаем нашу функцию Protect
.
Акт II
Функция Protect
сама ничего интересного не делает, а содержит вызовы вроде StartupLicensing
, NeedsLicenseAsk
, ValidDemoLaunch
, AskForLaunchOptions
, и, наконец, RunRealStaticInitializers
, после чего завершается. Ради интереса пробуем изменить возвращаемое значение (точнее, его проверку), например, у ValidDemoLaunch
— вдруг на этом всё и закончится. Но нет, после этого программа после запуска просто сразу завершается без всяких сообщений и ошибок. Так что будем смотреть функцию RunRealStaticInitializers
.
Здесь стоит сделать отступление и отметить некоторые другие найденные беглым просмотром имена функций:
DRCheckForModifiedClock DRVerifyKey DRInstallKey DRChangeHardwareLock DigitalRiver::CheckEccSignatureFromPublic(unsigned char*, int*, char const*, char const*, int) DigitalRiver::MakeEccPublicKeyBlock(char const*, int) DigitalRiver::Random128::init(char const*, unsigned long) DigitalRiver::TEA_Crypt(unsigned long*, void*, unsigned long, int) DigitalRiver::GetKeyMD5(unsigned long*, char const*, int) DigitalRiver::KeyAndCertificate::DecryptCertificate(void const*, unsigned long, int)
и так далее, что производит впечатление серьёзной защиты с применением, как минимум, некоторой нетривиальной криптографии. Ну да ладно, вернёмся к нашей функции — криптография это страшно, но использующие её программы, взламывающиеся при этом заменой одного jne
на je
, уже видеть приходилось.
Ниже практически полный листинг RunRealStaticInitializers
за исключением неинтересной инициализации/выхода (извиняюсь за простыню):
+18 00002de8 movl 0x0c(%ebp),%eax +21 00002deb leal 0xfffffee8(%ebp),%esi +27 00002df1 movl $0x00000100,0x08(%esp) +35 00002df9 movl %esi,0x04(%esp) +39 00002dfd shrl $0x02,%eax +42 00002e00 movl %eax,0xfffffee0(%ebp) +48 00002e06 leal 0x00019bef(%ebx),%eax +54 00002e0c movl %eax,(%esp) +57 00002e0f calll _DRGetVariable +62 00002e14 movl 0xfffffee0(%ebp),%edx +68 00002e1a testl %edx,%edx +70 00002e1c setne %dl +73 00002e1f testl %eax,%eax +75 00002e21 movl %eax,0xfffffee4(%ebp) +81 00002e27 setne %al +84 00002e2a testb %al,%dl +86 00002e2c je 0x00002ebd +92 00002e32 movl %esi,(%esp) +95 00002e35 calll 0x00026557 _strlen +100 00002e3a movl 0x0c(%ebp),%edx +103 00002e3d movl %edx,(%esp) +106 00002e40 movl %eax,%esi +108 00002e42 calll 0x000264f3 _malloc +113 00002e47 xorl %ecx,%ecx +115 00002e49 movl %eax,%edi +117 00002e4b jmp 0x00002e71 +119 00002e4d movl %ecx,%eax +121 00002e4f xorl %edx,%edx +123 00002e51 divl %esi +125 00002e53 movl %edx,0xfffffed4(%ebp) +131 00002e59 movl 0x08(%ebp),%edx +134 00002e5c movzbl (%edx,%ecx),%eax +138 00002e60 movl 0xfffffed4(%ebp),%edx +144 00002e66 xorb 0xfffffee8(%ebp,%edx),%al +151 00002e6d movb %al,(%edi,%ecx) +154 00002e70 incl %ecx +155 00002e71 cmpl 0x0c(%ebp),%ecx +158 00002e74 jb 0x00002e4d +160 00002e76 xorl %esi,%esi +162 00002e78 jmp 0x00002e9f +164 00002e7a movl (%edi,%esi,4),%eax +167 00002e7d testl %eax,%eax +169 00002e7f je 0x00002e9e +171 00002e81 movl 0x1c(%ebp),%edx +174 00002e84 movl %edx,0x0c(%esp) +178 00002e88 movl 0x18(%ebp),%edx +181 00002e8b movl %edx,0x08(%esp) +185 00002e8f movl 0x14(%ebp),%edx +188 00002e92 movl %edx,0x04(%esp) +192 00002e96 movl 0x10(%ebp),%edx +195 00002e99 movl %edx,(%esp) +198 00002e9c call *%eax +200 00002e9e incl %esi +201 00002e9f cmpl 0xfffffee0(%ebp),%esi +207 00002ea5 jb 0x00002e7a
Для начала пробуем вообще сразу выходить из этой функции с «хорошим» кодом возврата — мало ли её просто так назвали, а на самом деле они ничего важного не инциализирует. Но нет, тогда программа начинает падать в основном своём коде, там мы видим постоянные вызовы функций по некоторым адресам, которые записаны в память по другим адресам и так в три этажа. Ага, значит, эта штука-таки что-то важное инициализирует. Будем смотреть внимательнее.
Первое, что мы тут видим, это вызов функции DRGetVariable
на строке +57. Без валидного ключа она, что логично, переменную не находит и выполнение завершается. Пробуем подсунуть её хоть какое-то значение, получаем падение на строке +198
. Теперь смотрим совсем внимательно и видим поразительные вещи:
+57
Получение значения переменной, размер буфера 255 байт.
+95
Смотрим, какая длина значения получилась.
+108
Выделяем 336 байт памяти (=84*4).
+119
Побайтово XOR'им некоторые значения из памяти с нашей переменной и кладём в выделенную память.
+160
Рассматриваем полученные значения как 84 адреса и вызываем их.
Вот мы и получили первую лажу — после всех функций с умными названиями у нас XOR с длиной ключа, меньшей, чем данные, да ещё и подметим, что раз это адреса в нашем небольшом бинарнике, то старший байт должен получаться равен нулю, а следующий тоже 0, 1 или 2. Таким образом, нам известен каждый четвёртый байт ключа как минимум, а чем меньше его длина, тем больше известный байт уже есть. Вот только длину мы не знаем.
Акт III
Но знаем, что в результате должны получаться адреса функций (а не данных или середин функций). Берём и пишем несложную программку для перебора с учётом указанных выше условий, пытаясь попасть на начала каких-то функций. Ничего не получается, поэтому несколько теряем оптимизм, но, хотя бы узнаём, что длина ключа минимум 170 с чем-то байт. На этом откладываем наш взлом на денёк.
Через день совершенно случайно обнаруживаем в нашем основном бинарнике некоторое количество функций _static_initialization_and_destruction_0(int, int)
следом за каждой из которых идёт маленький трамплинчик
+0 00004270 movl $0x0000ffff,%edx +5 00004275 movl $0x00000001,%eax +10 0000427a jmp xxxx +15 0000427f nop
Оптимизм сразу возвращается — их ровно 84 штуки! Вот и нужные нам адреса. На этом можно было бы и закончить, выписать все их адреса, засунуть туда, где они должны быть, убрать XOR и всё. Но я ради интереса всё же нашёл реальный ключ для расшифровки, тем более, что мог оказаться важен порядок вызовов этих функций. Правда, надо сказать, это не помогло бы, потому возможный ключ не один, длина, кстати, возможна только одна — 251 байт. Но порядок не важен, берём любой ключ или сразу любую последовательность расшифрованных адресов, записываем, куда надо, запускаем, наслаждаемся игрушкой и проделанной рабой.
Вывод
Вывод в целом банален и написан в начале статьи — после реализации, например, защиты надо свежим взглядом оценить самые слабые его места и возможные атаки на них. Может оказаться, что непробиваемая криптография в работе с ключами — отдельно, а ничем не защищённая проверка результата этой работы — отдельно.
Конкретно же в этом случае мне совершенно непонятно, что помешало солидной фирме не халтурить, ведь улучшить защиту можно очень просто — для начала заменить прямой вызов ptrace()
на что-то более хитрое, не использовать XOR или хотя бы увеличить длину ключа и избавиться от предопределённости каждого 4го байта. А самое главное как-то замаскировать или хотя бы стрипнуть эти 84 функции инициализации, ибо из-за лёгкого нахождения их в первую очередь по их говорящим именам, вся имеющаяся защита становится просто бессмысленна.
Автор: mifki