lyl610abc 发表于 2023-9-18 20:04

通杀爆改 Unity FPS 游戏系列-第三章:il2cpp mono 差异

# 索引

[通杀爆改 Unity FPS 游戏系列-序章](https://www.52pojie.cn/thread-1829474-1-1.html)

[通杀爆改 Unity FPS 游戏系列-第一章](https://www.52pojie.cn/thread-1830230-1-1.html)

[通杀爆改 Unity FPS 游戏系列-第二章](https://www.52pojie.cn/thread-1832964-1-1.html)

# 本章内容

本篇先给出 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 函数,跳转到函数头部,然后快捷键下断点(这部分在第一章和详细演示过,这里稍微简略些)

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230914095713979.png)

查看寄存器和堆栈数据

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230914100208176.png)

------

根据函数原型可以推断出堆栈内容的含义:(忘记如何推断的可以回顾 [通杀爆改 Unity FPS 游戏系列-第一章](https://www.52pojie.cn/thread-1830230-1-1.html) 中的改血部分,说明了函数原型和对应堆栈的联系)

| 内容| 含义   |                                           |
| :---- | :------- | ----------------------------------------- |
| ESP   | 1EABF33B | 完成调用后要返回的内存地址                |
| ESP+4 | 1A1DED00 | 我们想要的指向 EnemyController 的内存地址 |

------

把得到的地址丢进 Structure dissect 验证 ESP+4 对应的地址就是 EnemyController

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230914101318305.png)

------

整理下前面得到的信息:

同时记下要改的 CurrentHeatlh 对应的偏移:

|                      | 内容            |
| -------------------- | --------------- |
| ESP                  | 返回地址      |
| ESP+4                | EnemyController |
| EnemyController+0060 | m_Health      |
| m_Health+20          | CurrentHealth   |

------

开始 Auto assemble 写脚本,关于脚本部分在第二章已经说得比较详细了,这里不再赘述

直接给出脚本代码:

```assembly

//code from here to '' 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,
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,
//这里就相当于是 = = = 0
mov ,(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:


//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

使用文本比较工具 (https://down.52pojie.cn/Tools/Editors/Beyond.Compare.Pro.v4.x.Windows.CracKed.v2.7z) 对比 il2cpp 的修改代码和 mono 的修改代码

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230914105325275.png)

------

可以看到有 3 个区别:

| mono                              | il2cpp                  | 差异说明                     | 补充            |
| ----------------------------------- | ------------------------- | ---------------------------- | --------------- |
| mov eax,                  | mov eax,          | 结构体偏移有所区别         | 编译差异      |
| 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:

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918134527570.png)

------

双击跳转到对应的汇编,按 F5 下断点,然后打敌人一枪,发现命中后会触发断点:

可以推测出该函数的作用:在敌人扣血时,判断血量是否小于等于 0 ,如果是则执行死亡逻辑

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918134952220.png)

------

### Health死亡

确定了 HandleDeath 的作用后,就可以在全屏改血的基础上,再调用它实现全屏秒杀

在[通杀爆改 Unity FPS 游戏系列-第二章](https://www.52pojie.cn/thread-1832964-1-1.html)中已经说明了如何在汇编中调用函数

要注意的点有 2 个:

- 函数的参数
- 堆栈平衡

------

先看函数原型:

System.Void HandleDeath()

是最简单的函数,没有参数也没有返回值

但在第二章中也说过:在汇编中相比函数原型,会多出一个参数:指向该类自身的指针(对 HandleDeath 来说就是 Health)

因此可以先一步给出伪代码:

```assembly
//给参数
push m_Health
//调用死亡函数
call Unity.FPS.Game.Health:HandleDeath
//堆栈外平衡
add esp,4
```

------

有了伪代码,就可以在全屏改血的基础上,加上这部分的逻辑

```assembly
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,
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,
//这里就相当于是 = = = 0
mov ,(float)0

//-------------从这里开始是调用死亡函数的逻辑 -----------------
//改完血以后准备调用死亡函数,再拿到 EnemyController
mov eax,
//通过 EnemyController 再拿到 m_Health
mov eax,
push eax
call Unity.FPS.Game.Health:HandleDeath
add esp,4
//-------------从这里结束了调用死亡函数的逻辑 -----------------

//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax
originalcode:
```

------

最后附上完整的修改逻辑

```assembly

//code from here to '' 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,
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,
//这里就相当于是 = = = 0
mov ,(float)0

//-------------从这里开始是调用死亡函数的逻辑 -----------------
//改完血以后准备调用死亡函数,再拿到 EnemyController
mov eax,
//通过 EnemyController 再拿到 m_Health
mov eax,
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:


//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 别无二致,这里为了精简篇幅,直接贴出对应的代码:

```

//code from here to '' 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,
//参数是 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:


//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 这个类中,注释部分为个人补充

```c#
//受伤函数,当要扣血时会调用到,参数一个是受到的伤害量,还有一个是伤害源
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 包)

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918165520821.png)

------

双击跳转到对应的汇编代码段:

记下此时的偏移量 1F9940

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918170109998.png)

------

然后使用 IDA Pro 分析这部分逻辑的伪代码

限于篇幅,这里略去操作部分,关于 IDA Pro 分析 Unity 游戏留到后面的篇章

直接给出解析到的伪代码:

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918170544099.png)

------

```cpp
int __cdecl sub_101F9940(int a1, float a2)
{
int result; // eax
float v4; // xmm0_4
float v5; // xmm2_4
int *savedregs; //
int savedregsb; //
int savedregsc; //
float retaddr; //

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;
}
```

------

伪代码里有个明显且关键的地方:

```cpp
if ( !*(_BYTE *)(a1 + 36) && *(float *)(a1 + 32) <= 0.0 ){
    .....
}
```

这里可以对应到:

```c#
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 修改器脚本:[点我下载](https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/FpsDemo_By_lyl610abc_mono.CT)

!(https://610-pic-bed.oss-cn-shenzhen.aliyuncs.com/image-20230918195431994.png)

lyl610abc 发表于 2023-9-18 20:37

hongshao987 发表于 2023-9-18 20:32
楼主搞个二十年前的单机游戏来演示一下吧,不然我等好像看天书。

要从序章开始看,包含了单机游戏的演示{:301_999:}

冥界3大法王 发表于 2023-9-18 20:59

那些基础的东西还没整明白呢,直接看懂了感觉就跟被雷劈死一般难。{:301_1008:}

skupcll 发表于 2023-9-25 22:52

本帖最后由 skupcll 于 2023-9-25 22:53 编辑

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,
//这里就相当于是 eax = EnemyController+60 = m_Health
mov eax,
//这里就相当于是 = = = 0
mov ,(float)0

//-------------从这里开始是调用死亡函数的逻辑 -----------------
//改完血以后准备调用死亡函数,再拿到 EnemyController
mov eax,
//通过 EnemyController 再拿到 m_Health
mov eax,
push eax
call Unity.FPS.Game.Health:HandleDeath
add esp,4
//-------------从这里结束了调用死亡函数的逻辑 -----------------

//用完以后要还原回去,保存的是什么寄存器就还原什么寄存器
pop eax

对于这段代码,我有一些堆栈方面的疑惑
1.我的理解中,一个push应该对应一个pop来实现堆栈平衡,这里为什么有两个push eax而只有一个pop eax
2.在第二个push eax后面的那个esp+4是不是也相当于pop eax了,如果是的话这两者有什么区别呢,哪一种方式会更好
3.在第二次push eax之后,并没有再修改过eax寄存器,那为什么还要再push进去呢

Youame 发表于 2023-9-18 20:24

看的一脸萌~~

lyl610abc 发表于 2023-9-18 20:30

Youame 发表于 2023-9-18 20:24
看的一脸萌~~

需要结合前面的章节看~~{:301_975:}

hongshao987 发表于 2023-9-18 20:32

楼主搞个二十年前的单机游戏来演示一下吧,不然我等好像看天书。

hanchao2021 发表于 2023-9-18 21:19

哈哈哈!认真学习!!!{:300_961:}

艾莉希雅 发表于 2023-9-18 22:28

好诶,更新了。不过这章怎么没有正己相关的内容了。

debug_cat 发表于 2023-9-18 22:49

奇怪的知识增加了

张道陵 发表于 2023-9-18 23:23

技术研究很有乐趣
页: [1] 2 3 4 5
查看完整版本: 通杀爆改 Unity FPS 游戏系列-第三章:il2cpp mono 差异