Абсурдная незащищенность проекта @gamee

@gamee — это бот в Телеграме, который помимо своей криптомишуры, позволяет участникам одного чата соревноваться в аркадных играх. Один человек отправляет приглашение на игру с помощью инлайн-квери данного бота, а все участники затем могут вечно играть в прикрепленной мини-аппе. Для каждого чата формируется свой отдельный лидерборд. Предоставляются типы игр, которые вы скорее всего встречали в мобильных апп сторах, а также пасьянс и известный City Bloxx.

Так выглядит приглашение в игру в любом чате.

Так выглядит приглашение в игру в любом чате.

(не советую пытаться понять их лендинг — так как это позиционируется как плафторма для криптоигр, на их сайте соответственно вы и найдете только шизофазию о вебе 3.0, и откуда-то еще логотип NASA)

Возможно вы подумаете, что этот многопользовательский видеоигровой проект, основанный практически десять лет назад должен сейчас содержать хотя бы что‑то на подобии античита:  лично я сразу начинаю представлять в своей голове фоновые капчи, слежку за всеми касаниями экрана, биг дату, самостоятельную криптографию, сервер‑сайд стейт, и прочее. Как показывает час моего свободного времени — ничего подобного там нет. Сейчас я вам подробнее покажу, почему этот «веб 3.0 проект», как и многие другие, является полным бредом разработанным на коленках.

Сетевая активность

Игры Гейма — лишь HTML страницы, и запускаются внутри Телеграмоских мини‑апп. По неизвестной мне причине, вся документация этой фичи Телеграма находится на сайте Тона, их блокчейна. Неважно. Это значит, что игры можно запускать как на телефоне, так и на компьютере, установив должный пакет вебвью. Т. десктоп заодно позволяет включить DevTools в твоем вебвью, и изучить как HTML страницы, так и бандл сайта со всеми исходящими запросами.

Включив DevTools, мы можем вызвать у себя в сохраненных сообщениях какую‑то игру, и пойти ее реверсить.

Приглашение в игру, когда никто еще в нее не играл.

Приглашение в игру, когда никто еще в нее не играл.

Запускаем игру, тыкаем Inspect, завершаем раунд, и переходим в сетевую вкладку с фильтром XHR. Что мы видим? При завершении игры уходит один очень ясный запрос game.saveWebGameplay.

Похоже, что это просто JSON-RPC (Гуглу стоило бы у них поучиться). В хедерах тоже ничего особенного. Лишь JWT ключ для авторизации и некий x-install-uuid, который должен соотноситься со значением внутри JWT. Античитом и не пахнет.

Содержание JWT ключа.

Содержание JWT ключа.

К счастью, DevTools любого современного браузера позволяет всем желающим слизать любой HTTP запрос в форматах fetch и curl. Поэтому мы не тратим время и сразу вставляем этот фетч в новый TS-файл — наш скрипт будет написан на Дено. Затем перепроверяем все хедеры (включая Origin) и меняем строчный JSON на нативный объект в JSON.stringify(). Мы конечно могли бы подключить официальную библиотеку JSON-RPC, но нас сейчас не так волнует масштабирование.

Теперь можно посмотреть на содержание нашего JSON-объекта. Видно, что филды gameId, gameUrl и releaseNumber вероятно указывают в какой игре мы сейчас находимся. Филды score и playTime должны отвечать за кол-во очков и длину раунда (в секундах). createdTime, gameplayId и uuid должны идентифицировать раунд. Другие филды не до конца понятны, поэтому мы оставим их значения такими в коде.

const res = await fetch("https://api.gamee.com/", {
    method: "post",
    body: JSON.stringify({
        jsonrpc: "2.0",
        id: "game.saveWebGameplay",
        method: "game.saveWebGameplay",
        params: {
            gameplayData: {
                gameId,
                score,
                playTime,
                gameUrl,
                releaseNumber,
                createdTime: createdTime.toString(),
                metadata: {
                    gameplayId,
                },
                isSaveState: false,
                gameStateData,
                gameplayOrigin: "game",
                replayData: null,
                replayVariant: null,
                replayDataChecksum: null,
                uuid,
                checksum,
            },
        },
    }),
    headers: {
        "Authorization": `Bearer ${authToken}`,
        "Content-Type": "text/plain;charset=UTF-8",
        "Origin": "https://prizes.gamee.com",
        "Accept": "*/*",
        "Referer": "https://prizes.gamee.com/",
        "User-Agent":
            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
        "Accept-Language": "en-US",
        "Sec-Fetch-Dest": "empty",
        "Sec-Fetch-Mode": "cors",
        "Sec-Fetch-Site": "same-site",
        "client-language": "en",
        "x-install-uuid": installUuid,
    },
});

Работает?

Пробуем запустить наш скрипт и отправить идентичный запрос. В классическом стиле REST мы получаем длинный и подробный ответ ни о чем — с небольшим филдом об ошибке.

