Оживление старого кода или как сделать хорошо приложению, которому плохо

в 13:50, , рубрики: reverse engineering, разработка игр, разработка под iOS, реверс-инжиниринг

Признаться, я играю в игры не чаще, чем пишу на хабре, но к жанру «ритмических» всегда питал некую слабость. В своё время мне очень нравилась Audiosurf, позже сталкивался с разными её клонами, Beat Hazard, osu. Ещё позже наткнулся в App Store на Deemo и Duet, от которых получил немало приятных минут.
Оживление старого кода или как сделать хорошо приложению, которому плохо - 1
Во время бесконечного бессонного вечера, блуждая в полудрёме по разным сайтам, я заметил вещицу, которая входила в круг моих интересов. Бесплатная загрузка, знакомая компания, ага, значит будет куча платного контента. С другой стороны, во что-нибудь поиграть наверняка дадут за просто так. Сделав напрашивающийся вывод, я скачал игру… и узрел девственно белый экран.

Дальнейшее изложение пойдёт не о том, какие озлобленные чувства у меня вызвала халатность издателей, потерявших код и/или забивших на обновление, а о получении желаемого в ситуации лёгкой безысходности.

Чтение описания, а это иногда всё же стоит делать, принесло две новости:

  • совместимости с iOS 9 нет, возникает пресловутый белый экран;
  • игра больше не поддерживается и будет удалена из магазина в августе, причём без возможности произведения встроенных покупок.

Шикарно. Наверное, многие после такого бы отступили, тем более, что платформа не x86 да и игра, скажем, не хитовая (хотя механика мне потом очень понравилась). Но у меня засела личная обида, и на следующее утро я решил пациента осмотреть.

Оживление

Первым делом стоит глянуть, а не выводит ли что-нибудь чудовище в лог? Заходим по ssh, открываем мониторинг syslog и запускаем приложение. Моментально экран заполняет куча сообщений вида:

bird[1792] <Error>: setting error: <NSError:0x15df7ba0(BRCloudDocsErrorDomain:5) - {
    NSDescription = "No document at URL";
    NSFilePath = "/private/var/mobile/Library/Mobile Documents/JZKSZCX743~com~square-enix~tact/oks_savedata.bin";
    NSUnderlyingError = "<NSError:0x15df7b60(NSPOSIXErrorDomain:2) - {n    NSDescription = "No such file or directory";n}>";
}>

Оп-па, в яблочко. iOS 9 поломала игрушке iCloud, файл с сохранениями и настройками по какой-то причине не создался, а запуск перешёл в бесконечный цикл. Попробуем его создать:

touch "/private/var/mobile/Library/Mobile Documents/JZKSZCX743~com~square-enix~tact/oks_savedata.bin"

