你肯定见过这样的场景:一个游戏里,角色的血量明明显示是“100”,但用内存修改工具搜这个值,却死活搜不到。或者,你费劲找到了一个地址,改了个很大的数,结果游戏直接崩溃,或者数值变成了一个诡异的负数。这不是游戏在跟你玩捉迷藏,而是你很可能掉进了“带符号整数”和“内存大小”这两个最基础,也最容易让人翻车的坑里。
在C++逆向,尤其是游戏外挂的初级探索中,很多人一上来就急着找基址、写Call、搞注入,却常常在第一步——理解数据在内存中到底长什么样——就栽了跟头。int、unsigned int、short、char,这些看似简单的类型,配合上sizeof这个运算符,直接决定了你在CE(Cheat Engine)、OD(OllyDbg)或x64dbg里应该用什么姿势去搜索和修改。不理解它们,你的逆向之路就像在黑暗中摸索,碰壁是常态。
这篇文章不会教你高深的反汇编技巧或复杂的Hook技术,我们就解决一个最实际的问题:如何准确理解游戏内存中的数值,并确保你的修改是安全有效的?关键在于吃透带符号整数类型和sizeof运算符。这不仅是C++的基础,更是逆向工程中“看”懂内存的必备显微镜。
1. 为什么“看到的”和“搜到的”不一样?—— 带符号整数的内存陷阱
让我们从一个最经典的翻车案例开始。假设你在一个游戏里看到角色金币数是“500”。你兴冲冲地打开CE,选择“精确数值”扫描500(4字节),结果可能一无所获,或者地址多如牛毛难以定位。为什么?
1.1 有符号 vs 无符号:同一个比特,两种解读
在C++中,int默认是**有符号(signed)**的。这意味着它最高位(对于32位int是第31位)用来表示符号:0为正,1为负。剩下的位表示数值。而unsigned int则所有位都用来表示正数。
关键点来了:在内存中,一个int变量500和一个unsigned int变量500,存储的二进制序列是一模一样的。区别只在于程序(以及我们)如何去“解释”这一串比特。
int value = 500;内存存储(假设小端序,32位):0xF4 0x01 0x00 0x00(十六进制0x000001F4)。unsigned int value = 500;内存存储完全一样:0xF4 0x01 0x00 0x00。
问题出在“解释”上。如果你告诉CE用“4字节”扫描,它默认通常是按有符号整数去匹配内存值的。这大多数时候没问题。但真正的麻烦在于边界值和负数。
1.2 负数的补码表示与逆向搜索
这是逆向中最容易迷惑人的地方。假设游戏里某个状态值可能是负数,比如“健康度”在受伤后变为“-10”。这个-10在内存中并不是直接存一个负号加10。
计算机用补码存储负数。对于一个32位有符号整数int:
-10的绝对值的二进制:10=0x0000000A。- 按位取反:
0xFFFFFFF5。 - 加1:得到
0xFFFFFFF6。
所以,int health = -10;在内存中(小端序)存储为:0xF6 0xFF 0xFF 0xFF。
现在,如果你在游戏UI上看到-10,但在CE里直接搜-10(4字节),CE会帮你做这个转换,去匹配0xFFFFFFF6。但如果你搜的是10,或者游戏实际用的是unsigned int但逻辑上表现了负值(这属于设计缺陷),你就永远搜不到。
更常见的情况是,游戏用了一个short(2字节)或char(1字节)来表示小范围的数值,比如背包格子索引。如果你错误地用4字节去搜,不仅找不到,还会被海量的错误地址淹没。
逆向经验:当精确数值搜不到时,第一个要怀疑的就是数据类型和扫描长度。尝试切换“所有类型”扫描,或者分别用1、2、4字节有符号/无符号去尝试。对于变化的值,使用“变动的数值”“增加的数值”等模糊扫描方式,再结合类型变化观察。
2.sizeof:你的内存测量尺与逆向导航仪
sizeof在C++中是一个运算符,用于获取类型或对象在内存中所占的字节数。在正向开发中,它常用于数组计算和内存操作。在逆向中,它是你理解数据布局、计算偏移、猜测结构体的核心工具。
2.1sizeof的基本规则与逆向推断
int a; short b; char c; struct Player { int health; short level; char name[20]; }; cout << sizeof(a); // 通常是 4 (字节) cout << sizeof(b); // 通常是 2 cout << sizeof(c); // 一定是 1 cout << sizeof(Player); // 可能是 28 (4 + 2 + 20,但要注意内存对齐!)在逆向时,你面对的是一堆十六进制的内存数据。sizeof的知识能帮你做出关键推断:
- 识别数组或缓冲区:如果你在内存中看到一连串的
int值(每个4字节),这很可能是一个int数组。数组的起始地址和元素类型(通过sizeof推断)能帮你定位遍历逻辑。 - 计算结构体偏移:假设你找到了玩家的基址,并且知道
health在偏移+0x0的位置,是4字节的int。你观察到level在偏移+0x4的位置,是2字节的short。那么下一个字段很可能从+0x6开始,但编译器可能会进行内存对齐,在short后面插入2字节的填充(padding),让下一个int从4的倍数地址开始。所以实际偏移可能是+0x8。理解sizeof和对齐规则,能让你更准确地画出内存结构图。 - 区分指针和实例:
sizeof(指针)在32位程序中是4字节,在64位程序中是8字节,无论它指向什么。如果你找到一个4字节/8字节的值,它指向另一片内存区域,那它很可能是一个指针。
2.2 逆向中的实战应用:定位多维数组与嵌套结构
游戏数据往往是复杂的结构。比如,一个玩家对象(Player)包含一个背包(Bag),背包里又是一个物品指针数组(Item* array[100])。
- 通过
sizeof(Player)你可以估算玩家对象的大小。 - 通过
sizeof(Bag)你知道背包在玩家对象中的偏移范围。 - 通过
sizeof(Item*)你知道背包里每个指针元素占4或8字节,从而计算出第N个物品指针的地址:背包基址 + N * sizeof(Item*)。
在内存修改工具中,你可以利用这个知识来手动添加指针地址。例如,如果你找到了背包基址0x12345678,物品数组首地址偏移是+0x30,每个指针4字节,那么第5个物品的地址可能就是:[0x12345678 + 0x30] + (5-1)*4。这里的4,就是sizeof(Item*)在32位程序中的值。
排查链路:当修改后游戏崩溃或数据错乱
- 检查类型是否匹配:你修改的内存区域,原程序是用
int还是unsigned int读的?改成极大正数(如0x7FFFFFFF)如果被当作有符号数解读,是合法的;但如果被当作unsigned int,值会非常大。反之,如果你按unsigned int写了一个超过0x7FFFFFFF的数,被当作int读就会变成负数。- 检查长度是否溢出:你是否只改了4字节中的一部分?比如,原本是4字节
int,你只改了低2字节,高2字节残留旧数据,组合起来就是一个错误的值。- 检查是否越界访问:你计算出的地址,是否通过
sizeof正确考虑了结构体大小和数组边界?写到了相邻的其他变量上,会导致不可预知的崩溃。
3. 从理论到CE:在内存编辑器中验证与操作
理解了原理,我们必须在工具中实践。以Cheat Engine为例。
3.1 扫描策略:如何设置正确的类型和大小
首次扫描(精确值):
- 情况A(已知UI值):如果游戏UI明确显示“500”,先尝试“4字节”扫描。如果结果太多或没有,尝试“2字节”(
short)和“1字节”(char或byte)。别忘了勾选“十六进制”选项,如果你怀疑值是内存地址或标志位。 - 情况B(未知或变化值):使用“未知初始值”,然后根据数值变化(增加/减少),选择“变动的数值”或“增加的数值”等扫描方式。这时,
sizeof的知识帮助你选择正确的“数值类型”:是Byte、2 Bytes、4 Bytes、8 Bytes还是Float/Double?选错类型,扫描会失效。
- 情况A(已知UI值):如果游戏UI明确显示“500”,先尝试“4字节”扫描。如果结果太多或没有,尝试“2字节”(
指针扫描与偏移计算: 当你找到一个动态地址(每次重启游戏都变),需要找到指向它的静态指针。CE的“找出是什么改写了这个地址”和“找出是什么访问了这个地址”功能是神器。分析出来的汇编指令,常常会包含类似
[esi+0x10]这样的偏移。这里的0x10,就是相对于esi寄存器所指向基址的偏移量。你需要结合上下文猜测[esi]处结构体的大小,+0x10这个偏移很可能就是某个字段(比如mana)的位置。如果mana是int,那么sizeof(int)=4,下一个字段可能从+0x14开始。
3.2 手动修改与冻结:注意数据宽度
在CE的内存查看窗口,右键点击地址,选择“浏览相关内存区域”。你可以直接看到原始的字节序列。
- 假设地址
0x12345678处是玩家的血量,你判断它是4字节有符号int,当前值是100(0x64 0x00 0x00 0x00)。 - 你想改成
9999。9999的十六进制是0x0000270F。在小端序机器上,你需要写入的字节序列是0x0F 0x27 0x00 0x00。 - 关键操作:在CE的地址列表里,双击“类型”列,可以更改该地址的解析方式。如果你发现改成
4 Bytes后值不对,可以尝试2 Bytes或Float。冻结一个地址时,CE会按照你设定的类型和宽度持续写入值,确保你设定的类型是正确的,否则会破坏相邻内存。
4. 构建逆向思维框架:数据类型与内存分析清单
把上面的知识沉淀成一个可操作的排查框架,当你面对一个陌生的游戏内存时,可以按以下顺序建立认知:
4.1 第一步:观察与假设
- UI显示:数值的范围和变化幅度是多少?是整数还是小数?(决定用整数类型还是浮点类型扫描)
- 初步扫描:用“所有类型”或分别用1、2、4字节进行精确值/模糊值扫描。
4.2 第二步:定位与验证
- 找到地址:通过数值变化,锁定少数几个或一个地址。
- 验证类型:
- 手动修改该地址的值(比如改成50000),观察游戏内数值变化。如果游戏内显示异常(如变成负数或很小),说明类型或符号判断错误。
- 在内存查看器中,观察该地址附近的数据布局。是否有规律的可读字符串(可能是
char数组)?是否有连续类似的4字节值(可能是int数组)?这帮助你推测它所属的结构体。
- 确定大小:通过“找出访问/改写”功能,看汇编指令中使用的数据宽度指令(如
MOV DWORD PTR [eax]是4字节,MOV WORD PTR [eax]是2字节)。
4.3 第三步:深入与关联
- 计算偏移:如果找到了基址和多个相关变量(血量、魔法、坐标等),记录它们的偏移。利用
sizeof概念,检查偏移间隔是否合理(如两个int变量偏移相差4,中间可能没有填充;相差8则可能有填充或其他小字段)。 - 绘制结构图:用注释或草图画出你推测的内存结构,包括字段偏移、类型、大小。这是后续写外部DLL或内部Hook的基础。
4.4 第四步:修改与边界检查
- 安全修改:修改前,先备份原始内存。修改时,使用正确的数据类型和宽度。
- 边界意识:不要随意写入超出合理范围的值(如将血量改为
0xFFFFFFFF)。某些游戏有反作弊检测,异常数值会触发封禁。尽量模拟游戏正常逻辑产生的数值范围。 - 指针链验证:对于多层指针,每层偏移都要结合
sizeof来理解。例如,[[基址 + 0x10] + 0x20] + 0x0C,每个+后面的偏移,都对应着某一层结构体内部的字段位置。
回到开头的问题,为什么搜“500”搜不到?现在你有了系统的排查思路:可能是short类型的500,内存中存为0xF4 0x01,你用4字节搜0xF4 0x01 0x00 0x00自然对不上;也可能是无符号数但扫描选项是有符号;甚至它可能根本不是整数,而是一个被缩放了的float(比如实际内存存的是500.0f的浮点表示0x43FA0000)。
掌握带符号整数和sizeof,并不能让你立刻写出功能强大的外挂,但它能确保你在逆向的起点——内存分析上,走得稳、看得准。它解决的是“看到的是什么”和“该怎么改”的根本问题。跳过这一步,所有高级技巧都如同建立在流沙之上。当你下次再打开内存扫描工具时,不妨先花一分钟想想:这个值,在程序的眼里,究竟是何模样?