{
  "error": {
    "code": 1105,
    "message": "Gameplay already exists",
    "data": {
      "reason": "Gameplay already exists"
    }
  }
  // ...

Похоже, что их бекенд серьезно запоминает все прошлые UUID сыгранных раундов. Ну что ж, тогда мы заменим последний символ этого UUID и отправим такой же запрос.

{
  "error": {
    "code": -32009,
    "message": "Invalid checksum",
    "data": {
      "reason": "Invalid checksum"
    }
  },
  // ...

О нет! Неужели мы все-таки наткнулись на античит этой игры?

Контрольная сумма

Нет. Забытый нами филд checksum в нашем запросе, как можно понять из названия, содержит некую «контрольную сумму» для этого запроса, а скорее всего хеш. Что это значит на самом деле? Так как у нас есть доступ к удобным DevTools, мы можем сразу узнать какой код ее генерирует. Отправляемся во вкладку Sources и жмем Shift + Ctrl + F, чтобы найти слово checksum в бандле. Ага — вот и наша обфусцированная красавица — функция, формирующая данный запрос.

e7 = (0, r.P1)(c.t4, c.Sf, c.zf, c.Hb, (e, t) => t, (e, t, n, r, E) => {
  if (!e || !r)
      return null;
  let u = (0, _.Z)(),
      {isSaveStateEvent: l, saveState: a} = E,
      s = window.location.pathname,
      c = o.ze();
  return l || o.fb(), {
      gameId: e,
      score: t || 0,
      playTime: n || 0,
      gameUrl: s,
      releaseNumber: r.number,
      createdTime: (0, A.hA)(),
      metadata: {
          gameplayId: c + 1
      },
      isSaveState: l,
      gameStateData: a,
      gameplayOrigin: "game",
      replayData: null,
      replayVariant: null,
      replayDataChecksum: null,
      uuid: u,
      checksum: i()("".concat(t || 0, ":").concat(n || 0, ":").concat(s, ":").concat(a || "", ":").concat(u, ":crmjbjm3lczhlgnek9uaxz2l9svlfjw14npauhen"))
  }
}),

Тут видно, что некоторые значения действительно заданы статично. Но нас интересует checksum. Переменные t, n, s, a и u отвечают за другие филды в том же запросе, так что их можно переименовать. А глаз опытного реверс-инженера сразу узнает, что вебпак здесь заполифилил интерполяцию строки, иными словами:

checksum = i()(
  ""
    .concat(t || 0, ":")
    .concat(n || 0, ":")
    .concat(s, ":")
    .concat(a || "", ":")
    .concat(u, ":crmjbjm3lczhlgnek9uaxz2l9svlfjw14npauhen")
)
checksum = i()(`${score ?? 0}:${playTime ?? 0}:${gameUrl}:${gameStateData ?? ""}:${uuid}:crmjbjm3lczhlgnek9uaxz2l9svlfjw14npauhen`)

То есть `${score ?? 0}:${playTime ?? 0}:${gameUrl}:${gameStateData ?? ""}:${uuid}:crmjbjm3lczhlgnek9uaxz2l9svlfjw14npauhen` — это строка, которая должна пройти через какую-то трансформацию из функции i(), чтобы стать контрольной суммой вида 280eb18f07d5b6764b257b93fb8c0ea9.

DevTools понятия не имеет, что находится в функции i(). Однако, мы можем поставить на этом месте брейкпойнт и снова вызвать его завершением одного раунда игры. Брейкпойнт выскакивает, и дает нам посмотреть на все локальные значения этого блока. Становится понятно, что это дефолтный экспорт другого модуля в этом бандле.

e.exports = function(e, t) {
    if (null == e)
        throw Error("Illegal argument " + e);
    var r = n.wordsToBytes(s(e, t));
    return t && t.asBytes ? r : t && t.asString ? a.bytesToString(r) : n.bytesToHex(r)
}

Вызываемая функция s(), которая видимо отвечает за всю логику процесса, представляет из себя смертельную криптографическую кашу:

s = function(e, t) {
    e.constructor == String ? e = t && "binary" === t.encoding ? a.stringToBytes(e) : i.stringToBytes(e) : o(e) ? e = Array.prototype.slice.call(e, 0) : Array.isArray(e) || e.constructor === Uint8Array || (e = e.toString());
    for (var r = n.bytesToWords(e), l = 8 * e.length, u = 1732584193, c = -271733879, d = -1732584194, f = 271733878, h = 0; h < r.length; h++)
        r[h] = (r[h] << 8 | r[h] >>> 24) & 16711935 | (r[h] << 24 | r[h] >>> 8) & 4278255360;
    r[l >>> 5] |= 128 << l % 32,
    r[(l + 64 >>> 9 << 4) + 14] = l;
    for (var p = s._ff, g = s._gg, y = s._hh, m = s._ii, h = 0; h < r.length; h += 16) {
        var b = u,
            v = c,
            w = d,
            k = f;
        u = p(u, c, d, f, r[h + 0], 7, -680876936),
        f = p(f, u, c, d, r[h + 1], 12, -389564586),
        d = p(d, f, u, c, r[h + 2], 17, 606105819),
        c = p(c, d, f, u, r[h + 3], 22, -1044525330),
// Еще 20 строк из этого...

Многие эксперты уже бы здесь догадались, что эта функция считает хеш MD-5, но не я. Так что допустим, что это вам не очевидно, и вы хотите найти смысл этого кода сами. По всему коду разбросаны общие идентификаторы, которые сохранились в обход обфускации, так что мы можем взять три или четыре из них, и просто пойти по всему Гитхабу в поисках такого же кода с объяснением.

18243b0ae1a8efcdc759142afca76b65.png


Становится ясно, что функции, которые мы ищем, исходят из пакета Crypto-JS, а сам код — из пакета node-md5. Почему-то этот проект решил выбрать пакет из 2013 в качестве своей реализации MD-5. Неважно. Подключаем нужную библиотеку к нашему скрипту, и получаем настолько простой код:

const checksum = md5(
    `${score ?? 0}:${playTime ?? 0}:${gameUrl}:${
        gameStateData ?? ""
    }:${uuid}:crmjbjm3lczhlgnek9uaxz2l9svlfjw14npauhen`,
);

Работает?

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

Доброжелательные ошибки

{
  "error": {
    "code": -32602,
    "message": "Invalid params",
    "data": {
      "reason": "Parameters are not valid: gameplayData.createdTime : Invalid date-time \"Wed Oct 30 2024 21:16:07 GMT+0100 (Central European Standard Time)\", expected format YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DDThh:mm:ss+hh:mm"
    }
  },
  // ...

Просто слов нет. Здесь мы узнаем, что их продакшн бекенд, который бы у любой игровой компании останавливал попытки эксплуатации, говорит напримую, что тебе надо делать, чтобы реверснуть их API. Забыл четыре обязательных параметра, а в тех, что оставил, вписал не те типы данных? Не волнуйся — бекенд тебе перечислит все твои ошибки! Он скажет какие филды отсутствуют, какие должны быть их типы, а также какие должны быть типы данных у всех тех, что указаны неверно. Списком, от А до Я.

Последуем совету бекенда и заменим свой createdTime.toString() на createdTime.toISOString() как во втором разрешенном варианте. Поехали дальше.

{
  "error": {
    "code": -32603,
    "message": "Server error",
    "data": {
      "reason": "Server error"
    }
  },
  // ...

Здесь мы натыкаемся на… Нечто. Одно из двух: либо это ошибка-заглушка, которая узнала поддельный запрос, либо это настоящая ошибка со стороны разработчиков бекенда. Может им бы пригодился тикет на багтрекере. Неважно. Ошибка, на самом деле, вызвана тем, что из двух разрешенных выше форматов на деле работает только первый.

Я потратил порядка получаса, чтобы разобраться в этом странном вранье от бекенда. Заменяем формат даты, и идем дальше.

1.
- createdTime: createdTime.toString(),
+ createdTime: createdTime.toISOString(),
2.
- createdTime: createdTime.toISOString(),
+ createdTime: moment(createdTime).format("YYYY-MM-DDTHH:mm:ssZ"),

Работает?

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

Через пару минут мой аккаунт забанили в этом боте за читерство. Судя по всему, их античит смог увидеть, что я наотправлял порядка 50 ошибочных запросов. Хоть и на этом спасибо. Я перешел на другой аккаунт, и с уже готовым кодом смог успешно побить рекорд всех своих чатов с отрывом больше 100%, и остаюсь без бана.

Дополнительная работа

Копировать x-install-uuid и JWT ключ каждый раз, когда мы хотим нарисовать очки — не очень удобно. Еще два других JSON-RPC запроса, которые точно так же легко скопировать, занимаются авторизацией и выдают айди игры и релиза.

В итоге выяснилось, что в этой игре для авторизации не нужно ничего кроме индивидуальной строки URL, содерживающей сам ключ авторизации, который можно обменять на пару access и refresh JWT ключей. Строка URL выглядит вот так:

/game-bot/astrocat-c6d185e817410ccca2fe1d3a8fa929403e1c68ac

Итоги

Десятилетний игровой бот с 4,5 миллионами месячными пользователями «взламывается» за час работы. Настоящим античитом в их системе и не пахнет, зато проект хвалит себя за то, как двигает прогресс веба 3.0. Такие случаи отражают действительную реальность этого движения: блокчейн и геймдев, для них, — это всего лишь очень эффективные способы продвигать финансовые пирамиды о которых даже еще не думает правительство. Разочарование? Я так не считаю, ведь тем не менее это не отменяет веселье, которое приносят эти быстрые аркадные игры:)

Код: https://hastebin.skyra.pw/havapajipi.php

Habrahabr.ru прочитано 22685 раз