第九章:数据结构逆向——结构体、数组、字符串与链表
数据结构逆向,说白了就是跟内存布局打交道。你想想看,游戏里一个角色有多少属性?血量、蓝量、坐标、装备列表……这些数据不会散落在内存里,而是被组织成各种结构。我们逆向工程师要做的,就是把这些结构从二进制里“挖”出来。
我个人习惯把数据结构逆向分成四块:结构体、数组、字符串、链表。这四块搞定了,大部分游戏的数据逻辑你都能摸透。今天我就把这四块的核心思路和实战技巧,一次性讲清楚。
9.1 结构体逆向:从内存碎片到完整蓝图
结构体逆向,说白了就是猜布局。游戏里一个角色对象,在内存里就是一块连续的区域。比如一个玩家结构体,可能前4字节是血量(int),接着4字节是蓝量(int),再接着2字节是等级(short),然后可能有个4字节的指针指向名字字符串……
我刚开始做逆向时,最头疼的就是结构体对齐。明明看起来是连续的,中间却总有几个字节的“空洞”。后来我才明白,编译器为了性能,会在成员之间插入填充字节。
结构体逆向三步法:
- 找特征值:先定位一个已知的数值(比如血量100),然后看它周围的内存布局
- 猜成员类型:根据数值范围猜类型——0~65535可能是short,负数可能是int,很大的数可能是指针
- 验证偏移:修改某个偏移的值,看游戏里对应属性是否变化
举个例子。我在逆向某款FPS游戏时,发现玩家对象的血量地址是0x12345678。我查看周围内存:
0x12345678: 64 00 00 00 (血量: 100)
0x1234567C: 32 00 00 00 (蓝量: 50)
0x12345680: 0A 00 (等级: 10)
0x12345682: 00 00 (填充)
0x12345684: 78 56 34 12 (指针, 指向名字)
嗯,这里要注意:0x12345682处的两个00字节,就是对齐填充。因为下一个成员是指针(4字节),所以编译器把等级后面的2字节补齐了。
个人经验:我习惯在CE里用“结构体分析”功能,把候选地址按4字节对齐显示。这样一眼就能看出哪些是真正的成员,哪些是填充。
9.2 数组逆向:连续内存的暴力美学
数组逆向比结构体简单,因为它的规律性太强了。你想想看,如果一组数据每个元素大小相同,地址连续,那十有八九就是数组。
数组逆向的核心公式就一个:基址 + 索引 × 元素大小。但实际中,我们往往不知道元素大小和数组长度。怎么办?
我的做法是:先找到两个相邻的元素,算出它们的地址差,这就是元素大小。然后看数组末尾——如果后面全是0或者重复的无效数据,那基本就到头了。
数组逆向常见场景:
- 背包物品数组:每个物品占固定大小(比如0x20字节),通过索引访问
- 玩家列表:每个玩家对象指针组成一个数组,通过指针间接访问
- 技能冷却数组:每个技能冷却时间是一个float,连续排列
我曾经逆向过一个MMORPG的背包系统。背包有40格,每格占0x20字节。我通过修改第5格的物品ID,发现游戏里第5个格子确实变了。然后我用“数组遍历”脚本,把40格的数据全部dump出来,直接还原了整个背包内容。
注意:有些游戏会用“稀疏数组”或“动态数组”。稀疏数组中间可能有空洞(无效元素),动态数组则是一个指针指向堆上分配的内存。遇到这两种情况,别死板地用连续地址去套。
9.3 字符串逆向:编码与长度的博弈
字符串逆向,说白了就是猜编码和长度。游戏里常见的字符串格式有两种:
| 编码类型 | 特点 | 常见场景 |
|---|---|---|
| ASCII | 每个字符1字节,以0x00结尾 | 英文游戏、玩家名字(纯英文) |
| UTF-16 | 每个字符2字节,以0x0000结尾 | 中文游戏、多语言支持 |
| 长度前缀 | 前2或4字节存长度,后面跟字符数据 | 网络协议、序列化数据 |
我个人习惯先看字符串末尾。如果看到连续的00字节,那基本就是空终止字符串。如果开头有个小整数(比如0x0A表示10个字符),那就是长度前缀格式。
嗯,这里有个坑:有些游戏会用“字符串池”来节省内存。所有字符串都放在一个大的缓冲区里,玩家名字只是一个索引或指针。这种情况下,你直接修改字符串内容可能不会生效,因为游戏可能从池里重新读取。
避坑指南:我曾经在逆向一个日系游戏时,发现玩家名字明明是ASCII格式,但修改后游戏里显示乱码。后来才发现,游戏内部用的是Shift-JIS编码,只是看起来像ASCII。所以,遇到非英文游戏,先确认编码格式再动手。
9.4 链表逆向:指针的舞蹈
链表逆向,是数据结构逆向里最考验耐心的。链表不像数组那样连续,它的节点分散在内存各处,靠指针串联起来。
典型的链表节点结构:
struct ListNode {
void* data; // 指向实际数据
ListNode* next; // 指向下一个节点
// 有些链表还有 prev 指向前一个节点
};
逆向链表的关键,是找到“头节点”。头节点通常被一个全局指针或静态变量引用。找到头节点后,顺着next指针一路往下走,就能遍历整个链表。
我常用的方法:
- 特征扫描:搜索一个已知的数据值,然后看它周围有没有指向其他地址的指针
- 指针链追踪:在CE里用“指针扫描”功能,看哪些地址指向了当前节点
- 循环检测:如果遍历时发现某个节点又指回了前面的节点,那就是循环链表
实战案例:我在逆向某款赛车游戏时,发现赛车列表是用链表实现的。每个赛车节点里,除了next指针,还有一个prev指针(双向链表)。我通过修改prev指针,让游戏里两辆赛车的顺序互换——结果两辆车在赛道上直接穿模了。嗯,这个bug后来成了我讲课时的经典反面教材。
警告:修改链表指针要格外小心。如果你把next指针改成一个无效地址,游戏在遍历链表时就会崩溃。我建议先备份原始指针值,或者用“只读”方式验证结构。
9.5 综合实战:从内存到逻辑
好了,前面讲了四种数据结构的逆向方法。实际游戏中,它们往往是组合出现的。比如一个玩家结构体里,可能包含一个数组(技能列表),数组元素又是指向字符串的指针(技能名称),而技能本身可能是一个链表(技能链)。
我的建议是:先拆后合。先把每个独立的数据结构逆向清楚,再组合起来看整体逻辑。就像拼图一样,先拼出每个小块的形状,再拼成大图。
举个例子,我逆向某款MOBA游戏时,遇到了这样一个结构:
struct Player {
int hp; // 血量
int mp; // 蓝量
char name[16]; // 名字(固定长度字符串)
int skillCount; // 技能数量
Skill* skillList; // 指向技能数组的指针
};
struct Skill {
int id; // 技能ID
float cooldown; // 冷却时间
char* namePtr; // 指向技能名字符串的指针
};
你看,这里结构体、数组、字符串、指针全用上了。逆向时,我先确定了Player结构体的大小(0x20字节),然后找到skillList指针,顺着它找到技能数组,再通过每个技能的namePtr找到技能名称字符串。
整个过程就像剥洋葱,一层一层往里剥。每剥一层,你对游戏逻辑的理解就深一层。
最后说一句:数据结构逆向没有捷径,就是多练。我刚开始时,一个简单的结构体可能要花一整天。现在熟练了,大部分结构体10分钟就能搞定。你只要把今天讲的四种类型吃透,以后遇到再复杂的数据结构,心里都有底。
公众号:蓝海资料掘金营,微信deep3321