
Несколько лет назад сеть McDonald проводила промо-акцию, в которой через простую мини-игру можно было выиграть iPhone. Называлась эта игра «FriesHit» и была доступна в мобильном приложении McDonald’s (бельгийская версия). Это просто примитивная тапалка, целью которой являлось занять первую строчку таблицы лидеров, чтобы заполучить заветный iPhone (или Samsung S10, если вы окажетесь на втором месте; но кому он нужен).
Каждый день таблица лидеров аннулировалась, то есть компания ежедневно отдавала iPhone X занявшему первое место (как я понял, это происходило целый месяц). Естественно, в игре есть свой подвох — чтобы набрать победные очки, нужно соревноваться с другими игроками. Хитрость в том, что для покупки жизней в игре вы тратите «баллы лояльности». Кто не знает, эти баллы вам начисляют за покупку товаров в сети (типа потратьте €1 в ресторане McDonald's и получите 10 баллов).
Так что теперь ясно, как работает эта их бизнес-модель. Чем больше баллов у вас есть, тем больше жизней в игре вы можете купить, а значит, и заработать больше очков, увеличив шанс на выигрыш. Такая схема должна подталкивать людей идти в один из ресторанов для получения дополнительных баллов.
Но давайте об этом забудем и предположим, что можем сделать реверс-инжиниринг игры и выяснить, как занять первое место без использования этих баллов.
Приступим!
Для начала я запустил mitmproxy на ноутбуке и настроил свой телефон так, чтобы он использовал его в качестве прокси. Теперь я могу отслеживать все ответы и запросы, поступающие с моего телефона, включая те, которые были отправлены на сервер McDonald’s. В процессе игры я вижу, как экран заполняется серией запросов.

Как видите, URL содержит слова mcd-game-api и saveGame, они и привлекли моё внимание. При внимательном изучении я убедился, что они поступают из игры FriesHit. Теперь пора заглянуть внутрь…
Реверсинг подключения к серверу
Вот содержимое этих запросов:
a: MGJmNDFiNzktOGI4OC00OWVmLWFmMGQtMmM3YTZjZmVlY2U3 b: MTI2MmMyMmViNmYxZjY4N2JiZTAyZWU5Yzk0NDNjYTllNzk0N2M2ZTJhNGM3YmUyNzUyOWUyYW... c: eyJzdGFnZSI6Mywic2NvcmUiOjI1LCJsaXZlcyI6Miwic3RhZ2VTY29yZSI6MTAsInN0YXJ0VGltZSI6MTU4MzI1MDQxMjY0OH0= d: MTU4MzI1MDQxMjY0OA== e: MTU4MzI1MDQyNzUxNA== f: MTAwMDAxNzg4
Узнаёте? Это Base64!
Так что, декодировав эти строки, можно получить интересные, вполне читабельные данные:
a: 0bf41b79-8b88-49ef-af0d-2c7a6cfeece7 b: 1262c22eb6f1f687bbe02ee9c9443ca9e7947c6e2a4c7be27529e2aa77730c73e6ae.. c: {"stage":3,"score":25,"lives":2,"stageScore":10,"startTime":1583250412648} d: 1583250412648 e: 1583250427514 f: 100001788
Примечание: часть лишнего текста я удалил.
Первым делом меня заинтересовали, конечно же, данные JSON. В них содержатся все характеристики игры, которые нам и нужны. Ещё один интересный нюанс в том, что значение "startTime" идентично значению d. То есть можно смело предположить, что d — это startTime. Но что насчёт других значений? Чтобы разобраться, можно попробовать отправить запрос, исключив, для примера, значение a. Вот результат:

Видите? Сервер жалуется на NoGameId. Итак, мы выяснили, что a означает GameId. Проведя аналогичную проверку со всеми другими переменными, я выяснил следующее:
a = GameId b = UserData c = ScoreData d = StartTime e = EndTime f = UserData
Вы можете подметить, что здесь дважды встречается UserData. Предполагаю, что b — это некий токен для идентификации устройства, а f — это ID текущего пользователя, авторизованного в сервисе. Хотя уверенно сказать сложно. Целые числа преимущественно используются для ID, а b просто похожа на какой-то хэш либо токен.
Как бы то ни было, теперь мы знаем содержимое пакетов, и нам нужно несколько штук, чтобы понять их поведение. Я запустил игру и, дойдя до 5 уровня с 48 очками, включил паузу. Посмотрим, какие пакеты за это время логировал мой прокси:
-------------------------------------------------------------------------------- a: MGJmNDFiNzktOGI4OC00OWVmLWFmMGQtMmM3YTZjZmVlY2U3 b: MTI2MmMyMmViNmYxZjY4N2JiZTAyZWU5Yzk0NDNjYTllNzk0N2M2ZTJhNGM3YmUyNzUyOWUyYW... c: {"stage":1,"score":7,"lives":3,"stageScore":7,"startTime":1583249911148} d: 1583249911148 e: 1583249916832 f: 100001788 -------------------------------------------------------------------------------- a: MGJmNDFiNzktOGI4OC00OWVmLWFmMGQtMmM3YTZjZmVlY2U3 b: MTI2MmMyMmViNmYxZjY4N2JiZTAyZWU5Yzk0NDNjYTllNzk0N2M2ZTJhNGM3YmUyNzUyOWUyYW... c: {"stage":2,"score":15,"lives":3,"stageScore":8,"startTime":1583249916845} d: 1583249916845 e: 1583249919799 f: 100001788 -------------------------------------------------------------------------------- a: MGJmNDFiNzktOGI4OC00OWVmLWFmMGQtMmM3YTZjZmVlY2U3 b: MTI2MmMyMmViNmYxZjY4N2JiZTAyZWU5Yzk0NDNjYTllNzk0N2M2ZTJhNGM3YmUyNzUyOWUyYW... c: {"stage":3,"score":25,"lives":3,"stageScore":10,"startTime":1583249919807} d: 1583249919807 e: 1583249923286 f: 100001788 -------------------------------------------------------------------------------- a: MGJmNDFiNzktOGI4OC00OWVmLWFmMGQtMmM3YTZjZmVlY2U3 b: MTI2MmMyMmViNmYxZjY4N2JiZTAyZWU5Yzk0NDNjYTllNzk0N2M2ZTJhNGM3YmUyNzUyOWUyYW... c: {"stage":4,"score":36,"lives":3,"stageScore":11,"startTime":1583249923290} d: 1583249923290 e: 1583249926686 f: 100001788 -------------------------------------------------------------------------------- a: MGJmNDFiNzktOGI4OC00OWVmLWFmMGQtMmM3YTZjZmVlY2U3 b: MTI2MmMyMmViNmYxZjY4N2JiZTAyZWU5Yzk0NDNjYTllNzk0N2M2ZTJhNGM3YmUyNzUyOWUyYW... c: {"stage":5,"score":48,"lives":3,"stageScore":12,"startTime":1583249926692} d: 1583249926692 e: 1583249930171 f: 100001788 --------------------------------------------------------------------------------
К слову, на первом stage я заработал 5 картошек, за каждую из которых мне начислили по одному очку, плюс 2 бонусом за завершение уровня. Этот результат отражён значением stageScore. В первом пакете значение stage равно 1, в следующем — это 2, в третьем — 3 и так далее. Теперь мы знаем, что пакет отправляется после завершения каждого уровня. Интересно, что произойдёт, если персонаж умрёт.
Когда я самоубился в игре, значение lives в пакете оказалось равно 0. Это означает, что клиент отправляет пакет, только когда уровень завершён или провален. Последнее, что нам нужно для самостоятельной отправки валидных пакетов, это понимание механизма начисления очков. Главный вопрос в том, сколько фри может принести каждый уровень.
В процессе игры я отслеживаю, сколько картошек получаю на каждом уровне. Как выяснилось, иногда можно получить 1 или не получить вовсе. Вот результаты:
Уровень 1: 4 ~ 5 Уровень 2: 6 ~ 7 Уровень 3: 8 Уровень 4: 9 Уровень 5: 10 Уровень 6: 12 Уровень 7: 13 Уровень 8: 14 Уровень 9: 15 Уровень 10 (босс): 24
Не уверен, всегда ли получается именно столько, но примерные значения таковы. На каждый обычный уровень уходило всего по несколько секунд, и если стремительно тапать по экрану, вы будете укладываться в это время. А вот на уровне с боссом всё иначе. Начинается этот уровень, когда Stage % 10 == 0. На нём установлен таймер, и мне удалось пройти этот уровень минимум за 25 секунд. Нужно запомнить это значение, иначе при последующей подмене пакетов сервер может заметить его превышение (если пакеты вообще проверяются; не удивлюсь, если нет).
Эмуляция пакетов
Хорошо, теперь мы знаем характеристики валидного пакета и можем начать отправлять свои. Вот псевдокод, который это реализует:
int goal = 420; int currentScore = 0; int[] FriesByLevel = {24, 4, 6, 8, 9, 10, 12, 13, 14, 15}; GamePacket pkt = new GamePacket(); pkt.live = 3; pkt.score = 0; pkt.stage = 1; int waitSeconds = 0; while(currentScore < goal) { pkt.stageScore = FriesByLevel[pkt.stage % 10]; if(pkt.stage % 10 == 0) { pkt.stageScore += 5; // Уровень босса, +5 очков waitSeconds = random(11,25); // Продолжительность уровня босса от 11 до 25 секунд } else { pkt.stageScore += 2; // Обычный уровень, +2 очка waitSecond = random(2,8); // Обычный уровень заключается просто в бешеном тапаньи по картошкам } pkt.score += pkt.stageScore; pkt.startTime = DateTime.now; Sleep(waitSecond); sendPacket(pkt, DateTime.now); // Второй аргумент — это endTime } // Самоубийство pkt.startTime = DateTime.now; pkt.lives = 0; sendPacket(pkt, DateTime.now);
Примечание: сам проект реализован на C#, и его полная версия лежит на GitHub.
Исходя из собранной информации, этот псевдокод должен отправлять валидные пакеты. На GitHub лежит проект целиком, и там в разных местах есть дополнительный код, гарантирующий достижение поставленной цели. Забавы ради, я решил поместить себя на первое место таблицы лидеров с отрывом всего в одно очко.
Представляете чувства того парня, который оказался на втором месте, и у которого буквально из-под носа уплыл iPhone?
Реверсинг самой игры
Пока я проделывал все перечисленные шаги, мне удалось найти исходный код этой игры. Как оказалось, он размещён на сайте mcdorehitfrieshit.lwprod.nl — его URL встречался в некоторых заголовках пакетов.
Если гуглить с запросом lwpord, то можно выйти на сайт livewallconcepts.nl. То есть именно эти ребята разработали приложение для McDonald's.
Но вернёмся к главному. При посещении первого домена мы находим игру FriesHit, поэтому я сразу же решил проверить исходный код сайта. В нём я нашёл JS-файл main.js, где меня поджидал джекпот.
Поскольку большинство прежних решений в коде были основаны на догадках, я хотел убедиться в корректности этих догадок. Помните переменные a, b, c, d, e и f, которые отправляются в запросе saveScore? Вот как они обрабатываются в коде игры:

Этот код я нашёл через поиск строки saveScore в том же файле JS. Он подтверждает верность моих догадок. Для преобразования значений в Base64 используется функция btoa(), имена аргументов которой очень похожи на те, которые я определил.
Я также предположил, что отправка пакета происходит лишь в двух случаях. Первый — это при завершении уровня, а второй — при смерти персонажа. Подтвердить это можно, заглянув в POST-запросы к /game/saveScore. Их там всего два, и первый я уже показал. Вот второй:

Как видите, функция очень похожа на предыдущую. Единственное отличие в том, что она вызывается, когда завершается уровень, а первая — когда умирает персонаж. Об этом говорит ключевое слово перед функцией и переменная, которая устанавливается после выполнения POST-запроса.
На этом мы подтвердили результаты тестирования и можем переходить к завершающим штрихам.
Бонус
В качестве небольшого бонуса я выяснил, как получаются данные таблицы лидеров. Ниже показан ответ от её API:

Я воспроизвёл этот вызов API и создал функцию, проверяющую, кто на данный момент является лидером. Мой код сканирует таблицу каждые X минут и проверяет, находится ли на первом месте пользователь с ником “Ferib”. Если нет, он определяет количество очков у лидера и начинает эмулировать пакеты для его опережения.

Примечание: когда я уведомил разработчиков игры об этой лазейке, они подтвердили, что на сервере реализована проверка на читерство. При этом они не только разрешили мне опубликовать статью, но и предложили возможную работу на будущее!

