Универсальный дампер/инжектор Unity3D(Mono, Android)

image

Приветствую!
Не так давно я увлекся исследованием игр под android. Как оказалось, весьма немалое количество разработчиков используют Unity3D (наверное, процентов 50–60 игр, которые мне были интересны, базируются на этом движке). Сразу оговорюсь — я не специалист по взлому и даже практически не знаю C++/asm (не смотря на небольшое знакомство с этой темой), так что просьба не швыряться унитазами при помощи гравипушек. Также небольшое уточнение — я исследовал практически только ММО/полу-онлайновые игры в стиле «крабишь сюжетный данж до посинения, а потом сражаешься на арене с другими игроками, причем полу-оффлайн). Оффлайновые игры на Unity3D исследовать просто-напросто скучно.
Собственно, насколько мне известно игрушки под Unity3D используют 2 технологии: Mono и Il2cpp.
В пределах данного материала я хочу рассмотреть процесс подмены .NET dll’ок и дампа даже шифрованных версий этих самых dll’ок напрямую из игры.


Я разрабатываю под windows/node.js, потому стек технологий буду описывать в контексте того, что использую сам.

Итак, нам понадобятся:
1. Рутованный android (без рута не заведется frida-server)
2. Android SDK (точнее, adb)
3. Frida.
Что это такое и зачем нужно, можно прочесть здесь — Frida.
Пример гайда под android — Android guide.

Нам же сейчас нужна frida-node, frida-load и frida-server (какой из архивов нужен, точно не скажу, зависит от архитектуры, у меня завелся
frida-server-10.6.19-android-x86.xz).

Собственно, извлекаем куда-нибудь файл из архива, переименовываем его как-нибудь покороче (к примеру, serv) и запихиваем куда-нибудь через adb push или ручками.
Заливка:
-Переименовываем файл, к примеру, в serv
-Заливаем на устройство:
adb push serv /data/local/tmp/serv

Запуск:
-adb shell
-su
-/data/local/tmp/serv

4. Рядом с кодом создать папку csharp. Да, я настолько ленив, что бы добавить 2 строчки кода для проверки существования этой папки (даже с учетом того, что на разъяснение этого ушло больше символов).

5. Собственно, код.
Устанавливаем вышеупомянутую frida-node, создаем 2 файла — app.js и unity_bootstrap.js.

Код файлов:

app.js

const frida = require('frida');
const load = require('frida-load');
const fs = require('fs');
const spawn = require('child_process').spawn;




const spawnAwait = (file)=>new Promise((resolve, reject)=>{
    const child = spawn('adb', ['push', 'csharp/'+file, "/sdcard/"+file]);
    child.on('close', (code) => {
        console.log(`child process exited with code ${code}`);
        resolve();
    });
});
const waitBuild = (file)=>new Promise((resolve, reject)=>{
    const child = spawn('build.bat', []);
    child.on('close', (code) => {
        console.log(`child process exited with code ${code}`);
        resolve();
    });
});

let appName=process.argv[2];
if(!appName){
    appName="COM.ANDROID.SOMETHING";
}

let session, script;


const hexToBytes=(hex)=>{
    let newLine=0;
    for (var bytes = [], c = 0; c < hex.length; c += 2){
        bytes.push(hex.substr(c, 2));
        newLine+=2;
        if(newLine>=40){            
            bytes.push("\n");
            newLine=0;
        }
    }
    return bytes.join(" ");
}
// /data/local/tmp/serv
(async () => {
    fs.writeFileSync("session_log.txt", "Starting session\n", ()=>{});
    const device = await frida.getUsbDevice();
    let pid = await device.spawn([appName]);
    session = await device.attach(pid);
    const source = await load(require.resolve('./unity_bootstrap.js'));
        
    script = await session.createScript(source);
    script.events.listen('message', (message,b) => {
        if (pid && message.type === 'send' && message.payload && message.payload.event === 'ready'){
            device.resume(pid);
            console.log("Resume");
        }
        else
        {
            if(!message.payload){
                console.log(message);
                return;
            }
            if (message.payload.event == "dump") {
                fs.appendFile("csharp/"+message.payload.name, b, ()=>{});
            }
        }
    });
    await script.load();
    let injectedLibs=['Assembly-CSharp.dll'/* , 'UnityEngine.dll' */];
    injectedLibs=injectedLibs.filter(x=>fs.existsSync("csharp/"+x));
    if(!injectedLibs.length){
        script.post({type: 'loadData', count: 0});
    }
    await Promise.all(injectedLibs.map(x=>spawnAwait(x)));
    injectedLibs.forEach(x=>script.post({type: 'loadData', count: injectedLibs.length, payload: x}, fs.readFileSync("csharp/"+x)));
    
    process.on('exit', function (){});
    console.log("Done");
})();