А вот и интерфейс 0_o. Умиротворённый я полез в Story, попутно одевая наушники. Там увидел: есть-то всё равно нечего :(

Оживление старого кода или как сделать хорошо приложению, которому плохо - 2

Вполне объяснимо, в файле должна же быть какая-то структура, а я варварски взял и пихнул заглушку. Дальнейший поиск по имени файла приводит к двум местонахождениям:

find /private/var -name oks_savedata.bin
/private/var/mobile/Containers/Data/Application/длинный-uuid/Documents/oks_savedata.bin
/private/var/mobile/Library/Mobile Documents/JZKSZCX743~com~square-enix~tact/oks_savedata.bin

Но увы, первый файл сам по себе не создаётся без второго. We need to go deeper.

Дампим приложение, распаковываем ipa (напомню, это обычный zip), смотрим, что внутри application bundle:

  • Info.plist
  • PkgInfo
  • ResourceRules.plist
  • Shader.fsh
  • Shader.vsh
  • _CodeSignature
  • archived-expanded-entitlements.xcent
  • en.lproj
  • oks
  • oks_icon_a.png
  • oks_sqex copy-Info.plist
  • oks_sqex.entitlements
  • pre_build.sh
  • и ещё ~1500 файлов с именами вида ffdf8df97e7c9bcb7f42d1cc8ad09b08.

Исполняемый файл, согласно CFBundleExecutable в Info.plist — это oks, FAT бинарь с двумя архитектурами:

jtool -h oks
Fat binary, big-endian,  2 architectures: armv7, armv7s
Specify one of these architectures with -arch switch, or export the ARCH environment variable

Ура, нет arm64. Что ж, для меня это в каком-то смысле плюс, так как iOS и её тевтоновские ограничения да ещё и в специфике arm — несмотря на вступление, не моя тема. Суюсь я сюда редко, и не от хорошей жизни. Смотрим entitlements, вроде стандартный набор с iCloud.

Вывод jtool

jtool --ent -arch armv7s oks
Warning: companion file ./oks.ARM (unknown).69981636-7F33-3C43-BD58-7F5BBE2A6CCA not found
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
	<dict>
		<key>keychain-access-groups</key>
		<array>
			<string>JZKSZCX743.com.square-enix.tact</string>
		</array>

		<key>com.apple.developer.ubiquity-container-identifiers</key>
		<array>
			<string>JZKSZCX743.com.square-enix.tact</string>
		</array>

		<key>application-identifier</key>
		<string>JZKSZCX743.com.square-enix.tacthd</string>

	</dict>
</plist>

Сразу посещает мысль, а нужно ли нам это. Если потом ставить приложение на девайс без джейлбрейка (переподписывая его), то iCloud всё равно работать не будет, а если он работать не будет и так, может, его сразу отрезать. Заодно вдруг само заработает? Сказано — сделано: переподписываем самоподписным сертификатом без указания entitlements (codesign -f -s mycert oks), заодно добавляем в Info.plist свойство UIFileSharingEnabled для бэкапа сохранений через iTunes (а вдруг пригодится), запаковываем приложение обратно в ipa и устанавливаем на девайс.

Вы угадали, после этого я несколько минут удовлетворённо играл. Ровно до прохождения первых четырёх уровней, когда малина была жестоко обрезана встроенными покупками. Иду в раздел Store и понимаю, что не просто дорого, а бесценно. Store я сломал, когда чинил запуск, потому даже если очень хочется, то ничего купить не выйдет. К тому моменту оригинальный ipa я уже удалил и перекачивать его не хотелось. Думаю, но да ладно, запатчу быстренько инаппы и дело с концом.

Исправление косяка № 1

Просматриваем список классов Objective-C, ищем что-нибудь относящееся к проблеме.

Список классов

jtool -d objc oks 
oksAppDelegate
oksExtendView
EAGLView
oksViewController
SeqLogo
SeqTitle
Sequence
SeqMan
Sprite
SprMan
SndOne
SndMan
SeqIngame
DataOne
DataMan
TouchEff
TouchOne
KeyMan
ChartObj
ResultIconOne
ResultIcon
Gakudan
ChobjEndEffOne
ChobjEndEff
FadeMan
NumberSpr
SeqResult
MenuStatus
PopUp
SeqMainMenu
SeqStory
SptMan
SptCharaOne
SptChara
SptMsgLog
SptMsg
SptBg
SptStill
SptCol
SptShake
SptSnd
SeqSelConcert
MenuCmnBtn
OnpuEffOne
OnpuEff
SelStoryChap
SelStoryLine
SeqSelStory
StaffData
SeqStaffRoll
MusicStill
SeqMusic
MenuOption
SeqDownload
MyStoreObserver
Tutorial
FontMan
VerificationController
Reachability

Судя по названию, это что-то может быть в SeqStory, SelStoryChap или SeqSelStory. И уже во втором классе нам улыбается удача.

Дамп методов

jtool -d SelStoryChap -arch armv7s oks
Warning: companion file ./oks.ARM (unknown).69981636-7F33-3C43-BD58-7F5BBE2A6CCA not found
// Dumping class 45 (SelStoryChap)
@interface SelStoryChap : CoreFoundation::_OBJC_METACLASS_$_NSObject
// No properties..
// 11 instance variables
 /* 0 */  unsigned int flag; // I
 /* 1 */  int storyId; // i
 /* 2 */  int prio; // i
 /* 3 */  float oriPosx; // f
 /* 4 */  float oriPosy; // f
 /* 5 */  float posx; // f
 /* 6 */  float posy; // f
 /* 7 */  float plate_w_2; // f
 /* 8 */  float plate_h_2; // f
 /* 9 */   sprAry; // ^@
 /* 10 */  int sprNum; // i
// 25 instance methods
 /* 0 */ 0x38f01 - isUnlock;  // Protocol c8@0:4
 /* 1 */ 0x38f15 - isHave;  // Protocol c8@0:4
 /* 2 */ 0x38f29 - canPlay;  // Protocol c8@0:4
 /* 3 */ 0x38f3d - canSelect;  // Protocol c8@0:4
 /* 4 */ 0x38f81 - isTouch;  // Protocol c8@0:4
 /* 5 */ 0x38fd5 - plateTye;  // Protocol i8@0:4
 /* 6 */ 0x3900d - isKeyDisp;  // Protocol c8@0:4
 /* 7 */ 0x39031 - isPlayingDisp;  // Protocol c8@0:4
 /* 8 */ 0x39045 - isChapTitleDisp;  // Protocol c8@0:4
 /* 9 */ 0x39085 - alpha;  // Protocol f8@0:4
 /* 10 */ 0x390d9 - isDisp;  // Protocol c8@0:4
 /* 11 */ 0x39135 - clear;  // Protocol v8@0:4
 /* 12 */ 0x391d5 - reset;  // Protocol v8@0:4
 /* 13 */ 0x392a5 - load;  // Protocol v8@0:4
 /* 14 */ 0x396d1 - initWithPrio:;  // Protocol @12@0:4i8
 /* 15 */ 0x39725 - dealloc;  // Protocol v8@0:4
 /* 16 */ 0x397a1 - setStoryId:;  // Protocol v12@0:4i8
 /* 17 */ 0x39859 - updatePos;  // Protocol v8@0:4
 /* 18 */ 0x3991d - setOriPos:y:;  // Protocol v16@0:4f8f12
 /* 19 */ 0x3994d - setOfstPos:y:;  // Protocol v16@0:4f8f12
 /* 20 */ 0x3996d - setDisp:;  // Protocol v12@0:4c8
 /* 21 */ 0x39aa1 - startSelEff;  // Protocol v8@0:4
 /* 22 */ 0x39c7d - storyId;  // Protocol i8@0:4
 /* 23 */ 0x39c8d - posx;  // Protocol f8@0:4
 /* 24 */ 0x39c9d - posy;  // Protocol f8@0:4
@end

Членские методы isUnlock/isHave ненавязчиво намекают: смотреть надо там. Грамотный человек на моём месте написал бы патч Flex или собрал коротенькую библиотеку для Mobile Cydia Substrate. Но как самый обычный неадекват theos я не ставил, а Flex не пользую. Можно было написать и обычную динамическую библиотеку, используя стандартные API для method swizzling, но тогда мне это в голову не пришло, а потом стало ясно, что и не помогло бы. Загружаем файл в IDA, переходим к методам, и заменяем содержимое на конструкцию mov r0,#1: bx lr.

Вот так

Оживление старого кода или как сделать хорошо приложению, которому плохо - 3

Оживление старого кода или как сделать хорошо приложению, которому плохо - 4

После обновления исполняемого файла на устройстве по внешнему виду я догадался, что isUnlock — это «доступен ли эпизод для игры», а «isHave» — это cостояние покупки. Открываю ранее закрытый эпизод, уясняю, что встал на грабли:
Оживление старого кода или как сделать хорошо приложению, которому плохо - 5

Придётся посмотреть, что внутри. Так как arm не самая знакомая мне архитектура, без надобности к ассемблерному коду обращаться не буду, тем более, что функции короткие, а времени тратить много не хотелось. Смотрим код isHave/isUnlock:

char __cdecl -[SelStoryChap isHave](struct SelStoryChap *self, SEL a2)
{
  return (self->flag >> 1) & 1;
}

char __cdecl -[SelStoryChap isUnlock](struct SelStoryChap *self, SEL a2)
{
  return (self->flag >> 2) & 1;
}

Ага, эти методы лишь читают уже заданное значение, вероятно, реальная проверка где-то в другом месте. По XREF flag находим метод, который пишет в flag (здесь и далее имена частично добавлены вручную для облегчения читабельности):

// SelStoryChap - (void)setStoryId:(int) 
void __cdecl -[SelStoryChap setStoryId:](struct SelStoryChap *self, SEL a2, int story_id)
{
  self->storyId = story_id;
  self->flag &= 0xFFFFFF80;
  if ( checkStoryFlag1(self->storyId) )
    self->flag |= 1u;
  if ( checkStoryFlag2(self->storyId) ) // Для isHave
    self->flag |= 2u;
  if ( checkStoryFlag4(self->storyId) ) // Для isUnlock
    self->flag |= 4u;
  ...
}

Зная, что нам нужна проверка 2-го бита, смотрим содержимое checkStoryFlag2 и корректируем по надобности.

Чуть более подробно

int __fastcall checkStoryFlag2(int a1)
{
  return checkStoryStatus(a1, dword_7D84C);
}

signed int __fastcall inRange(int value, int start, int end)
{
  signed int result; // r0@1

  result = 0;
  if ( start <= value && value <= end )
    result = 1;
  return result;
}

signed int __cdecl checkStoryStatus(int story_id, int *table)
{
  signed int ret; // r4@1

  ret = 0;
  if ( table )
  {
    ret = 0;
    if ( inRange(story_id, 0, 63) )
    {
      ret = 0;
      if ( sub_xxxx(global_entry1, story_id, table, 's') )
      {
        if ( !memcmp(global_entry1, &table[8 * story_id + 6], 0x20u) )
          ret = 1;
      }
    }
  }
  return ret;
}

Похоже, в dword_7D84C хранится какая-то таблица значений (позже я выяснил, что там хеши SHA-256), с которой сверяются свежевычисленные для текущего id. При совпадении глава открывается. Думаю, здесь самое место убрать проверку и не думать. Безусловное возвращение единицы сделало своё дело и я прошёл все 16 глав :).

