Most tutorials on hex patching do not explain how hex patching works. I'll explain exactly what hex patching is and how it works, from the very beginning.
@MainCand @BadCasedid a great job explaining, but they didn't go in-depth.
To understand what the libil2cpp.so file is, you have to understand how the game's libil2cpp.so file is generated. All games with libil2cpp.so files are made with Unity's Il2cpp Backend. Unity is a game engine where you write your code in the C# programming language, and the engine compiles it into an apk. Most of the game's code, such as the 'headshot' method, is converted from C# to IL (Intermediate Language) to C++. This C++ code is then compiled to assembly code, which is encoded into hex and stored in the libil2cpp.so file. Different devices use different assembly languages (called architectures) - android has armv7 (32bit) and armv8 (64bit). There is a libil2cpp.so file for each architecture. In most Unity games, there is armv7, armv8, and sometimes x86 support. In armv7, armv8, and x86, all assembly instructions are 4 bits long when encoded into hex.
When the game executes a method like the 'headshot' method, the hex for the headshot method is taken from the libil2cpp.so file for the device's respective architecture, decoded back into assembly code, and run.
This is not the exact process, but I left some information out and simplified some stuff.
To visualize this, I like comparing c code to assembly code to hex. Arm Hex Converter Online can be used to converted between assembly code and hex, and Compiler Explorer can be used to convert c code to assembly code. There is currently no way to perfectly convert from assembly code back to c code, so you will have to learn assembly code to understand it.
So what does
Mov R0, #1
bx lr
mean? I like looking at it in c. This is the same as:
return(1)
There are multiple ways to write this in assembly, so compiler explorer might contradict us. But
Mov R0, #1
bx lr
is the simplest way to do it.
Let's make up an example of how we would use hex patching and walk through it step by step. Say that the offset for the 'headshot' method is 0x67AB0AB. This means that the function's code begins at the 67AB0AB's byte of the libil2cpp.so file. The offsets are usually in hexadecimal - this is what most hex editors use, and what most tools / resources like dnspy and Il2cppDumper use. This represents the decimal (base10) number 108703915. So, we know that the function begins at the 108703915th byte of the libil2cpp.sp file. If we go to this offset in our hex editor (most hex editors use hexadecimal offsets, so we go to offset 67AB0AB, the first 8 bytes are 06 00 00 15 00 88 FC BF. Each assembly instruction is 4 bytes, so we are looking at the first 2 assembly instructions of the function. We edit this to our hex (if we want to working with armv7, and we want the function to always return 1, we use the hex 01 0 0A0 E3 1E FF 2F E1, which decodes to
Mov R0, #1
bx lr
. We only have to edit the first 8 bytes (2 instructions) of the function because if we always edit the first instructions to return, the function will always return before it executes any other instructions. This is how return statements work in almost all programming languages. For this reason, we edit only two instructions and do not have to overwrite the whole function. We need to edit two instructions and not just one because
return(1)
takes two instructions in assembly. If we want to return a number that cannot be expressed in one statement (ex: for armv8, the maximum number you can directly use is 65535, or 0xffff in hexadecimal), or we want to do something more complicated than always returning a number, we may need more than two instructions. In this case, we overwrite as many instructions as we need. If our new function is very complex, it might be longer than the original function. This will rarely happen, but just in case, you should use workarounds when your new function is long.
I do not currently know how to find where a function ends, only how it starts. A .so file is a linux shared library file (armv7, armv8, and x86 architectures use linux), so if anybody wants to try to find out an easy way to find out where a function ends, or even better, a way to list all of the functions in a given .so file, (it would be awesome if somebody does this!), this is a starting point to start researching.
Hopefully this is a helpful guide and it explains hex patching in-depth! Sorry I wrote so much, I got a little carried away
NOTE: In most assembly languages, including armv7, armv8, and x86, the numbers 1 and 0 are used respectively instead of true and false. The hexadecimal representations of 1 and 0 (0x1 and 0x1) can also mean true and false respectively.