Как стать автором
Обновить

Взломать мобильную онлайн игру? Легко!.

Время на прочтение 24 мин
Количество просмотров 636K
Привет, хабр! Сегодня я расскажу вам о том, с чем вы можете столкнуться, если вдруг решите залезть в дебри чужого приложения на Android (в данном случае, онлайн игры). Приключения с просмотром Java-классов в .dex, изучение оп-кодов Dalvik и даже программирование в двоичном коде. Но обо всем по порядку.

Под катом трафик на ~800кб, 293 из которых – скриншоты кода (!)

Эта статья написана исключительно в ознакомительных целях. Автор не несёт никакой ответственности за любые действия пользователей, прочитавших статью. Любые совпадения в статье случайны.

Однажды, дождливым летним вечером мы с моей девушкой искали чем себя занять. Фильм смотреть не хотелось, но и вылезать из постели желания не было. Выбор пал на мобильную игрушку. Основных требований к игре было не слишком много:

  • должна поддерживать iOS и Android;
  • должна иметь единый сервер для обеих платформ;
  • должна быть совместная игра, чтобы был смысл играть вдвоем.


Таким образом, мы нашли игру из топа Google Play. Чтобы не раскрывать имен, назовем ее игрой N. Правда, как оказалось позже, третий пункт из списка выше там реализован чуть более, чем никак.

Ниже я спрятал под спойлер краткое описание игры, читать его необязательно, но для полноты картины будет полезно.

Описание
Игра основывается на монстрах. Вы их призываете, качаете их и их спеллы, надеваете им руны, которые апают различные статы. У монстров, помимо привычных уровней (levels), существует еще и градация по звездам:

1 star – max level 15
2 star – max level 20
3 start – max level 25

и так далее до 6 звезд и максимального уровня 40. Достигнув максимального уровня, вы можете поднять монстру количество звезд, при этом его уровень сбросится до первого. Этот процесс в игре называется «Evolve». Чтобы это сделать, вам придется «съесть» других определенных монстров, например:

для эволва одного монстра 2s -> 3s, вам надо съесть 2 уже существующих монстра 2s.
4s -> 5s – надо поглотить 4 монстра 4s
5s -> 6s – 5 монстров 5s – это очень трудозатратно по меркам игры.

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

  1. выбить в локации (максимально 3s, шанс довольно мал)
  2. свитки призыва 1-3 (1s-3s, соответственно. 95%, что с призыва вы получите 1s или 2s. Тут стоит уточнить, что монстры 1s-2s – шлак, и в 99% случаях идут в расход, т.к. по статам сильно проигрывают. Падают свитки очень часто, в день без особого задротства можно получать по 20-30 штук);
  3. свитки призыва 3-5 (~90-95%, что вы получите 3s, 4s падают редко, 5s монстры мне никогда не падали с этих свитков. Свитки можно в неограниченных количествах покупать за красные кристаллы. Падают очень редко)


Теперь о валюте игры:

Энергия – нужна для походов в PvE локации и данжи. Расход – от 3 до 8 в зависимости от локации. Копится по одной в 5 минут, часто падает прямо с убитых мобов. Существует потолок энергии, который увеличивается с уровнем игрока (не путать с уровнем монстров) и с помощью специального здания.

Арена энергия – используется для походов на арену, «PvP». О том, почему PvP в кавычках, вы узнаете чуть ниже. 1 поход на арену тратит 1 единицу арена энергии, максимум – 10, накапливается по одной раз в полчаса.

Синие кристаллы – основная валюта игры. На нее покупаются вещи из магазина, большинство зданий. Накапливается различными зданиями, падает с убитых мобов, дают в качестве награды за задания.

Красные кристаллы – второстепенная валюта, которую можно купить за реальные деньги. Их можно тратить на те самые свитки 3-5, на обновление энергии и арена энергии и покупать за них синие кристаллы. Очень редко они падают с убитых мобов или на арене. В день их можно заработать около 30-40, к слову, один свиток 3-5 стоит 75.

Очки славы – валюта, которую дают за победу на арене. За нее покупается очень много интересных зданий и предметов. Эту валюту нельзя приобрести за какую-либо другую или за донат.

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

Система боя напоминает Final Fantasy 7-10 или, если хотите, HoMM – пошаговый бой, на выбор 2-4 спелла. В подземельях от 3 до 10 уровней (чаще всего 3 или 5), на каждом уровне стоит пачка мобов, убиваете – проходите дальше, не убиваете – получаете все то, что успели заработать (кристаллы, энергия, опыт).

По поводу «PvP» и «совместной игры». Как оказалось, возможности играть вместе с человеком или против человека у вас нет. Играешь либо сам, либо на «автоатаке» и всегда против компьютера. Поэтому PvP тут довольно скучное. Заключается оно в следующем: каждый игрок выставляет на деф 4 монстра и покупает башни для защиты. Попадая на арену, вы деретесь против ИИ, который довольно глуп. Чтобы уравновесить это, через некоторое время по вам начинают палить те самые башни, с каждым разом все чаще и чаще.


Я – программист-удаленщик, поэтому работаю дома. А дома всегда хороший wi-fi, и я особо не задумывался, как и когда игра взаимодействует с сервером.

До тех пор, пока я не решил зайти в игру с мобильного интернета. Пробежав все уровни в очередном подземелье, я получил сообщение «Network connection is delayed. Resend Battle Results? (If Battle Results are not sent, the battle will count as loss.)». После того как я нажал на кнопку «Yes», результаты все же ушли на сервер.

Если вы прочитали описание игры, то и для вас многое встало на свои места: почему нет нормального PvP или игры вдвоем – игра связывается с сервером редко, скорее всего по HTTP, никаких сокетов. И самое главное — судя по всему, в этой игре клиент высчитывает результаты боя, а сервер их только получает.

Сразу же поэкспериментировав с отключением интернета в различные моменты игры, я выяснил следующее:

  • при заходе в локацию вы отправляете запрос на сервер. Если он проходит, и вы получили ответ – начинается загрузка в локацию;
  • сервер не контролирует ваши перемещения между уровнями локации;
  • когда вы убиваете финальный пак мобов, отправляется еще один запрос. В ответе приходит ваш лут.
  • если вы сможете отправить результаты боя, но не сможете получить ответ – вам покажут то же самое сообщение – «Network connection is delayed...». Но если вы попытаетесь сделать это еще раз (и запрос-ответ пройдут), вы получите сообщение «Can’t find match data». Это наводит на мысли, что при загрузке в локацию вам в ответе отправляют id вашего боя, который в конце подземелья используется для отправки его результатов.
  • как я говорил в описании, с мобов падают не только синие кристаллы. Довольно часто падает энергия и изредка красные кристаллы, причем вы видите, что и сколько упало при убийстве моба. И вот тут возникает мысль: неужели клиент решает, сколько и чего упадет, а потом отправляет на сервер? Если так, то отправив «правильный» запрос, мы не только сможем «выиграть» подземелье, которое нам не по зубам, но еще и забрать с него пару-тройку сотен красных кристаллов и энергии.
  • при всем этом, можно не опасаться за дальнейшее прохождение отряда по подземелью. Закончив прохождение (так или иначе), мы просто получим сообщение «Can’t find match data», результаты-то уже отправлены.


Идея 1. Подделываем запрос


Самая логичная идея, которая может прийти после описанного выше.
Для того, чтобы подделать запрос, нам нужен оригинал. Вариантов мониторинга трафика много, я выбрал самый простой для себя – пустить трафик через ноут и смотреть на все это с помощью WireShark.

Инструкции для превращения компьютера в точку доступа можно найти вот тут.
Чтобы было меньше лишнего «шума» в логах, закрываем все приложения и отключаем синхронизацию.
Во время загрузки игра подгружает информацию об акциях, партнерах, друзьях из фейсбука – в общем, трафика много, и он идет на разные сервера, поэтому нам не интересен. Заходим в локацию:

Запрос
POST /api/gateway.php HTTP/1.1
User-Agent: Dalvik/1.6.0 (***)
Host: ***
Connection: Keep-Alive
Accept-Encoding: gzip
Content-Type: application/x-www-form-urlencoded
Content-Length: 556

hEuirYJfxnRGR7vITBb1x/3KpY+0DZmSWBLuxNnJn+Vqdb2eg7X+TKBKgcldJEY1A59yw8ocPnyD/A+Xec5uLtk9IEojAmPTwDiJDNu6BcRKXQpCGIildUlzpBxCSSp0PU8OgSaXNqlP+aGrXnHGoZXKVREMPRAXZPwlOGD6XIemvS5wkWrv+4GBPhO3v21CEfify4wO0x6GWUswX55TbAiLbT4Lr3C4TKXJyJQ8eujxC7Nd0OFEK8d92KbamPEFocxJotYTNuLa6qwVfdpgvaoDCCeKGXvu+z3ZZCjaruh2XMHtg6MRU0ywpB7d5qR9b4JOYRGwe2z9WHXKZesSwl7S7HsLgKtjLKz9oIRJAiTgRIWGn2WM9WaMtFSn9fXyEj31lS3JWtJ4hkgntVYh4qC6DvnTt0XJTZBXWm93Ki2FVASHDkr+tTPNZm69uBWXQ8/a768aoaTO+v+1o/ymQ8TQBDeMt79XZ8w3kGYrXC9PZh8IacSA3KPqzVQxlFeXnyzuTuqTzef9ZB9B2yc0VGD+xeIwgswiyNOjTBYqKmI=


Ответ
HTTP/1.1 200 OK
Server: nginx
Date: Sat, 19 Jul 2014 15:04:16 GMT
Content-Type: application/octet-stream; charset=utf-8
Content-Length: 1048
Connection: close
X-Powered-By: PHP/5.4.11
Cache-Control: no-cache, must-revalidate
Pragma: no-cache

vyb3BQeGoRphqgM/QCIBv0m2Ms5lBf3iUEKauCdpgRCXWjyITAUa+t1w36VzBxlkZlYF+jH+Lmw+hmPbLtKj2pBidUrZ0CO44AsQ2erYG+m0n2WK8SY5m7Ioar1gNtim67rwpZQeGzP+dUvLE5T3Q/3iqua8dHNAhkrsrYyHYevbh535JODq0Qwd5y8ZQtsYQnl+Vtc2YeL6O3hvUc6UEKNHmc79saLz+PhH64nRA7xih+OLip3FGrgJdgDTTNBn2xBxyRmTeuoD1LqwKa/eWfDkwoA9IOMoc9NozoRyiUDRZjaya0VZH+k6DR4lRGMrDJtmBwjIWfAIbEg5K3xUEPtJIvU7QryaDTqZT+FOgQRr1sZ5MFzoDzJ+titAfbKNKZxF7QUDbAzi+o3j6pzZkioPbWMotsUWKUM/IOrfKcRGIV1yd0w8sGHhh9m7wDwbFA1/RCpifJBV528TZ9Ql3P3gQGvvqnGf5n88BpKXo3IF5+T3Fr9QXijR4kJ5H9MxltRCLB3DU41XZ9bQuP9o7IvWkt+TGvByAW4bI3JkLz855R7AzSoyrVfYPaguKH7yvJX6cQkm5GDqYRCwFA+mRQmHg6AEYbBS2M+DrZ1U/UvdB5bMY+sLDEPE6MLVeRadFjNzFlKkQxWUlGmzD06ia9kCOoyC9du4bCRTziH03qNK4m1tgwqw2jsUtu8I+HTK30YibHmrUWdqtfyIm0EqiXKI8ZsZYxsG1qeqYQNnQ9HIYxpYRXIETlLBl1Fs3bY1tXoUxetaNDVhxW/PMvFCLSJJCNJ6V8iJRieA0o23hqAKsJpDRANz8oEJ0vNKubXn7HeXq48UzkStqobE/UotzQ8ocdVBW7MgErF5DzjWLxgMjB3pnKiOFl8pqacD6qFWSywQtcF8xJ2RQMuEefusrrfFi67e5PLSdJW2utMIMAudJyllcOK4wNur6fo18J6zHyjzSklIuPhzPn2XQj+FcgUh1pU0wLKhuWq39PFCi4ekupSLt7j0VSZCoKDmKQO4q1b/SpeA5Bb7lW5TvgRSsg==



С первого взгляда видно, что это Base64. Но вбив текст в первый попавшийся декодер, я получил полную бессмыслицу, хотя ожидал JSON (шучу, это было бы слишком наивно – у приложения 10кк+ загрузок).

We need to go deeper ©

Скачиваем apk игры (вариантов много, я воспользовался этим). APK-файл – это обычный ZIP-архив, в нем находится много всего, но в первую очередь нас интересует файл classes.dex. Это формат Dalvik executable. По сути – скомпилированные Java-классы. Чтобы открыть их, нам понадобятся dex2jar и jd-gui. Первый преобразовывает dex в jar, второй пытается восстановить из jar исходный код.
То, что восстановит jd-gui, выглядит довольно кошмарно и read-only. Вам не стоит пытаться это скомпилировать. Исходники из jd-gui можно сохранить и открыть в вашем любимом редакторе. Я скачал 30-дневный триал IDEA от JetBrains, потому что очень люблю то, как в их продуктах сделан поиск (сам пользуюсь купленными PyCharm и PHPStorm).

Предупреждение для тех, кто предпочтет этот редактор – не выставляйте SDK, вас завалит ошибками.

Из программирования для андроид я знал только азы и не имел особого понятия, с чего мне начать свои поиски. Поэтому я запустил поиск «base64» по проекту, и нашел класс, реализующий Base64 decode и encode. Этому я очень удивился, потому что эти методы не были просто оберткой для библиотечных, а, судя по коду, действительно реализовывали кодирование и декодирование Base64.

Первая мысль, которая мне пришла – создатели написали что-то свое, что похоже на Base64, но кодируется по-другому. Так как код выглядит кошмарно (методы по тысячи строк, goto’s, инструкции в методах сразу после return и прочие радости жизни), то переписать это я бы не смог. Потом я вспомнил, что сервер написан на PHP, и решил не отчаиваться, потому что содержать две собственных реализации Base64 на двух очень разных платформах довольно накладно для разработки. Чуть позже я загуглил Base64 на Java и понял, что в стандартных Java библиотеках (6 и 7 версии) base64 кодирования нет, что окончательно отогнало мои страхи по поводу альтернативной реализации.

Поискав использование этого класса, я вышел на другой – StringEncrypter, который реализовывал несколько методов, но основные – decrypt и encrypt. Бегло просмотрев метод decrypt я понял, что это то, что мне нужно. Данные расшифровывались из base64, прогонялись через AES/CBC/PKCS7Padding и возвращались. Оставалось только найти ключ, который использовался для Cipher и начальный вектор (Initialization vector).

Для этого я начал искать использование этих методов. И обнаружил, что класс StringEncrypter нигде не используется. Это сильно меня удивило, но я подумал, что это огрехи jd-gui.

Я начал поиск по проекту заново, в этот раз я сразу искал Cipher. Результатов было много, и, бродя по ним, я наткнулся на файл, исходный код которого, видимо, восстановить не получилось. Вместо кода висело «INTERNAL ERROR». Запустив поиск этой самой «INTERNAL ERROR» по проекту, я получил 55 результатов. Стало понятно, почему я не мог найти использование некоторых классов. Среди этих файлов нашелся один с интересным названием ActiveUserNetwork.

We need to go deeper ©

Я догадывался, что дальше – только ассемблер. Так и получилось.

У Dalvik VM есть множество оп-кодов, и, на самом деле, smali код довольно user-friendly для чтения, особенно, если вы ковырялись в ассемблере.

В этот раз нам понадобятся smali и baksmali. Backsmali преобразовывает файл classes.dex в папку с исходным кодом, при этом сохраняя иерархию и имена папок и файлов. На первых порах будет гораздо проще разбираться в smali коде, открыв этот же файл java (если, конечно, jd-gui смог его декомпилировать). В интернете хватает ресурсов, где можно найти примеры кода, вот тут, например, показывается, как выглядят массивы в smali и инструкции for/switch.

Но вернемся к нашей игре, а конкретно к заинтересовавшему меня файлу ActiveUserNetwork. Тут нашлось всё – Cipher, методы encrypt и decrypt, Base64, (кстати, тут он использовался из библиотеки android.utils) и даже константа «http://***.com/gateway.php». Да, это не /api/gateway.php, но хоть что-то. Кстати, поиск «api/gateway.php» даже по smali коду не давал ничего, но я не особо расстраивался, потому что видел, что частенько используется StringBuilder.
Подсветки smali кода на хабре нет (будем откровенны, она вообще мало где есть), поэтому большие куски этого кода я буду выкладывать скриншотами.

Итак, метод decrypt:



Пояснения по коду: в первой строчке идет стандартное описание метода: что он принимает и что возвращает. Метод принимает 2 параметра – строку и байт-массив (тут он выглядит как [B). Возвращает байт-массив.
Директива .locals указывает на количество регистров, которые будет использовать метод, не считая своих параметров. Помимо этой директивы, есть похожая, которая называется .registers, она определяет количество регистров, которые используются методом, включая параметры метода. Т.е. в общем виде, .registers = .locals + params. При этом, если вы объявляете количество регистров через директиву .registers, параметры метода попадают в последние регистры. Доступ к регистрам осуществляется через v0, v1, v2 и так далее, к параметрам – p0, p1 и так далее.
Данный метод статический и вызывается без объекта, в противном случае тут было бы 3 параметра, первым из них был бы объект, для которого вызывается метод (this). Следующие две директивы могут отсутствовать, это названия параметров. Директива .annotaions объявляет дополнительную информацию о методе, в данном случае – выбрасываемое исключение. Директива .prologue говорит о том, что дальше идет тело метода.
Учитывая все это, первые 11 строк преобразовываются в одну строчку Java кода:

public static byte[] decrypt(String key, byte[] data) throws Exception {


Заглядывая в таблицу оп-кодов и помня, что в invoke-virtual первым параметром всегда передается сам объект, в отличие от invoke-static, «дословно» переписываем метод на Java:

public static byte[] decrypt(String key, byte[] data) throws Exception {
    String v1 = "AES/CBC/PKCS7Padding";
    Cipher cipher;
    cipher = Cipher.getInstance(v1);
    int v1_1 = 2;
    SecretKeySpec v2 = createSecretKey(key);
    AlgorithmParameterSpec v3 = spec;
    cipher.init(v1_1, v2, v3);
    byte[] v1_2 = cipher.doFinal(data);
    return v1_2;
}


Здесь spec – статическая переменная класса ActiveUserNetwork, она инициализируется в конструкторе класса

.line 78
new-instance v0, Ljavax/crypto/spec/IvParameterSpec;

const/16 v1, 0x10

new-array v1, v1, [B

invoke-direct {v0, v1}, Ljavax/crypto/spec/IvParameterSpec;-><init>([B)V

sput-object v0, Lcom/com2us/module/activeuser/ActiveUserNetwork;->spec:Ljava/security/spec/AlgorithmParameterSpec;


Я заменил этот код конструктора на метод getSpec. Приводим метод decrypt к нормальному виду:

public static byte[] decrypt(String key, byte[] data) throws Exception {
    String alg = "AES/CBC/PKCS7Padding";
    Cipher cipher = Cipher.getInstance(alg);
    SecretKeySpec secretKeySpec = createSecretKey(key);
    cipher.init(2, secretKeySpec, getSpec());
    return cipher.doFinal(data);
}


Итак, надо осталось разобраться с методами createSecretKey и getSpec.

public static AlgorithmParameterSpec getSpec() {
    byte[] v1 = new byte[16];
    return new IvParameterSpec(v1);
}


Это преобразованный код из конструктора. Дело было уже ночью, и число 0x10 мой мозг перевел в десятичную систему как «10». Хорошо, что я решил перепроверить в калькуляторе, а то дальше ждало бы сплошь разочарование :)

Метод createSecretKey (тут, кстати, опечатка в названии, ее мы тоже поправим)



Метод совсем простой, преобразовывается в

public static SecretKeySpec createSecretKey(String key) {
    return new SecretKeySpec(key.getBytes(), "AES");
}


Отлично, осталось только выяснить, что же передается в метод ключом и данными.
За это отвечает метод processNetworkTask, который одновременно и отправляет запрос (с encrypt и Base64 encode), и получает ответ. Метод объемный (1к строк), поэтому выложу только сборку интересующих нас кусков (v18 – объект класса org.apache.http.HttpResponse)



Вкратце:
Берется значение хедера REQ-TIMESTAMP, вызывается метод createHash("MD5", header_value). Из возвращаемой строки получаем подстроку с первого символа по шестнадцатый, и эта подстрока передается в метод decrypt ключом. Данными передается байт-массив из Base64.decode().

Итак, на руках у нас все есть, кроме метода createHash.
smali код:



Этот метод уже потяжелее для восприятия: тут есть и цикл, и исключения, и условия. А ошибаться в криптографических методах нельзя. Одна эта конструкция чего стоит:

const/4 v7, 0x1

new-array v7, v7, [Ljava/lang/Object;

const/4 v8, 0x0

aget-byte v9, v3, v1

invoke-static {v9}, Ljava/lang/Byte;->valueOf(B)Ljava/lang/Byte;

move-result-object v9

aput-object v9, v7, v8


А превращается она в банальное Byte v9 = mdByte[i];
В качестве подсказки: почти всегда, когда вы видите инкремент (add-int/lit8 v1, v1, 0x1) перед goto – это цикл for. Финальный код Java:

public static String createHash(String algorithm, byte[] data) {
    try {
        MessageDigest md = MessageDigest.getInstance("MD5");
        md.update(data);
        byte[] mdByte = md.digest();
        String mdString = "";
        int i = 0;
        int len = mdByte.length;
        for (i = 0; i < len; i++) {
            StringBuilder v5 = new StringBuilder(mdString);
            String v6 = "%02x";
            Byte v9 = mdByte[i];
            v6 = String.format(v6, v9);
            v5.append(v6);
            mdString = v5.toString();
        }
        return mdString;
    } catch (NoSuchAlgorithmException e) {
        return "";
    }
}


Собираем все воедино. Я создал новое приложение и закинул все в MainActivity:

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    String b64 = ""; // Base64 request body
    String req_ts = ""; // REQ-TIMESTAMP header

    byte[] decodeBase64Byte = Base64.decode(b64, 4);
    String hash = createHash("MD5", req_ts.getBytes());
    hash = hash.substring(0, 16);
    try {
        Log.d("MYAPP", "Decrypted: " + new String(decrypt(hash, decodeBase64Byte)));
    } catch (Exception e) {
        Log.d("MYAPP", e.getClass() + "-" + e.getMessage());
    }
}


Теперь вернемся к тому, зачем мы все это делаем. Внимательный читатель мог заметить, что в ни в ответе, ни в запросе, которые были приведены выше, не было хедера REQ-TIMESTAMP. Однако, там и запрос идет к /gateway, а не к /api/gateway. Запросы к /gateway идут во время инициализации приложения. Их всего два, расшифровав их, я получил… ничего. Нет, там были данные об устройстве, MAC-адресе и даже о том, рутован ли планшет. Но ничего стоящего я не получил. Запросы к /api/gateway шли откуда-то еще и никак не были связаны с /gateway.

Пока я разбирался со smali кодом, я сделал еще несколько попыток в разных направлениях, прежде чем пришел к решению разобраться со всеми методами и переписать их на Java.

Попытка 1: не искать то, как генерируется ключ, а просто сделать запрос с ним на свой сервер. Smali код можно изменить и скомпилировать обратно, так что идея была проста – перед методом decrypt делаем запрос на свой сервер, передав ключ как GET параметр, а потом смотрим логи веб-сервера.
В поисках как скомпилировать smali код я нашел apktool. Этот инструмент умеет разбирать apk файл сразу же на smali код, а также собирать все это обратно в apk.

./apktool decode ~/Downloads/***.apk ~/Documents/out/
./apktool build ~/Documents/out/ ~/Downloads/***.new.apk


Но когда вы попытаетесь установить новое приложение, вы получите ошибку:

./adb install -r ~/Downloads/***.new.apk
Failure [INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATE]


На stackoverflow советуют удалить приложение вручную, а потом еще раз установить, но и это меня не спасло. Делаем новый ключ, для этого нам понадобятся keytool и jarsigner (входят в пакет openjdk)

keytool -genkey -keystore ~/debug.keystore -validity 10000 -alias debug
jarsigner -keystore ~/debug.keystore -verbose ~/Downloads/***.new.apk debug


Важное замечание – jarsigner ведет себя по-разному в 6 и 7 версии jdk, и на 7 версии команда ругнется на alias. Я не нашел решения этой проблемы и установил себе дополнительно 6 версию.

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

Попытка 2: если нет желания ковыряться в smali коде (а у меня его не было, я думал, что это задачка на ~5 часов), то можно поступить проще. В своем приложении создаете пустой метод с таким же интерфейсом, что и у того, который хотите скопировать, создаете apk, декомипилируете, копируете тело метода, собираете обратно. Каждая такая итерация отнимает довольно много времени. Поэтому быстрее будет изучить оп-коды. При этом понятно, что открыть исходный код собранного приложения вы уже не сможете.

Эта статья писалась не за один день, и это замечание я пишу через неделю после абзаца выше. Способ описания интерфейса и копирования smali кода в тело метода очень мне помог, когда я так и не смог восстановить исходный код метода генерации ключа. Чтобы упростить себе жизнь и сократить время итерации можно объединить все в одну команду

apktool build ~/myapp/ ~/myapp.apk &&
jarsigner -keystore ~/debug.keystore -verbose ~/myapp.apk debug && adb install -r ~/myapp.apk &&
adb shell am start -n "com.example.myapp/com.example.myapp.MyActivity" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER &&
adb shell logcat MYAPP:D *:S


Идея 2. Делаем супермонстра.


Что ж, если не получается через запрос – пойдем другим путем.
Новые монстры загружаются в игру не через обновление в гуглплей, а через внутреигровой апдейт. Значит, что они хранятся где-то вне apk, и теоретически мы можем их модифицировать.
Долго папку искать не пришлось – /sdcard/Android/data/com.***/files/patch/
Тут нашлись все спрайты и звуки, а самое главное – много файлов с именами монстров и расширением .dat. Открываем hex-редактором и бегло просматриваем – никаких заголовков и строк, за которые бы зацепился взгляд. Я взял первого попавшегося монстра первого уровня, посмотрел его HP и начал искать/заменять эти байты в надежде, что файл не зашифрован. Нашлось 7 совпадений. Последовательно их заменяя, я получил 4 краша игры и 3 «ничего не изменилось». К сожалению, зашифрован.
Но что-то же его должно расшифровывать! Ищем в коде по регэкспу "\.dat\b" (чтобы исключить в результатах методы, начинающиеся с «data»). Находится только файл CommonData.dat. Этот файл прячется в папке /data/data/com.***/ (если в папке /data у вас пусто – вам нужен root-доступ).
Файл зашифрован и имеет размер в 1кб. Понятно, что ничего стоящего там нет, однако, алгоритм шифрования может быть одинаковый. В этот раз код я выкладывать не буду, он занимает около тысячи строк. Самое главное – ключом в decrypt передается строка, основанная на ANDROID_ID. После вскрытия в файле обнаружился MAC-адрес (опять). Испытав тот же алгоритм на файлах монстров, я получил ошибку.
Грусть-тоска.

Привет, ассемблер!


Однако, в той же папке /data/data/com.***/lib/ я наткнулся на .so библиотеки. Я их уже видел в apk и видел их подключение в MainActivity (честно, я все это время очень надеялся на то, что мне не придется их ковырять). Библиотек было две – libgame.so, libcom***.so. Вторая весила очень мало и не несла для меня никакой ценности. Первую я открыл hex-редактором и через полминуты нашел строку «http://***.com/gateway/api.php».

We need to go deeper ©

Надеюсь, вы еще не устали :) Потому что мы начинаем практически с самого начала.
Скажу честно, на этой стадии я потратил около 20-30 часов своего времени. Если вы не знакомы с оп-кодами процессора, регистрами и памятью – вы тоже тут встрянете надолго. Меня спасло только мое упорство и желание доказать, что человек круче какого-то там приложения.
Очень много времени ушло на подбор инструментов и методики. И если оставшаяся статья кому-то сэкономит в дальнейшем несколько часов – это будет замечательно.

Инструментарий.
Ida Pro 6.1+ – основной инструмент для отладки. С версии 6.1 идет в комплекте с файлом android_server и возможностью удаленной отладки андроид приложений.
gdbserver – еще один инструмент для удаленной отладки.
На этом этапе root доступ к устройству обязателен.

Закачиваем оба сервера на устройство:

adb push gdbserver /data/local/tmp
adb push android_server /data/local/tmp


Настраиваем проброс портов на локалхост:

adb forward tcp:5039 tcp:5039
adb forward tcp:23945 tcp:23945


Выставляем нужные права:

adb shell
su
chmod 755 /data/local/tmp/gdbserver
chmod 755 /data/local/tmp/android_server


Основная идея (спасибо этому топику, он сэкономил мне тонну времени):
  1. подключаемся через android_server, находим библиотеку, запоминаем смещение;
  2. загружаем библиотеку в Ida с указанным смещением;
  3. Ida проводит анализ, выставляет имена функций, графики вызовов и даже пытается воспроизвести исходный код;
  4. запускаем приложение на устройстве;
  5. уже проанализированную библиотеку подгружаем в приложение через gdbserver
  6. ставим брекпоинты, анализируем. Если приложение падает – goto 4.


Почему используются два сервера для удаленной отладки? android_server может «красиво» показывать подгруженные библиотеки и смещение для нужной библиотеки находится в нем очень быстро. Но брейкпоинты в нем не работают. Зато они отлично работают в gdb. Смещения вроде бы можно искать и через gdb клиент с помощью info sharedlibrary, но у меня это не вышло.

Расположение нужных настроек в Ida
Загрузить файл со смещением можно через File > Open > Выбор файла > Manual load. В этом же окне стоит указать тип процессора.
Выбор удаленного дебаггера: Debugger > Switch debugger.
Настройка подключения для дебаггера: Debugger > Process options. Тут выставляем localhost и порт сервера, который сейчас используется.


Еще один важный момент:
Если приложение упадет – вам (с некоторой вероятностью) придется проделать все шаги с первого. Все дело в технологии ASLR. Чтобы отключить ее, выполните в шелле:

echo 0 > /proc/sys/kernel/randomize_va_space

Внимание! Это сильно влияет на безопасность вашего устройства. Не забудьте запомнить значение этого параметра и вернуть его на место после ваших экспериментов.

Итак, более подробный план действий:
  1. Запускаем android_server:

    adb shell
    su
    /data/local/tmp/andoid_server

  2. Открываем Ida без загрузки чего-либо, заходим в Debugger > Attach to > Remote ARM/Android Debugger.
  3. Выбираем из списка нужное приложение.
  4. Ищем нашу библиотеку (вариантов много, но быстрее всего у меня получалось найти ее глазами – прокруткой и просмотром. Поиск работает медленно, jump к метке работает не всегда.
  5. Запоминаем смещение библиотеки (5D699000 в моем случае).
  6. Отключаемся от процесса (Debugger > Detach from process). Процесс на устройстве остается жив.
  7. Открываем файл, выставляем нужное смещение (0:5D699000 в моем случае).
  8. Пока Ida анализирует файл, убиваем android_server и подготавливаем gdbserver:

    adb shell
    su
    /data/local/tmp/gdbserver --attach :5039 1234

    (опция --attach говорит серверу, что надо присоединяться к уже запущенному процессу, :5039 – номер порта, 1234 – pid процесса, который можно посмотреть привычным ps в шелле).
  9. Меняем настройки Ida на работу с gdbserver.
  10. Подключаемся к процессу.


Теперь, если все сделано правильно, код библиотеки, который проанализировала Ida, встанет на нужное место.

Изучив перечень функций из библиотеки, я нашел очень занятную группу, которая отвечала за взаимодействие с сервером. Вот некоторые из функций:
  • battleArenaStart
  • battleArenaResult
  • battleDungeonStart
  • battleDungeonList
  • battleDungeonResult

Все эти функции формировали JSON, заполняли его и вызывали функцию sub_5D839994. Эта функция – краеугольный камень общения с сервером. Она шифрует строки, запаковывает в base64 и отправляет данные.
Возиться с криптографией было тяжело даже в более или менее понятном smali коде. Тут же это был сущий ад. Хотя я и нашел ключ, который используется для шифрования, я все равно заблудился в поисках IV.
Но раз мы уже висим на приложении дебаггером, то нам достаточно перехватить строку перед шифровкой, изменить ее и продолжить работу приложения. И на сервер уйдет модифицированная строка.
Как я и говорил, эта функция (sub_5D839994) используется везде (или почти везде), где есть общение с сервером, поэтому ставить на нее брейкпоинт почти бесполезно, он сработает сразу же, так как в игре есть чат.
Немного провозившись, я нашел решение. Я ставил 2 брейкпоинта: один перед вызовом sub_5D839994 в нужной функции, второй, в отключенном виде, – непосредственно перед функцией AESConvertEncode, которая отвечает за шифрование строки.

Итак, момент истины, заходим на арену против сильного противника. С разгромом ему проигрываем, приложение замирает – сработал брейкпоинт. Отключаем его, включаем брейкпоинт перед AESConvertEncode, запускаем приложение и… Ура! Дебаггер перехватил управление прямо перед шифрованием. Содержимое регистра R0:

{
    "command": "BattleArenaResult",
    "id": 1234567,
    "session_key": "***",
    "win": 2,
    "unit_status": [
        {"unit_id": 1,"result": 2},
        {"unit_id": 2,"result": 1},
        {"unit_id": 3,"result": 1},
        {"unit_id": 4,"result": 1}
    ],
    "unit_list": [
        {"unit_id": 123456781, "pos_id": 1},
        {"unit_id": 123456782, "pos_id": 2},
        {"unit_id": 123456783, "pos_id": 3},
        {"unit_id": 123456784, "pos_id": 4}
    ],
    "position": {"island_id": 1, "pos_x": 14, "pos_y": 22}
}


Догадка, в которой я был уверен на 99.(9)%, подтвердилась – в этой игре клиент решает, выигран ли бой, а сервер лишь получает результат.
Я переключил брейкпоинты обратно и, на всякий случай, не стал ничего менять – здесь присутствовали непонятные мне данные, в частности unit_status. В том бою я успел убить одного из четырех монстров противника, поэтому догадывался, что связка id-result отправляла на сервер данные об убийстве того или иного монстра (2 – мертвый, 1 – живой). Чуть позже я понял, что это верно. Возможно, эти данные используются как дополнительная проверка на победу и поиск злоумышленников, но, видимо, главная их цель – лут с монстров. Если помните, в самом начале я писал следующее:
как я говорил в описании, с мобов падают не только синие кристаллы. Довольно часто падает энергия и изредка красные кристаллы, причем вы видите, что и сколько упало при убийстве моба. И вот тут возникает мысль: неужели клиент решает, сколько и чего упадет, а потом отправляет на сервер? Если так, то отправив «правильный» запрос, мы не только сможем «выиграть» подземелье, которое нам не по зубам, но еще и забрать с него пару-тройку сотен красных кристаллов и энергии.

Получается, что при входе в подземелье, сервер создает список монстров, привязывает к каждому лут и отправляет данные на клиент. Поэтому вы сразу же видите в игре, что и в каком количестве упало – клиент просто отображает ранее полученную информацию. Позже, он отправляет не материалы, которые вы заработали, а только статус монстров – мертвый или живой (поясню, зачем это надо в случае победы: в игре есть локации, которые можно пройти не убивая всех монстров на уровне, например, босс + два монстра. В этом случае вам достаточно убить только босса и вы пройдете дальше). Сервер получает эту информацию, сравнивает со своей таблицей лута и присваивает вам опыт/ресурсы в зависимости от убитых монстров. Как раз для получения опыта вы отправляете список ваших монстров, участвующих в сражении.

По идее, все то, что мы делаем с помощью дебаггера, для игры выглядит вполне законно – победа, все монстры мертвы. Банить нас вроде бы не за что. Однако, чуть позже я выяснил один неприятный момент. Как я говорил, локации делятся на уровни – три или больше. На каждом уровне стоят свои мобы. Для примера представим такую ситуацию:

Stage 1 – 3 монстра (мы их убили, прошли дальше)
Stage 2 – 4 монстра (тут мы убили одного и погибли)
Stage 3 – 3 монстра (в этот уровень мы не зашли, так как погибли раньше)
Теперь, если мы перехватим JSON перед шифрованием, мы получим примерно такие результаты:

"unit_status": [
    // Stage 1
    {"unit_id": 1,"result": 2},
    {"unit_id": 2,"result": 2},
    {"unit_id": 3,"result": 2},

    // Stage 2
    {"unit_id": 4,"result": 2},
    {"unit_id": 5,"result": 1},
    {"unit_id": 6,"result": 1},
    {"unit_id": 7,"result": 1}
]


Как видите, из-за того, что мы не дошли до третьего уровня, мы не загрузили данные о монстрах на нем. Даже если мы изменим запрос на «win» и статусы существующих монстров на «dead», мы все равно не сможем добраться до трех оставшихся мобах на третьем уровне. И для сервера они живы, хоть в ответе и приходит, что мы одержали победу. Теоретически на этом могут поймать и забанить. Но я пока жив :)
В то же время, на арене только один уровень, и запрос с нее выглядит правдивым на 100%.

Теперь все стало ясно. Хоть и получить неограниченное количество красных кристаллов мы не сможем, зато по-прежнему мы можем выигрывать любое сражение, какое пожелаем. Правда, для этого приходится держать устройство подключенным к компьютеру, постоянно переключать брейкпоинты и вручную переделывать запрос перед шифрованием. Это не очень удобно. По идее, нам надо не слишком много: всегда записывать в «win» единицу, а в «result» двойку для всех мобов.

Ищем код, который добавляет «win» в JSON. Немного повозившись с брейкпоинтами, я нашел этот кусок.

MOVS   R0, R6
BLX     __floatsidf
BL      cJSON_CreateNumber
LDR     R1, =(unk_5D8C3240 – 0x5D84666E) ; "win"
MOVS    R2, R0
LDR     R0, [SP,#0x30+var_2C]
ADD     R1, PC
BL      cJSON_AddItemToObject


Судя по всему, в R6 находится то, что нам нужно – значение победы. Теперь нам надо изменить эту инструкцию на автоматическое присваивание победы.
Тут стоит отметить, что в игре есть возможность в любой момент выйти из боя, при этом засчитывается поражение. То есть у нас всегда есть возможность получить поражение, и мы можем рассчитывать, что в R6 всегда 2. Но мне было не очень понятно, будет ли там просто 2 (0x2) или «2» (0x32 ASCII).
Дело за малым – изменить инструкцию. К сожалению, Ida не дает изменять ASM код, поэтому придется менять биты инструкций.

Вы хотели научиться программировать на нулях и единицах? Их есть у меня!

Действительно, я получал какое-то особое удовольствие, программируя двумя кнопками и изучая инструкции. Вот тут и тут хорошие материалы и подсказки по инструкциями.
Переходим в hex-редактор, смотрим как оно все выглядит изнутри.
MOVS R0, R6 ; 321С
Переворачиваем инструкцию, получаем 1C32. В бинарном виде:
0001 1100 0011 0000
Как видно, используются двухбайтовые инструкции. Это не ARM (там используются четырехбайтовые), а Thumb или Thumb-2.
Однако, вы не найдете инструкцию MOVS, которая бы выглядела так. На самом деле, инструкция выглядит как ADDS R0, R6, #0. Во время анализа Ida преобразовывает инструкции в более удобный вид, из-за чего могут возникнуть небольшие проблемы с редактированием.

0001110 000 110 000
ADDS    Imm Rn  Rd


ADDS – эта часть постоянна для этой инструкции
Imm – immediate value. Непосредственное значение, которое мы хотим прибавить.
Rn – регистр, к которому мы будем прибавлять.
Rd – destination register. Регистр, который будет хранить сумму после исполнения инструкции.

Итак, нам надо заменить эту инструкцию. Из-за того, что я не знал, какая именно двойка используется, я выбрал следующий вариант:
SUBS R0, R6, #1
Инструкция SUBS вычтет из R6 единицу и положит результат в R0. В итоге, какая бы двойка ни была в R6, в R0 будет находиться нужная единица.
Переводим в бинарный формат:

0001111 001 110 000
SUBS    Imm Rn  Rd


В шестнадцатеричном виде – 1E70. Переворачиваем – 701E. Теперь заменяем эту инструкцию в библиотеке.
Было:
MOVS R0, R6
Стало:
SUBS R0, R6, #1

На всякий случай проверяем с помощью брейкпоинта регистры на входе в функцию AESConvertEncode и убеждаемся, что все верно.

Осталось заменить только инструкцию, отвечающую за присвоение статуса монстрам.
Она находится тут же, чуть ниже.

ADD     R8, PC          ; "unit_id"
MOV     R9, R3
ADD     R9, PC          ; "result"

loc_5D8466E2
BL      cJSON_CreateObject
MOVS    R4, R0
LDMIA   R7!, {R0,R1}
BLX     __floatundidf
BL      cJSON_CreateNumber
MOV     R1, R8
MOVS    R2, R0
MOVS    R0, R4
BL      cJSON_AddItemToObject
LDMIA   R6!, {R0}
BLX     __floatsidf
BL      cJSON_CreateNumber
MOV     R1, R9
MOVS    R2, R0
MOVS    R0, R4
BL      cJSON_AddItemToObject
ADDS    R5, #1
MOV     R0, R10
MOVS    R1, R4
BL      cJSON_AddItemToArray
CMP     R5, R11
BNE     loc_5D8466E2


Здесь идет обход массива в цикле for.

Объяснение
В R11 хранится длина массива, цикл узнается по трем строкам:

ADDS R5, #1       ; инкремент счетчика
CMP  R5, R11      ; сравнение счетчика с длинной массива
BNE  loc_5D8466E2 ; если не равны - в начало цикла



Нужный нам регистр – R6. Инструкция LDMIA считывает один байт из R6, передвигает R6 на один байт дальше и записывает значение полученного байта в R0. Нам же такие сложности не нужны, нам нужно записать в R0 двойку – MOVS R0, #2

00100 000 00000010
MOVS  Rd  Imm


Hex – 2002. Переворачиваем (0220) и заменяем.
Момент истины: отключаем все брейкпоинты, заходим в подземелье…









Profit! Самое главное не забывать, что выходить из боя надо всегда. Если случайно одержите победу — на сервер отправится поражение. Теперь можно даже отключить устройство от компьютера. До тех пор, пока приложение не перезапустится, в его памяти будет висеть измененная библиотека.

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


Взлом этого приложения стал возможен во многом из-за того, что авторы фактически сами раскрыли, что результат боя они считают на клиенте. В то же время, в мобильной онлайн игре надо соблюсти грань между безопасностью и удобством для пользователя. Если игра будет требовать постоянного подключения, ее аудитория убавится. Разработчики постарались усложнить жизнь потенциальным взломщикам: сделали шифрование данных и вынесли основной код игры в shared object. Но меня смущает одно — почему не обфусцированы имена функций? Я не программировал на C/C++ и не знаю, есть ли такая опция в компиляторе. Но если бы все функции назывались "sub_xxxxxxxx", то время, потраченное на взлом приложения, выросло бы в разы. Рад буду услышать ответ в коментариях.

Спасибо тем, кто дочитал до конца.
О грамматических или орфографических ошибках просьба писать в личку.
О логических или грубых технических — лучше в комментарии.
Теги:
Хабы:
+114
Комментарии 29
Комментарии Комментарии 29

Публикации

Истории

Работа

Java разработчик
356 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн