Вводный реверс Unity-игр на IL2CPP: как устроен патч баланса
Реверс Unity-игр, собранных через IL2CPP, заметно отличается от анализа старых проектов на Mono. В Mono C#-код обычно сохранялся в виде .NET-байткода, который можно было относительно легко изучать в декомпиляторе. IL2CPP сначала переводит C# в C++, а затем компилирует его в нативный код под конкретную архитектуру Android. В результате основная логика оказывается в библиотеке libil2cpp.so, а сведения о классах и методах — в файле global-metadata.dat.
Для практического разбора удобно использовать офлайн-игру Traffic Racer версии 4.0. Такой пример позволяет сосредоточиться на устройстве бинарника и не затрагивает серверную экономику или сетевых игроков. Цель эксперимента — понять, как по метаданным найти нужный метод, сопоставить его с машинными инструкциями и изменить возвращаемое значение локальной функции.
Зачем нужен дамп IL2CPP
После компиляции в нативный код библиотека не содержит привычных имён методов в том виде, в котором их видит разработчик. В дизассемблере вместо понятных названий появляются функции вроде sub_1768E74. Чтобы восстановить связь между именем метода и его адресом, применяют дампер. Он анализирует global-metadata.dat и таблицы внутри libil2cpp.so, а затем формирует файл dump.cs.
Такой файл не является исходным кодом и не восстанавливает тело функций. Это карта бинарника: в ней указаны классы, сигнатуры методов и адреса точек входа. Например, запись может выглядеть так: RVA 0x1768E74, Offset 0x1764E74, VA 0x1768E74, после чего следует имя метода getTotalMoney(). Получив адрес, исследователь открывает соответствующий участок в дизассемблере и анализирует инструкции.
RVA, Offset и VA
Три адреса в дампе обозначают разные вещи. RVA — относительный виртуальный адрес функции внутри загруженного модуля. VA — виртуальный адрес, который в данном примере совпадает с RVA. Offset — смещение байтов функции внутри файла на диске. В рассматриваемой библиотеке RVA равен 0x1768E74, а Offset — 0x1764E74; разница составляет 0x4000, или 16 КБ.
Расхождение возникает из-за того, что ELF-файл раскладывается в память сегментами с выравниванием по страницам. Поэтому статический патч в файле нужно применять по Offset, а точку останова или рантайм-хук устанавливать по RVA с учётом базы загруженного модуля. Универсального смещения для всех библиотек нет: в больших ELF-файлах сегментов может быть несколько, поэтому значение следует брать непосредственно из дампа.
Поиск метода, который возвращает баланс
Поиск по dump.cs обычно начинают с ключевых слов money, coins, gold или cash. В примере находятся три подходящих метода: saveTotalMoney, который записывает баланс, increaseTotalMoney, который увеличивает его, и getTotalMoney, возвращающий текущее значение.
Для эксперимента наиболее удобен именно геттер. Игра обращается к нему при отображении баланса, проверке возможности покупки и обновлении магазина. Если функция начнёт возвращать большое значение, остальные части программы будут получать его через обычный вызов. Патч записи сработал бы только во время сохранения, а изменение метода увеличения денег — только при начислении награды.
Возврат максимального значения на arm64
В дампе результат может быть обозначен как object, поскольку инструменты не всегда корректно восстанавливают тип. По используемым инструкциям можно определить, что в данном случае функция возвращает 32-битное целое через регистр w0. Для arm64 значение 0x7FFFFFFF собирается двумя инструкциями: сначала movz w0, 0x7fff, lsl 16 формирует старшую часть, затем movk w0, 0xffff записывает младшие 16 бит. После этого команда ret завершает функцию до выполнения оригинального тела.
Число 0x7FFFFFFF — это максимальное значение знакового 32-битного целого, то есть 2 147 483 647. Использование 0x80000000 могло бы привести к отрицательному числу из-за установленного знакового бита и вызвать переполнение или ошибку в логике игры. В машинном коде последовательность для arm64 занимает 12 байт: E0 FF AF 52, E0 FF 9F 72 и C0 03 5F D6.
Ограничения и разные ABI
Такой патч действует прежде всего на чтение баланса в текущей сессии. Если игра отдельно кэширует значение и сохраняет его на диск, файл сохранения обновится только после того, как приложение само выполнит операцию записи. Кроме того, подход рассчитан на signed int32. Для 64-битного целого потребуется работать с полным регистром x0, а для float или другой структуры понадобится иная логика.
Важно выбрать библиотеку под фактически используемую архитектуру. В APK могут находиться отдельные варианты в каталогах lib/arm64-v8a, lib/armeabi-v7a, lib/x86 и lib/x86_64. У каждой библиотеки собственные адреса и дамп. На современных устройствах при наличии arm64-варианта обычно загружается именно arm64-v8a, поэтому изменение v7a-библиотеки может никак не проявиться.
В armeabi-v7a используются инструкции AArch32: результат возвращается через r0, а константу можно собрать командами movw и movt, завершив функцию через bx lr. Дополнительная сложность этой ABI — режим Thumb, где инструкции имеют длину два или четыре байта. Младший бит адреса указывает на режим Thumb, и его нельзя игнорировать при расчёте смещений.
Что изменится после обновления игры
Адреса из дампа относятся к конкретной версии приложения. После обновления разработчик может изменить компоновку библиотеки, добавить код или полностью перестроить бинарник, поэтому старый RVA перестанет указывать на нужную функцию. В этом случае требуется заново снять дамп, найти актуальный геттер и проверить архитектуру библиотеки.
Проверки подписи, контроль хеша библиотеки, серверная валидация баланса и другие механизмы защиты также могут обнаружить изменённый файл. Поэтому описанный подход следует рассматривать как учебный способ изучения IL2CPP и локальной логики офлайн-приложений, а не как универсальный метод работы с сетевыми играми.
Весь разбор сводится к трём этапам: получить карту методов, найти функцию, которая возвращает нужное значение, и сопоставить её адрес с правильным смещением и архитектурой. На примере баланса хорошо видны ключевые принципы реверса IL2CPP: различие между RVA и Offset, роль геттеров и особенности генерации нативного кода для arm64 и armeabi-v7a.