Исправление косяка № 2

На этом повествование можно было бы закончить, если не одно но:
Оживление старого кода или как сделать хорошо приложению, которому плохо - 6

А где оставшиеся 24 трека? Правильно, докупаются отдельно. Вот здесь игрушку я начал уже немножко недолюбливать, но ничего, предыдущие шаги дались слишком легко, должно же быть что-то ещё. Впрочем, мне опять повезло. Вскоре внутри одного из методов, услужливо именованного setupSelMusic, был обнаружен код, итерирующий по трекам и вызывающий какую-то функцию :D

  v3 = 0;
  memset(self->ctrl_music_idx, 0, 0x200u);
  v1 = 0;
  self->music_max = 0;
  do
  {
    if ( sub_36C24(v1) )
      self->ctrl_music_idx[v3++] = v1;
    ++v1;
  }
  while ( v1 != 128 );
  self->music_max = v3;

т.е.

  i = 0;
  memset(self->ctrl_music_idx, 0, 0x200u);
  track_id = 0;
  self->music_max = 0;
  do
  {
    if ( trackCheckingFunction(track_id) )
      self->ctrl_music_idx[i++] = track_id;
    ++track_id;
  }
  while ( track_id != 128 );
  self->music_max = i;

На основе контекста проверок я интерпретировал функцию как набор четырёх подтверждений: трек входит в допустимый диапазон (0~127), трек существует в базе игры, трек приобретён и хотя бы один из уровней доступен для игры.

