Исходник Информация Гайд [Reverse-Engineering] Как работает VM у Grand Mobile.

fakeevade

Новичок
Автор темы
20
24
Всех приветствую дорогие читатели.
В этой статье будет разбор того, как именно у топ 3 проекта по пулярности в КРМП мобайл устроена защита от сторонних клиентов
Начнем ровно с того что, с чего начинали здесь:
1786390310174.png
Только увы и ах, порядок действий будет сильно отличаться. И так, в случае Grand Mobile нам понадобится достать libgrand.so
И сразу же после того как я закинул либу в иду меня уже ждут подарки от разработчиков, а именно
1786390612252.png

Каким то невообразимым чудом в библиотеке весом в 31 мб нашлось всего то 1634 функции(540 из которых импорты), да и еще вместо привычного .text сегмента какой то GEKAS непонятный. начнем с просмотра функций в этом сегменте.
И первой же функцией которую я встретил оказался Flush(то есть функция, которая отвечает за очистку кэша процессора, и инвалидацию кэша инструкций). вот ее содержимое

flush:
__int64 __fastcall sub_1DC2750(__int64 result, unsigned __int64 a2)
{
  unsigned __int64 i; // [xsp+0h] [xbp-20h]
  unsigned __int64 j; // [xsp+0h] [xbp-20h]
  unsigned int v8;    // [xsp+Ch] [xbp-14h]

  // тут идет проверка, кеширован ли размер линий кеша
  if ( !dword_215AF14 )
    dword_215AF14 = _ReadStatusReg(CTR_EL0);

  // тут идет вычисление размера линии кеша инструкций в байтах
  v8 = 4 << (dword_215AF14 & 0xF);

  // а вот тут начинается первый цикл: выравнивается начальный адрес (result) по границе линии кеша данных
  // и проходит до конца диапазона (a2) с шагом, равным размеру линии кеша данных
  for ( i = result & ~(unsigned __int64)(unsigned int)((4 << (BYTE2(dword_215AF14) & 0xF)) - 1);
        i < a2;
        i += (unsigned int)(4 << (BYTE2(dword_215AF14) & 0xF)) )
  {
    // тут очищается кэш данных по VA(виртуальному адресу) до точки объединения
    __asm { DC         CVAU, X8 }
  }

  // тут просто ждет, пока все операции записи и очистки кэша данных полностью завершатся
  __dsb(0xBu);

  // а вот тут идет второй цикл: выравнивание адреса по размеру линии кеша инструкций
  // и проход по всему диапазону памяти до a2
  for ( j = result & ~(unsigned __int64)(v8 - 1); j < a2; j += v8 )
  {
    // тут идет сбрасывание кэша инструкций по VA
    __asm { IC         IVAU, X8 }
  }

  __dsb(0xBu);

  __isb(0xFu);
  return result;
}
по сути, это уже намек на то что тут есть функции, которые расшифровываются и выполняются в рантайме.
следом я нахожу функцию расшифровщика либы(так как она на 1000+ строк, я не буду вставлять ее содержимое сюда)

1786391231841.png

которая конечно же, по забавному стечению обстоятельств окажется первой в .init_array.
ну и перед тем, как идти и дампить библиотеку, я нашел еще одну функцию, которую читателю стоить запомнить, по своей сути, это vm_dispatch

some:
void __fastcall sub_1DE417C(
        __int64 a1,
        int a2,
        int a3,
        __int64 a4,
        __int64 a5,
        __int64 a6,
        __int64 a7,
        __int64 a8,
        __int64 a9,
        __int64 a10,
        __int64 a11,
        __int64 a12,
        __int64 a13,
        __int64 a14,
        __int64 a15)
{
  int v15; // w12
  int v16; // w15
  int v17; // w14
  int v18; // w13
  int v19; // w10
  int v20; // w11
  int i; // [xsp+1Ch] [xbp-7C4h]
  _BYTE dest[564]; // [xsp+30h] [xbp-7B0h] BYREF
  _BYTE v23[1028]; // [xsp+264h] [xbp-57Ch] BYREF
  _BYTE *v24; // [xsp+668h] [xbp-178h]
  char *v25; // [xsp+670h] [xbp-170h]
  char v26; // [xsp+678h] [xbp-168h] BYREF
  int v27; // [xsp+778h] [xbp-68h]
  int v28; // [xsp+77Ch] [xbp-64h]
  __int64 v29; // [xsp+780h] [xbp-60h]
  __int64 v30; // [xsp+7C8h] [xbp-18h]
  unsigned int v31; // [xsp+800h] [xbp+20h]
  int v32; // [xsp+804h] [xbp+24h]
  unsigned int v33; // [xsp+808h] [xbp+28h]
  unsigned __int8 v34; // [xsp+80Ch] [xbp+2Ch]

  v30 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
  v29 = a1;
  v28 = a2;
  v27 = a3;
  memcpy(dest, &unk_1E064C8, 0x230u);
  for ( i = 0; ((i - 1027) | i) < 0; i = ((2 * i) | 2) - (i ^ 1) )
    v23[i] = off_1E506D0[i + 225];
  v25 = &v26;
  v24 = v23;
  v34 = *(_BYTE *)STACK[0xE48];
  v33 = 0;
  v32 = 34;
  while ( (int)((v33 & ~v32) + ((int)(v33 ^ ~v32) >> 1)) < 0 )
  {
    v15 = *((unsigned __int8 *)&a15 + 16 * (int)v33);
    v16 = (v34 & ~v15) - (v15 & ~v34);
    v17 = (v32 & ~v33) - (v33 & ~v32);
    v18 = *((unsigned __int8 *)&a15 + 16 * v32);
    v31 = ((v16 & v17) * (v16 | v17) + (v16 & ~v17) * (v17 & ~v16)) / ((v18 & ~v15) - (v15 & ~v18)) + v33;
    v19 = *((unsigned __int8 *)&a15 + 16 * (int)v31);
    if ( ((v34 & ~v19 | (v34 | ~v19) & (v19 - v34)) & 0x80) != 0 )
    {
      v33 = (v31 ^ 0xFFFFFFFE) + ((2 * v31) | 2) + 1;
    }
    else
    {
      v20 = *((unsigned __int8 *)&a15 + 16 * (int)v31);
      if ( ((v20 & ~v34 | (v20 | ~v34) & (v34 - v20)) & 0x80) != 0 )
        v32 = 2 * (v31 & 0x7FFFFFFE) - (v31 ^ 1);
      else
        v32 = -1;
    }
  }
  JUMPOUT(0x1DE4428);
}

Что-же, перейдем теперь к дампу. Для того чтобы сдампить библиотеку я буду использовать Frida и скрипт ниже
dumper:
function safeDump(libName) {
    const module = Process.findModuleByName(libName);
    if (!module) {
        console.error(`module ${libName} not found.`);
        return;
    }
    console.log(` dumping ${libName} [${module.base} - ${module.base.add(module.size)}]`);
    const outBuffer = Memory.alloc(module.size);
    Process.enumerateRanges('---').forEach(range => {
        if (range.base.compare(module.base) >= 0 &&
            range.base.add(range.size).compare(module.base.add(module.size)) <= 0) {
         
            console.log(`read section: ${range.base} | size: ${range.size} | access: ${range.protection}`);
         
            try {
                if (range.protection.indexOf('r') !== -1) {
                    Memory.copy(outBuffer.add(range.base.sub(module.base)), range.base, range.size);
                } else {
                    console.warn(`skip, can't read: ${range.base}`);
                }
            } catch (e) {
                console.error(`error ${range.base}: ${e.message}`);
            }
        }
    });
    const path = `/sdcard/Download/${libName}_dumped.so`;
    const file = new File(path, "wb");
    file.write(outBuffer.readByteArray(module.size));
    file.flush();
    file.close();
    console.log(`\n dump saved: ${path}`);
}

