Monakez
Об игрушках unity и android
Делать было нечего, дело было до +30 за окном и появился интерес вернуться к задачке. которая у меня долго пылилась на полке: а именно получить способ забраться во внутренности запущееного нативного приложения на андроид-эмуляторе, который использует libhoudini в качестве транслятора ARM. Для несведующих поясню: есть устройство андроид - оно в основе своей использует процессор ARM архитектуры. есть приложения, которые написаны так, что в конечном счете представляют собой машинный код по эту конкретную архитектуру. при желании что-то сделать с этим приложением необходим какой-то способ управлять этим машинным кодом. чем мы и займемся.
итак, у нас имеется некое приложение. а именно игрушка, скачаная с гугль плей( в процессе ковыряния я пришел к выводу, что это купленый кем-то движок с накручеными на него магазином. звуками и графикой, т.е. игрушек таких - много). у нас имеется понимание, что для "ковыряния" внутри запущенного приложения нам потребуется устройство с root доступом и очевидным решением тут видится эмулятор.
мы знаем, что эмуляторы( memu, bluestacks, nox, genymotion, etc...) запускаются на платформах с базовой архтектурой x86_64, но позволяют исполнять код для архитектуры arm. это достигается за счет исползования intel овской поделки под названием lib(rary)houdini - некий транслятор, встраиваемый в конкретный эмулятор его разработчиками и позволяющий собственно транслировать arm код в x86( как это делается - не знаю, да и код этого транслятора вроде как нигде не опубликован).
мы так же имеем базовые понятия о том, что такое apk, pm, adb, (ba)sh
таким образном мы имеем возможность выполнять почти все приложения, созданные для arm-устройств на наших настольных ПК.
дополнительно к этому у нас есть: текстовый редактор + javascript/python/c и некоторое ПО для отладки и внедрения в приложения. в частности - frida
ну чтож. приступим.
для начала устанавливаем наше приложение себе на эмулятор (я взял genymotion) и запускаем его. оно работает. чтож... теперь нам стоит "найти" его на эмуляторе и скачать к себе на локальную машину для дальнейшего анализа ( adb pull нам в помощь).
конкретно эта игрушка, которую разбирал я шла в split- apk. соответственно выкачиваем все apk файлы и ищем в них что-то относящееся к unity ( а конкретно нас интересуют libunity.so libil2cpp.so global-metadata.dat)
коротко, в двух словах зачем все это нужно:
unity - мета-фреймворк для разработки приложений с последюущим их релизом под различные платфформы. основной инструмент там - c4+ соответственно для "кроссплатформенности" был создан некий транслятор, а именно cSharp->IL(intermediate langauge)->platform. вот этот последний переход осуществляется посредством специальной виртуальной машины(ВМ), разной для каждой платформы и эта ВМ использует различные данные из global-metadata.dat в процессе трансляции IL и его последующего исполнения.
т.к. сам по себе c4+ вполне себе легко подает реверзу, то задача на первоначальном этапе сводится к вытаскиванию этого c4+ кода каким-то образом из IL с целью последующего изучения и выбора направлений дальнейшего анализа.
не вдаваясь в дебри всей этой внутренней кухне, отмечу лишь, что в природе существуют открытые решения для этой процедуры, в частности il2cppdumper коим и воспользуемся.
к сожалению( или к счастью) те данные, которые необходимы для работы этого ПО частично или полностью unity-разработчки определенным образом шифруют\обфусцируют. а именно файл global-metadata.dat и наша задача, в случае если il2cpp dumper не сможет корректно отработать будет заключаться в том, чтобы этот global-metadata.dat "восстановить".
на самом сайте il2cppdumper разобраны некие типовые случаи этого "восстановления"
в нашем же все свелось к небольшому анализу(найти место запуска IL virtual machine просмотреть до момента обращения к global-metadata.dat) и copy-paste листинга(анализировал libil2cpp с помощью IDA)
в итоге имеем следующий листинг, который нам любезно предоставила ida
fileHandle2 = (__int64)fileHandle;
fileBuffer = (const void *)utils::MemoryMappedFile::Map(fileHandle);
os::File::Close(fileHandle2, &error);
if ( (_DWORD)error )
{
utils::MemoryMappedFile::Unmap(fileBuffer);
LABEL_21:
fileBufferInMem = 0LL;
goto LABEL_22;
}
size_of_fileBuffer = get_size_of_fileBuffer((unsigned __int64)fileBuffer);
fileBufferInMem = malloc(size_of_fileBuffer);
memcpy(fileBufferInMem, fileBuffer, size_of_fileBuffer);
unXorArray = malloc(64u);
v15 = LoadMetadataFile_Val_1;
v16 = LoadMetadataFile_Val_2;
for ( i = 0LL; i != 64; ++i )
{
v16 = 18000 * (unsigned __int16)v16 + HIWORD(v16);
unXorArray[i] = v16;
v15 = 36969 * (unsigned __int16)v15 + HIWORD(v15);
}
LoadMetadataFile_Val_1 = v15;
LoadMetadataFile_Val_2 = v16;
if ( size_of_fileBuffer >= 1 )
{
bufidx = 0LL;
uXidx = 1;
do
{
uXidxOffset = uXidx + 62;
if ( uXidx - 1 >= 0 )
uXidxOffset = uXidx - 1;
notDone = size_of_fileBuffer <= uXidx;
*((_BYTE *)fileBufferInMem + bufidx) ^= unXorArray[uXidx - 1 - (uXidxOffset & 0xFFFFFFC0)];
bufidx = uXidx++;
}
while ( !notDone );
}
LABEL_22:
if ( (v24 & 1) != 0 )
operator delete(v26);
if ( (v27 & 1) != 0 )
operator delete(v29);
return fileBufferInMem
итак, не обращая внимания на всевозможные страшные буквы и названия переменых - сразу видим(и переименовыываем очевидное) следующее:
мапится файл в память(fileHandle, который указывает на global-metdata.dat)
магия
возвращается fileBufferInMem
вот эта вот магия - это и есть то ,что разработчики конкретной игры пытались выдать за "обфускацию" а именно
xor данных по ключу из генерируемого массива. крайне удобно тем, что a == xor(xor(a,b),b)
т.е. у нас на руках сразу есть ключ и закодированные данные. немного C и
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
typedef uint64_t QWORD; // DWORD = unsigned 64 bit value
typedef uint32_t DWORD; // DWORD = unsigned 32 bit value
typedef uint16_t WORD; // WORD = unsigned 16 bit value
typedef uint8_t BYTE; // BYTE = unsigned 8 bit value
#define LOWORD(l) ((WORD)(l))
#define HIWORD(l) ((WORD)(((DWORD)(l) >> 16) & 0xFFFF))
#define LOBYTE(w) ((BYTE)(w))
#define HIBYTE(w) ((BYTE)(((WORD)(w) >> 8) & 0xFF))
BYTE* readContent(char* filename, size_t* size_t_ptr) {
FILE *f = fopen(filename, "rb");
fseek(f, 0, SEEK_END);
*size_t_ptr= ftell(f);
fseek(f, 0, SEEK_SET); /* same as rewind(f); */
BYTE *string = malloc(*size_t_ptr);
fread(string, *size_t_ptr, 1, f);
fclose(f);
return string;
}
void storeContent(char* filename, BYTE* buffer,size_t bufferSize){
FILE *f = fopen(filename, "wb");
fwrite(buffer,bufferSize,1,f);
fclose(f);
}
BYTE* initXor() {
BYTE* buf=(BYTE*)malloc(64);
DWORD v16=0x2A;
for(int i=0;i!=64;i++) {
v16=18000*((WORD)v16)+HIWORD(v16);
buf[i]=LOBYTE(v16);
//printf("%02X,", buf[i]&0xFF);
}
return buf;
}
void unXorBuffer(QWORD size_of_fileBuffer,BYTE* fileBufferInMem) {
if (size_of_fileBuffer<1){
return;
}
BYTE* unXorArray=initXor();
QWORD bufidx = 0LL;
QWORD uXidx = 1;
BYTE notDone;
do
{
DWORD uXidxOffset = uXidx + 62;
if ( uXidx - 1 >= 0 )
uXidxOffset = uXidx - 1;
notDone = size_of_fileBuffer <= uXidx;
fileBufferInMem[bufidx] ^= unXorArray[uXidx - 1 - (uXidxOffset & 0xFFFFFFC0)];
bufidx = uXidx++;
}
while ( !notDone );
free(unXorArray);
}
int main(int argc, char *argv[]) {
BYTE* uXor=initXor();
size_t gmSize;
BYTE* gmBuffer=readContent("./global-metadata.dat",&gmSize);
printf("%d",gmSize);
unXorBuffer(gmSize,gmBuffer);
storeContent("./global-metadata.dat.dec",gmBuffer,gmSize);
return 0;
}
на выходе получим "правильный" global-metdata.dat
далее можно воспользовваться il2cppdumper и восстановить структуру( не содержимое!!) приложения, т.е. классы их поля и методы.
и тут скорее всего (а на это яно намекали имена функций в IDA в libil2cpp) мы ничего понять сходу не сможем. т.к все эти классы\поля\методы\ обфусцированы, т.е. нет возможности по названиям понять кто за что отвечает:
в таком случае наиболее очевидным и простым является анализ самого приложения с точки зрения "а что там вообще проиcходит. и куда мы можем сваять заплатку". в процессе анализа выявились 3 направления - данные игры( т.е всевозможные цены. шансы. награды и тп.) сохраниения - дада. в игре есть облачное сохранение. и ГПСЧ(random generator - вот он как раз и отвечает в конечном счете за всякие шансы\награды и тп)
разглядывая то, что "забыли\не смогли" закрыть разработчики своим обфускатором видно:
какой-то античит(значит нас "пасут" где?)
используется json( полистав листинг классов, аннотации на методах\полях - игра вдоль и поперек использует json для маршаллинга. учтем.)
какой-то savegamefree - значит можно приицепиться к save & load фукциям и посмотреьт что сохраняется и как ( сразу скажу, что необходимости в этом нет, т.к у нас есть п2)
чтож. приступим.
не буду долго описывать процесс поиска античита - он пристуствует. и подвязан на эти самые шансы. цены. игровую валюту и т.п) и в процессе "исследования" приходилось несколько раз начинать сначала ввиду "банхаммера" с "той стороны".
оставим за кадром процесс "прицепляния" frida к libhoudini в эмуляторе ( намекну только, что загрузка DLL происходит через последовательный поиск искомой библиотеке в каталоге "для библиотек приолжения" в апк в системе...)
для упрощения работы с frida и il2cpp я наткнулся в гугле на il2cpp-bridge - эдакая удобная надстройка над функционалом frida для более удобного вызова\анализа\перехвата интересующих меня функций (я нашел ее много позже и до этого полз через классический вариант. поиск RVA искомых функций в дампе c4+ файле, который получил из il2cppdumper и последующим их перехватом)
Атаковать будем ГСЧ - т.е. искуственно выдавать нужные нам шансы для какого-либо дейтсвиия. а именно "выпадение нужной нам вещи". для этого надо понимать. что ГСПЧ - это некая функция, которая имеет "начальную точку" и период(т.е. через какое-то большое количество обращений будут возвращаться те же самые значения от старта)
для этого находим все функции, где упомянется слово Random и "прицепимся" к ним. посмотрим какой именно ГПСЧ используется ( как минимум у нас их два - это System::Random и unity random ) входные и выходные значения в момент "выпадения вещи" (и продолжим уже исключительно с ним)
згрузим сохранение - повторим -загрузим-повторим.
наблюдеаем следующее:
каждый раз выпадает одна и та же вещь . следовательно где-то в сохранении (и даже можно найти где. но не нужно) сохраняется текущее значение для "начального значения" нашего ГПСЧ(initial seed, seed) и используется вызов всяких функций randomXXXX
по семантике понятно, что это "качество" вещи.
аттрибуты. значения аттибутов.
нас интересует только качество
следовательно схема такая:
перехватываем установку seed - устанавливаем свое значение. ловим нужную нам вещь
собственно вот рожденный в процессе ковыряния быдлокод, который приводит к требуемому результату
boilerplate взят тут
git remote -v
origin https://github.com/oleavr/frida-agent-example.git (fetch)
origin https://github.com/oleavr/frida-agent-example.git (push)
import {Buffer} from "buffer";
import "frida-il2cpp-bridge";
console.log("Rebuilded")
function awaitForCondition(callback: any) {
var i = setInterval(function () {
var addr = Module.findBaseAddress('libil2cpp.so');
console.log("Address found:", addr);
if (addr) {
clearInterval(i);
callback(+addr);
}
}, 0);
}
function _attach(base: any, klass: any, mtd: any, get_StackTrace: any) {
const va = mtd.virtualAddress
if (va == 0x0) {
//console.log("Attach fail", mtd.virtualAddress)
return
}
try {
Interceptor.attach(va, {
onEnter: function (args) {
console.log("onenter ", klass.fullName, `mtd:"${mtd}"`);
if (get_StackTrace != null) {
console.log(get_StackTrace.invoke());
}
//console.log("encode password", [>input<]pwd);
}
});
} catch (err) {
console.log(klass.fullname.tostring(), mtd, err)
console.log("error interception rva", mtd, va.sub(base))
}
}
const klassesOfInterests = [
// /*MainCharacterData*/"SH.491471447.sh_cxse1",
// /*some json related stuff*/"SH.491473776.sh_dbdo1",
// /*some json related stuff*/"SH.491473773.sh_dbdl1",
// /*some json related stuff cur character data? */"SH.491471465.sh_cxsw1",
// /*some json related stuff to files/sc.d? */"SH.491471742.sh_cydk1",
// /*diamond related data*/"SH.491471457.sh_cxso1",
// /*some logs to json handling?*///"SH.491475490.sh_ddri1",
// //"SH.491471477.sh_cxth1",
//"UnityEngine.Random",
// "System.Random",
// "RandomGemRewardVisualInfo",
// "PseudoRandom",
// "DefaultRandom",
"CodeStage",
// "SH.Feature.MS.CardProcess",
// "SH.Feature.RL.DiamondProcess"
]
const mtdsToSkip = [".ctor", "CurrentOwned", "get_IsRunning"]
/*
*
* enum of some events related to diamondProcess and game
* SH.491471427.sh_cxrk1
*
* ????
* SH.491471477.sh_cxth1
* assets related stuff?
* SH.491471546.sh_cxvx1
* */
awaitForCondition(function (base: any) {
const il2cpp = ptr(base);
//bind(il2cpp, ObscuredRefsRVAs)
//bind(il2cpp, BayatGamesFns)
Il2Cpp.perform(() => {
const SystemString = Il2Cpp.corlib.class("System.String");
const single = Il2Cpp.corlib.class("System.Single");
const int32 = Il2Cpp.corlib.class("System.Int32");
const get_StackTrace = Il2Cpp.corlib.class("System.Environment").method("get_StackTrace");
const SystemBoolean = Il2Cpp.corlib.class("System.Boolean");
const SystemType = Il2Cpp.corlib.class("System.Type");
const cSharp = Il2Cpp.domain.assembly("Assembly-CSharp");
const cSharpFP = Il2Cpp.domain.assembly("Assembly-CSharp-firstpass");
/*
with SEED=1 we have right top legendary item
this is Range(x,y) return values
inside 1 Range 0,1 System.Single 0.0003153085708618164
inside 1 Range -0.20000000298023224,0 System.Single -0.11122268438339233
inside 1 Range -0.20000000298023224,0 System.Single -0.06576250493526459
inside 1 Range -0.20000000298023224,0 System.Single -0.03238866850733757
*/
let seedValue = 1;
global.setRandomSeed = function (value) {
seedValue = value;
console.log("seed", seedValue)
}
let rrValues = []
const setRandomRange = function (...values) {
rrValues = [];
const rrTmp = [];
values.forEach(v => {
//const sV = Il2Cpp.string(`${v}`);
//rTmp.push(single.tryMethod("Parse", 1).invoke(sV));
rrTmp.push(v)
})
rrValues = rrTmp.reverse().sort()
console.log(rrValues.reverse());
}
global.overloadValues = function (vMin, vMax) {
var x = [];
for (var i = vMin; i <= vMax; i++) {
x.push(i);
}
rrValues = x.reverse();
console.log(rrValues)
}
global.setRandomRange = setRandomRange;
global.fixRandom = function () {
cSharp.image.classes.filter(klass => klass.fullName.includes("SH.Feature.E.RandomEquipmentProcess"))
.forEach(k =>
k.methods.filter(m => m.name.includes("Generate"))
.forEach(m => {
if (m.parameterCount != 2) {
return
}
m.implementation = function (a, b) {
console.log('genrate', k.fullName, m)
console.log(a)
console.log(b)
return this.tryMethod("Generate").invoke(a, b);
}
})
)
/*
* mix SEED with UNITY int32 RANDOM.RANGE gives us neceesarry results
* setRandomSeed(1123120)
* setRandomSeed(1023121)
* overloadValues(20,23)
* */
Il2Cpp
.domain.assembly("UnityEngine.CoreModule")
.image.class("UnityEngine.Random")
.methods.forEach(mtd => {
console.log('inject in ', mtd)
mtd.implementation = function (...args) {
const rva = mtd.relativeVirtualAddress;
if (!!rva && rva.equals(ptr(0x12e8d0c))) {
const a = args[0]
const b = args[1]
//this is item id, but idk what value it need to be?
// if (a === 0 && b === 100) {
if (a === 0 && b === 100) {
const v = rrValues.pop()
if (v !== undefined) {
console.log('overload Range(0,100) with', mtd, a, b, v)
return 0 + v
}
}
const rv = mtd.invoke(a, b);
console.log('range(x,y)', a, b, rv)
// int32 Range(int32, int32)
return rv
}
if (rva.equals(ptr(0x12e8cc0))) {
//float Range
//console.log('floatRange');
return 0.0006;//mtd.invoke(seedValue !== undefined ? seedValue : 1);
}
if (rva.equals(ptr(0x12e8b64))) {
//InitState
console.log('initState');
return mtd.invoke(seedValue !== undefined ? seedValue : 1);
}
const rv = mtd.invoke(...args);
//console.log('invoked', mtd, mtd.relativeVirtualAddress, args, rv)
return rv;
}
})
}
});
})
запустили приложение на эмуляторе.
запустили frida с нашим скриптом. загрузили свою сохраненную игру.
пробуем без "своего" ГСПЧ:
включаем "свой":
вуаля.
цель достигнута. любые "шансы" в игре теперь под нашим контролем. значения контролируемых античитом участков памяти - не затрагивались( т.е. всю валюту и прочая целесообразнее добывать "законными" методами..)
таким образом используя frida и немного терпения можно получить результат без затрат времени и\или денег( то, что в основном используют как валюту подавляющее большинство игроков)
на этом интерес к этой задаче интерес мой закончился, но этот пост - пусть остается. в том числе и как памятка мне
ЗЫ техники изменения значений переменных в памяти малоэффективны из-за используемого античита
Спасибо, что прочитал.