杀手5逆向分析(二)

杀手5逆向分析(二)
bahadir这一期主要分析射击函数,分析步骤跟上一期一样先用Cheat Engine来定位函数位置,再通过IDA Pro进行静态分析。大多数FPS游戏中找射击函数是要通过子弹数来定位的,第一步自然而然就是找出内存中的子弹存储地址。再查看此地址被哪个汇编代码所更改来锁定相应的函数。
射击函数的分析
一,定位子弹内存地址
首先把Cheat Engine附加进程到游戏的可执行程序当中,然后搜索随便一个枪械的子弹数量。子弹数量多数情况下会被存储为整数形式,有可能是2字节的也有可能是4字节的。要是没有搜到可以换扫描类型来搜索,一些游戏会对子弹数或者生命值之类的数据进行加密处理,这种情况后续会单独出一期教程来讲解。现在回到主题在4字节搜索的情况下进行多次搜索还是不能定位到子弹数量。切换2字节类型来进行搜索很快就能找到子弹数量,但是有三个搜索结果都是子弹数量。
可以看到其中两个是静态地址,把这其中一个数据进行修改回到游戏查看实际子弹数量有没有发生改变。把三个数据都挨个进行测试之后发现两个静态地址修改后不会发生任何变化。所以可以确定这两个静态地址不是真正的子弹内存地址。
二,找出子弹相关的汇编代码
筛选来子弹地址之后进行 Find out what writes to this address 操作,然后在游戏中几枪就能看到修改子弹的汇编代码了。
点击 Show disassembler 就能看到所有相关的代码了,此时打开IDA把这个段汇编代码的地址复制过去。
三,在IDA中分析此函数
1.UpdateAmmo函数
可以看到这个函数的调用约定为 __thiscall 接受两个参数,其中一个是this指针另一个为当前子弹数。
1 | int __thiscall ShootMaybe(_WORD *this, __int16 currentAmmo); |
将函数名改为 ShootMaybe ,把存储子弹的变量命名为 currentAmmo 就可以在IDA中慢慢分析了。在代码的第二行就能看到我们的子弹内存地址被传入的参数给写入了。
1 | this_cpy = *(_DWORD *)this; |
所以说剩余子弹是在上个函数或者更早的函数中被计算好的而这个函数是负责写入的。那可以推断此函数负责弹药值的直接写入并不是负责射击的,因此把函数名改成 UpdateAmmo 。继续往下分析,this指针的某一个地址传给v4变量之后就进行了调用,可以确定是在调用虚函数。
1 | v4 = *(void (**)(void))(this_cpy + 0x180); //从虚表偏移0x180获取函数 |
按照正常游戏的逻辑,推测后面调用的基本都是一些子弹射击之后的事件,比如播放射击音效,生成弹道,HUD更新之类的事情。我们需要找到的是真正的计算子弹的部分,所以需要研究调用 UpdateAmmo 函数的函数。
2.Shoot函数
那么为了查看上一层的函数就要回到Cheat Engine,然后对修改内存值的那一行汇编代码加断点,选中代码后按快捷键 F5 就可以加断点操作了。有断点代码行会变成红色表示加断点成功。回到游戏中随便开一枪会发现游戏处于暂停状态了。此时在 Memory Viewer 窗口的右下角出现了一个小窗口记录了栈中的返回地址。
双击第一行就能跳转到调用 UpdateAmmo 函数的调用处,也就是说当前断点出的每一层级的调用都可以在这里看到。调用 UpdateAmmo 是通过eax寄存器记录的值为调用处调用的并不是静态函数地址调用。
1 | HMA.NMP::Matrix<float>::postMultiply+F72 - FF D0 - call eax |
这种调用方式大概率为虚函数调用,为了验证这一想法在IDA中打开此地址。可以看到这是个相当大并且复杂的函数。把这个函数暂时命名为 callUpdateAmmo 方便后续理解。这个函数接受四个参数,调用方式没有被IDA正确识别显示 __usercall ,但是第一个参数是通过ecx寄存器传递的指针类型,所以大差不差大概率又是个 __thiscall 调用约定。将第一个参数重新命名为 this 打开 Cheat Engine 在函数第一行加入断点查看一下这个 this 到底是啥。
1 | int __usercall callUpdateAmmo@<eax>(DWORD this@<ecx>, int a2@<ebp>, int a3@<edi>, int a4@<esi>); |
将IDA中把函数的第一行汇编代码的地址复制到 Cheat Engine 当中加入断点,游戏中进行射击击中断点后查看ecx寄存器的值。把这个地址粘贴到 ReClass.NET 当中可以发现是一个 ItemWeapon 类的实例。 ReClass.NET 会自动获取 RTTI信息 重建继承树在右侧用红字显示。
在IDA Pro中通过安装 Class Informer 插件来解析RTTI记录,安装方法以及插件地址可以从此GitHub地址获取。
(1)弹药量识别
在IDA中查看 call eax 的C++伪代码处,确实是一个接受两参数的函数调用。
1 | (*(void (__thiscall **)(DWORD, int))(*(_DWORD *)(this + 0xF0) + 0x64))( |
ReClass.NET中看到 this + 0xF0 指向的也是一个实例,为了弄清楚这些类的结构,在IDA中打开 Class Informer 插件进行解析,解析出来的结果有3000多个类。按快捷键 Ctrl + F 进行搜索类名称或者是类静态地址(在ReClass.NET中显示)。搜索完毕后可以看到 callUpdateAmmo 函数的this指针为一个叫 ZHM5ItemWeapon 的类,继承了很多类。
1 | 00EBD1D4 33 MA ZHM5ItemWeapon ZHM5ItemWeapon: ZHM5Item, ZReusablePropEntity, ZEntityImpl, IEntity, IComponentInterface, IReusableProp, IHM5Item, IComponentInterface, IHM5ItemWeapon, IComponentInterface; |
this + 0xF0 指向的是也是叫 ZHM5ItemWeapon 的类,但是只继承了两个类。
1 | 00E6B1B4 101 MA ZHM5ItemWeapon IHM5ItemWeapon: IComponentInterface; |
双击打开这个 00E6B1B4 类的虚函数表可以发现我们之前定义的 UpdateAmmo 函数。了解类的结构对我们的分析和理解有很大的帮助。
在分析类似射击函数数优先寻找 – 或 -1 操作,通常对应弹药消耗。**++** 或 +1 操作通常对应总射击次数的递增。回到之前的分析这个函数传入的第一个参数是 ZHM5ItemWeapon 类的是一个实例。第二个参数是个条件表达式,简单来说这段代码的目的是将当前弹药量( this + 0x406 )减1,但确保弹药量不会变成负数。这段代码才是真正的子弹数量减少代码,对应的汇编代码为:
1 | text:009ECEAD dec eax ; Decrease Ammo |
可以看到函数的前面有一个++操作,WORD 类型(2字节)也符合此游戏的子弹存储大小:
1 | ++*(_WORD *)(this + 0x40A); // 射击总数递增 |
动态分析确认 this + 0x40A 指向的的确是射击总数。弹药系统的典型特征还包括边界保护类似于:
1 | if (新值 <= 0) 设为0; |
(2)扩散系数识别
扩散一般跟浮点运算有关,涉及乘除加减的操作通常与弹道物理相关。在函数中可以发现有一个很大的for循环,这个循环大概率是开枪时循环的。
在循环当中有一个关于浮点运算的部分:
1 | *(float *)(this + 0x420) = 1.0 / (double)v15 + *(float *)(this + 0x420);// this + 0x420 == 扩散系数 |
可以发现 this + 0x420 地址的浮点数会随着循环次数会一直叠加,在下面还有个关于边界保护的判断:
1 | if ( *(float *)(this + 0x420) > 1.0 ) // 扩散限制在 1 以内 |
0x3F800000 是1.0的IEEE754编码,根据这些特征可以推测 this + 0x420 是扩散增长系数,通过动态分析发现确定是扩散增长系数。
我们再看函数的第一个if判断分析一下,因为这段也是关于浮点数的加减乘除:
1 | v5 = (*(int (__thiscall **)(DWORD, int, int))(*(_DWORD *)(this + 0xF0) + 0x180))(this + 0xF0, a3, a4); |
把这段进行缩短提取关键部分看一下:
1 | if ( *(float *)(this + 0x1AC) > 0.0 ) // 判断条件:this + 0x1AC > 0 |
把核心计算公式按照数学公式来分析:
$$
结果 = \frac{Δ}{this+0x1AC}+偏移值
$$
当 this+0x1AC 的值越大,计算结果越小。计算结果 this + 0x1A8 需要小于阈值 flt_11536DC 否则就被清零。典型的FPS武器扩散公式:
$$
扩散值 = 基础扩散 + (\frac{扰动值}{恢复速度})
$$
代码中的 this + 0x1A8 跟这个公式相似为实时扩散值,所以 this+0x1AC 可以为扩散恢复速度, this + 0x1B0 为基础扩散值。
(3)射击循环
来分析函数的关键部分也就是for循环,每一次子弹射击都会执行这个循环:
1 | for ( i = 0; i < v76; ++i ) //v76表示本次射击需要发射的子弹数 |
循环次数表示每次需要发射的子弹数,v76是通过前面的 sub_8F83E0 函数计算得出的:
1 | *(float *)&v10 = COERCE_FLOAT(sub_8F83E0((int *)(this + 0x290), (int)v60, v9)); |
v76一般值可能为1(单发),3(三连发),全自动(根据按下按键的时间来计算)。把v75命名为 shootBullet 。
(4)射击间隔
在循环内计算扩散值之前会先计算射击间隔,前面确定过 this + 0x420 是扩散系数。计算当中有个 v15 的变量参与计算,根据上下文这个变量大概率就是射击间隔:
1 | v15 = *(_BYTE *)(v73 + 0x42); // v73+0x42 = 射击间隔倒数(如30=每秒30发) |
从 v73 + 0x42 这个地址读取射击速率比如10(代表10发/秒),1.0除以射击速率得到一发子弹的射击间隔(如0.01秒)。把这个时间加到扩散系数当中,射速越快扩散增长就越快,v15变量可以改名为 bulletLag 。
(5)特殊射击检测
下一段代码if判断有可能是判断特殊射击的,因为通过 Cheat Engine 把判断对应的汇编指令 Nop 掉发现造成的伤害变低了(原本冲锋枪3发打腿死,Nop 之后需要5发才腿能解决)。根据上下文可以大致猜测一下此判断内容:
1 | if (!v14) { // v14表示是否处于特殊射击模式(如狙击开镜) |
因此 v14 变量可以重新命名为 specialShoot 。后面的操作基本都是SSE指令的解析,进行的操作大概率为弹道向量计算,碰撞检测,子弹实例生成,播放射击音效,触发命中特效等等操作。这里就不一一展开讲解了,需要解析的话可以参考英特尔官方的 SSE指令解析 来辅助解析。
(6)连发计数器
即使把减少子弹代码Nop掉,但是冲锋枪射击一个弹匣子弹之后还是自动停顿了。函数最后能看到有个对地址 this + 0x404 的判断。当我查看这个地址并在游戏中射击时发现每发射一个子弹会让这个地址值增加,当子弹数达到当前弹匣数也就是 this + 0x404 == this + 0x406
时射击就停止了。因此可以猜测这里的判断是计算射击数并强制清空当前后坐力:
1 | if (*(_WORD *)(this + 0x404)) // 检查连发计数器是否非零 |
在这个判断之前就有一个对连发计数器检测 ( this + 0x404 ) 的增加操作:
1 | ++*(_WORD *)(this + 0x404); |
当把这个对应的汇编代码Nop掉之后发现可以进行无限射击并且不受当前弹匣子弹数限制了。分析到这里就可以知道此函数就是游戏的射击模块,因此可以把函数名称改为 Shoot 。
下面放完整 UpdateAmmo函数 和 Shoot函数 的代码以供参考:
1 | int __thiscall UpdateAmmo(DWORD this, __int16 currentAmmo) |
1 | int __usercall Shoot@<eax>(DWORD this@<ecx>, int a2@<ebp>, int a3@<edi>, int a4@<esi>) |
因为逆向工程大部分都是搞猜测验证来进行的,所以文章中有错误猜测或错误分析的部分请在评论区纠正。有任何私人问题可以联系我的私人邮箱,邮箱地址在首页。谢谢观看!