setImmediate(() => {
    safeDump("libgrand.so");
});
А для восстановления дампа следующую утилиту: https://github.com/F8LEFT/SoFixer
И так, судя по логу, в восстановлении дампа софиксер отработал отлично.

Код:
C:\Users\ollvm-fan\Desktop\bypasses>sofixer.exe -s libgrand.so_dumped.so -o fixed.so
[main_loop:87]start to rebuild elf file
[Load:69]dynamic segment have been found in loadable segment, argument baseso will be ignored.
[RebuildPhdr:25]=============LoadDynamicSectionFromBaseSource==========RebuildPhdr=========================
[RebuildPhdr:37]=====================RebuildPhdr End======================
[ReadSoInfo:549]=======================ReadSoInfo=========================
[ReadSoInfo:696]soname
[ReadSoInfo:699]Unused DT entry: type 0x6ffffffb arg 0x00000001
[ReadSoInfo:699]Unused DT entry: type 0x00000009 arg 0x00000018
[ReadSoInfo:699]Unused DT entry: type 0x6ffffff9 arg 0x000029d8
[ReadSoInfo:591] plt_rel (DT_JMPREL) found at 851f0
[ReadSoInfo:595] plt_rel_count (DT_PLTRELSZ) 1458
[ReadSoInfo:584]symbol table found at 308
[ReadSoInfo:580]string table found at 17634
[ReadSoInfo:699]Unused DT entry: type 0x6ffffef5 arg 0x000134e0
[ReadSoInfo:629] constructors (DT_INIT_ARRAY) found at 1dc1e38
[ReadSoInfo:633] constructors (DT_INIT_ARRAYSZ) 155
[ReadSoInfo:637] destructors (DT_FINI_ARRAY) found at 1dc1e28
[ReadSoInfo:641] destructors (DT_FINI_ARRAYSZ) 2
[ReadSoInfo:699]Unused DT entry: type 0x6ffffff0 arg 0x00011cf0
[ReadSoInfo:699]Unused DT entry: type 0x6ffffffe arg 0x00013470
[ReadSoInfo:699]Unused DT entry: type 0x6fffffff arg 0x00000003
[ReadSoInfo:703]=======================ReadSoInfo End=========================
[RebuildShdr:42]=======================RebuildShdr=========================
[RebuildShdr:536]=====================RebuildShdr End======================
[RebuildFin:709]=======================try to finish file rebuild =========================
[RebuildFin:733]=======================End=========================
[main:123]Done!!!

И вы только посмотрите, невероятнейшим образом нам уже сходу открылось более 20 тысяч функций, в сравнении с прошлыми 1600
1786392006914.png

Значит как я и думал, библиотека находилась под пакером.
И так, по завершению анализа я решил не тянуть, и сразу перейти к тем самым функциям гекаса которые я видел до дампа(за статьей остался момент, когда я прокинул готовый клиентджоин с жвт токеном и девайс идом, зашел на сервер, и, убедился в том что проверка находится в 253 рпс с 18 типом, поэтому акцентировать на этом внимание я не буду).
ну и вот он наш красавец
1786393025250.png

и так, начнем с детального разбора этой функции.
первым что меня зацепило это unk_1DBB128
1786393060191.png

исходя из такой последовательности это очень сильно похоже на опкоды виртуальной машины, и скорее всего так и есть, на это нам ответит byte_1E053A9, если сейчас там будет огромный массив, то с вероятностью в 99.9% это байткод, и если это так, то перед нами действительно vm dispatcher
1786393181566.png

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

vm emulator:
#include <windows.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <stdexcept>
#include <iostream>
#include <iomanip>

