Где тонко, там и взломают

в 19:56, , рубрики: cracking, OS X, reverse engineering, информационная безопасность, метки: , ,

Как известно, надёжность какой-либо системы в целом определяется надёжностью её самого слабого звена. Сейчас мы рассмотрим защиту от копирования одной популярной выпущенной на днях для 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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js