内存修改技术的原理及实现 第二节 - 基本寻址方法和指针

基本寻址方法和指针

如何修改内存(使用工具)

我们想要修改内存时,首先需要知道数据的位置——即内存地址,寻找内存地址的过程叫寻址。那么我们如何寻找内存地址呢?

我们的CE老大哥为我们解决了这个问题。CE全称为Cheat Engine,这个工具不仅是为内存修改而服务,还提供了调试器等非常高级的功能。它长这个样子。

image-20200905101733141

我们寻址时基本会出现两种情况,分别是已知变量值或未知初始值。前者类似金钱 子弹数等直接反馈数值的数据,后者则是进度条 生命红条 法力值蓝条等不直接反馈数值的数据。

已知变量值

我们以CE的文本教程(CE安装目录下的Tutorial-i386.exe)为例,很明显我们想要修改的是生命值,目标是将其修改至1000,程序中我们每点击一下Hit me便会使生命值减少一点

image-20200905102117534

回到CE,我们首先需要让CE知道我们想要修改的内存在哪个进程,聪明的小火汁可能猜到是操作系统的权限问题。确实是这个原因,当然除去权限问题外,CE不可能将所有的内存全部扫描一遍(32位的4GB内存全扫下来要好长时间,而且内存越多精度越差)。

我们选择进程点击Open。

image-20200905102418875

image-20200905102447487

由于生命值是整数,我们猜测其数据类型为4字节整型,即4 Bytes,我们搜索100

注:扫描浮点数类型时,注意数据类型有floatdouble两种。

image-20200905123953724

我们通过内存扫描找出了43个值为100的内存地址,接下来我们点击Hit me,生命值变为93。然后我们输入数值93继续搜索,然后我们只找到了一个地址,这个即是我们想要修改的地址,我们双击将其放入列表(Cheat Table)中,然后将其改为1000。

image-20200905124227832

image-20200905124315346

再次点击Hit me,发现这次的数值是从1000向下减,至此修改完成。

image-20200905124400416

未知的初始值

image-20200905200615573

这种情况下,我们发现我们不知道数值,但我们可以通过提供的信息获得正确的地址,首先我们选择未知的初始值。

image-20200905200743875

image-20200905200757899

这种操作会扫描所有内存中的值,接下来我们点击Hit me,通过回显得知数值减少了6,按照图中的方法扫描即可。

注:在没有回显的情况下,我们选择减少的数值,即Decreased value。

image-20200905200847855

image-20200905200924397

image-20200905200931774

我们找到了7个数据,重复上述步骤。

image-20200905201051058

最终我们找到了4个数据,显而易见,最可靠的地址是01931610。

注:剩下三个429开头的数值,在修改后点击Hit me,仍然会恢复应有的值,所以115为有效值。

image-20200905201149314

将其改为5000,即修改完成。

总结

在扫描过程中,我们需要做的只有缩小数值范围,即利用现有的信息(该数据的值、该数据改变与否、该数据如何改变、该数据的范围等),不断缩小范围,最终找到有效地址。

例如在未知的初始值一节,我们通过教程的文本可得知该值在0-500之间,所以我们可以通过设置数值范围来缩小范围,也可以避免后三个过大值的情况。

image-20200905201522087

我们可以注意到,CE可以保存Cheat Table,那么有的小火汁比较有发散思维,于是保存了Cheat Table,认为这样无论何时打开程序都可以随时更改生命值。很遗憾,这样做并不可以达到这样的目的,但是从技术手段上是可以达到的。为达到这种目的,我们需要了解“指针”。

指针

什么是指针?

在上一节我们说过,内存中所有的数据都以数字的方式存储,那么指针作为数据也仅仅是一段数字,而重要的是这段数字代表的含义——内存地址。指针是存放内存地址的一段数据。学过C语言的小火汁应该知道,使用取值运算符&可以得到变量的地址,用取值运算符*可以得到指针指向的内存的数据,下面的代码将帮助你进一步了解指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int a; //定义整型变量a,假设其地址为0x0
int *b; //定义整型指针变量b,假设其地址为0x4(在定义变量时在变量前加入*代表该变量是指针变量)
int **c; //定义指向指针的指针变量c(二级指针变量),假设其地址为0x8

a = 1337;
b = &a; //将变量a的地址放入指针变量b中,此时b中的内容为0x0
c = &b; //将指针变量b的地址放入二级指针变量c中,此时c中内容为0x4

printf("%d %d %d", a, *b, **c); //打印出的三个数值都为1337,但过程不相同
/*
在上面打印的过程中,变量a直接传递至函数中打印。
变量b则是使用了取值运算符取出了0x4中存放的地址0x0的内容,即变量a
变量c先取出0x8中存放地址0x4,在取出0x4中存放的地址0x0的内容,到达变量a
*/

所以,指针可以理解为一种路径,但实际上指针非常简单,只是一组数字。

多级指针在程序中的表示

首先我们需要了解基址偏移量的概念,基址指的是程序在运行时分配的初始地址,这项工作由操作系统来完成。在上一节我们提到,程序运行时操作系统会将程序拷贝到内存中,那么初始地址则是程序拷贝到内存时第一个字节的位置。偏移量是我们想要访问的数据和我们已知的内存地址的距离,假设一个程序的初始地址为0x4000,我们想要访问的数据地址为0x4020,则偏移量为0x20。将初始地址换成结构体/结合体,也是同一概念。

我们看一段C++代码,在这段代码中,我们定义了玩家结构体、物品栏结构体、武器结构体。

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
struct weapon {
int magSize = 100;
int maxAmmo = 24;
};

struct Inventory {
int item[4];
weapon* weapon; //偏移量:0x10
/*
在32位Windows系统中,int数据类型的大小为4 Bytes,我们定义了一个长度为4的int数组,所以该数组长度为16 Bytes,即0x10 Bytes。所以weapon结构体指针的偏移量即为0x10。
*/
};

struct Player {
int health = 100;
int mana = 100;
Inventory* Inventory; //偏移量:0x8
}

Player* player;

int main() {
player = new Player; //分配Player结构体,指针指向Player结构体
player->Inventory = new Inventory; //分配Inventory结构体,指针指向Inventory结构体
player->Inventory->weapon = new weapon; //分配weapon结构体,指针指向Inventory结构体中的weapon结构体
}

注:每个new运算符都是随机分配内存并返回一个指向分配内存地址的指针,因此我们必须使用指针来寻址。

以上代码中->类似于取值运算符,但其作用为访问结构体/结合体中的元素,而不是单纯访问变量。所以上述代码main函数中访问weapon结构体的步骤是:由player结构体的Inventory指针指向Inventory结构体,Inventory结构体中的weapon指针指向weapon结构体,从而让我们可以访问weapon结构体。而程序中结构体的分配都是动态的,因此,我们需要找到其对应的基址偏移量,才能顺利的在程序外部找到我们想要修改的内存地址。

例:

在以上例子中,我们找到了所有的偏移量,那么我们现在假设基址为0x4000、player结构体的偏移量为0x1000。那么我们寻址的步骤为:

  1. 由CE或其他程序找到基址(0x4000)
  2. 基址 + 0x1000 = 0x5000,在0x5000中存放数值为0x7100,即为player结构体的地址。
  3. 0x7100+0x8 = 0x7108,在0x7108中存放数值为0x6020,即为Inventory结构体的地址。
  4. 0x6020+0x10 = 0x6030,在0x6030中存放数值为0x8132,即为weapon结构体的地址。
  5. 0x8132+0x4=0x8136,即为maxAmmo的地址。

总结

经过以上的讨论,我们可以得知,在程序内部多级指针的应用很广泛,我们得知了基址、偏移量后便可以随时访问并修改对应数值,在CE中我们可以手动添加多级指针,例如我现在添加了一个偏移量分别为0x2426B0和0x0的二级指针。

image-20200905202048417

在下一节中,我们会讲到如何找到偏移量,有了偏移量我们就可以快速且准确的找到对应数值的地址并对其进行修改。