static const uint8_t bytecode[1028] = {
        0xDF,0x40,0x00,0x00,0x00,0x84,0x10,0x00,0x00,0x00,0xD3,0xDF,0x08,0x00,0x00,0x00,
        0x84,0x14,0x00,0x00,0x00,0xD3,0xDF,0x00,0x00,0x00,0x00,0x84,0x18,0x00,0x00,0x00,
        0xD3,0xA4,0x04,0x00,0x00,0x00,0x84,0x10,0x00,0x00,0x00,0x34,0xBC,0x01,0x00,0x00,
        0x00,0x34,0x58,0x84,0x18,0x00,0x00,0x00,0x34,0xAE,0x4A,0x0E,0x00,0x00,0x00,0xA4,
        0x04,0x00,0x00,0x00,0xA4,0xBF,0x02,0x00,0x00,0xDF,0x3C,0x8F,0x09,0x62,0xDF,0x49,
        0x75,0x00,0x00,0xBC,0x02,0x00,0x00,0x00,0x34,0x21,0x0D,0x00,0x00,0x00,0xC3,0x53,
        0xDF,0x08,0x00,0x00,0x00,0xBC,0x02,0x00,0x00,0x00,0x34,0xDF,0x03,0x00,0x00,0x00,
        0xC6,0x98,0xD1,0xD1,0x84,0x1C,0x00,0x00,0x00,0xD3,0xDF,0x8C,0x44,0x00,0x00,0x84,
        0x1C,0x00,0x00,0x00,0x34,0x21,0x10,0x00,0x00,0x00,0xC3,0x53,0x84,0x20,0x00,0x00,
        0x00,0xD3,0xDF,0x00,0x00,0x00,0x00,0x84,0x24,0x00,0x00,0x00,0xD3,0xDF,0x00,0x00,
        0x00,0x00,0x84,0x28,0x00,0x00,0x00,0xD3,0xA4,0x04,0x00,0x00,0x00,0xDF,0x08,0x00,
        0x00,0x00,0x84,0x28,0x00,0x00,0x00,0x34,0xAE,0x4A,0x0E,0x00,0x00,0x00,0xA4,0x04,
        0x00,0x00,0x00,0xA4,0x24,0x02,0x00,0x00,0xDF,0x3C,0x8F,0x09,0x62,0xDF,0x49,0x75,
        0x00,0x00,0xBC,0x02,0x00,0x00,0x00,0x34,0x21,0x0D,0x00,0x00,0x00,0xC3,0x53,0xDF,
        0x08,0x00,0x00,0x00,0xBC,0x02,0x00,0x00,0x00,0x34,0xDF,0x03,0x00,0x00,0x00,0xC6,
        0x98,0xD1,0xD1,0x84,0x1C,0x00,0x00,0x00,0xD3,0xDF,0x8C,0x44,0x00,0x00,0x84,0x1C,
        0x00,0x00,0x00,0x34,0x21,0x10,0x00,0x00,0x00,0xC3,0x53,0x84,0x20,0x00,0x00,0x00,
        0xD3,0xDF,0x00,0x00,0x00,0x00,0x84,0x2C,0x00,0x00,0x00,0xD3,0xA4,0x04,0x00,0x00,
        0x00,0xDF,0x04,0x00,0x00,0x00,0x84,0x14,0x00,0x00,0x00,0x34,0x58,0x84,0x2C,0x00,
        0x00,0x00,0x34,0xAE,0x4A,0x0E,0x00,0x00,0x00,0xA4,0x04,0x00,0x00,0x00,0xA4,0x7A,
        0x01,0x00,0x00,0xBC,0x01,0x00,0x00,0x00,0x34,0x71,0xF1,0x01,0x00,0x00,0x00,0x00,
        0x00,0x00,0x00,0x7A,0xBC,0x00,0x00,0x00,0x00,0x1D,0x25,0x6C,0x84,0x18,0x00,0x00,
        0x00,0x34,0x84,0x10,0x00,0x00,0x00,0x34,0x98,0x71,0xF1,0x01,0x00,0x00,0x00,0x00,
        0x00,0x00,0x00,0x7A,0xDF,0x04,0x00,0x00,0x00,0x84,0x2C,0x00,0x00,0x00,0x34,0x98,
        0x71,0xF1,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7A,0x84,0x24,0x00,0x00,0x00,
        0x34,0x71,0xF1,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7A,0xBC,0x00,0x00,0x00,
        0x00,0x1D,0x25,0x25,0x25,0x6C,0x94,0x4A,0x09,0x00,0x00,0x00,0xA4,0x0E,0x00,0x00,
        0x00,0xA4,0x04,0x00,0x00,0x00,0xA4,0x4B,0x02,0x00,0x00,0xDF,0xB3,0xCF,0x90,0x78,
        0xBC,0x02,0x00,0x00,0x00,0x34,0xD1,0x84,0x18,0x00,0x00,0x00,0x34,0x84,0x10,0x00,
        0x00,0x00,0x34,0x98,0x71,0xF1,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7A,0xDF,
        0x04,0x00,0x00,0x00,0x84,0x2C,0x00,0x00,0x00,0x34,0x98,0x71,0xF1,0x01,0x00,0x00,
        0x00,0x00,0x00,0x00,0x00,0x7A,0x84,0x24,0x00,0x00,0x00,0x34,0x71,0xF1,0x01,0x00,
        0x00,0x00,0x00,0x00,0x00,0x00,0x7A,0xBC,0x00,0x00,0x00,0x00,0x1D,0x25,0x25,0x25,
        0xE3,0x34,0xD1,0x84,0x18,0x00,0x00,0x00,0x34,0x84,0x10,0x00,0x00,0x00,0x34,0x98,
        0x71,0xF1,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7A,0xDF,0x04,0x00,0x00,0x00,
        0x84,0x2C,0x00,0x00,0x00,0x34,0x98,0x71,0xF1,0x01,0x00,0x00,0x00,0x00,0x00,0x00,
        0x00,0x7A,0x84,0x24,0x00,0x00,0x00,0x34,0x71,0xF1,0x01,0x00,0x00,0x00,0x00,0x00,
        0x00,0x00,0x7A,0xBC,0x00,0x00,0x00,0x00,0x1D,0x25,0x25,0x25,0xE3,0xD3,0x84,0x1C,
        0x00,0x00,0x00,0x34,0xBC,0x02,0x00,0x00,0x00,0x34,0xD1,0xBC,0x02,0x00,0x00,0x00,
        0xD3,0xDF,0x73,0x10,0x51,0x06,0x84,0x1C,0x00,0x00,0x00,0x34,0x98,0x84,0x20,0x00,
        0x00,0x00,0x34,0x46,0x84,0x1C,0x00,0x00,0x00,0xD3,0x84,0x2C,0x00,0x00,0x00,0x34,
        0x84,0x20,0x00,0x00,0x00,0x34,0xD1,0x84,0x20,0x00,0x00,0x00,0xD3,0xDF,0x01,0x00,
        0x00,0x00,0x84,0x2C,0x00,0x00,0x00,0x34,0x46,0x84,0x2C,0x00,0x00,0x00,0xD3,0xA4,
        0x71,0xFE,0xFF,0xFF,0xA4,0x6C,0xFE,0xFF,0xFF,0x84,0x14,0x00,0x00,0x00,0x34,0x84,
        0x24,0x00,0x00,0x00,0x34,0x46,0x84,0x24,0x00,0x00,0x00,0xD3,0xDF,0x01,0x00,0x00,
        0x00,0x84,0x28,0x00,0x00,0x00,0x34,0x46,0x84,0x28,0x00,0x00,0x00,0xD3,0xA4,0xCE,
        0xFD,0xFF,0xFF,0xA4,0xC9,0xFD,0xFF,0xFF,0xDF,0x01,0x00,0x00,0x00,0x84,0x18,0x00,
        0x00,0x00,0x34,0x46,0x84,0x18,0x00,0x00,0x00,0xD3,0xA4,0x2B,0xFD,0xFF,0xFF,0xA4,
        0x26,0xFD,0xFF,0xFF,0xDF,0x3C,0x8F,0x09,0x62,0xDF,0x49,0x75,0x00,0x00,0xBC,0x02,
        0x00,0x00,0x00,0x34,0x21,0x0D,0x00,0x00,0x00,0xC3,0x53,0xDF,0x08,0x00,0x00,0x00,
        0xBC,0x02,0x00,0x00,0x00,0x34,0xDF,0x03,0x00,0x00,0x00,0xC6,0x98,0xD1,0xD1,0x84,
        0x30,0x00,0x00,0x00,0xD3,0xDF,0x00,0x00,0x00,0x00,0x84,0x34,0x00,0x00,0x00,0xD3,
        0xA4,0x04,0x00,0x00,0x00,0xBC,0x01,0x00,0x00,0x00,0x34,0x84,0x10,0x00,0x00,0x00,
        0x34,0x3A,0x84,0x34,0x00,0x00,0x00,0x34,0xAE,0x4A,0x0E,0x00,0x00,0x00,0xA4,0x04,
        0x00,0x00,0x00,0xA4,0x99,0x00,0x00,0x00,0x84,0x38,0x00,0x00,0x00,0xBC,0x01,0x00,
        0x00,0x00,0x34,0x71,0xF1,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7A,0xBC,0x00,
        0x00,0x00,0x00,0x1D,0x25,0x84,0x34,0x00,0x00,0x00,0x34,0x71,0xF1,0x01,0x00,0x00,
        0x00,0x00,0x00,0x00,0x00,0x7A,0x0E,0x21,0x01,0x00,0x00,0x00,0xAD,0xF1,0x01,0x00,
        0x00,0x00,0x00,0x00,0x00,0x00,0x7A,0x0E,0x1B,0x84,0x30,0x00,0x00,0x00,0x34,0xDF,
        0xFF,0x00,0x00,0x00,0xC6,0x0C,0x11,0x84,0x38,0x00,0x00,0x00,0x1D,0x43,0x11,0x31,
        0x8C,0x84,0x38,0x00,0x00,0x00,0x1D,0x9C,0xDF,0x73,0x10,0x51,0x06,0xBC,0x02,0x00,
        0x00,0x00,0x34,0x98,0x84,0x30,0x00,0x00,0x00,0x34,0x46,0x84,0x30,0x00,0x00,0x00,
        0xD3,0xDF,0x01,0x00,0x00,0x00,0x84,0x34,0x00,0x00,0x00,0x34,0x46,0x84,0x34,0x00,
        0x00,0x00,0xD3,0xA4,0x51,0xFF,0xFF,0xFF,0xA4,0x4C,0xFF,0xFF,0xFF,0xA4,0x04,0x00,
        0x00,0x00,0x67,0x00,
};
static const size_t bytecodelen = sizeof(bytecode);

namespace grandvm {
    struct unkopcode { uint8_t op; size_t ip; };

    class VM {
    public:
        VM(const uint8_t* code, size_t code_len, uint8_t* buf, int32_t len, int32_t seed) : code_(code), code_len_(code_len), ip_(0) {
            opstack_.assign(8192, 0);
            locals_.assign(4096, 0);
            sp_ = 0;
            a1_ = reinterpret_cast<int64_t>(buf);
            a2_ = len;
            a3_ = seed;
        }