unity_bootstrap.js

var dllData={}
var globalCaller;

function onMessage(message, data) {
    if(message.type=="loadData"&&message.count>0){
        dllData[message.payload]=data;
        console.log(message.payload, dllData, Object.keys(dllData).length);
        send({
            event: "waiting"
        })
        if(Object.keys(dllData).length==message.count)
            send({
                event: "ready"
            });
        else
            send({
                event: "waiting"
            })
    }
    if(message.type=="loadData"&&message.count==0){
        send({
            event: "ready"
        });
    }
    recv(onMessage);
}
recv(onMessage);


var awaitForCondition = function (callback) {
    var int = setInterval(function () {
        var addr = Module.findExportByName(null, "mono_get_root_domain");
        if (addr) {
            clearInterval(int);
            callback();
            return;
        }
    }, 0);
}
function _s(str){
    return Memory.allocUtf8String(str);
}

function hookSet(){    
    var mono_assembly_get_image=new NativeFunction(Module.findExportByName(null, "mono_assembly_get_image"), 'pointer', ['pointer']);
    var mono_image_open_full=new NativeFunction(Module.findExportByName(null, "mono_image_open_full"), 'pointer', ["pointer", "pointer", "int"]);
    var imgLoads={};
    
    for(var i in dllData){
        var img=mono_image_open_full(_s("/sdcard/"+i), NULL, 1);
        imgLoads[i]=img;
    }
    var addr = Module.findExportByName(null, "mono_assembly_load_from_full");

    Interceptor.attach(addr, {
        onEnter: function (args) {      
            var name=Memory.readUtf8String(ptr(args[1]));
            console.log(name);
            
            var parts=name.split('/');
            if(parts.length<2){
                parts=name.split(',');
            }
            var dllName=parts[parts.length-1];
            this.dllName=dllName;
            if(dllData[dllName]){                
                var img=imgLoads[dllName];
                args[0]=img;
                args[1]=_s("/sdcard/"+dllName);
                console.log("Replaced");     
            }
        },
        onLeave: function(retval){            
            if(this.dllName=='Assembly-CSharp.dll'){
                console.log(retval, this.dllName);
            }

            //DUMP DLL
            if(!dllData[this.dllName]){
                var image=mono_assembly_get_image(retval);
                var dataPtr=ptr(Memory.readInt(image.add(8)));
                var dataLength=Memory.readInt(image.add(12));
                var result=Memory.readByteArray(dataPtr, dataLength);
                send({
                    event: 'dump',
                    name: this.dllName
                }, result);
            }
        }
    });
}
awaitForCondition(hookSet);

Рассмотрим код поподробнее (кстати, я в курсе, что код далеко не идеален, но вылизывать его надобности пока нет).

App.js выступает в роли загрузчика. Запуск стандартный — node app PACKAGE_ID (можно захаркодить в исходнике, заменив COM.ANDROID.SOMETHING).
По большей части, здесь обычная загрузка frida из их мануала, за исключением некоторых дополнительных функций:

await Promise.all (injectedLibs.map (x=>spawnAwait (x)));

и