Псевдокод

signed int __fastcall trackCheckingFunction(int track)
{
  signed int ret; // r5@1
  int lvl; // r6@4
  char open; // r0@6

  ret = 0;
  if ( inRange(track, 0, 127) ) // проверка диапазона
  {
    ret = 0;
    if ( trackExists2(track) ) // проверка наличия
    {
      ret = 0;
      if ( checkTrackStatus(track, dword_7D84C) ) // проверка покупки
      {
        lvl = 1;
        do
        {
          ret = 0;
          if ( lvl > 4 )
            break;
          open = checkTrackPassedLevel(track, lvl++); // проверка уровня
          ret = 1;
        }
        while ( !open );
      }
    }
  }
  return ret;
}

Заставив checkTrackStatus возвращать единицу без условий… я получил облом. Треки уже не отображались в магазине, как доступные для покупки, но и в меню игры их не было. Вот тут какое-то время я голову поломал, изначально думая, что имею слишком низкий уровень и что вот-вот всё разблокируется. Однако рационалист во мне немногим позже вспомнил, что в игрушке каждый трек имеет до 4 режимов сложности, каждый из которых открывается при получении достаточно высокой оценки за предыдущий. А значит, переменная lvl в этой функции не имеют никакого отношения к очкам игрока, она просто определяет «открытость» хотя бы одного режима сложности для прохождения. Дальнейшее изучение кода это подтвердило.