        void run() {
            const size_t n = code_len_;
            size_t executed_count = 0;

            while (ip_ < n) {
                uint8_t op = code_[ip_];
                size_t start_ip = ip_;
                executed_count++;
                ip_ += 1;
                switch (op) {
                case 0x0C: { int32_t v = top_i32(); top_i32() = (uint8_t)v; break; }
                case 0x0E: { int64_t t = top_i64(), b = below_i64(); below_i64() = b - t; pop(8); break; }
                case 0x11: { uint8_t v = (uint8_t)top_i32(); top_i32() = v; break; }
                case 0x1B: { int64_t value = top_i64(), ptr = below_i64(); *reinterpret_cast<int64_t*>(ptr) = value; pop(16); break; }
                case 0x1D: { int64_t ptr = top_i64(); top_i64() = *reinterpret_cast<int64_t*>(ptr); break; }
                case 0x21: case 0xDF: { int32_t val = read_i32_operand(); ip_ += 4; push_i64(0); top_i32() = val; break; }
                case 0x25: { int64_t t = top_i64(), b = below_i64(); below_i64() = b + t; pop(8); break; }
                case 0x31: case 0xD1: { int32_t t = top_i32(), b = below_i32(); below_i32() = b ^ t; pop(8); break; }
                case 0x34: { int64_t ptr = top_i64(); top_i32() = *reinterpret_cast<int32_t*>(ptr); break; }
                case 0x3A: { uint32_t t = (uint32_t)top_i32(), b = (uint32_t)below_i32(); if (t == 0) throw std::runtime_error("mod by zero"); below_i32() = (int32_t)(b % t); pop(8); break; }
                case 0x43: { int64_t ptr = top_i64(); top_i32() = *reinterpret_cast<uint8_t*>(ptr); break; }
                case 0x46: { int32_t t = top_i32(), b = below_i32(); below_i32() = b + t; pop(8); break; }
                case 0x4A: { int32_t cond = top_i32(); pop(8); int32_t disp = read_i32_operand(); if (cond != 0) ip_ += disp; else ip_ += 4; break; }
                case 0x53: { int32_t t = top_i32(), b = below_i32(); below_i32() = b | t; pop(8); break; }
                case 0x58: { uint32_t t = (uint32_t)top_i32(), b = (uint32_t)below_i32(); if (b == 0) throw std::runtime_error("division by zero"); below_i32() = (int32_t)(t / b); pop(8); break; }
                case 0x67:
                    std::cout << "vm done" << std::endl;
                    return;
                case 0x6C: case 0xE3: break;
                case 0x71: { uint32_t v = (uint32_t)top_i32(); top_i64() = (int64_t)(uint64_t)v; break; }
                case 0x7A: { int64_t t = top_i64(), b = below_i64(); below_i64() = b * t; pop(8); break; }
                case 0x84: { int32_t offs = read_i32_operand(); ip_ += 4; int64_t addr = reinterpret_cast<int64_t>(locals_.data() + locals + offs); push_i64(addr); break; }
                case 0x8C: { int32_t v = top_i32(); top_i32() = (uint8_t)v; break; }
                case 0x94: { uint64_t t = (uint64_t)top_i64(), b = (uint64_t)below_i64(); below_i32() = (b < t) ? 1 : 0; pop(8); break; }
                case 0x98: { int32_t t = top_i32(), b = below_i32(); below_i32() = b * t; pop(8); break; }
                case 0x9C: { int64_t ptr = top_i64(); int32_t value = below_i32(); *reinterpret_cast<uint8_t*>(ptr) = (uint8_t)value; pop(16); break; }
                case 0xA4: { int32_t disp = read_i32_operand(); ip_ += disp; break; }
                case 0xAD: { int32_t v = top_i32(); top_i64() = (int64_t)v; break; }
                case 0xAE: { uint32_t t = (uint32_t)top_i32(), b = (uint32_t)below_i32(); below_i32() = (t < b) ? 1 : 0; pop(8); break; }
                case 0xBC: {
                    int32_t sel = read_i32_operand(); ip_ += 4;
                    int64_t addr;
                    if (sel == 0) addr = reinterpret_cast<int64_t>(&a1_);
                    else if (sel == 1) addr = reinterpret_cast<int64_t>(&a2_);
                    else if (sel == 2) addr = reinterpret_cast<int64_t>(&a3_);
                    else throw std::runtime_error("bad param selector");
                    push_i64(addr);
                    break;
                }
                case 0xC3: { int32_t t = top_i32(), b = below_i32(); below_i32() = (int32_t)((uint32_t)b << (t & 31)); pop(8); break; }
                case 0xC6: { int32_t t = top_i32(), b = below_i32(); below_i32() = b & t; pop(8); break; }
                case 0xD3: { int64_t ptr = top_i64(); int32_t value = below_i32(); *reinterpret_cast<int32_t*>(ptr) = value; pop(16); break; }
                case 0xF1: { int64_t val = read_i64_operand(); ip_ += 8; push_i64(val); break; }
                default:
                    throw unkopcode{ op, start_ip };
                }
            }
        }

    private:
        static const int32_t locals = 2048;
        const uint8_t* code_;
        size_t code_len_;
        size_t ip_;
        std::vector<uint8_t> opstack_;
        size_t sp_;
        std::vector<uint8_t> locals_;
        int64_t a1_;
        int32_t a2_;
        int32_t a3_;

        int32_t read_i32_operand() { int32_t v; memcpy(&v, code_ + ip_, 4); return v; }
        int64_t read_i64_operand() { int64_t v; memcpy(&v, code_ + ip_, 8); return v; }
        int64_t& top_i64() { return *reinterpret_cast<int64_t*>(&opstack_[sp_]); }
        int32_t& top_i32() { return *reinterpret_cast<int32_t*>(&opstack_[sp_]); }
        int64_t& below_i64() { return *reinterpret_cast<int64_t*>(&opstack_[sp_ - 8]); }
        int32_t& below_i32() { return *reinterpret_cast<int32_t*>(&opstack_[sp_ - 8]); }
        void push_i64(int64_t v) { sp_ += 8; *reinterpret_cast<int64_t*>(&opstack_[sp_]) = v; }
        void pop(int bytes) { sp_ -= bytes; }
    };
}

static std::string b64_encode(const uint8_t* data, size_t len) {
    static const char* chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    std::string out;
    out.reserve(((len + 2) / 3) * 4);
    size_t i = 0;
    for (; i + 3 <= len; i += 3) {
        uint32_t n = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
        out.push_back(chars[(n >> 18) & 0x3F]);
        out.push_back(chars[(n >> 12) & 0x3F]);
        out.push_back(chars[(n >> 6) & 0x3F]);
        out.push_back(chars[n & 0x3F]);
    }
    size_t rem = len - i;
    if (rem == 1) {
        uint32_t n = data[i] << 16;
        out.push_back(chars[(n >> 18) & 0x3F]);
        out.push_back(chars[(n >> 12) & 0x3F]);
        out.push_back('='); out.push_back('=');

    }
    else if (rem == 2) {
        uint32_t n = (data[i] << 16) | (data[i + 1] << 8);
        out.push_back(chars[(n >> 18) & 0x3F]);
        out.push_back(chars[(n >> 12) & 0x3F]);
        out.push_back(chars[(n >> 6) & 0x3F]);
        out.push_back('=');
    }
    return out;
}

