Пожвавлення старого коду або як зробити добре додатком, якому погано

Зізнатися, я граю в ігри не частіше, ніж пишу на хабре, але до жанру «ритмічних» завжди мав якусь слабкість. У свій час мені дуже подобалася Audiosurf, пізніше стикався з різними її клонами, Beat Hazard, osu. Ще пізніше натрапив в App Store на Deemo і Duet, від яких отримав чимало приємних хвилин.

Під час нескінченного безсонного вечора, блукаючи в напівдрімоті по різним сайтам, я помітив штучку, яка входила в коло моїх інтересів. Безкоштовне завантаження, знайома компанія, ага, значить буде купа платного контенту. З іншого боку, у що-небудь пограти напевно дадуть за просто так. Зробивши висновок, що напрошується, я скачав гру… і побачив невинно білий екран.

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

Читання опису, а це іноді все ж варто робити, приніс дві новини:
  • сумісність з iOS 9 немає, виникає горезвісний білий екран;
  • гра більше не підтримується і буде видалена з магазину в серпні, причому без можливості твори вбудованих покупок.
Шикарно. Напевно, багато хто після такого б відступили, тим більше, що платформа не x86 так і гра, скажімо, не хітова (хоча механіка мені потім дуже сподобалася). Але у мене засіла особиста образа, і на наступний ранок я вирішив оглянути пацієнта.

Пожвавлення

Першим ділом варто глянути, а не виводить чи що-небудь чудовисько в лог? Заходимо по ssh, відкриваємо моніторинг syslog і запускаємо додаток. Моментально заповнює екран купа повідомлень виду:
bird[1792] <Error>: error setting: <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, попутно одягаючи навушники. Там побачив: є-то все одно нічого :(



Цілком зрозуміло, у файлі повинна ж бути якась структура, а я варварськи узяв і пхнув заглушку. Подальший пошук по імені файлу призводить до двох місцезнаходжень:
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.
Висновок jtooljtool --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" "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.
Ось так



Після оновлення виконуваного файлу на пристрої за зовнішнім виглядом я здогадався, що isUnlock — це «доступний епізод для гри», а «isHave» — це стан покупки. Відкриваю раніше закритий епізод, уясняю, що встав на граблі:


Доведеться подивитися, що всередині. Так як 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

На цьому розповідь можна було б закінчити, якби не одне але:


А де решта 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 зробила свою справу, і все стало зовсім добре.
А саме



Замість висновку

Очевидно, що настільки дрібні зміни чи на щось претендують. Я знаю, що бувають куди більш вимогливі і витратні ситуації. Та навіть тут, наприклад, можна було додати російську мову (текстові дані зберігаються з вигляду простенькому форматі з кодуванням 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.40034 f, // 曲尺 [秒]
625, // オブジェクト総数
154.00015 f, // 初期スクロールスピード[dot / sec]
154.00015 f, // 初期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.00000 f , 0.00000 f , E_HAKU_2_4},
};
ST_CHOBJ_BPM s_chdata_main_0001[] = {
{E_CHOBJ_BPM , 20 , 0.00000 f , 0.00000 f , 120.00000 f , 500000},
};
...

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

P. S. Дякую, що дісталися до кінця.
P. P. S. Гадаю, дистрибутиви викладати до кінця продажів не варто з легальних міркувань, та й навіщо, для кого-то це прекрасна можливість попрактикуватися.

Disclaimer: Ця стаття ні в якому разі не закликає до порушень ліцензійних угод, злому або недобросовісного користування. Її текст подано суто в навчальних і пізнавальних цілях.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.