injectedLibs.forEach (x=>script.post ({type: 'loadData', count: injectedLibs.length, payload: x}, fs.readFileSync («csharp/»+x)));

На самом деле, здесь комбинируется 2 способа.
Вообще я начинал с передачи массива байт, но в 1 из игрушек напоролся на ситуацию, когда подгрузка библиотеки из памяти не работала, потому в итоге загружаю массив байт и файл, но в примере использую только файл.
waitBuild — функция-хелпер для упрощения сборки своей dll’ки. В данном примере не используется, потому можно игнорировать.

Если вкратце, работает все это приблизительно так: запускается app.js, frida-server инжектит js-движок в целевой процесс, app.js пересылает исходный код unity_bootstrap.js, встроенный движок исполняет код.
app.js считывает библиотеки, которые нужно встроить, после чего пересылает их в unity_bootstrap.js, ждет окончания загрузки и продолжает исполнение основного процесса.

Теперь рассмотрим собственно основной код (unity_bootstrap.js).

Функция awaitForCondition отвечает за ожидание подгрузки mono. Т.к. мы встраиваем код до начала исполнения основного кода, на момент выполнения нашего кода искомых функций еще нет.
Дальше собственно отрабатывает тот код, ради которого это все и затевалось. Почитать API по mono можно тут, пример использования — тут. Еще при разработке помогла эта статья.
Собственно, делаем мы следующее: перехватываем подгрузку библиотеки через mono_assembly_load_from_full, после чего считываем путь подгружаемой библиотеки и, в случае необходимости, подменяем на свою (при помощи mono_image_open_full мы считываем бинарь из файловой системы android).
Фокус вот в чем: мы, по факту, заменяем двоичный код, который был подгружен в MonoImage.

Дальше по коду можно увидеть кусок, отвечающий за дамп dll’ок (см. комментарий //dump dll).
Он ждет выполнения функции, после чего считывает возвращаемое значение и отправляет его обратно в app.js, который дампит dll’ку в папку csharp.

Собственно, после запуска приложения стоит дождаться появления строчек вида
/data/app/OUR_AWESOME_GAME.APK/assets/bin/Data/Managed/System.dll, это значит, что перехват сработал и дело пошло.
После 1 загрузки можно заккоментировать код, отвечающий за дамп библиотек, что бы он не портил малину. Мне, если честно, было просто лень писать код, делающий это программно.

Если Вы сделали все правильно и вам повезло, у Вас появятся все необходимые библиотеки в папке csharp. На данный момент я исследовал около 20 игрушек на unity3d, этот код с оговорками (в 1 игрушке приходилось добавлять исскуственные задержки, во 2 — подгружать код из файловой системы вместо памяти) сработал во всех, что использовали Mono.

P.S. Из всех исследованных игрушек, я нашел действительно серьезную уязвимость только в 1(правда, почти ни на 1 я не тратил большого кол-ва времени): во многих игрушках подобного плана соло-данжи рассчитываются в оффлайне, но только в этой игре этот дроп уходит на сервер и там же и сохраняется. В результате получилось полностью заменить дроп в данже, подгрузив свою версию sqlite’овской базы, после чего я получил сходу 20 VIP, кучу алмазов, всякого хлама, бан, репорт в саппорт, обещание передать баг разработчикам и последующий фикс. Даже сказали спасибо, было приятно.).
Еще в 1 игрушке, написанной на Corona с использованием lua получилось подменить кол-во голды за данж, но у них стоит какое-то ограничение на сервере, потому выдавалось все время статично по 5k. А так — всякая мелочь, которая на клиенте рассчитывается, правда, ее как раз можно менять, как душе угодно.
P.P. S. Если кому-то будет интересно, в принципе могу написать мини-гайд по редактированию кода в dnSpy (очень крутая штука), встраиванию своей библиотеки, пересылки логов на свой веб -сервер и прочие забавные и не очень штуки.

Благодарю за внимание и надеюсь на конструктивную критику!

© Habrahabr.ru