Содержание статьи
Небольшой вводный разбор по реверсу Unity‑игр на il2cpp для тех, кто только подступается к теме. Разбираю на живом примере: беру оффлайновую гонку Traffic Racer (v4.0), снимаю дамп адресов, нахожу переменную денег и патчу её тремя инструкциями, после чего в игре становится неприлично богато. Игра одиночная и оффлайновая, так что всё это чистое локальное ковыряние в образовательных целях, разбираемся как оно устроено под капотом.
Теории по минимуму, зато подробно про нативную часть: как читать адреса из дампа, что такое RVA/Offset/VA и откуда берётся смещение, как собрать INT_MAX парой инструкций и чем патч под arm64-v8a отличается от armeabi‑v7a. Весь разбор это три действия: дамп адресов, поиск переменной, патч.
1. Что такое il2cpp
Старые Unity‑игры собирались под Mono: C# превращался в обычный.NET‑байткод, и APK открывался в dnSpy как книга. il2cpp это другое. Unity переводит C# в C++, а потом компилятор платформы собирает из этого честный нативный код под arm64. Вся логика уходит в libil2cpp.so, а остатки имён в global-metadata.dat.
Для нас это значит простое: в.so нет ни одного читаемого имени, только машинные инструкции по адресам. Откроешь вслепую, увидишь тысячи функций sub_1768E74 без подписей. Поэтому реверс il2cpp всегда начинается с восстановления связи имя ↔ адрес. Этим и занимается дампер.
2. Дамп: что он выдаёт
Дамп я снимаю своим инструментом, это часть моего no‑root тулкита Alter. Он проходит по global-metadata.dat и таблицам внутри.so, сшивает их и выдаёт dump.cs. Это не исходники, это карта: псевдо‑C#, где у каждого метода в комментарии проставлен его адрес. Тело пустое, логику дампер не декомпилирует, он даёт точку входа и подпись:
// RVA: 0x1768E74 Offset: 0x1764E74 VA: 0x1768E74 public static object getTotalMoney() { }
Дальше идёшь по адресу в дизассемблер и смотришь инструкции. Игровая логика живёт в сборке Assembly-CSharp, туда и смотрим первым делом.
3. Три адреса в строке: RVA, Offset, VA
Момент, который новички пропускают, а потом не понимают, почему патч молчит. Три числа это не дубли одного адреса.
RVA это адрес функции в памяти относительно базы модуля. VA это виртуальный адрес, в этом образе совпадает с RVA. Offset это смещение байтов функции внутри файла.so на диске.
RVA и Offset не равны:
RVA: 0x1768E74 Offset: 0x1764E74
Разница ровно 0x4000 (16 КБ). Когда система грузит ELF в память, сегменты раскладываются с выравниванием по страницам, и между тем, как байты лежат в файле, и тем, где они оказались в памяти, появляется зазор. Здесь он постоянный, потому что код в одном сегменте.
Правило, из‑за которого всё и ломается у новичков: статический патч по файлу пишем по Offset, а рантайм‑хук или точку останова ставим по RVA + база модуля. Перепутал, промахнулся на 0×4000, попал в середину чужой функции.
Зазор не всегда один на весь файл: в крупных либах сегментов несколько, у каждого своя дельта. Не заучивай 0×4000, бери Offset прямо из дампа.
4. Ищем деньги
Грепаем dump.cs по money, coins, gold, cash. Попадаем в класс сохранений:
// 0x1768C60 saveTotalMoney(money, writeToDisk) // 0x1768E18 increaseTotalMoney(moneyToAdd) // 0x1768E74 getTotalMoney()
Три метода, три подхода. saveTotalMoney пишет значение, патчить запись плохо: сработает только при сохранении. increaseTotalMoney прибавляет, сработает только когда что‑то заработал. getTotalMoney это геттер, его зовут каждый раз, когда игре нужен баланс: нарисовать цифру, проверить хватает ли на покупку, обновить магазин.
Геттер это бутылочное горлышко чтения. Патчим одну точку, а остальной код честно спрашивает «сколько денег» и получает «очень много».
Тип в дампе object, хотя это явно число: дампер не всегда уверенно восстанавливает тип. На нативном уровне там обычный 32-битный int в регистре, и патч это подтвердил.
5. Собираем 0×7FFFFFFF
Идём по Offset 0x1764E74, переписываем начало getTotalMoney:
movz w0, 0x7fff, lsl 16 movk w0, 0xffff ret
Три факта про arm64.
w0 это младшая 32-битная половина x0, регистра результата. Функция отдаёт int, значит ответ лежит в w0.
Почему две инструкции: в arm64 нельзя загрузить произвольное 32-битное число одной командой, mov берёт максимум 16 бит. movz обнуляет регистр и кладёт 0x7FFF со сдвигом на 16: w0 = 0x7FFF0000. movk не трогает остальное и вписывает 0xFFFF в младшие биты: w0 = 0x7FFFFFFF. ret выходит, не дав исполниться настоящему коду.
Почему именно 0x7FFFFFFF: это INT_MAX, максимум знакового int32, 2 147 483 647. Баланс почти всегда хранится как signed int32. Вернёшь на единицу больше, 0x80000000, старший бит это знак, число мгновенно превращается в минус два миллиарда и часто вылет. INT_MAX это ровно потолок до переполнения. Универсальная отмычка для любой знаковой 32-битной переменной.
Те же инструкции в байтах, которые легли по 0x1764E74 (little‑endian):
E0 FF AF 52 movz w0, 0x7fff, lsl 16 E0 FF 9F 72 movk w0, 0xffff C0 03 5F D6 ret
6. Почему геттер с ret это чисто
Мы не ищем баланс в памяти, не ловим момент покупки, не правим сохранёнку. Просто сделали так, что на вопрос «сколько денег» всегда приходит INT_MAX. UI рисует огромную цифру, проверки на покупку проходят, магазин считает миллиардером. Ранний ret означает, что тело функции вообще не выполняется, так что внутренние проверки внутри геттера тоже не мешают. Одна точка, минимум байтов, ноль побочек.
7. Нюансы и где ломается
Сохранение: патч влияет на чтение в рамках сессии. Если баланс кешируется и отдельно пишется на диск, сохранёнка обновится только когда игра сама вызовет save с большим значением. Обычно это происходит само при первой трате.
Тип и разрядность: INT_MAX выбран под signed int32. Наткнёшься на long (64 бита) или float, арифметика другая. Для 64-битного результата работаешь уже с полным x0, а не с w0.
Античит: пропатченная.so меняет размер и хеш. Проверка подписи, серверная валидация баланса или PairIP‑подобная защита спалят голый патч. Это уже следующий уровень.
Адрес уедет: 0x1768E74 это адрес этой версии игры. Вышло обновление, раскладка сдвинулась, адрес сменился. На новой версии не ищешь старый адрес, а заново грепаешь getTotalMoney.
8. А если архитектура другая: armeabi‑v7a и прочие
Внутри APK нативные либы лежат в lib/<abi>/, под каждую архитектуру своя: arm64-v8a (64 бита, сейчас основная), armeabi-v7a (32 бита, для старых аппаратов), иногда x86 и x86_64 (эмуляторы). В каждой папке своя libil2cpp.so.
Главное: устройство грузит только одну из них. 64-битный телефон при наличии arm64-v8a берёт её, а v7a остаётся мёртвым грузом. Отсюда частая причина «я всё сделал, а не работает»: пропатчил не ту либу.
Это физически разные.so, а значит у каждой свой дамп и свои адреса. Адрес из arm64-дампа в v7a‑либе не указывает никуда.
Другой набор инструкций. Мы делали AArch64: w0/x0, 4 байта на инструкцию, movz/movk, возврат через ret. armeabi‑v7a это AArch32: результат в r0, константу INT_MAX собирают парой movw/movt, возврат через bx lr. Тот же патч на v7a:
movw r0, #0xffff movt r0, #0x7fff bx lr
На совсем старых ядрах без movw/movt тот же результат даёт одна mvn r0, #0x80000000, потому что 0×7FFFFFFF это побитовая инверсия 0×80000000.
Засада именно v7a: режим Thumb. Инструкции бывают либо ARM (всегда по 4 байта), либо Thumb (2 или 4 байта вперемешку). У Thumb‑функции младший бит адреса выставлен в единицу, это и есть признак режима. Не учёл, пишешь по 4 байта как в ARM, и раскладка поехала. В arm64 такого нет, там всегда ровно 4 байта.
Логика патча для всех одна (вернуть INT_MAX и выйти), а конкретные инструкции и байты у каждой архитектуры свои. Скопировать 12 байт из arm64 в v7a нельзя, будет мусор. Определись, какую ABI грузит устройство (на всём свежее примерно семи лет это arm64-v8a), и дампь с патчем именно её. Нужен мод на любой телефон, включая старьё, патчишь обе либы по отдельности.
9. Итог
Весь «взлом» это три честных шага. Дампер превратил немую libil2cpp.so в карту с именами и адресами. По карте нашли бутылочное горлышко чтения денег, getTotalMoney. Тремя инструкциями заставили его отдавать INT_MAX, собрав константу из movz и movk и закрыв досрочным ret.
Магии здесь ноль, одна внимательность: не перепутать RVA с Offset, выбрать геттер, а не сеттер, взять правильную константу под разрядность. Освоишь на деньгах, дальше точно так же патчится энергия, жизни, разблокировка уровней. Карта на руках, осталось читать её без спешки.
Ссылки
-
Инструмент, которым снимаю дампы: Alter, мой no‑root тулкит для модинга.
-
Игра из примера: Traffic Racer, версия 4.0.
