杀手5逆向分析(一)

本系列HITMAN 5的教程主要是针对游戏的主要功能进行逆向分析,使用的工具分别有Cheat EngineIDA ProReClass.NET 。Cheat Engine 是用来实时调试,修改内存值,测试验证推理等等用途。IDA Pro 用来静态分析函数的主要实现逻辑等。ReClass.NET 可以分析数据的内存结构,类指针的分析。

这一期教程的分析内容主要针对生命值函数。下面对这一函数进行锁定位置和详细分析运行逻辑。本系列教程的后续会对游戏的主要功能函数都挨个进行分析。


生命值函数的分析

一,定位生命值

首先打开Cheat Engine并且点击左上角的选择程序按钮,选择游戏程序点确定就能把调试器附加到游戏上面。此时可以对游戏内存地址进行任意的搜索。

Scre1

生命值一般在游戏当中以浮点值的形式存储的,单浮点数占用4个字节的位置,因此搜索时可以用4字节方式搜索也可以直接按浮点数形式搜索。一般情况下都是按四字节来搜索,因为少部分游戏不采用浮点值存储而用整数值,所以以防万一我这里还是按照四字节方式来搜索。在这款游戏中生命值的具体数值时看不到的,在低难度的情况下收到伤害之后生命值会慢慢的进行回复。在满生命值状态下我们按照100来进行搜索或者按照未知数值进行搜索。搜索完之后可以看到有上千万的结果,为了筛选出自己所需的值就要让生命值进行变动。游戏中的生命值变动之后扫描类型就可以换成增加的值或者减少的值来反复搜索直到找到自己所需要的生命值。详细步骤可以参考网上各种教程来学习,这里就不展开讲述了。

Scre2

最终找到的浮点变量可以进行修改看看游戏中的生命值是否有发生改变,要是没有发生变化就说明找错了得重新找。

二,找出生命值相关的汇编代码

在找到的变量上鼠标右键选择 Find out what writes to this address 会弹出一个窗口来检测修改此内存地址的汇编代码的窗口。

Scre3

在游戏中被敌方击中后就会在窗口中显示相应的汇编代码,在窗口的下方显示各个寄存器在执行此代码时存储的值。

1
008082E4 - D9 9E 18020000  - fstp dword ptr [esi+00000218] 		;修改生命值的汇编代码

点击窗口右边的 Show disassembler 后会弹出另一个大窗口叫 Memory Viewer 。窗口上方显示的就是汇编代码区域记录着内存地址,机器码,汇编代码,备注信息。窗口下方显示的则是内存具体值可以右键改变显示形式。

Scre5

在我们刚刚找到的那一行代码上面鼠标右键选择 Replace with code that does nothing 就可以将此处的代码全部改成 Nop 指令。在游戏中我们就实现了真正的无敌。

三,在IDA Pro中分析此函数

1.调用约定分析

将游戏的可执行文件 HMA.exe 拖入到IDA Pro当中等待分析完毕。在 Cheat Engine 中的代码窗口中选择刚刚找到的那一行代码,然后按快捷键 Ctrl + G 就会显示地址,复制此地址打开IDA Pro按快捷键 G 在弹出的窗口当中粘贴并按回车就能跳转到相应的代码位置。按 F5 键会把汇编代码转换成C++的伪代码以便我们看懂。

1
bool __userpurge sub_808080@<al>(int a1@<ecx>, float a2@<ebp>, __m128 *a3)

我们可以看到函数名称为 sub_808080 代表函数的开始地址为808080,在名称上面按快捷键 N 就可以重新命名此函数为 takeDamage (名称可以随意设置主要是为了方便理解)。函数往下滑可以定位到我们计算生命值的位置:

1
*(float *)(a1 + 0x218) = *(float *)(a1 + 0x218) - v1[0];	//(a1 + 0x218) == 当前生命值 

在汇编代码中[esi+00000218]代表的是当前生命值的内存地址,在这个伪代码当中(a1 + 0x218)代表的是当前生命值。在IDA当中一些没有被识别的调用约定会被认定为 __userpurge 约定,在函数的参数中可以发现a1变量是作为第一个参数传递的实体对象而且是用ecx寄存器传递的那么基本可以确定这个函数是 thiscall 调用约定。

  • 关键特征
    • a1@ 表示 a1 参数通过 ECX 寄存器 传递。
    • thiscall 约定在 x86 架构中默认将 this 指针通过 ECX 寄存器传递。

2.函数内容的分析

由于已经确定a1是this指针因此可以在a1变量上按快捷键 N 重命名为this,翻到函数的第一行可以看到有个if判断:

1
if ( *(_BYTE *)(this + 0x214) ) return 0;

要是偏移为0x214的变量为真的话就直接返回不执行整个函数了,也就是说不会被扣除血量。因此可以猜测这里的1字节大小的变量可能表示对象的无敌状态标志(如 isInvincible )。

下一个判断也同样出现了 this + 0x210 转换为指针后加上偏移计算的值,由此可以确定 this + 0x210 是个指针。

1
2
3
4
5
if ( (*(_DWORD *)(*(_DWORD *)(this + 0x210) + 0x6EC) & 0x10000000) != 0 && a3->m128_i32[3] )
{
sub_6D3380(a3); //此函数内部不会对生命值进行修改
return 0;
}

通过ReClass.NET查看可以看到 this + 0x210 指针指向的是 BaseCharacter 类。游戏角色的基类一般包含生命值、位置、状态等信息。a3是个 __m128 类型,通常用于存储 4 个单精度浮点数,在参数当中的a3被声明为一个**__m128**类型的指针数组。从宏观的角度猜测这个函数的情况下,判断角色的某一个属性和a3数组中的某一个值都是否为真,是的话玩家就不会收到伤害。那么可以猜测此代码可能为玩家在某种状态下不会收到伤害。a3数组可以推断为是一个包含攻击的详细信息(伤害值、攻击类型、来源等),所以可以把a3改名成 AttackInfo

大致了解一些参数之后我们回头来看生命值被写入的那一行,发现生命值是从 v1[0] 的值减去而得到的。那么这里的 v1 数组大概率就是存储伤害值的数组了。可以给这个变量改名为 f_damage ,命名之后可以看到 f_damage 在上面的多个判断和计算中得来的。

继续分析下面的代码,可以大致的猜测下面的算法内容以便在后续的逆向工程中得到验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
v18 = 1.0;
if ( dword_1154278 ) // 全局难度或模式标志
{
v6 = flt_1156B94; //基础伤害系数
if ( flt_1156B94 >= 0.2 )
v6 = 0.2; // 限制最大伤害系数
f_damage = v6;
v18 = 0.0;
if ( v6 >= 0.0 )
v18 = v6;
*(double *)&v14.m128_u64[1] = 1.0;
v18 = 1.0 - (double)*(int *)(sub_7803C0(v4) + 8) / 5.0 * (5.0 * v18);
}

dword_1154278 可能是游戏难度或者是游戏模式,v6 赋予的应该是基础伤害的系数,接着判断伤害系数是否大于0.2,是的话就限制最大伤害系数。根据下面的代码可以分析 v18 应该是某种防御系数的值。

1
2
3
4
v7 = ((double (__thiscall *)(_DWORD, __int32))*(_DWORD *)(**(_DWORD **)(this + 0x210) + 0x1A8))(
*(_DWORD *)(this + 0x210),
AttackInfo->m128_i32[2]);
v8 = v7 * v18; // 应用防御减伤

在这一行调用了玩家基类的某一个虚函数,传递的参数有玩家对象和伤害信息。可以推断从角色虚表中获取了基础伤害计算方法然后赋值给 v7 ,后面对 v7 乘以猜测的防御系数值。根据推断信息可以把 v7 命名为baseDamagev8 命名为 curDamage , v18 命名为 defenseRate

AttackInfo 在游戏开发中一般包含攻击来源,伤害倍率,攻击方向等信息,可以参考此文档。在接下来的代码中使用了通过攻击信息来进行判断的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
v9 = AttackInfo->m128_i32[3] == 0;
curDamage_2 = curDamage;
if ( v9 ) // 无来源攻击
{
v10 = _mm_and_ps(_mm_mul_ps(AttackInfo[2], AttackInfo[2]), (__m128)xmmword_ECD230);
v11 = _mm_hadd_ps(v10, v10);
v14 = _mm_sqrt_ps(_mm_hadd_ps(v11, v11)); //计算向量值
curDamage = v14.m128_f32[0] * 2.0; //伤害加倍
}
else // 有来源攻击
{
(*(void (__thiscall **)(__int32, __m128 *))(*(_DWORD *)AttackInfo->m128_i32[3] + 0x14))(
AttackInfo->m128_i32[3],
AttackInfo + 1); // 处理特定攻击效果
}

根据上下文可以推断此处的判断条件是玩家收到的攻击是否有明确来源(如玩家,敌人),因此 v9 可以命名为 b_damageComeFrom 。在游戏开发中AttackInfo 类通常包含:

  • 攻击力(**m128_i32[0]/m128_f32[0]**)
  • 攻击方向(**m128_i32[1-2]**,存储向量)
  • 攻击来源(**m128_i32[3]**,指向发起攻击的对象)

在有来源攻击的情况下会先计算关于攻击方向的一堆向量值然后让算出来的值进行加倍操作。在无来源攻击的情况下会调用特殊处理的虚函数。接下来通过计算获得了我们的 f_damage 变量。

1
f_damage = defenseRate * curDamage_2;		// 最终伤害 = 调整后系数(防御系数) * 基础伤害

在下一个判断当中能看到某一个地址值为真的话就让生命值等于某一个值,动态分析可以发现 this + 0x21C 为最大生命值。那么这个判断类似于开启作弊模式之类的让角色一直获得最大生命值。

1
2
3
4
if ( dword_114F5E0 )                          // cheatMode ?
{
*(_DWORD *)(this + 0x218) = *(_DWORD *)(this + 0x21C); // this + 0x21C == 最大生命值
}

else 下面又进行了跟上面类似的计算以及判断,最终对生命值进行了写入操作,在文章最下面我会把这部分计算内容的分析放上去以供参考。

在函数的下方还有对生命值对于小于0的检测,要是生命值低于0就让他强行等于0。

1
2
3
if ( *(float *)(this + 0x218) < 0.0 )       // 生命值不能小于0
*(_DWORD *)(this + 0x218) = 0;
}

最后函数返回之前会进行一个简单的判断,根据生命值和伤害数值判断玩家是否死亡。

1
return curDamage_2 > 0.0 && *(float *)(this + 0x218) == 0.0;	// 返回是否死亡

接下来就是整个完整的函数代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
bool __userpurge takeDamage@<al>(int this@<ecx>, float a2@<ebp>, __m128 *AttackInfo)
{
int v4; // ecx
float v6; // edx
double baseDamage; // st7
double curDamage; // st7
bool b_damageComeFrom; // zf
__m128 v10; // xmm0
__m128 v11; // xmm0
int *p_defenseRate; // eax
float v13; // eax
__m128 v14; // [esp+18h] [ebp-3Ch] BYREF
float damageFix; // [esp+34h] [ebp-20h] BYREF
float healthPercent; // [esp+38h] [ebp-1Ch] BYREF
float curDamage_2; // [esp+3Ch] [ebp-18h]
float defenseRate; // [esp+40h] [ebp-14h] BYREF
float f_damage; // [esp+44h] [ebp-10h] BYREF
_DWORD v20[3]; // [esp+48h] [ebp-Ch] BYREF
_UNKNOWN *retaddr; // [esp+54h] [ebp+0h]

*(float *)v20 = a2;
v20[1] = retaddr;
damageFix = 0.0;
if ( *(_BYTE *)(this + 0x214) ) // this + 0x214 == isInvincible 是否处于无敌状态
return 0;
v4 = *(_DWORD *)(this + 0x210);
if ( (*(_DWORD *)(v4 + 0x6EC) & 0x10000000) != 0 && AttackInfo->m128_i32[3] )// this + 0x210 指向 BaseCharacter
{
sub_6D3380(this, (int)v20, (int)AttackInfo);
return 0;
}
defenseRate = 1.0;
if ( dword_1154278 )
{
v6 = flt_1156B94;
if ( flt_1156B94 >= 0.2 )
v6 = 0.2;
f_damage = v6;
defenseRate = 0.0;
if ( v6 >= 0.0 )
defenseRate = v6;
*(double *)&v14.m128_u64[1] = 1.0;
defenseRate = 1.0 - (double)*(int *)(sub_7803C0(v4) + 8) / 5.0 * (5.0 * defenseRate);
}
baseDamage = ((double (__thiscall *)(_DWORD, __int32))*(_DWORD *)(**(_DWORD **)(this + 0x210) + 0x1A8))(
*(_DWORD *)(this + 0x210),
AttackInfo->m128_i32[2]);
curDamage = baseDamage * defenseRate; // 应用防御减伤
b_damageComeFrom = AttackInfo->m128_i32[3] == 0;
curDamage_2 = curDamage;
if ( b_damageComeFrom )
{
v10 = _mm_and_ps(_mm_mul_ps(AttackInfo[2], AttackInfo[2]), (__m128)xmmword_ECD230);
v11 = _mm_hadd_ps(v10, v10);
v14 = _mm_sqrt_ps(_mm_hadd_ps(v11, v11));
curDamage = v14.m128_f32[0] * 2.0;
}
else
{
(*(void (__thiscall **)(__int32, __m128 *))(*(_DWORD *)AttackInfo->m128_i32[3] + 0x14))(
AttackInfo->m128_i32[3],
AttackInfo + 1);
}
defenseRate = curDamage;
f_damage = defenseRate * curDamage_2;
v14.m128_i32[2] = (__int32)&unk_1012F44;
v14.m128_i32[3] = (__int32)&f_damage;
sub_9FAF70(dword_12233E8, &v14.m128_u16[4], 0);
curDamage_2 = *(float *)(this + 0x218);
if ( dword_114F5E0 ) // cheatMode ?
{
*(_DWORD *)(this + 0x218) = *(_DWORD *)(this + 0x21C);// this + 0x21C == max_Health
}
else
{
b_damageComeFrom = AttackInfo->m128_i32[3] == 0;// 是否为无来源攻击
defenseRate = *(float *)(this + 0x21C); // 读取最大生命值
healthPercent = curDamage_2 * 100.0 / defenseRate;// 对生命值进行百分比计算
if ( b_damageComeFrom ) // 无来源攻击
{
defenseRate = 0.0;
p_defenseRate = (int *)&defenseRate; // 没有防御倍率
}
else
{ // 有来源攻击
p_defenseRate = (int *)(*(int (__thiscall **)(__int32, float *))(*(_DWORD *)AttackInfo->m128_i32[3] + 0x18))(
AttackInfo->m128_i32[3],
&damageFix); // 获取防御率
}
v14.m128_i32[2] = 0;
v14.m128_i32[2] = *p_defenseRate;
v14.m128_i32[3] = sub_602920(p_defenseRate, (int)&unk_10BECB8);
defenseRate = sub_83D9A0(&v14.m128_u16[4]); // 通过一系列计算获得防御率
damageFix = sub_4B9D80(flt_1159670); // 读取伤害修正值
if ( AttackInfo->m128_i32[3] ) // 有来源攻击时
{
if ( defenseRate >= (double)healthPercent )// 防御率大于生命百分比时
{
healthPercent = sub_836480((_QWORD *)this, 1.0);
damageFix = damageFix - (healthPercent + f_damage);// 重新计算伤害修正值
if ( damageFix < 0.0 ) // 修正值小于零时
{
f_damage = f_damage + damageFix;
damageFix = 1000.0;
f_damage = sub_93E700(0, (int)&healthPercent, &damageFix);// 重新计算伤害修正值
}
}
}
*(float *)(this + 0x218) = *(float *)(this + 0x218) - f_damage;// this + 0x218 == health
if ( dword_1221378 && (*(_BYTE *)(dword_1221378 + 0xC) & 4) != 0 )
{
damageFix = *(float *)(this + 0x21C);
defenseRate = damageFix * 0.1;
v13 = defenseRate;
if ( *(float *)(this + 0x218) >= (double)defenseRate )
v13 = *(float *)(this + 0x218);
*(float *)(this + 0x218) = v13;
}
if ( *(float *)(this + 0x218) < 0.0 ) // 生命值不能小于0
*(_DWORD *)(this + 0x218) = 0;
}
sub_6D3380(this, (int)v20, (int)AttackInfo);
sub_53ECF0(AttackInfo, LODWORD(f_damage)); // 可能记录伤害日志
sub_40CEB0(f_damage);
return curDamage_2 > 0.0 && *(float *)(this + 0x218) == 0.0;// 返回是否死亡
}

因为逆向工程大部分都是搞猜测验证来进行的,所以文章中有错误猜测或错误分析的部分请在评论区纠正。有任何私人问题可以联系我的私人邮箱,邮箱地址在首页。谢谢观看!