static std::vector<uint8_t> encrypt_vm_raw(const std::string& input, int32_t seed) {
    std::vector<uint8_t> buf(input.begin(), input.end());
    if (!buf.empty()) {
        grandvm::VM vm(bytecode, bytecodelen, buf.data(), static_cast<int32_t>(buf.size()), seed);
        vm.run();
    }
    return buf;
}

static std::string encrypt_vm(const std::string& input, int32_t seed) {
    std::vector<uint8_t> buf = encrypt_vm_raw(input, seed);
    return b64_encode(buf.data(), buf.size());
}

int main() {
    std::string input = "";
    int32_t seed = 12345;
    std::cout << "input: " << input << std::endl;
    std::cout << "seed: " << seed << std::endl;
    std::string encrypted = encrypt_vm(input, seed);
    std::cout << "encrypted: " << encrypted << std::endl;
    return 0;
}

ну а вот собственно говоря зашифрованные клиентом гранда девайс ид и жвт токен, с сидом необходимым для шифрования.
seed: 785537089
jwt: l+4IPu0qIBZznpyDBPeHXpz3F2rmCimEf73zlTjv9t+k1HBtoQhCN0yt7YFnwMw44NBieJ8qR/pvt6/NGeC9zMTaFjW7IykyCbmSoSbDtxi7xAZx2C0R3mSwwMRDysLgitpAFbwLalN2nsa6FsPcLpX2H0rlImjQV6qm6ibgzrFgzd0jLCOnxgIlXrfNFSRhjN0o
device id: Sn8s1JkaeHSBmB0sc9WITA==
а вот не зашифрованные девайс ид и жвт токен

08857a0ac7aee681
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MzU2NDc0NDIsImlhdCI6MTc4NjM5NDkwNywiZXhwIjoxNzg2NDgxMzA3fQ.JihrGNjCGh9QJFkcKTdKWd04s-pYk7FhymKUbimwomU

теперь прогоним их через эмулятор с тем же сидом и посмотрим на результат:
result vm:
в случае с жвт

input: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MzU2NDc0NDIsImlhdCI6MTc4NjM5NDkwNywiZXhwIjoxNzg2NDgxMzA3fQ.JihrGNjCGh9QJFkcKTdKWd04s-pYk7FhymKUbimwomU
seed: 785537089
encrypted: l+4IPu0qIBZznpyDBPeHXpz3F2rmCimEf73zlTjv9t+k1HBtoQhCN0yt7YFnwMw44NBieJ8qR/pvt6/NGeC9zMTaFjW7IykyCbmSoSbDtxi7xAZx2C0R3mSwwMRDysLgitpAFbwLalN2nsa6FsPcLpX2H0rlImjQV6qm6ibgzrFgzd0jLCOnxgIlXrfNFSRhjN0o

в случае с девайс идом

input: 08857a0ac7aee681
seed: 785537089
encrypted: Sn8s1JkaeHSBmB0sc9WITA==

как мы видим, 100% совпадений. и казалось бы, на этом можно закончить статью со словами "ну вот, мы эмулируем вм, можно спокойно зайти на гранд, и пройти так проверку", но, это не интересно, и мы приступим к девиртуализации этого чуда. для старта предстоит понять, какие опкоды отвечают за какую инструкцию. поэтому, путем трейса всего процесса работы вм, я смог восстановить ПРЕДПОЛОЖИТЕЛЬНЫЕ нейминги инструкций, которые эмулирует определенный опкод, и примерно понять весь принцип работы вм:
vm:
у вм есть два стека:
  opstack - стек операндов размером 8 кб
  локальный - стек локальных переменных размером 4 кб

параметры вм (летят при инициализации):
  a1 (int64) - поинтер на буфер данных
  a2 (int32) - размер буфера
  a3 (int32) - сам сид

обозначения, которые я буду использовать  ниже:
  top32 верхнее значение i32 в стеке (по адресу sp)
  top64 верхнее значение i64 в стеке
  below32 значение i32 на 8 байт снизу стека
  below64 значение i64 на 8 байт снизу стека
  imm32/imm64 непосредственный операнд (little endian) в потоке байткода, расположенный сразу после байта опкода

Notice!!!
для store операций (0x9c, 0xd3) указатель находится на верху стека,
а значение под ним

исключение только store_i64 (0x1b)  здесь указатель находится в below64,
а значение в top64

этот момент очень легко проебать, я потратил на это часа 2 пока реверсил

опкод  | мнемоника           | операнд | что он делает
0x0c   | u8_trunc_i32        |    -    | top32 = (uint8_t)top32
0x0e   | sub_i64             |    -    | below64 = below64 - top64; затем pop 8
0x11   | u8_mask_i32         |    -    | top32 = (uint8_t)top32  (то же самое, что и 0x0c, дубликат)
0x1b   | store_i64           |    -    | *(int64_t*)below64 = top64; pop 16
0x1d   | load_i64            |    -    | top64 = *(int64_t*)top64
0x21   | push_i32_const      |  imm32  | сначала push i64(0), затем top32 = imm32   (то же самое, что и 0xdf)
0x25   | add_i64             |    -    | below64 = below64 + top64; pop 8
0x31   | xor_i32             |    -    | below32 = below32 xor top32; pop 8   (то же самое, что и 0xd1)
0x34   | load_i32            |    -    | top32 = *(int32_t*)top64
0x3a   | umod_i32            |    -    | below32 = (u32)below32 mod (u32)top32; pop 8 выбрасывает исключение, если делитель равен нулю
0x43   | load_u8             |    -    | top32 = *(uint8_t*)top64  (дополнение байта нулями до i32)
0x46   | add_i32             |    -    | below32 = below32 + top32; pop 8
0x4a   | jmp_if_nz           |  imm32  | cond = top32; pop 8; если (cond != 0), тогда ip += imm32, иначе ip += 4
0x53   | or_i32              |    -    | below32 = below32 or top32; pop 8
0x58   | udiv_i32_swapped    |    -    | below32 = (u32)top32 / (u32)below32; pop 8 операнды поменяны местами, хз почему автор вмки решил это сделать
0x67   | ret                 |    -    | остановить вм и вернуть управление из run()
0x6c   | nop                 |    -    | ничего не делает                  (то же самое, что и 0xe3)
0x71   | zext_i32_to_i64     |    -    | top64 = (uint64_t)(uint32_t)top32
0x7a   | mul_i64             |    -    | below64 = below64 * top64; pop 8
0x84   | lea_local           |  imm32  | push &locals[4096 + imm32] по сути получить адрес локальной переменной
0x8c   | u8_trunc            |    -    | top32 = (uint8_t)top32        снова то же самое, что и 0x0c
0x94   | ult_i64             |    -    | below32 = ((u64)below64 < (u64)top64) ? 1 : 0; pop 8
0x98   | mul_i32             |    -    | below32 = below32 * top32; pop 8
0x9c   | store_u8            |    -    | *(uint8_t*)top64 = (uint8_t)below32; pop 16
0xa4   | jmp                 |  imm32  | ip += imm32  безусловный относительный переход
0xad   | sext_i32_to_i64     |    -    | top64 = (int64_t)(int32_t)top32  знаковое расширение
0xae   | ult_i32_swapped     |    -    | below32 = ((u32)top32 < (u32)below32) ? 1 : 0; pop 8 снова с поменянными местами операндами, как в 0x58
0xbc   | lea_param           |  imm32  | push адрес параметра вмки:
       |                     |         |    imm=0 -> &a1 (указатель на буфер)
       |                     |         |    imm=1 -> &a2 (длина буфера)
       |                     |         |    imm=2 -> &a3 (seed)