Чуть подробнее

BOOL __fastcall isEntryAvailble(int *table, signed int index)
{
  return (table[index >> 5] & (1 << (index & 0x1F))) != 0;
}

// Где-то мы такое уже видели...
signed int __fastcall checkTrackStatus(int track, int *table)
{
  signed int ret; // r4@1

  ret = 0;
  if ( table )
  {
    ret = 0;
    if ( inRange(track, 0, 127) )
    {
      ret = 0;
      if ( loadEntryHash(hash, track, table, 'm') )
      {
        if ( !memcmp(hash, &table[8 * track + 522], 0x20u) )
          ret = 1;
      }
    }
  }
  return ret;
}

BOOL __fastcall checkTrackPassedLevel(int track, int level)
{
  BOOL ret; // r6@1

  ret = 0;
  if ( inRange(track, 0, 127) )
  {
    ret = 0;
    if ( inRange(level, 1, 4) )
    {
      ret = 0;
      if ( checkTrackStatus(track, dword_7D84C) )
        ret = isEntryAvailble(&track_status_list, level + 4 * track - 1);
    }
  }
  return ret;
}

Ну, эм, диагноз поставлен, а что делать будем? Можно было просто запатчить checkTrackPassedLevel, но тогда в доступности будут все треки и режимы сложности вне зависимости от прохождения. Такой вариант мне показался слишком грубым даже для личного пользования, поэтому перефекционист во мне полез искать инициализатор. Адекватных XREF'ов на track_status_list не было и уже хотелось взять столь нелюбимый отладчик. В последний момент у меня возникла идея: если дело упирается в сгенерированный хеш в некой таблице, то нечто должно и его туда положить, а там, где хэш, там и статус. Вряд ли разработчик бы стал использовать две разные функции (хотя про копипасту уже всё ясно даже из пары выкладок в этом сообщении) для его расчёта, и я посмотрел XREF loadEntryHash. Угадал, буквально через несколько минут поисков была найдена функция с вот таким содержимым:

int *__fastcall sub_xxxx(int track)
{
  int *result; // r0@1

  result = inRange(track, 0, 127);
  if ( result )
  {
    performLoadHashForTrack(track);
    sub_36EC0(track, 1);
    result = dword_7D84C;
    unk_7D860[0] |= 1u;
  }
  return result;
}

