This post is a follow-up of the How to mod a simple Android App post and heavily based on this great article https://hacking.kurcin.com/android/use-frida-to-controll-unity-app-il2cpp/
Background about this Project
I chose a game that I know and that I played myself for a while, this helps to identify useful methods and properties where you want to hook into. As this Game is currently online and active played, I decided to not publish the name of the game to prevent cheating.
Requirements
I used a rooted physical android device with a running frida-server as root. Emulators may won’t work (based on the architecture and different .so loads) and also patching the app with objection will maybe not work.
Decompilation
First, we can confirm that the APK is really using the Unity engine by checking the entry point.
It’s very common that Unity games using this method as main activity com.unity3d.player.UnityPlayerActivity
Next we are going to decompile the app (using the APK Tool Gui) and extract the libil2cpp.so
we will find this file in lib\arm64-v8a
folder. We also need the global-metadata.dat
which we can find at assets\bin\Data\Managed\Metadata
Now we want to decompile the libil2cpp.so
to find the definitions and offsets. For this we are going to use the Il2CppInspector (https://github.com/djkaty/Il2CppInspector)
as our App is using a UnitEngine Version 29 we need to use a custom build for this: https://github.com/ElCapor/Il2CppInspector/releases/tag/2021.2
We can drag and drop both files (libil2cpp.so
and global-metadata.dat
) into the Gui to start the decompiling.
We are using File per assembly
and select all for our export
You can also drag and drop the APK directly to tool, so you don’t need to decompile it by yourself
Depending on your target device you may want to choose another image
Investigation
After exporting the c# files we can start to investigate. Usually the created Assembly-CSharp.cs
is a good start, as this is the namespace where most of the calls will come from.
This part takes a lot of analyze, searching and code reading, your goal should be to find a method name which you can associate with something in the game. For example: if your game involves some fishing or you can gain experience, search for methods or properties which contains words like fishing
or experience
. It is also recommended to search for a method which you can trigger in your game on demand, e.g. random drop
is probably not a good starting method. You also want to search over all your exported files to find the correct namespace.
In my project I found some interesting method with the name AddHeroIsFishingMarkerToHero
which I could assume will be called as soon I start fishing in my app
We are interested in the offset address of this method
Hooking
Now its time to jump over to frida and start to hook into this function. I used this create template from the page mentioned at the beginning of this post.
function awaitForIL2CPPLoad(callback) {
var i = setInterval(function () {
var addr = Module.findBaseAddress('libil2cpp.so');
if (addr) {
clearInterval(i);
callback(+addr)
}
}, 0);
}
var il2cpp = null;
Java.perform(function () {
awaitForIL2CPPLoad(function (base) {
il2cpp = ptr(base)
console.log("base: " + base)
attachHacks()
})
})
// AddHeroIsFishingMarkerToHero in the Game.ECS.cs
var startFishingAddress = 0x00D0B410
Interceptor.attach(il2cpp.add(startFishingAddress), {
onEnter: function (args) {
// On enter function
this.instance = args[0];
},
onLeave: function (resoult) {
// On enter function
console.log("start fishing...")
}
})
We are launching the game with frida and our script.
frida -U -l .\hack.js -f <target-app>
As soon we start „fishing“ in our game we can see our print statement in the console log 🎉
This means we are on the correct path, next we want to try some more useful stuff by changing values. For this I was looking for some speed
changing values and finally found a get
property
Overwrite Return Values
If we attach our Interceptor to this property, we can see that it will be called like every second, this means some other class or method will use it to ask for the current movement speed. To investigate in a more clean way, we will save the value and only print it if we start fishing, this way we prevent spawning our terminal with log messages.
[snip]
var currentMovementSpeed
Interceptor.attach(il2cpp.add(movementSpeed), {
onEnter: function (args) {
// On enter function
this.instance = args[0];
},
onLeave: function (resoult) {
currentMovementSpeed = ptr(resoult.toString())
}
})
Interceptor.attach(il2cpp.add(startFishingAddress), {
onEnter: function (args) {
// On enter function
this.instance = args[0];
},
onLeave: function (resoult) {
// On enter function
console.log("start fishing...")
console.log("current movement speed: " + currentMovementSpeed)
}
})
as we can see, the return value is 0x3ff999999999999a
. the GUI of the app tells us that the speed value is currently at Level 16
. If we play a little bit an level up our speed to level 17
we will get the following movement speed 0x3ffb333333333333
and for level 18
we will get 0x3ffccccccccccccd
. If we take a look at the first to bytes we can see the value change:
0x3ff9 = 16377
0x3ffb = 16379
0x3ffc = 16380
It seems the level ups will change the movement speed by 1 or 2 (maybe higher levels will change the speed even more). But lets try to increase the first 2 bytes to something higher.
With frida its also possible to overwrite the return value. I am not sure how the rest of the return values need to be formatted but we just change to increase the speed by 20
, so we take the value 16400
= 0x4010
and ad some random bytes so our final value will be this 0x401099999999999a
Interceptor.attach(il2cpp.add(movementSpeed), {
onEnter: function (args) {
// On enter function
this.instance = args[0];
},
onLeave: function (resoult) {
// movementspeed + 20
resoult.replace(0x401099999999999a)
currentMovementSpeed = ptr(resoult).toString()
}
})
We can’t see the level update in our GUI, but we can recognize that we are moving much faster now. Of course, this hack is not persistent, as it only works until Frida is running and overwriting this function. As soon we restart our game the movement speed is the original one.
So let’s try to figure out if we find something which has some lasting impact.
Overwrite Method Parameters
Instead of changing the return value of getter methods, we can try to find some methods which increase something permanently. By looking at the code, I found the method which is called by add an item (like gems or coins)
Here we can see that the AddAmount
method is called with a simple BigDouble parameter. Frida offers a really useful function to print all parameters and register in Json format, when our target function get called
Interceptor.attach(il2cpp.add(currencies), {
onEnter: function (args) {
// On enter function
this.instance = args[0];
console.log('Context information:');
console.log('Context : ' + JSON.stringify(this.context));
},
onLeave: function (resoult) {
console.log("Currency added!)
}
})
we can see at x1 the value 0x4000000000000000
"pc": "0x7b050a65e4",
"sp": "0x7b03de4000",
"nzcv": 1610612736,
"x0": "0x79e05d1300",
"x1": "0x4000000000000000"
with further tracking I figured out how the values are stored:
0x4000000000000000 = 1 item
0x4010000000000000 = 2 items
0x4018000000000000 = 3 items
0x4020000000000000 = 4 items
This means we can increase our collect item amount (x * 8) to whatever we want if we overwrite the parameter
Interceptor.attach(il2cpp.add(currencies), {
onEnter: function (args) {
// On enter function
this.instance = args[0];
args[1] = ptr(0x4100000000000000)
},
onLeave: function (resoult) {
console.log("Currency added!)
}
})
And indeed, as soon we collect something in our game we can see that we now get a huge amount of items. As this is „add“ action, this will be stored to our character and also be saved. So this will be persistent also after a game restart without frida.
I found several other methods which lead to some significant game changes:
- catchingSpeed
- currentCargo
- rodsCount
- addExp
look for methods which return (or take) a bool, string or number as value, manipulating these values is much easier then manipulate something like a struct, dictionary or object
Patch the Game
Even if we hook with frida in some function who change our game progress, our save-game is only stored on our rooted device.
To store our changes into the APK we need to mod the .so file.
This is the cherry on the cake, for my project I chose the get
property method which will change my current movement speed.
irst, we should check with Frida what the return value of this method is and overwrite until we have our desired value. In my case 0x400099999999999a
was the default value and 0x405099999999999a
was a good amount to move fast enough in the game.
Next, we want to write a return function in armv8 (which is the 64bit version of arm). The easiest way is to use an online Code to ARM compiler like https://godbolt.org/
Now we just write a simple function which will return our value, as this is a very big number we need to select a long function. Also we need to select a armv8 compiler
mov x0, #-7378697629483820647
movk x0, #39322
movk x0, #16464, lsl #48
ret
As we can see, the compiler translate our code into nice arm assembly code, which we now only need to convert into hex, for this we can use this online compiler: https://shell-storm.org/online/Online-Assembler-and-Disassembler/
Replace some instructions
Now we only need to replace our target function with our. For this we can use a hex editor, open the libil2cpp.so
and jump to our offset 0x00C82B6C
(Ctrl + g in HxD), select the next 16 bytes and overwrite these with our function
e0 e7 01 b2 40 33 93 f2 00 0a e8 f2 c0 03 5f d6
finally, we will save the file (delete .bak from the lib folder) and recompile the APP
If everything was correct, we can now install the app on every device and our movement speed should be fine 😎
With Frida we can confirm if our return value is patched.
Interceptor.attach(il2cpp.add(movementSpeed), {
onEnter: function (args) {
// On enter function
this.instance = args[0];
},
onLeave: function (resoult) {
console.log(resoult);
}
})
To make this working on x86 devices also, we need to adjust the
libil2cpp.so
in thearmeabi-v7a
folder, retrieve the offsets again from this file and also write a ARMv7 assembly function and (maybe) adjust the return value