0xc3   | shl_i32             |    -    | below32 = (u32)below32 << (top32 and 31); pop 8
0xc6   | and_i32             |    -    | below32 = below32 and top32; pop 8
0xd1   | xor_i32             |    -    | (то же самое, что 0x31)
0xd3   | store_i32           |    -    | *(int32_t*)top64 = below32; pop 16
0xdf   | push_i32_const      |  imm32  | (то же самое, что 0x21)
0xe3   | nop                 |    -    | ничего не делает
0xf1   | push_i64_const      |  imm64  | push imm64

чуток моих наблюдей, как работает сама вм:

1. push всегда увеличивает sp на 8, даже если мы помещаем i32 маленькие значения хранятся в младшей половине 8 байтового слота, старшие биты просто мусор или нули

2. store операции (0x1b / 0x9c / 0xd3) берут пару (значение, указатель) из стека:
   для 0x9c и 0xd3 указатель находится сверху, значение снизу
   для 0x1b наоборот (указатель снизу, значение сверху)
   этот прикол вообще выглядит как баг оригинальной вмки, но алгоритм опирается на нее,
   поэтому я тупо эмулировал ее в точности

3. 0x21 и 0xdf делают одно и то же, но имеют разные опкоды. скорее всего, компилятор
   генерировал их из разных контекстов, то же самое с 0x31/0xd1, 0x6c/0xe3, 0x0c/0x8c/0x11.

4. 0x58 (деление) и 0xae (сравнение) у них почему то поменянные местами операнды. внутри вм это
   работает последовательно, но пиздец как сбивает, особенно когда впервые смотришь на трейс

идиомы, которые можно увидеть в конкретно этом байткоде шифрования строк:
df ii ii ii ii                      поместить 32-битную константу в стек
84 oo oo oo oo                      получить адрес local[offs]
84 oo oo oo oo 34                   загрузить i32 из local
84 oo oo oo oo  d3                  сохранить i32 в local
bc 02 00 00 00 34                   загрузить seed (a3) в стек
bc 00 00 00 00 34                   загрузить младшие 32 бита указателя на буфер (a1)
71 f1 01 00 00 00 00 00 00 00 00 7a
 расширить до i64, поместить 1, умножить. выглядит кстати как тупой способ
  просто "расширить до i64", разраб видимо поленился
  добавить отдельный опкод только для zext
a4 dd dd dd dd                      безусловный переход на смещение dd
4a dd dd dd dd                      условный переход, если top не равен нулю

если хотите понять полный алгоритм шифрования, то он будет буквально под этим кодом,
он уже восстановлен из этого байткода и работает 1 в 1 как и в вм.



и на основе этого, я смог восстановить исходный алгоритм "шифрования" который и был спрятан под этим чудо-байткодом, а именно, вот он:

encryption:
static std::vector<uint8_t> encrypt(const std::string& input, int32_t seed) {
    std::vector<uint8_t> buf(input.begin(), input.end());
    size_t total_len = buf.size();
    if (total_len == 0) return buf;
    uint32_t key = static_cast<uint32_t>(seed);
    const uint32_t MULTIPLIER = 0x06511073;
    const uint32_t CONST_A = 0x62098F3C;
    const uint32_t CONST_B = 30025;
    const uint32_t CONST_G = 0x7890CFB3;
    const size_t BLOCK_SIZE = 64;
    auto init_states = [](uint32_t a3) -> std::pair<uint32_t, uint32_t> {
        uint32_t s1 = CONST_A ^ ((CONST_B | (a3 << 13)) ^ ((a3 & 3) * 8));
        uint32_t s2 = (s1 << 16) | 0x448C;
        return { s1, s2 };
        };

    size_t num_full_blocks = total_len / BLOCK_SIZE;
    size_t rem_len = total_len % BLOCK_SIZE;
    uint32_t a3 = key;
    for (size_t block_idx = 0; block_idx < num_full_blocks; ++block_idx) {
        size_t local_24 = 0;
        for (int outer = 0; outer < 8; ++outer) {
            auto [state1, state2] = init_states(a3);
            for (int inner = 0; inner < 2; ++inner) {
                size_t addr = block_idx * BLOCK_SIZE + inner * 4 + local_24;
                uint32_t gamma = CONST_G ^ a3;
                uint32_t v;
                memcpy(&v, buf.data() + addr, 4);
                v ^= gamma;
                memcpy(buf.data() + addr, &v, 4);
                a3 ^= state1;
                state1 = state1 * MULTIPLIER + state2;
                state2 ^= static_cast<uint32_t>(inner);
            }
            local_24 += 8;
        }
    }
    if (rem_len > 0) {
        auto [state, _s2] = init_states(key);
        (void)_s2;
        uint32_t step = key * MULTIPLIER;
        for (size_t j = 0; j < rem_len; ++j) {
            size_t idx = total_len - 1 - j;
            uint8_t gamma = static_cast<uint8_t>(state & 0xFF);
            buf[idx] ^= gamma;
            state += step;
        }
    }
    return buf;
}

ну и после того как строка проходит через эту функцию то результат шифрования енкодится в base64 и отправляется серверу. собственно проверим, получилось ли у меня обойти эту проверку? так как я уже проверил, совпадает ли результат моего порта функции с результатом эмулятора и он совпал. а теперь зайдем в игру
1786397788297.png

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

src:
#include <windows.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <stdexcept>
#include <iostream>
#include <iomanip>

static std::string b64encode(const uint8_t* data, size_t len) {
    static const char* chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    std::string out;
    out.reserve(((len + 2) / 3) * 4);
    size_t i = 0;
    for (; i + 3 <= len; i += 3) {
        uint32_t n = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
        out.push_back(chars[(n >> 18) & 0x3F]);
        out.push_back(chars[(n >> 12) & 0x3F]);
        out.push_back(chars[(n >> 6) & 0x3F]);
        out.push_back(chars[n & 0x3F]);
    }
    size_t rem = len - i;
    if (rem == 1) {
        uint32_t n = data[i] << 16;
        out.push_back(chars[(n >> 18) & 0x3F]);
        out.push_back(chars[(n >> 12) & 0x3F]);
        out.push_back('='); out.push_back('=');
    }
    else if (rem == 2) {
        uint32_t n = (data[i] << 16) | (data[i + 1] << 8);
        out.push_back(chars[(n >> 18) & 0x3F]);
        out.push_back(chars[(n >> 12) & 0x3F]);
        out.push_back(chars[(n >> 6) & 0x3F]);
        out.push_back('=');
    }
    return out;
}

static std::vector<uint8_t> encrypt(const std::string& input, int32_t seed) {
    std::vector<uint8_t> buf(input.begin(), input.end());
    size_t total_len = buf.size();
    if (total_len == 0) return buf;
    uint32_t key = static_cast<uint32_t>(seed);
    const uint32_t MULTIPLIER = 0x06511073;
    const uint32_t CONST_A = 0x62098F3C;
    const uint32_t CONST_B = 30025;
    const uint32_t CONST_G = 0x7890CFB3;
    const size_t BLOCK_SIZE = 64;
    auto init_states = [](uint32_t a3) -> std::pair<uint32_t, uint32_t> {
        uint32_t s1 = CONST_A ^ ((CONST_B | (a3 << 13)) ^ ((a3 & 3) * 8));
        uint32_t s2 = (s1 << 16) | 0x448C;
        return { s1, s2 };
        };

    size_t num_full_blocks = total_len / BLOCK_SIZE;
    size_t rem_len = total_len % BLOCK_SIZE;
    uint32_t a3 = key;
    for (size_t block_idx = 0; block_idx < num_full_blocks; ++block_idx) {
        size_t local_24 = 0;
        for (int outer = 0; outer < 8; ++outer) {
            auto [state1, state2] = init_states(a3);
            for (int inner = 0; inner < 2; ++inner) {
                size_t addr = block_idx * BLOCK_SIZE + inner * 4 + local_24;
                uint32_t gamma = CONST_G ^ a3;
                uint32_t v;
                memcpy(&v, buf.data() + addr, 4);
                v ^= gamma;
                memcpy(buf.data() + addr, &v, 4);
                a3 ^= state1;
                state1 = state1 * MULTIPLIER + state2;
                state2 ^= static_cast<uint32_t>(inner);
            }
            local_24 += 8;
        }
    }
    if (rem_len > 0) {
        auto [state, _s2] = init_states(key);
        (void)_s2;
        uint32_t step = key * MULTIPLIER;
        for (size_t j = 0; j < rem_len; ++j) {
            size_t idx = total_len - 1 - j;
            uint8_t gamma = static_cast<uint8_t>(state & 0xFF);
            buf[idx] ^= gamma;
            state += step;
        }
    }
    return buf;
}

static std::string encrypt_native(const std::string& input, int32_t seed) {
    std::vector<uint8_t> buf = encrypt(input, seed);
    return b64encode(buf.data(), buf.size());
}

void c_plugin::game_loop() {
    static bool initialized = false;
    if (initialized || !rakhook::initialize() || c_chat::get()->ref() == nullptr)
        return game_loop_hook.call_original();

    initialized = true;

    StringCompressor::AddReference();
    c_input::get()->ref()->add_command("plugin_samp", [](const char* param) {
        c_chat::get()->ref()->add_message(-1, "plugin loaded");
        });

    rakhook::on_receive_rpc += [](unsigned char& id, RakNet::BitStream* bs) -> bool {
        if (id == 253) {
            int8_t type = 0;
            bs->Read(type);
            if (type == 18) {
                int32_t key = 0;
                bs->Read(key);
                std::string jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MzU2NDc0NDIsImlhdCI6MTc4NjM5NDkwNywiZXhwIjoxNzg2NDgxMzA3fQ.JihrGNjCGh9QJFkcKTdKWd04s-pYk7FhymKUbimwomU";
                std::string deviceid = "08857a0ac7aee681";
                std::string jwtencr = encrypt_native(jwt, key);
                std::string deviceidencr = encrypt_native(deviceid, key);
                uint8_t type = 18;
                int32_t jwtlen = static_cast<int32_t>(jwtencr.length());
                int32_t deviceidlen = static_cast<int32_t>(deviceidencr.length());
                RakNet::BitStream outbs;
                outbs.Write(type);
                outbs.Write(jwtlen);
                outbs.Write(jwtencr.c_str(), jwtlen);
                outbs.Write(deviceidlen);
                outbs.Write(deviceidencr.c_str(), deviceidlen);
                rakhook::send_rpc(253, &outbs, SYSTEM_PRIORITY, RELIABLE, 0, false);
                return false;
            }
        }
        return true;
        };

    return game_loop_hook.call_original();
}
и так, вернемся теперь к flush функции,
1786398598050.png

то есть к ней, если кто забыл.

я написал скрипт для фриды, который хукнет эту функцию и посмотрит что через нее вообще проходит, и попытается сдампить.
и действительно, кое что через эту функцию проходит.
1786400530331.png

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

MEMORY[0x23E271C](v175, &qword_188DD30[72], &STACK[0x34D0], 1, 10, 0); вероятнее всего обращение к vtable для отправки рпс/пакета с 10 надёжностью.
MEMORY[0x2160980](&STACK[0x34D0], 33); - вероятнее всего либо айди пакета/рпс, либо его подтип. я здесь вижу зашифрованные строки, поэтому я написал такой скрипт для их расшифровки:

src:
import idc
import idaapi

def xor_decrypt(v0, v1):
    key0 = 0xE9B6329AA09B1E67
    key1 = 0x74625F230927F0A0
    result = ""
    bytes_val = v0 ^ key0
    bytes2_val = v1 ^ key1
    for i in range(8):
        b = (bytes_val >> (i * 8)) & 0xFF
        if b == 0:
            break
        result += chr(b)
    for i in range(8):
        b = (bytes2_val >> (i * 8)) & 0xFF
        if b == 0:
            break
        result += chr(b)
 
    return result

def main():
    idc.msg("str1: {}\n".format(xor_decrypt(0x9CD25DE8D0B57115, 0x18073B4C640984C3)))
    idc.msg("str2: {}\n".format(xor_decrypt(0xC9C65DE8D0EF7B00, 0x74625F230927F0A0)))
    idc.msg("str3: {}\n".format(xor_decrypt(0xE9B6329AA09B1E15, 0x74625F230927F0A0)))
    idc.msg("str4: {}\n".format(xor_decrypt(0xADFF7EDBF6D55746, 0x74625F230927F081)))
    idc.msg("str5: {}\n".format(xor_decrypt(0x9CD25DE8D0B57115, 0x12173142640984C3)))
    idc.msg("str6: {}\n".format(xor_decrypt(0x9CD25DE8D0B57115, 0x100C3E516B0984C3)))
    idc.msg("str7: {}\n".format(xor_decrypt(0x9ED240FBC8B57115, 0x74625F23094282C1)))
    idc.msg("str8: {}\n".format(xor_decrypt(0x8DDA5BEFC2B57115, 0x1A0D36507B42868E)))
    idc.msg("str9: {}\n".format(xor_decrypt(0x8DDA5BEFC2B57115, 0x1A0D36507B42868E)))
    idc.msg("str10: {}\n".format(xor_decrypt(0xACFA6DD3F0DA4131, 0x74625F23096BB5F6)))
    idc.msg("str11: {}\n".format(xor_decrypt(0xA6FA77C8F0C45A2B, 0x74625F230927B4E1)))
    idc.msg("str12: {}\n".format(xor_decrypt(0xDBC9D0F1E19CC6FF, 0x74625F230927F0A0)))

if __name__ == "__main__":
    main()

и судя по результату расшифровки, эта функция отвечает за сбор и отправку информации об устройстве пользователя
str1: ro.product.model
str2: getprop
str3: r
str4: !INVALID!
str5: ro.product.manuf
str6: ro.product.brand
str7: ro.hardware
str8: ro.build.version
str9: ro.build.version
я подгрузил остальные куски дампа, и вы не поверите какой красавец в них спрятался!
1786401033246.png
к сожалению, из-за того что я криво прожал P в иде, эта функция разбилась на три части, но это отправка 25 рпс!!!
1786401090121.png

0xFD9 и 43 это челленж ансвер и размер GPCI, то есть, клиент джоин у них выглядит вот так(ну просто для справки, такой метод поиска это ебейший оверхед, и я в вайршарке при просмотре трафика видел его структуру, но просто для баланса вселенной оставил этот момент в статье):

src:
function EVENT.onSendClientJoin(VERSION, MOD, NICKNAME, CHALLENGERESPONSE, AUTHKEY, CLIENTVER, UNKNOWN)
    local BS = raknetNewBitStream()
    raknetBitStreamWriteInt32(BS, 4057) -- тот самый челленж ансвер
    raknetBitStreamWriteInt8(BS, 1) -- мод
    raknetBitStreamWriteInt8(BS, #NICKNAME) -- ник
    raknetBitStreamWriteString(BS, NICKNAME)
    raknetBitStreamWriteInt32(BS, CHALLENGERESPONSE)
    raknetBitStreamWriteInt8(BS, 43) -- тот самый размер gpci, на скрине выше
    raknetBitStreamWriteString(BS, "149C15C7E69314B147D55069C245763C07DD8AB4429") -- gpci
    raknetBitStreamWriteInt8(BS, 5)
    raknetBitStreamWriteString(BS, "0.3.7")
    raknetBitStreamWriteInt16(BS, 34215)
    raknetBitStreamWriteInt32(BS, #JWT) -- жвт токен
    raknetBitStreamWriteString(BS, JWT)
    raknetBitStreamWriteInt32(BS, #DEVICEID) -- девайс ид
    raknetBitStreamWriteString(BS, DEVICEID)
    raknetBitStreamWriteInt32(BS, 5802)
    raknetBitStreamWriteInt8(BS, 1)
    raknetBitStreamWriteInt8(BS, 64)
    local CLVER = "58.61-apk" -- версия их билда
    raknetBitStreamWriteInt8(BS, #CLVER)
    raknetBitStreamWriteString(BS, CLVER)
    raknetSendRpc(25, BS)
    raknetDeleteBitStream(BS)
    return false
end

и та самая функция на почти 1600 строк оказалась подтипом 253 рпс которая летит в ответ на инитгейм, на плюсы я ее переписывать не стал как и клиент джоин, поэтому вот реализация на луа:
src:
    local baseapkpath = "/data/app/~~iU4ONobijBqG2dLIv7Y3jA==/com.grand.cr-glJL680y9tXzbhdFrUvoCg==/base.apk"
    local model = "SM-G985F"
    local brand = "SAMSUNG"
    local brand1 = "SAMSUNG"
    local proc = "EXYNOS990"
    if (ID == 139) then
        local BS = raknetNewBitStream()
        local XZ = math.random(4096, 9999999999)
        local MODELENCR = encrypt(model, XZ)
        local BRANDENCR = encrypt(brand, XZ)
        local BRAND1ENCR = encrypt(brand1, XZ)
        local PROCENCR = encrypt(proc, XZ)
        local BASEAPKPATHENCR = encrypt(baseapkpath, XZ)
        raknetBitStreamWriteInt8(BS, 33)
        raknetBitStreamWriteInt8(BS, 1)
        raknetBitStreamWriteInt32(BS, XZ)
        raknetBitStreamWriteInt8(BS, #MODELENCR)
        raknetBitStreamWriteString(BS, MODELENCR)
        raknetBitStreamWriteInt8(BS, #BRANDENCR)
        raknetBitStreamWriteString(BS, BRANDENCR)
        raknetBitStreamWriteInt8(BS, #BRAND1ENCR)
        raknetBitStreamWriteString(BS, BRAND1ENCR)
        raknetBitStreamWriteInt8(BS, #PROCENCR)
        raknetBitStreamWriteString(BS, PROCENCR)
        raknetBitStreamWriteInt32(BS, 30) -- sdk ver
        raknetBitStreamWriteInt32(BS, 11) -- android ver
        raknetBitStreamWriteInt8(BS, #BASEAPKPATHENCR)
        raknetBitStreamWriteString(BS, BASEAPKPATHENCR)
        raknetBitStreamWriteInt8(BS, 0)
        raknetBitStreamWriteInt8(BS, 0)
        raknetSendRpc(253, BS)
        raknetDeleteBitStream(BS)
    end

В целом, на этом все. Спасибо за прочтение статьи, по традиции рекомендую ее сохранить.

ну и подарок от @libsamp.so

всем привет дорогие читатели этой статьи, с вами libsamp.so и сегодня мы продолжаем наш разбор защиты на Grand Mobile(мы проигрываем все больше и больше, но мы не отчаиваемся и будем идти вперёд!)
если кратко, самый простой метод детекта фриды - это открытие proc/self/maps при помощи fopen, и сканирование на наличие frida-agent.so
напишем небольшой скрипт под фриду(да, детект фриды не происходит постоянно, это происходит в некоторых моментах которые мы обсудим чуть позже) который будет отслеживать открытие fopen из библиотеки libc.so:
JavaScript:
const fopen = Process.getModuleByName("libc.so").getExportByName("fopen");
Interceptor.attach(fopen, {
    onEnter(args) {
        const path = args[0].readUtf8String();
        const caller = Process.findModuleByAddress(this.returnAddress);
        const offset = caller ? caller.name + " + 0x" + this.returnAddress.sub(caller.base).toString(16): this.returnAddress.toString();
        console.log(`fopen: ${path} offset: ${offset}`);
    }
});
активируем его, и посмотрим на результат:
[SM G985F::GRAND MOBILE ]-> fopen: /proc/self/statm offset: libflutter.so + 0x9a6edc
fopen: /proc/self/statm offset: libflutter.so + 0x9a6edc
fopen: /proc/self/maps offset: libsigner.so + 0x1c180
fopen: /data/app/~~KlQJhT0tJozU9hwhK_uSTQ==/com.grand.cr-S6PwBjr-JPpWzuMfoGwPuA==/base.apk offset: libsigner.so + 0x124efc
fopen: /proc/self/maps offset: libsigner.so + 0xda034
Connection terminated
как мы видим, тут открывается proc/self/maps сразу два раза, а именно на оффсетах 0x1c180 и 0xda034 в библиотеке libsigner.so, и сразу после фрида завершает работу(защита убила её), перейдем на них...

1786404293885.png

1786404308652.png

(первый скриншот 0xda034 - функция sub_D78B8, второй 0x1c180 - функция sub_18540)
как мы видим на втором скриншоте, идет открытие proc/self/maps(строка расшифрованная потому что я сделал дамп, в оригинальной библиотеке там будет зашифрованный массив)
на первом же скриншоте явно не указан путь для открытия, но знать нам это и не особо нужно.
функции в которых находятся вызовы fopen находятся под сильной обфускацией, из за чего понять точную логику что происходит после детекта фриды сложно, но нам это и не нужно, нам нужно узнать откуда эти функции вызываются...
Проходимся по хрефам функций, и в конце у каждой функции будет одна функция вызова, это экспорт Java_com_adjust_sdk_sig_NativeLibHelper_nSign.
1786404325198.png

переходим в неё и видим довольно обычную картину: вызываются довольно много функций детекта "аномалий" в устройстве пользователя, а также наши вызовы проверки фриды.
самое очевидное что можно сделать в такой ситуации - отменить вызов этого экспорта, напишем еще один скрипт который сделает это:
JavaScript:
const signer = Process.getModuleByName("libsigner.so");
const sign = signer.getExportByName("Java_com_adjust_sdk_sig_NativeLibHelper_nSign");
Interceptor.replace(sign, new NativeCallback(function (a1, a2, a3, a4, a5, a6) {
    console.log("sended to saratov")
    return ptr(0); // возвращает ptr(0), в ином случае нам будет вылезать ошибка в консоль
}, 'pointer', ['pointer', 'pointer', 'pointer', 'pointer', 'pointer', 'int']));

Заходим в игру и проверяем:

Да, всё работает ура!(байпасс гуи не за горами)
1786404350619.png

Что же насчет моментов в которые происходит сканирование, так это клики по любому интерфейсу в лаунчере и некоторые действия в игре(тут уже сами ищите откуда вызывается Java_com_adjust_sdk_sig_NativeLibHelper_nSign)

 

Вложения

  • 1786404305961.png
    1786404305961.png
    218.9 KB · Просмотры: 25
Последнее редактирование: