之前我在阅读很多技术类书籍的时候,总会反感那些堆砌在开篇的背景知识。但当我开始写这个系列教程时,我才发现有些内容是无可避免的,尤其是当我想把这个系列写的系统而“基础”时。因为从某个角度讲,游戏修改与软件工程和操作系统原理等计算机科学密不可分。
所以尽管我非常不乐意写这种很有水字数倾向的内容,但我还是努力写了,旨在照顾真正零基础却依然想踏入这个世界的读者。对于这部分读者,我诚恳建议学习一门编程语言。C语言是我强烈推荐的,因为它非常简单,极易上手,而且更为接近系统的底层,或者说程序的本质。
本章内容将以C语言为基础,介绍程序的本质和修改原理。
C语言的教程有很多,我推荐零基础的读者使用菜鸟教程进行学习:
https://www.runoob.com/cprogramming/c-tutorial.html 而对于只熟悉更为高级的编程语言,但未曾接触过低级编程语言的读者,更好的方式是直接阅读C语言参考手册:
https://zh.cppreference.com/w/c/language 至于C语言的开发环境,除了菜鸟教程的线上测试环境之外,我推荐开源免费的集成开发环境Code::Blocks,它体积小,而且开箱即用。你可以在它的官网获得它的最新版本:
https://www.codeblocks.org/ 最后我想说的是,尽管我尽了最大的耐心去写这个章节,但我的耐心显然不怎好。如果你暂时没有理解其中的全部内容,这绝对不是你的问题。请在遇见不清楚的词汇或概念时,努力百度或谷歌……
我们都知道当代非量子计算机的工作原理,高电平表示1,低电平表示0。基于这个事实,计算机采用二进制的方式理解和记录数据。这部分内容非常简单,但比较庞杂,这里只做最基础的描述,作为读者查阅资料的索引。网络上相关资料格外丰富。
0.1 二进制和位
二进制(binary)数就是由0和1构成的数字,下文我们用0B作为前缀表示二进制数字。
0用二进制表示是0B00;
1用二进制表示是0B01;
2用二进制表示是0B10;
……
每个记录0或1的“位置”被称作位(bit),在计算机中,位是描述数据长度的最小单位。
0.1.1 十六进制
二进制数是由一串01组成的,可读性不好,所以我们需要大的进位级别。日常用到十进制虽然能和二进制互转,但不利于体现二进制数的特征。想要解决这个问题,需要选择2的倍数的进制。在目前的主流设备上,四进制、八进制太小了所以不被使用(其实有些设备或系统还是用到了八进制的,比如Linux的chmod命令),而32进制需要记忆的符号又多的夸张,所以16进制成了主流的选择。
在十六进制中,10~15用A~F表示。
例如15用十六进制表示是0X0F;
例如16用十六进制表示是0X10;
例如32用十六进制表示是0X20;
而1个字节(Byte)可以表示成2个连续的16进制数字,即0x00~0xFF。
……
在游戏修改中,我们最常接触的就是十六进制数,因为表示起来很方便,也更好理解。
0.2 位运算
以位为基础单位进行的逻辑或算数运算被称作位运算。
0.2.1 And 与
两位进行And运算(用&表示),如果两位都是1,则返回1,其他情况则返回0:
0B0 & 0B0 = 0B0;0B0 & 0B1 = 0B0;0B1 & 0B1 = 0B1;0B01 And 0B10 = 0B00
除了一般计算,程序中还经常使用这个运算判断一个变量是否为0,因为它的执行效率更高。
0.2.2 Or 或
两位进行Or运算(用|表示),如果两位都是0,则返回0,其他情况则返回1:
0B0 | 0B0 = 0B0;0B0 | 0B1 = 0B1;0B1 | 0B1 = 0B1;0B01 |0B10 = 0B11
0.2.3 Not 非
两位进行Xor运算(用|表示),如果两位都是0,则返回0,其他情况则返回1:
~0B0 = 0B1;~0B1 = 0B0;~0B10 = 0B01
0.2.3 Xor 异或
进行Not运算(用^表示),如果两位相同,则变成0,否则变成1:
0B0 | 0B0 = 0B0;0B0 | 0B1 = 0B1;0B1 | 0B1 = 0B0;0B01 |0B10 = 0B11
除了一般计算,程序中还经常使用这个运算将一个变量设置为0,因为它的执行效率更高。
0.2.4 逻辑位移运算
逻辑位移运算表示将目标数字的每一位左或右移指定的位数。左移运算用>>表示,右移运算用<<表示,则有:
0B01>>1 = 0B00;0B10>>1=0B01;0B00<<1=0B00;0B01<<1=0B10
位移运算有很多技巧,最常见的就是使用位移取代目标数字是2或2的倍数的乘法/除法运算。
例如1*2可以用位运算1<<2来取代,程序中使用这种计算方式可能会得到微小的性能提升。
我不想用无关的废话占用读者时间,下面尝试以最简单质朴的方式阐述这一过程。
1.1 代码的诞生
代码从本质上看分为两类,一类是让CPU阅读的机器代码,另一类是便于人类阅读和修改的高级语言。
当程序员们用高级语言写好代码,会用编译器对代码进行编译,将其编译为CPU可以理解的机器代码,这个过程通常被称作“将源码编译为本机代码”。
当然这个说法并不准确,随着硬件技术和软件工程技术的进步,现在有更多的方式来处理高级代码。
一个比较流行的做法是,为代码设计一个执行者,比较专业的属于叫宿主程序或者解释器,即时解释代码并执行。曾经的JavaScript和现在主流的Python实现都是基于这种模式。
另一个流行的做法是,先用编译器将代码编译为一种比编程语言低级但比机器代码更高级的语言,这种语言被称作中间语言(IL,Intermediate Language),执行此程序后会由另外一组翻译和控制程序将IL编译为机器代码,而这组翻译和控制程序被称作运行时(Runtime)这样做的好处是可以获得比解释执行甚至直接编译为本机代码更好的效率,因为足够聪明的运行时会根据不同的计算机硬件进行专门的优化。像Java、现在的JavaScrpit、以及后文将要介绍的Uinty3D程序的编译方式之一,都是基于这种模式。
先把目光从那些你可能看起来很高大上的词汇上收回来。对于游戏修改而言,我们主要关注的还是机器代码。
机器代码本质上是又0和1组成的二进制序列,不同的数字代表不同的指令,以供CPU阅读执行。但对人类来说就很难阅读了,所以我们会使用一些约定的符号帮助我们阅读这些代码,这种约定的符号被称作指令助记符——它还有一个更为主流的名字:汇编语言。
与程序代码不同,汇编语言与机器指令之间,在大部分情况下可以准确的进行转换。在CE中,反汇编器将机器指令解析为汇编语言供我们阅读分析,而当我们写脚本的时候,CE会用汇编器将我们的汇编代码转换为机器指令。
1.1.1 指令集
指令集表示CPU的基础架构,它决定了CPU支持哪些机器指令。目前主流智能设备的CPU大致分为两大块。一类是移动平台使用的ARM架构,具体来说就是高通,麒麟,M1等处理器。另一类是桌面架构,就是Intel的X86架构。
哦,我当然忘不了AMD,毕竟AMD Yes对吧。这部分内容其实很狗血,因为实际上X86架构是面向32位平台的,而面向64位平台的架构被称作AMD64,这其中的恩怨情仇可以看知乎上的这个问题:
https://www.zhihu.com/question/28194051 不过为了便于表述,以及事实上的区别不大,我们以下统一称作X86处理器。
移动平台不去讨论,对于X86而言,学习其基础指令的最佳做法是阅读官方的指令集手册,无论是看Intel的还是AMD的都可以,任选:
# 名称:Combined Volume Set of Intel® 64 and IA-32 Architectures Software Developer’s Manuals
https://software.intel.com/content/www/cn/zh/develop/articles/intel-sdm.html
# 名称: AMD64 Architecture Programmer’s Manual Volumes 1-5
https://developer.amd.com/resources/developer-guides-manuals/ 如果觉得大部头很难肯,可以阅读任何一本现有的汇编教程,虽然这些教程通常都是比较老旧且嵌入式设备,不过可以对照此网站进行阅读,这是最好的X86指令工具手册:
http://ref.x86asm.net/index.html 无论采用什么方式,指令集的学习和理解都是必要的,因为熟练阅读和使用汇编代码是成为修改游戏达人的前提。
1.2 代码的执行
编译器在编译代码时,会将代码“转换”成操作系统认可的可执行文件。这个过程很有东西,足够写一本书,本系列不涉及这方面的内容,有兴趣的读者可以参考微软的官方文档:
https://docs.microsoft.com/zh-cn/windows/win32/debug/pe-format 当我们运行这个可执行文件后,系统会为程序分配内存,加载代码,并在代码区域约定好的位置(学名入口点)开始运行。
下面将通过C语言的示例解释这个过程。我强烈建议没有编程经验(或者忘了)的读者先学完C语言再看接下来的内容,如果一天可以学习2~3个小时的话,学会C语言不会超过一周。另一方面,如果读者能够理解这个示例,本节内容可以跳过。
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;time.h&gt;
//游戏信息结构
static struct GameInfomation {
char state; //游戏状态。 0:创建游戏;1~N:游戏中的轮数; -1:重开游戏; -2:退出游戏
char goal[4]; //要猜的四个数字
int aCount; //当前轮位置和数字都正确的数量
int bCount; //当前轮位置错误但数字正确的数量
} gameInfo, *pGameInfo = &amp;gameInfo; // 游戏信息的全局变量和指向变量的指针
// 设置游戏信息
void SetGameInfo(int state){
pGameInfo-&gt;state= state;
gameInfo.aCount = 0;
gameInfo.bCount = 0;
}
// 创建四个不重复的数字。
void CreateNumbers(){
static int source[10];
int num;
for(int i=0;i&lt;10;i++)
source[i]=i;
for(int i=0,j=10;i&lt;4;i++,j--){
num = rand() % j;
gameInfo.goal[i]=source[num];
if(num != j-1){
source[num]=source[j-1];
}
}
}
// 根据输入的数字更新游戏信息
// ch: 输入的数字
// index: 输入数字的编号。
void UpdateGameInfo(int ch, int index){
ch -= &#39;0&#39;;
for(int i=0;i&lt;4;i++){
if(gameInfo.goal[i]==ch){
if(i == index){
++gameInfo.aCount;
}else{
++gameInfo.bCount;
}
break;
}
}
}
// 处理用户输入
// inputBuf: 用于存储用户输入的缓冲区。
// 返回: 0:输入了数字; -1:要重开; -2:退出; 大于0:错误的输入。
int ProcessInput(char* inputBuf){
int i=0; // 输入计数
int j;
int fail=0; // 小于等于0:正常,大于0:失败
int ch;
while((ch = getchar()) != &#39;\n&#39;){
if(i &lt; 4){
if(ch &gt;= &#39;0&#39;&amp;&amp; ch &lt;= &#39;9&#39;){ //如果输入了0-9之间的数字。
for(j=0;j&lt;i;j++) // 检查重复输入
if(*(inputBuf+j) == ch) // 重复输入
goto errorInput;
*(inputBuf+i)=ch;
UpdateGameInfo(ch,i);
}else if(i ==0) {
switch(ch){
case &#39;X&#39;: // X或x退出
case &#39;x&#39;:
fail = -2;
break;
case &#39;R&#39;: // R或r重开
case &#39;r&#39;:
fail = -1;
break;
default: // 输入错误
fail= 1;
}
}else{ //输入错误
errorInput:
fail= 1;
}
}
++i;
}
if((fail==0 &amp;&amp; i!=4)||(fail&lt;0 &amp;&amp; i!=1) ) //输出长度错误
fail=1;
return fail;
}
int main()
{
char inputBuf[4]; //玩家输入内容
int inputState; //输入的状态
srand(time(0)); // 初始化随机数种子
SetGameInfo(0); //初始化游戏信息
printf(&quot;=== Bulls and Cows ===\n&quot;); //输出游戏标题
printf(&quot;X or x: exit game; R or r: restart game. \n&quot;);
while(1){
switch(gameInfo.state){
case -2: // 退出游戏
printf(&quot;Game exited. Press any key to continue.&quot;);
getchar();
return 0;
case -1: // 重开游戏
printf(&quot;Restart game...\n&quot;);
case 0: // 创建游戏
printf(&quot;========\n&quot;);
SetGameInfo(1); //初始化游戏信息
printf(&quot;Creating game...\n&quot;);
CreateNumbers();
printf(&quot;Input 4 different digits.\n&quot;);
default://正常游戏
inputNumbers:
SetGameInfo(gameInfo.state); // 清除AB统计
printf(&quot;Round %d :&quot;,gameInfo.state);
inputState = ProcessInput(inputBuf);
if(inputState == 0){
++gameInfo.state;
printf(&quot;%dA%dB\n&quot;,gameInfo.aCount,gameInfo.bCount);
if(gameInfo.aCount==4){
printf(&quot;You Win!\n&quot;);
SetGameInfo(0);
}
}else if(inputState &gt; 0){ //如果输入错误
printf(&quot;Input error.\n&quot;);
goto inputNumbers;
}else{
gameInfo.state = inputState;
}
}
}
} 示例是一个完整的猜数字游戏C语言源码。虽然简单,但它包含了一个游戏应有的基础内容:显示内容,以及接受用户操作,并且对于错误的操作进行屏蔽。
从整体上看,GameInfomation是一个数据结构,里面是游戏要用到的一些变量。而其他内容比如SetGameInfo、CreateNumbers等等,是游戏要执行的一个个函数。即使没有编程经验,结合英文连蒙带猜,应该也可以意识到一个问题,就是代码本质上就是由赋值、判断、循环组成的。基于这个理解,我们细看以下内容。
1.2.1 数据类型、变量、以及语句
先关注GameInfomation部分。GameInfomation是一个数据结构,其中包括state等变量成员,而int则是state变量的类型。
从某个角度说,CPU的机器代码并没有那么复杂的类型,各种编程语言中不同数据类型解释不在本文的讨论范围内,需要读者自行理解,这里只简述其意义。
高级语言中的各种不同的类型系统主要是有两个意义。第一是通过对抽象内容的高度语义化,帮助开发者更好的组织代码。另一个意义则是通知编译器/解释器如何处理相应的数据,例如对于访问不同的类型的语句,编译器可能会将他们编译为不同的机器代码。
但从本质上看,CPU所理解的数据类型只有三种:整数型数据,指针类型数据(包括矢量),以及小数型(浮点型)数据。
在修改游戏的过程中通常需要阅读汇编代码,其中mov指令是很常见的,例如:
mov eax, 1 这条语句的意思很简单,让eax的值为1。
eax是X86架构中的整数寄存器之一。当前主流的32位X86处理器有8个整数寄存器,而64位的X86处理器有16个整数寄存器。
对于小数类型,或者更科学的说法浮点数而言,主流的X86处理器提供了8个FPU数据寄存器,以及8个xmm寄存器,64位的版本这个数字则被翻倍成了16个。
不熟悉汇编的读者可能会被这些词汇搞晕,不过不要紧,至少目前要讨论的内容并不需要掌握这些内容。
这里想要表达的是,对于处理器而言,寄存器就相当于一个个变量,用来记录程序需要的数据,并参与运算。但很显然,这么点寄存器,肯定是不够记录程序全部数据的,所以常规的处理方式是,寄存器只用于存储当前参与运算的数据,而更多的数据则放到程序的内存中。
mov eax, [123]
inc eax
mov [123], eax 这段代码是说将内存地址为123处的值赋给eax寄存器,并增加1,然后将增加后的值写入到地址123的位置。
所以最后总结,程序中的变量,本质上就是存储着数据的地址,而语句的工作就是对于寄存器(以及内存的值)进行操作运算。
1.2.2 指针和偏移量
猜数字游戏示例中为GameInfomation结构分配了一个变量gameInfo,接着又为gameInfo分配和指向它的指针pGameInfo。
初学者可能会被指针的各种神神叨叨的定义搞的头晕脑胀,但如果从本质上看,它的原理极其简单。就像上一小节提到的,如果我们把变量理解为存储着某数据的地址的话,指向这个变量的指针就是记录着这个地址的地址。
例如假设变量A的地址是123,变量B是指向变量A的指针,且变量B的内存地址是234,则位于地址234的实际的值就是123。
具体到C语言,我们可以看到通过指针访问和直接访问的语法差异:
pGameInfo-&gt;state= state; //通过指针访问
gameInfo.aCount = 0;
gameInfo.bCount = 0; 相对于指针,偏移量的概念就很简单了:即,某个地址附近的地址与它的距离差。举例来说,假设变量A的地址是123,而变量B的地址是127,则变量B的内存地址可以表达为变量A的地址+4。
对于数据结构不熟悉的读者可能会困惑于这种设计,这里建议学习“表”的相关内容,包括数组和链表,可以帮助我们更好的理解指针与偏移量的概念。对修改游戏来说,对各种数据结构的熟练掌握是必须的。
1.2.3 内存
用过CE的读者可能会发现,修改游戏时找到的地址分成两类,一类是绿色的,每次打开游戏它都不会变动。而另一类地址则是黑色的,这类地址会变动,要想做成CE表格,需要查找指向它指针,尝试找到一个记录了这个指针的绿色地址。这个过程被称作寻找基址。
这种场景的出现与程序对内存的划分和利用方式有关。程序会将内存划分为以下几块:
代码区:记录要运行的机器代码
动态堆区:记录用户手动分配的数据。
全局堆区:记录全局变量。
静态堆区:记录静态全局变量。
常量堆区:记录字符串等固定常量区域。
栈区:记录局部变量。
上面列表的描述并不准确,因为实际内存的分配和利用方式,会受到不同的代码写法以及编译器实现等因素的影响。但从以此为例可以说明的是,我们找的绿色的地址,其实位于全局/静态/常量堆区(当然代码区也是),这些区域的地址在程序加载时就已经固定不变,所以被称作基地址。而动态堆区(以及栈区)的地址则是变动的,所以如果游戏的某个数值位于动态堆区,为了访问这个地址,我们才需要在基址区域查找指向这个地址的指针(或者指针的指针)。
1.2.3.2 内存布局
在1.2.2节中我们简单解了数据类型的知识。对于有编程经验的读者来说,会更容易这些内容。而在这里,我们会讨论这些类型在内存中的内存布局。
猜数字示例中GameInfomation结构包括2个int类型(4字节)成员和5个char类型成员(1字节),那么它在内存中占据的大小是多少呢?简单计算可知,2*4+5*1=13字节,但真的如此吗?
答案是未必。
有过修改游戏经验的读者可能会发现,很多游戏里,一些数据占用了4个字节的空间,但体现在游戏里,这个值可能只需要一个字节或两个字节。造成这个现象的可能有两个原因,一是游戏开发者直接使用了4字节数据类型。而另一个原因可能是,为了做到内存对齐,提高CPU对CPU的访问效率。
X86(当然还有AMD64)架构的CPU,在每个时钟周期访问内存的时候,会从偶数地址开始读取,对于位于奇数地址的数据,可能需要额外的时钟周期才可以读取完成。所以我们需要数据对齐——这也就是我们找到的游戏数据地址通常都是以0、4、8、C,10之类偶数开始的原因。
注:对于某些CPU来说,内存对齐不只是出于性能优化的考量,而是硬性设计要求,否则可能会导致CPU指令执行错误程序崩溃。另外,在X86架构中,很多高级硬件加速指令也需要内存对齐,以便在单次指令中计算更多的数据,这里不讨论这些内容。
像GameInfomation结构,在主流的操作系统和编译环境中,其char类型的成员state虽然只有1个字节的大小,但下一个成员goal与它却有四个字节的距离。而goal是一个数组,作为连续内存区域的类型,它不必使用内存对齐,这是因为它有独特的访问方式。
下面用汇编代码对这种情况进行说明,虽然不同的编译器和优化设置会导致汇编代码有很大的差异,但其核心思路是一样的。
假设GameInfomation结构所在的地址是1234,若要读取其中state的值,则有:
mov edx, 1234
# eax的值就是state的值
# 顺便说,此处的edx就是GameInfomation变量的指针~
mov eax, [edx] 相应的,如果要读取其中aCount的值,可能会有这样的代码:
mov edx, 1234
# eax的值就是aCount的值
mov eax, [edx+8] 而反过头来看goal,对于它的访问很有可能是这样的:
mov edx,1234
# 还记得前面位运算的内容吗? esi在执行此指令后位0
xor esi,esi
# 以esi作为索引访问数组,执行此指令后,eax的值就是goal第一个元素的值
mov eax,[edx+esi*1+4] 1.2.3.2 地址空间配置随机化(ASLR)
心细(以及倒霉)的读者或许会发现,对于某些游戏而言,即使目标数据位于基址区,每次重开游戏后这个地址依然会因为变动而失效。关于这个现象我们从头说起。
一个程序,除非是一些工具类小程序,一般除了主程序之外,还需要一些额外的dll文件支持,这些dll文件也是程序,学名是运行库或类库(Library),事实上我们看dll的全称Dynamic Link Library(动态链接库)就可以很好的理解了。在这些dll中,有会被主程序所用到的各种函数。实际上哪怕是只有一个文件的小工具,它也需要很多额外的动态链接库,只不过这些库是操作系统自带的,不必随程序提供而已。
当运行程序后,程序的进程会将这些链接库加载到内存中。一般而言我们管这些东西(包括主程序)叫模块。每个模块在加载时,就会在固定偏移位置分配一块内存区域,作为全局/静态/常量数据的存储区。例如假设模块A的静态内存区域处于偏移量0x100的地方,且它自身在内存中的位置是B,则其静态内存区域的实际地址为B+0x100。
在早期的Windows中,一个程序运行后,它的所有模块都会加载到固定的位置。所以你可能会发现许多早期的游戏主模块(主程序)的基址起始地址通常都是0x400000。然而这种设计存在安全方面的不足,所以在后来,有了地址空间配置随机化(Address space layout randomization,ASLR)。它的意义在于,每次加载模块时,不再从固定的内存区域为其分配内存,这从某种程度上说提高了攻击者的攻击难度。
科普:缓冲区溢出攻击
可能有读者注意到我在猜数字示例中的ProcessInput函数里,没有使用scanf函数。这是因为我现在的C语言开发环境没有C11中的scanf_s函数,无法指定接受数据的最大长度。这会导致一个问题,就是一旦用户输入的内容过长,超过了缓冲区的大小,则可能引发很多不可预料的问题。更危险的是,假设这是一个网络服务,攻击者可以尝试利用这个漏洞,输入一段包含了可执行代码的文本,从而完成入侵。ASLR可以很好地降低这个风险,内存的随机化分配让攻击者更猜出程序堆栈以及各种系统函数的地址,极大的提升了构造攻击文本的难度,降低入侵的成功率。然而无论如何,缓冲区溢出依然会对程序造成无法预期的破坏,所以写程序时,应该尽量以最安全的方式实施代码。
从实践角度回顾,本小节开篇说的情况,就是ASLR在起作用。不过好消息是,ASLR主要是是为了防御远程攻击者,完全不影响我们修改游戏。我们在CE中记录地址的时候,不要写数值形式的地址,而是用模块名+偏移量的形式书写。例如游戏A的主模块起始基址是0x400000,为了防止ASLR的影响,我们可以把这个地址写为“游戏A.exe”,CE会帮我们处理这个问题。
尝试自己写修改器的读者可能会直面ASLR的干扰。对于这部分读者,我就没必要将那些基础的概念了,只说重点。首先模块地址可以EnumProcessModulesEx相关函数获取,而系统自带的模块的地址,虽然在每次开机后都会变动,但对于不同程序而言,这个地址则是完全相同的,这意味着我们通过GetProcAddress在自己的程序中找到的函数地址,在目标程序中一样适用。
1.2.4 栈与函数
函数,在面向对象的语言里可能被称作方法,这东西在内存中的表现就是一个个代码段,这是毫无疑问的,现在我们主要讨论一个函数是如何被调用的。要弄清楚这个问题,我们先要了解程序中的栈内存。
在1.2.3节中的开头提到了栈内存的概念,说它用于保存临时变量,这话并不准确,准确的说,它不止用于存储临时变量,还承担着更多的任务:例如保存当前寄存器的状态,以及记录函数的调用。
事实上一个变量是在堆还是栈中分配内存,因为不同的语言和不同的实现方式有很大差异,请读者知悉。
熟悉数据结构的朋友一定接触过栈的概念,它像是一个木桶,先放进去的东西在最下头,而最后放进去的东西在最上头,要想拿到下面的东西,需要先把上面的东西取出来。
在X86处理器的指令集中,有很方式可以对栈内存进行操作。其中有push(压入,将目标放入栈顶)、pop(弹出,将栈顶目标取出)这种将栈内存视为一个数据结构的操作指令,也可以直接对它中的某个地址进行访问。
在1.2.1节中提到过,32位处理器有8个整数寄存器,这些寄存器分别表示为eax、ecx、edx、ebx、esi、edi、esp、ebp 。
我们先看esp,它与其他整数寄存器之间最大的区别在于,它一直表示栈内存顶部地址的指针,也就是栈顶数据的内存地址。什么意思呢?假设当前栈顶的地址(即esp的值)是S,且eax的值是N则有:
# 此处esp=S, eax=N
push eax # 将eax的值压入栈中, esp=S-4, eax=N
mov edx,[esp] #将esp地址中的值赋予edx, esp=S-4, eax=N, edx=N
pop ecx # 将栈顶的值弹出并赋予ecx,esp=S,eax=N, ecx=N 明白了吗?压入(push)、弹出(pop)这类直接按数据结构的方式对栈内存进行操作的指令,会直接修改esp的值。
注:在实际操作中,ebp寄存器也是一个需要关注的内容,不过为了示例的简洁,这里不说了,请自行搜索。
有了这些了解,我们就可以看看调用函数是如何实现的了。
先介绍一个特殊的寄存器,eip,eip这个寄存器很特使,它存储了当前执行的代码的位置,比如当前代码xor eax,eax所在的内存地址是1234,则执行到此代码时,eip的值就是1234。可以说,eip和esp是维护程序运行的忠臣,但esp可以通过指令手动修改其值,比如mov esp,ebp(将esp的值设为ebp),或者add esp, 4(让esp的值增加4),而eip则不可以,eip的值只会通过某些指令,被间接的修改。
我们都知道(或者假装知道),汇编里有很多跳转指令,除了jmp这种无条件跳转,还有ja,jz这种根据标志寄存器的状态进行跳转的指令。这些指令的本质就是修改eip的值达到跳转的目的,而其实,调用函数的call指令本质上也是一个跳转指令,只不过它做了一些额外的工作。
假设主函数M种调用函数的指定的地址为A,而栈顶地址是S,目标函数F的起始地址为A2,则有:
#=============
# M
#=============
# 此时esp=S
A1: call F # 调用F函数,此时epi=A1
A1+5: # 此时esp=S,eip=A1+5
#=============
# F
#=============
# 此时esp=S-4,eip=A2
A2:mov eax, [esp] # eax=A1+5,即,此时栈顶存储着返回时要跳转的地址。
ret #函数结束返回,执行此语句后,将跳转到A1+5的位置
尽管已经有了足够的说明,但为了更清晰的理解,我们来看一下call和ret的工作。
用汇编代码描述,call指令相当于以下代码:
push eip+5 # 5 是call指令的长度
jmp F 而对于ret来说,则是:
pop eip 虽然就实际来说上面的代码是错误的,但原理方面很好理解吧?把“复杂”的指令转为更为“基础”的指令,有助于我们更好的理解指令的意义。
到了这里,我们已经了解了函数调用的基本原理,爱思考的你肯定会意识到我们遇到了新的问题:寄存器的值不能随便乱动,栈顶的指针和栈中的数据也不能乱动,那么不同函数之间的调用,很可能破坏这些内容。比如在上面的例子中,eax的值就被破坏了,这个问题要如何解决呢?
其实不止是不同的函数,哪怕是不同的编译器,也会有不同的利用寄存器和栈的方式,所以,为了解决不同的函数之间调用的兼容问题,我们需要一套统一的标准或规范,也就是调用约定。
关于调用约定的信息可以查看微软官方文档的说明:
https://docs.microsoft.com/zh-cn/cpp/build/x64-software-conventions?view=msvc-160#register-usage
https://docs.microsoft.com/zh-cn/cpp/cpp/argument-passing-and-naming-conventions?view=msvc-160 这组文档非常详细,建议完整阅读。不过为了照顾初学者,这里还是以32位环境为主要目标,简单对相关常用内容进行概括说明。
调用约定提供了调用函数的一套规范。其中把寄存器分为两大类,一类是易失寄存器,另一类是非易失寄存器。
易失寄存器的意思是说,函数的调用者不需要被调用函数维护这些寄存器的值,被调用方可以随意使用这些寄存器。易失寄存器包括eax,ecx,edx,xmm0~xmm5,以及全部标志寄存器。
非易失寄存器表示这些寄存器的值还有用,被调用方必须维护这些寄存器,在使用他们之前,先把原始值进行备份(比如存入栈中),在函数返回之前还原这些寄存器的值。非易失寄存器包括edi,esi,ebx,esp,ebp,xmm6、xmm7。
寄存器维护的问题解决了,栈维护的问题还没有,而且我们至今仍未知道函数的参数和返回值是怎么处理的。这部分内容在微软的文档中被称作“参数传递和命名约定”,我们统一称呼为调用约定。。。
对修改游戏而言,常见的调用约定有四个:StdCall、cdecl、FastCall、以及ThisCall。我们依次进行说明。
StdCall多见于32位程序。它的参数通过栈来传递,从右向左把参数放入栈中,而且栈的维护由被调用者(即函数自身)维护,而最后的返回值通过eax传递。怎么理解呢?假设我们要写一个加法函数,需要两个参数a和b,运行返程后返回运行结果,如果是StdCall的话,则是:
# esp=N
push 2 # 参数b=2,esp=N-4
push 1 # 参数a=1,esp=N-8
# 到这里,相当于栈里多了两个东西,就是要传给函数的参数。
call AddStdCall
# 返回后 esp=N
#===========
# AddStdCall
#===========
# esp=N-0xC
mov eax, [esp+4] # eax=1
mov ecx, [esp+8] # ecx=2
add eax,ecx # eax=3
ret 8 # 它意思是,返回到esp指向的地址,并将esp的值增加8,也就是说,把之前放进栈的两个参数(四字节*2)“掏”出来。
# 函数要保证把因为传递参数而改变的栈顶指针复原。
# ret 8 等效于:
# pop eip
# add esp, 8 那你可能会想,如果要传递的数据大于四字节——比如100个字节的结构——要怎么处理呢?处理这个问题,我们当然可以把数据一点点放进栈里,但这样效率非常低,所以,指针在这里派上了用场,就像猜数字示例中的ProcessInput函数一样……
除了StdCall,cdecl也很常见。它是“C Declaration”的缩写,即C风格的调用声明。对于C和C++来说,函数默认都是采用这种调用约定的,所以在32位程序中经常见到,毕竟默认嘛。另外,后面要介绍的Unity3D游戏的修改,也会用到cdecl的知识。
在cdecl约定中,参数一样通过栈来传递,从右向左把参数放入栈中,而与StdCall不同的是,它的栈是通过被调用者维护的:
# esp=N
push 2 # 参数b=2,esp=N-4
push 1 # 参数a=1,esp=N-8
# 到这里,相当于栈里多了两个东西,就是要传给函数的参数。
call Addcdecl
# 返回后 esp=N-8
add esp, 8 # esp=N
#===========
# Addcdecl
#===========
# esp=N-0xC
mov eax, [esp+4] # eax=1
mov ecx, [esp+8] # ecx=2
add eax,ecx # eax=3
ret 在少部分32位程序中,也有一切比较奇葩的程序选择FastCall。 FastCall是说,如果函数的参数数量不多于两个,则从左向右通过ecx和edx进行传递,如果多于两个,则剩余的参数通过从右向左的方式用栈传递。同样,栈指针的维护工作有函数本身而非调用者承担,最后的返回值通过eax传递。举例来说的话,还是加法函数,但这个加法有4个参数abcd,最后会返回a+b+c+d的结果,则其汇编代码为:
# esp=N
mov ecx,1 # 参数a=1,esp=N
mov edx,2 # 参数b=2,esp=N
push 4 # 参数d=4, esp=N-4
push 3 # 参数c=3,esp=N-8
call AddFastCall
# 返回后 esp=N
#===========
# AddFastCall
#===========
# esp=N-0xC
add ecx,edx # ecx=3
add ecx,[esp+0x4] # ecx=6
mov eax,[esp+0x8] # eax=4,ecx=6
add eax,ecx # eax=10
ret 8 前面说了,FastCall其实比较少见,之所以介绍它,是因为另一种调用约定:微软64位调用约定(Microsoft x64 calling convention,以下简称X64Call)。在所有Windows下的64位程序中,无论开发者如何指定,函数都将使用X64Call。
X64Call其实可以理解为FastCall的变体,它与FastCall最直观的区别是,它的栈是由调用者进行维护的(就如同cdecl那样)。
X64Call的参数传递规则如下:整数参数在寄存器 RCX、rdx、R8 和 R9 中传递。 浮点数参数在 XMM0L、XMM1L、XMM2L 和 XMM3L 中传递。 16 字节参数按引用传递。剩余的参数按从右到左的顺序压入到栈上。
至于X64Call的返回值规则,如果是小于等于8字节的整数,通过RAX传递,如果是非整数,例如浮点数、矢量等等,则通过XMM0传递。
X64Call还有栈内存对齐的要求。它要求栈顶指针(即RSP寄存器的值,RSP寄存器是ESP寄存器的64位扩展,ESP寄存器是RSP寄存器的低32位)应该以16个字节为单位进行对齐。什么意思呢,假设目前栈顶指针的地址是S,则此时S的值一定能被16整除。而且如果我们要改变栈顶指针,也应该以16字节为基础单位进行增减。 一个特殊情况是,当符合X64Call约定的函数被调用,并进入函数是,此时栈顶因为加入了返回的地址,所以不是16字节对齐的,而是16+8对齐。这是唯一的特殊情况。
最后,X64Call还有一个影子空间(shadow store)的概念。听起来逼格奇高对吧,但说白了,影子空间就是一个32字节的栈空间,它由调用方分配,供被调用方存储 RCX、rdx、R8 和 R9 四个寄存器参数的值。即使函数本身只有小于四个参数,也要留出32字节的地方来。而如果函数有多余四个参数,则影子空间必须位于其他需要入栈的参数之上。
为了更好理解我们继续用加法举例,这次的加法将计算abcde五个数的和。
# rsp=N
sub rsp, 0x30 # rsp=N-0x30, 0x30=影子空间32字节+参数8字节+对齐8字节
mov rcx, 1 # a=1
mov rdx, 2 # b=2
mov r8, 3 # c=3
mov r9, 4 # d=4
mov [rsp+20], 5 # e=5
call AddX64Call
# rsp=N-0x30
add rsp, 0x30 # rsp=N
#==============
# AddX64Call
#==============
# rsp=N-0x38
mov rax,[rsp+28] # rax=5
add rax, rcx
add rax, rdx
add rax, r9
add rax, r9
ret 如果你仔细阅读前文提供的文档链接,就会发现X64Call实际上并非只有这些内容,不过我们毕竟不是专业的开发者,所以目前只了解这些就足够了。而就实际经验来说,在我们逆向游戏的时候,不可能看到上面这么简单的调用代码以及函数实现代码,例如对于X64Call而言,一个函数,通常会通过一个套固定的代码(学名 prolog)进行寄存器维护、栈维护等操作,如果不熟悉的人很容易抓不到重点,不过,在了解这些的基础上,那些稍微复杂的代码,总会更好理解一些,对吧?
最后我们来谈谈ThisCall。请相信我吧,辛苦的日子结束了。本章内容中,函数这一块我是最后完成的,因为内容实在太多,不过,写到这里,我们就都很轻松了。
ThisCall涉及到一个概念,面向对象的程序设计。不熟悉这方面知识的读者,我建议学习一下C#语言,简单学学语法就好,学会了就理解什么是面向对象了。C#的教程很多,微软的官方教程就很不错,也很好找。。。
好吧我可能又欺骗了你,不过至少这部分内容我写起来将很轻松。假设读者已经知道面向对象是什么了,那么考虑一个问题,在我们访问实例的方法的时候,实际上做了哪些工作呢?
答案很简单,可能你已经意识到了,实例的方法其实就是一个个函数。只不过这个函数多了一个参数,这个参数是一个对象指针,由此函数就能对目标对象的状态进行修改了。
现在我们回看ThisCall,它在32位环境中,其实就是cdecl或fastcall,在64位环境中就是X64Call,只不过,它的第一个参数永远都是对象实例的指针。
还是用加法举例吧,不过这一次我们要稍微复杂一点,假设有一个类型N,N有两个成员a和b,都是是int类型的整数,N有一个方法add,它接受一个int类型的参数c,调用后会返回a+b+c的值。现在我们创建了一个N的实例X,且X.a=1,X.b=2。在32位环境下,则有:
mov ecx, X # X是N的实例指针
mov edx, 3 # 参数c=3
call AddThisCall
#=============
AddThisCall
#=============
mov eax, [ecx] # eax=a=1
add eax, [ecx+4] # eax=1+2=3
add eax, edx # eax=6
ret 这下很清楚了吧?虽然实际上这类程序的内存结构比这个例子呈现的要复杂的多,但机制上却完全一致。这里只讨论理论,实战分析等到第四章再说。
1.2.5 线程
这部分内容与游戏修改没有太大关联,只是面向无基础的读者简单说明。
我们买个CPU,会看它是几个核心的,一般少有人选单核或者2核的了吧,一般都是4核起。
Intel(和AMD)一个核心有两个线程(此处应有百度百科),然后用最粗浅最不专业的话来解释,线程就是让程序同时做不同的事情。用人来做例子,如果你是单线程的,你吃饭的时候就不能干别的了,但如果有两个线程,你就可以一边吃饭一边打字……
不过需要注意的是,线程多并不意味着速度就快。CPU主要参数由两个,核心数,主频率。主频率决定了CPU的运行速度,核心数决定了CPU可以同时进行的工作数量。对游戏来说,因为机制往往比较复杂,针对不同的CPU进行多线程的支持与优化需要极高的成本,所以玩游戏不用选线程太多的CPU,线程撕裂者的体验未必比千元的处理器更好,选个主频高的往往会有更好的效果,线程的话,目前6核心12线程足够了。
2、游戏修改的方式
现在我们可以看看游戏修改的方式了。和前面冗长的背景知识比起来,游戏修改的具体方式格外简单,因为我们不用理会很多操作系统底层的知识(这部分内容我或许会单独写一篇文章),CE已经帮我们做好了大部分工作。
2.1 数值查找
数值查找其实就是根据游戏数值的变化,在其内存中查找相应的地址。
假设游戏里的金钱是100,先用相关的工具(比如CE)找到游戏中全部的其值为100的内存地址。然后我们在游戏里让这个值进行变动,并在上一步的结果中进行搜索……如此往复直到找到目标地址。
2.2 指针查找
指针查找从底层原理上说,涉及到操作系统和调试器原理的知识,这里不做讨论。其核心思想是,对目标地址进行监视,查找访问了目标地址的指令,并记录该指令在访问此地址时的寄存器与堆栈的状态,由此可以进一步分析出这个地址本身记录在什么地方。
对CE来说这个过程相当容易,甚至即便你没有理清楚其中的原理,只要按照CE的说明机械性的操作,就可以找到大多游戏的指针了。
2.3 修改指令
修改指令作为“高级”游戏修改技巧,其本质上与找指针并无太大差别。同样是对目标地址进行监视,然后blabla……
之所以看上去比找指针难度大,是因为找指针的规则比较简单,CE可以自动完成大部分工作,而修改指令就不是了,需要我们自己去分析反汇编代码。
角色无敌可能是修改指令的最基础应用了,方式很多,因游戏的不同也有很多差异,比如可以把扣血的代码用空指令替换掉,自然毫发无伤。而很多游戏往往会有一个“命中”的设计,所以也可以修改这攻击命中的判断相关语句,改成无论是否被击中,都不会执行扣血的相关代码……比起不断的写入一个最大值,修改指令实现无敌无疑更为优雅一些。
2.4 调用函数
一个游戏往往有大量的代码,他们不可能写在一个函数中,而是按照功能分布于不同的函数中,比如金钱生命的变动,游戏事件的开启关闭……我们可以找到相关的函数,直接调用,即可达成我们想要的效果。
至于怎么找函数……在CE中其实还是跟指针查找差不多。后面的章节中会对此进行说明。
2.5 代码注入
代码注入就是在游戏中申请一块内存区域,在这里写入我们自己的代码,并让游戏执行。
比如我们想修改游戏中的一条指令,但修改后的指令长度比原指令长,这将破坏后面的代码。为了解决这个问题,我们就需要代码注入来间接完成这个操作:在目标语句处做个跳转,让CPU执行到这里后,跳转到我们自己的代码那里,在这里先执行我们的代码,然后执行剩余的代码,最后跳转回相应的位置继续执行。
3 总结
本章内容极为散碎。造成这个情况的原因是内容太多,而且我的耐心有限。
游戏修改就是这样,想要上手,或者说简单的搜索数值,那非常简单,随便照着教程就能做到,但如果想要实现一些复杂的效果,就需要了解各种各样的背景知识,只有知其所以然,才能知其然。
即使是现役程序员,可能也会对本章节的某些内容感到生疏,但了解这些内容并非没有意义,尽管它们不能让你直接写出优秀的代码,但可以让你更好的理解自己写的每一行代码背后的意义,从而为程序优化提供一定的支持。退一万步说,如果你不在乎自己的发型想转行逆向工程师,这些内容也会为你的学习提供一个简单的方向……
最后还是那句话,我耐心有限。所以我已经猜到本章的阅读难度曲线在某些读者眼里有多么扭曲了……我不想掩饰这是我表达能力低下和心虚浮躁导致的。请继续读下去,后面的章节内容将比本章简单特别多,当你读完后续的章节后,可以再回过头来和本章内容进行印证。