По-моему и без переименования понятно, что это своего рода открывалка, вызываемая после покупки/прохождения трека. Во всяком случае эти единички просто кричали об этом, да и -[MyStoreObserver complete_sub:] выше по XREF'ам со мной согласился :) Дело техники: вставить вызов этой функции в какое-нибудь удобное место, пожалуй, впервые мне серьёзно пригодился ассемблер. Простейшая проверка на id трека прямо в checkTrackStatus сделала своё дело, и всё стало совсем хорошо.

А именно

Оживление старого кода или как сделать хорошо приложению, которому плохо - 7

Оживление старого кода или как сделать хорошо приложению, которому плохо - 8

Вместо заключения

Очевидно, что столь мелкие изменения едва ли на что-то претендуют. Я знаю, что бывают куда более требовательные и затратные ситуации. Да даже здесь, например, можно было добавить русский язык (текстовые данные хранятся в с виду простеньком формате с кодировкой UTF-16 LE, а графические — вообще в PNG) или собственные треки с динамической загрузкой из библиотеки iTunes. Тем более, в случае с последним в ресурсах игры имелись не только бинарные структуры, но и исходные файлы для их получения.

Пример такого файла

/******************************************************************************
 * wav-file  : jupiter.mp3
 * midi-file : jupiter.mid
 * create at 2012/8/17 20:54
 ******************************************************************************/

//////////////// ヘッダ情報 ////////////////
ST_CHDATA_HEAD	s_chdata_head = {
	"OKCH",		// 固定値"OKCH"
	7 ,			// メジャーバージョン値(引き継ぎ不可更新) 1~
	0 , 		//マイナーバージョン値(引き継ぎ可更新)   0~
	294.40034f,		// 曲尺 [秒]
	625,		// オブジェクト総数
	154.00015f,		// 初期スクロールスピード[dot / sec]
	154.00015f,		// 初期BPM [beat / minutes]
	E_HAKU_2_4,	// 初期拍子
	0,			// (パディング用ダミー)
	0,			// (パディング用ダミー)
	0,			// (パディング用ダミー)
	0,			// (パディング用ダミー)
	0,			// (パディング用ダミー)
	0,			// (パディング用ダミー)
	0,			// (パディング用ダミー)
	0,			// (パディング用ダミー)
	0,			// (パディング用ダミー)
};


//////////////// 本体データ ////////////////


ST_CHOBJ_HAKU	s_chdata_main_0000[] = {
    {E_CHOBJ_HAKU , 16 , 0.00000f , 0.00000f , E_HAKU_2_4},
									};
ST_CHOBJ_BPM	s_chdata_main_0001[] = {
    {E_CHOBJ_BPM , 20 , 0.00000f , 0.00000f , 120.00000f , 500000},
									};
…

Можно, но у каждого увлечения есть предел как по времени, так и по фантазии, и мне было достаточно нескольких затраченных часов, которые без фанатизма привели к вполне сопоставимому результату. Цель данной статьи — отвадить людей бояться мобильных платформ, которые хоть и имеют множество ограничений, ну не запишем мы динамически байты в TEXT, особо ничем не отличаются от больших братьев. Не стесняйтесь вникать в чужой код, это не так сложно, а порой достаточно увлекательно (хотя в отличие от недавнего цикла статей про NFS в моём тексте это едва чувствуется).

P.S. Спасибо, что добрались до конца.
P.P.S. Полагаю, дистрибутивы выкладывать до конца продаж не стоит из легальных соображений, да и зачем, для кого-то это прекрасная возможность попрактиковаться.

Disclaimer: Эта статья ни в коем случае не призывает к нарушениям лицензионных соглашений, взлому или недобросовестному пользованию ПО. Её текст представлен сугубо в обучающих и ознакомительных целях.

Автор: vit9696

Источник

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


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