索引
通杀爆改 Unity FPS 游戏系列-序章
通杀爆改 Unity FPS 游戏系列-第一章
通杀爆改 Unity FPS 游戏系列-第二章
本章内容
本篇先给出 mono 和 il2cpp 的一些差异 (理论)
然后再以 windows mono 包为例实现功能(实际):
最后对比第二章中 il2cpp 包,再次说明 il2cpp 和 mono 差异(理论结合实际)
理论
mono 和 il2cpp 最大的差异点在于它们的编译方式
编译方式
|
编译方式 |
说明 |
il2cpp |
AOT |
Ahead Of Time 提前编译 |
mono |
JIT |
Just In Time 即时编译 |
对不熟悉的小伙伴们看到这里可能依旧一头雾水,这里再引入一个词:IL
IL
IL 全称:Intermediate Language ,中间语言
所谓的中间,自然意味着只是个临时的过渡的内容,并不是我们真正去执行的代码
这里就不贴过于官方的定义了,谈谈我个人的理解
作用
IL 的作用就是为了实现跨平台
不同平台对应的底层代码(汇编)并不相同
下面给出通常平台下对应的汇编,windows 也有 arm 版本,也有模拟器运行的安卓是 x86,这里说的是通常情况
重点在于说明不同平台的底层代码并不相同,如果想要实现跨平台需要编译四个对应平台的可执行文件(汇编代码)
|
32位汇编 |
64位汇编 |
对应可执行文件(例) |
Windows |
x86 |
x64 |
UnityPlayer.dll (32 位 + 64位) |
Android |
armeabi-v7a |
arm64-v8a |
libunity.so (32位 + 64位) |
联系
编译的路径是:
我们编写游戏的 C# 代码 → IL → 对应平台可执行的汇编代码
而 AOT 和 JIT 的区别就在于
AOT:提前编译,IL 只是在编译的时候中转一下,最终编译出来的游戏包就是对应平台的可执行文件,不包含 IL
JIT:即时编译,IL 是包含在最终生成的游戏包的,游戏在运行过程中动态地将 IL 编译成对应平台的可执行代码
再来个例子类比下:
同一道菜,比如西红柿炒鸡蛋,不同人(不同平台)的口味不同(汇编代码),有的人喜欢甜口(x86),有的人喜欢咸口(armeabi-v7a)
在上菜前,直接把盐或者糖放了,然后再问客人要吃什么口味再上对应的菜,叫 AOT ,提前编译
在上菜后,问客人要吃什么口味,然后再放对应的盐或者糖,叫做 JIT ,即时编译
IL 是还没有放盐和放糖的西红柿炒蛋,加盐和加糖相当于是编译成对应客人想要的菜
再加一个编译优化的概念
用上面的例子来说,就是放盐和放糖只是让菜符合客人的口味,而放多少,放哪里 就算是编译优化了
这里先点一下,后面会具体说明
实际
全屏改血
功能
依旧按照第二章的方式来操作
先下双击 fps.AI 下的 Unity.FPS.AI.EnemyController.Update 函数,跳转到函数头部,然后快捷键下断点(这部分在第一章和详细演示过,这里稍微简略些)
查看寄存器和堆栈数据
根据函数原型可以推断出堆栈内容的含义:(忘记如何推断的可以回顾 通杀爆改 Unity FPS 游戏系列-第一章 中的改血部分,说明了函数原型和对应堆栈的联系)
内容 |
含义 |
|
ESP |
1EABF33B |
完成调用后要返回的内存地址 |
ESP+4 |
1A1DED00 |
我们想要的指向 EnemyController 的内存地址 |
把得到的地址丢进 Structure dissect 验证 ESP+4 对应的地址就是 EnemyController
整理下前面得到的信息:
同时记下要改的 CurrentHeatlh 对应的偏移:
|
内容 |
ESP |
返回地址 |
ESP+4 |
EnemyController |
EnemyController+0060 |
m_Health |
m_Health+20 |
CurrentHealth |
开始 Auto assemble 写脚本,关于脚本部分在第二章已经说得比较详细了,这里不再赘述
直接给出脚本代码:
[ENABLE]
//code from here to '[DISABLE]' will be used to enable the cheat
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)
newmem: //this is allocated memory, you have read,write,execute access
//place your code here
//随便找个寄存器保存,因为中途需要借助寄存器来赋值,这里也可以改成 ebx ecx ...
push eax
//注意这个地方,原本 EnemyController 的地址应该是 ESP+4 但是因为我们保存了个寄存器,导致位置变化,所以需要再加 4
//这里就相当于是 eax = EnemyController
mov eax,[esp+4+4]
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,[eax+60]
//这里就相当于是 [eax+20]= [m_Health+20] = [CurrentHealth] = 0
mov [eax+20],(float)0
//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
push ebp
mov ebp,esp
push edi
sub esp,74
exit:
jmp returnhere
Unity.FPS.AI.EnemyController:Update:
jmp newmem
nop 2
returnhere:
[DISABLE]
//code from here till the end of the code will be used to disable the cheat
dealloc(newmem)
Unity.FPS.AI.EnemyController:Update:
db 55 8B EC 57 83 EC 74
//push ebp
//mov ebp,esp
//push edi
//sub esp,74
对比 il2cpp
使用文本比较工具 Beyond.Compare 对比 il2cpp 的修改代码和 mono 的修改代码
可以看到有 3 个区别:
mono |
il2cpp |
差异说明 |
补充 |
mov eax,[eax+60] |
mov eax,[eax+d8] |
结构体偏移有所区别 |
编译差异 |
push edi<br/>sub esp,74 |
push -01 |
原本要执行的汇编逻辑有所区别 |
编译差异 |
Unity.FPS.AI.EnemyController:Update |
"GameAssembly.dll"+1F4800 |
要 HOOK 的地址有所差别 |
JIT 和 AOT 导致 |
这里重点说明一下要 HOOK 地址的差别
|
内容 |
说明 |
地址 |
mono |
Unity.FPS.AI.EnemyController:Update |
函数名 |
JIT 即时编译,每次重开游戏都会变动 |
il2cpp |
"GameAssembly.dll"+1F4800 |
模块名 + 偏移量 |
AOT 提前编译,固定不变 |
这里不难看出,无论是 mono 还是 il2cpp ,我们的分析思路都是适用的,不存在所谓的 mono 太旧太过时,il2cpp 较新而有差异分析修改
差异最大的点,反而在 CE 修改器为我们生成的修改模板上,我们自己写的修改逻辑并无差异
PS:如果要我们自己写代码实现这个 HOOK ,mono 确实会比较麻烦,毕竟 mono 的地址是会变动的,得 JIT 后才能拿到
全屏秒杀
死亡函数
在第二章里特地留了个差异点,就是全屏秒杀的死亡函数 fps.Game → Unity.FPS.Game.Health → HandleDeath
只能在 mono 的情况下才会生效,在 il2cpp 时根本不会走到 HandleDeath 这个函数
先使用这个死亡函数实现全屏秒杀,再对比说明原因
现在 .NET Info 里找到 HandleDeath:
双击跳转到对应的汇编,按 F5 下断点,然后打敌人一枪,发现命中后会触发断点:
可以推测出该函数的作用:在敌人扣血时,判断血量是否小于等于 0 ,如果是则执行死亡逻辑
Health死亡
确定了 HandleDeath 的作用后,就可以在全屏改血的基础上,再调用它实现全屏秒杀
在通杀爆改 Unity FPS 游戏系列-第二章中已经说明了如何在汇编中调用函数
要注意的点有 2 个:
先看函数原型:
System.Void HandleDeath()
是最简单的函数,没有参数也没有返回值
但在第二章中也说过:在汇编中相比函数原型,会多出一个参数:指向该类自身的指针(对 HandleDeath 来说就是 Health)
因此可以先一步给出伪代码:
//给参数
push m_Health
//调用死亡函数
call Unity.FPS.Game.Health:HandleDeath
//堆栈外平衡
add esp,4
有了伪代码,就可以在全屏改血的基础上,加上这部分的逻辑
newmem: //this is allocated memory, you have read,write,execute access
//place your code here
//随便找个寄存器保存,因为中途需要借助寄存器来赋值,这里也可以改成 ebx ecx ...
push eax
//注意这个地方,原本 EnemyController 的地址应该是 ESP+4 但是因为我们保存了个寄存器,导致位置变化,所以需要再加 4
//这里就相当于是 eax = EnemyController
mov eax,[esp+4+4]
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,[eax+60]
//这里就相当于是 [eax+20]= [m_Health+20] = [CurrentHealth] = 0
mov [eax+20],(float)0
//-------------从这里开始是调用死亡函数的逻辑 -----------------
//改完血以后准备调用死亡函数,再拿到 EnemyController
mov eax,[esp+4+4]
//通过 EnemyController 再拿到 m_Health
mov eax,[eax+60]
push eax
call Unity.FPS.Game.Health:HandleDeath
add esp,4
//-------------从这里结束了调用死亡函数的逻辑 -----------------
//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
最后附上完整的修改逻辑
[ENABLE]
//code from here to '[DISABLE]' will be used to enable the cheat
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)
newmem: //this is allocated memory, you have read,write,execute access
//place your code here
//随便找个寄存器保存,因为中途需要借助寄存器来赋值,这里也可以改成 ebx ecx ...
push eax
//注意这个地方,原本 EnemyController 的地址应该是 ESP+4 但是因为我们保存了个寄存器,导致位置变化,所以需要再加 4
//这里就相当于是 eax = EnemyController
mov eax,[esp+4+4]
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,[eax+60]
//这里就相当于是 [eax+20]= [m_Health+20] = [CurrentHealth] = 0
mov [eax+20],(float)0
//-------------从这里开始是调用死亡函数的逻辑 -----------------
//改完血以后准备调用死亡函数,再拿到 EnemyController
mov eax,[esp+4+4]
//通过 EnemyController 再拿到 m_Health
mov eax,[eax+60]
push eax
call Unity.FPS.Game.Health:HandleDeath
add esp,4
//-------------从这里结束了调用死亡函数的逻辑 -----------------
//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
push ebp
mov ebp,esp
push edi
sub esp,74
exit:
jmp returnhere
Unity.FPS.AI.EnemyController:Update:
jmp newmem
nop 2
returnhere:
[DISABLE]
//code from here till the end of the code will be used to disable the cheat
dealloc(newmem)
Unity.FPS.AI.EnemyController:Update:
db 55 8B EC 57 83 EC 74
//push ebp
//mov ebp,esp
//push edi
//sub esp,74
OnDie死亡
il2cpp 版本中因为 Health 的 HandleDeath 不会被调用到,所以使用的是 fps.Game.dll → Unity.FPS.AI.EnemyController → OnDie
这个函数在 mono 版本同样是有效的,可以直接调用实现全屏秒杀的效果
实际的分析和逻辑和 il2cpp 别无二致,这里为了精简篇幅,直接贴出对应的代码:
[ENABLE]
//code from here to '[DISABLE]' will be used to enable the cheat
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)
newmem: //this is allocated memory, you have read,write,execute access
//place your code here
//随便找个寄存器保存,因为中途需要借助寄存器来赋值,这里也可以改成 ebx ecx ...
push eax
//注意这个地方,原本 EnemyController 的地址应该是 ESP+4 但是因为我们保存了个寄存器,导致位置变化,所以需要再加 4
//这里就相当于是 eax = EnemyController
mov eax,[esp+4+4]
//参数是 EnemyController
push eax
//调用 OnDie 函数
call Unity.FPS.AI.EnemyController:OnDie
//堆栈外平衡
add esp,4
//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
push ebp
mov ebp,esp
push edi
sub esp,74
exit:
jmp returnhere
Unity.FPS.AI.EnemyController:Update:
jmp newmem
nop 2
returnhere:
[DISABLE]
//code from here till the end of the code will be used to disable the cheat
dealloc(newmem)
Unity.FPS.AI.EnemyController:Update:
db 55 8B EC 57 83 EC 74
//push ebp
//mov ebp,esp
//push edi
//sub esp,74
不难发现,和 il2cpp 版本的区别只有 call 调用函数的区别
|
代码 |
说明 |
il2cpp |
call GameAssembly.dll+1F2D50 |
AOT 固定的地址:模块名+偏移量 |
mono |
call Unity.FPS.AI.EnemyController:OnDie |
JIT 不固定的地址:直接使用函数名 |
分析
这里的分析主要是针对 il2cpp 和 mono 中 Health.HandleDeath 这个死亡函数的调用差异
首先要明确的是:无论是 il2cpp 和 mono,它们的源代码是一样的
下面先给出源代码,然后再结合实际的执行逻辑分析
源代码
下面的源代码来自于 Health.cs ,也就是 Health 这个类中,注释部分为个人补充
//受伤函数,当要扣血时会调用到,参数一个是受到的伤害量,还有一个是伤害源
public void TakeDamage(float damage, GameObject damageSource)
{
//是否开启全局无敌,无敌则屏蔽所有受伤判定
if (GlobalInvincible)
return;
float healthBefore = CurrentHealth;
CurrentHealth -= damage;
CurrentHealth = Mathf.Clamp(CurrentHealth, 0f, MaxHealth);
// call OnDamage action
float trueDamageAmount = healthBefore - CurrentHealth;
if (trueDamageAmount > 0f)
{
OnDamaged?.Invoke(trueDamageAmount, damageSource);
}
//这里为调用死亡函数
HandleDeath();
}
//这个函数是当人物从高处坠落时摔死用到的,因为有调用到 HandleDeath 所以贴进来
//这次分析和这个函数无关
public void Kill()
{
CurrentHealth = 0f;
// call OnDamage action
OnDamaged?.Invoke(MaxHealth, null);
HandleDeath();
}
//死亡函数
void HandleDeath()
{
//已经死了就直接返回
if (m_IsDead)
return;
// call OnDie action
//判定当前血量是否小于等于 0 ,小于等于 0 则调用 OnDie
if (CurrentHealth <= 0f)
{
m_IsDead = true;
OnDie?.Invoke();
}
}
实际执行逻辑
看了前面的源代码,HandleDeath 确实是在每次扣血时就会调用到
但是符合预期的只有 mono 版 ,il2cpp 版却并没有调用到
因此需要进一步分析 il2cpp 实际的代码逻辑
以 TakeDamage 为突破口,查看其实际的逻辑(注意这里分析的是 il2cpp 包)
双击跳转到对应的汇编代码段:
记下此时的偏移量 1F9940
然后使用 IDA Pro 分析这部分逻辑的伪代码
限于篇幅,这里略去操作部分,关于 IDA Pro 分析 Unity 游戏留到后面的篇章
直接给出解析到的伪代码:
int __cdecl sub_101F9940(int a1, float a2)
{
int result; // eax
float v4; // xmm0_4
float v5; // xmm2_4
int *savedregs; // [esp+0h] [ebp+0h]
int savedregsb; // [esp+0h] [ebp+0h]
int savedregsc; // [esp+0h] [ebp+0h]
float retaddr; // [esp+4h] [ebp+4h]
if ( !byte_1114E2F7 )
{
savedregs = &dword_110E8900;
((void (*)(void))sub_10164120)();
byte_1114E2F7 = 1;
}
result = *(_DWORD *)(dword_110E8900 + 92);
if ( !*(_BYTE *)result )
{
v4 = *(float *)(a1 + 32) - a2;
if ( v4 < 0.0 )
{
v4 = 0.0;
}
else if ( v4 > *(float *)(a1 + 12) )
{
v4 = *(float *)(a1 + 12);
}
v5 = *(float *)(a1 + 32) - v4;
*(float *)(a1 + 32) = v4;
if ( v5 > 0.0 )
{
result = *(_DWORD *)(a1 + 20);
if ( result )
{
retaddr = v5;
savedregsb = *(_DWORD *)(result + 32);
result = (*(int (**)(void))(result + 12))();
}
}
if ( !*(_BYTE *)(a1 + 36) && *(float *)(a1 + 32) <= 0.0 )
{
result = *(_DWORD *)(a1 + 28);
*(_BYTE *)(a1 + 36) = 1;
if ( result )
{
savedregsc = *(_DWORD *)(result + 32);
result = (*(int (**)(void))(result + 12))();
}
}
}
return result;
}
伪代码里有个明显且关键的地方:
if ( !*(_BYTE *)(a1 + 36) && *(float *)(a1 + 32) <= 0.0 ){
.....
}
这里可以对应到:
void HandleDeath() {
......
if (CurrentHealth <= 0f)
{
......
}
}
结论
il2cpp 包直接将 HandleDeath 原本调用函数的逻辑去掉,换成直接把 HandleDeath 函数里的内容摁过来,所以不再去调用原本的函数
这个地方就要提到前面说的点:编译优化
可以发现 HandleDeath 这个函数本身的代码量很少,并没有多少
函数调用是有消耗的,因为在调用函数前,需要保存上下文信息,调用完后再还原
编译优化,就把原有的函数调用替换成了函数内的逻辑,直接执行,少了一层函数调用
因此 il2cpp 包并不会调用到 HandleDeath
补充
补充下 il2cpp 和 mono 包的目录结构 对比
限于篇幅,这里直接说结论,有兴趣的小伙伴可以自己拿 Demo 的 il2cpp 和 mono 包对比验证
|
游戏逻辑文件 |
说明 |
编译方式 |
il2cpp |
GameAssembly.dll |
编译成对应平台的可执行文件(动态链接库) |
AOT |
mono |
FpsDemo_By_lyl610abc_Data\Managed*.dll |
编译成 IL 文件,运行过程中会动态再编译 |
JIT |
总结
- il2cpp 和 mono 的核心差异在于编译方式:AOT 和 JIT
- il2cpp 和 mono 都会生成 IL 中间代码,区别在于一个直接在打包阶段再编译成对应平台可执行文件(AOT),另一个则是在运行阶段才去编译(JIT)
- 无论是 il2cpp 还是 mono , 都能够通过 CE 修改器的 mono 功能分析结构,查找实例等
- il2cpp 定位函数是以 模块名 + 偏移量 (GameAssembly.dll + offset),mono 定位函数则是直接以函数名 (如 Unity.FPS.Game.Health:HandleDeath)
- il2cpp 由于编译优化可能会产生通过 mono 找到的函数不被调用的情况
本章作为番外,算是稍微解答了一些关于 il2cpp 和 mono 的异同点,希望能对大家有所帮助
不同的编译方式对于分析的影响其实并没有有些人想象的那么多,只要能够抓住本质,基本上是通用的
本章还是留下了一些坑没填:
- CE 修改器的 mono 功能是怎么实现的
- 不依赖 CE 修改器自实现修改
- IDA Pro 分析游戏逻辑
-------------------ヾ(•ω•`)o----------------------------
预告
坑没填完没关系,再挖些坑债多不压身,预告下后面的内容
通过 mono 功能好像没有办法找到人物的坐标,如何实现全图瞬移?
FPS 游戏最常用的功能透视自瞄在 Unity 上如何实现?
附件
最后附上 mono 版的 CE 修改器脚本:点我下载