这次我们来看一个非常实际的话题:U3D 游戏里的“血量”和“坐标”在内存里到底是怎么存的。网上谈游戏逆向的文章很多,但大多数要么讲得太抽象,要么直接甩一堆汇编地址,看完也不知道怎么落地。这篇文章会把这套流程拆开,用 Cheat Engine 配合 Unity 自身的内存特征,完整演示从“数值搜索”到“数组结构定位”,再到“指针与偏移固化”的分析过程。
先说结论:U3D 项目里,角色单位的数据很少是孤立存在的,血量、坐标、ID、状态这些字段通常会挂在同一个对象结构上;当场景里同时存在很多怪物、NPC 时,这些对象又会以连续数组或链表的形式排布在内存里。你搜到某一个血量地址之后,如果不做指针固定,重启游戏地址就会变;如果你理解了下层是数组,就能通过“基址 + 索引 × 结构体大小 + 字段偏移”的方式稳定定位任意单位的数据。
这篇文章不涉及在线对战作弊,不涉及绕过反作弊系统,所有验证都建议放在本地单机学习环境里。适合三类读者:做游戏安全研究、想理解 Unity 内存数据结构的开发者,以及刚接触逆向、想搞明白“为什么对象列表在内存里是一个数组”的入门者。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 分析目标 | U3D 游戏中的血量数值、三维坐标、对象属性数组 |
| 核心方法 | CE 精确数值扫描、未知数值扫描、指针扫描、偏移计算 |
| 数据形态 | 血量通常是 int 或 float,坐标是 float 类型的 X/Y/Z |
| 结构特点 | 同类单位常以数组形式连续存放,结构体大小决定步长 |
| 关键工具 | Cheat Engine、进程模块查看工具、可选 Frida/IDA 辅助 |
| 启动环境 | Windows 单机测试环境,64 位系统更常见 |
| 是否需要调试器 | 不一定,CE 自带内存扫描与反汇编能力 |
| 是否支持批量处理 | 支持,CE Lua 脚本与特征码批量扫描可自动化 |
| 合规边界 | 仅限单机学习、安全研究、本地测试,须有合法授权 |
从材料归纳来看,U3D 逆向最常用的切入点是“数值变化跟踪”。血量会变、坐标会变,这两类数据的定位思路分别对应精确数值扫描和未知数值扫描。把这两条路走通之后,数组和指针的概念就自然串起来了。
2. 适用场景与合规边界
先明确边界,再谈技术。Unity 游戏逆向分析目前在安全研究、漏洞挖掘、外挂对抗和单机游戏 Mod 开发中都有应用场景。比如研究某个 U3D 单机 Demo 的数据布局,分析怪物列表的遍历方式,验证内存读写接口的稳定性,这些都属于合法学习范畴。
不适用于以下场景:
- 联机游戏中的对战作弊、修改器、加速器。
- 绕过商业反作弊系统、破解授权或盗用数据。
- 对未经授权的商业游戏做逆向后直接发布攻略、制作外挂或收费工具。
- 窃取他人账号信息、虚拟财产或触发封号风险的行为。
涉及本地分析和内存读写时,务必在单机、离线、测试目录的环境里操作。不要对真实运营服务或他人设备做测试。文章示例均以假设的本地 U3D 工程或单机 Demo 为分析对象,请自行替换为有权限测试的项目。
从安全边界看,最稳妥的做法是:先确认目标程序属于你拥有、你被授权测试,或者明确标注为学习用的开源/演示项目。分析过程中采集到的地址、偏移、数据结构,不要直接用于对第三方产品的恶意攻击。
3. 环境准备与前置条件
3.1 基础环境
推荐 Windows 10/11 64 位系统,因为 Cheat Engine、进程附加工具和多数 U3D 逆向脚本在 Windows 下支持最完整。Linux 环境也可以使用 CE 的跨平台版本,但附加进程和驱动加载步骤会多一些。
环境清单:
- Cheat Engine 最新稳定版。
- 一个本地 U3D 单机游戏或你自己导出的 Standalone 包,确认后缀为
.exe或 Linux 可执行文件。 - 可选:Process Hacker 或 Task Manager,用于确认进程 PID 和模块路径。
- 可选:IL2CPP 分析工具链,针对使用 IL2CPP 打包的项目。
- 建议:关闭杀毒软件对 CE 驱动的误报拦截,但仅在单机测试时这样处理。
检查项:
| 检查内容 | 要求 |
|---|---|
| 操作系统 | 64 位 Windows 或 Linux |
| 测试程序 | U3D 打包的单机项目,最好能暂停或脱机运行 |
| 内存扫描工具 | Cheat Engine 7.x 或更高版本 |
| 权限 | 使用管理员权限启动 CE,便于附加部分程序 |
| 磁盘空间 | 至少预留 1-2GB,用于存放扫描结果和脚本 |
3.2 理解 Mono 与 IL2CPP 的差异
U3D 脚本编译后有两种常见后端。
Mono 模式下,程序集和类型信息相对容易读取,CE 的 Mono 功能可以直接列出类、字段和静态属性,非常适合快速定位血量和坐标字段。
IL2CPP 模式下,C# 被转换成 C++ 再编译,符号通常会被剥离,内存布局更接近原生 C++ 对象。这种情况下,CE 的 Mono 列表可能失效,必须通过特征码搜索、vtable 分析或 IL2CPP 工具链辅助定位。
判断方法很简单:附加进程后,在 CE 菜单里打开 Mono 面板,如果能看到Assembly-CSharp相关类型和字段,说明是 Mono 模式;如果里面是空的,大概率是 IL2CPP。
4. 附加进程与 Unity 模式识别
4.1 附加测试进程
启动你的本地测试程序,例如demo.exe。打开 Cheat Engine,点击左上角进程图标,在进程列表中选择demo.exe。
# 示例:通过命令行确认目标进程PID tasklist | findstr demo.exe附加成功后,CE 会自动定位到进程的主模块,右边模块列表里会显示可执行模块和 DLL。U3D 游戏通常会有UnityPlayer.dll、GameAssembly.dll(IL2CPP 模式)或mono.dll、mono-2.0-bdwgc.dll(Mono 模式),这些模块是分析的重点区域。
把模块列表展开后,建议先记录UnityPlayer.dll和主程序模块的基址。后续指针扫描和偏移计算都依赖这些基址。
4.2 判断模式
进入 CE 的Mono菜单,点击Activate Mono Features。如果激活成功,就能看到当前进程的 .NET 程序集列表。
- 能看到
mscorlib、Assembly-CSharp等信息,说明是 Mono 模式。 - 激活失败或列表为空,说明大概率是 IL2CPP 或者反调试保护。
判断结果会直接影响后续扫描策略:Mono 模式下可以直接搜索类名、字段名;IL2CPP 模式下需要退回到原始的内存数值扫描和特征码定位。
5. 血量定位:精确数值扫描法
这是最基础也最稳定的一步。血量在多数 U3D 项目里用整数存储,少数游戏用浮点数。先按整数扫描,如果找不到再切换为浮点数。
5.1 首次扫描
在 CE 的扫描区域设置:
- Value Type:
Integer(4 字节) - Scan Type:
Exact Value - 勾选 Hex 与否不强制,但初始值建议用十进制
输入当前血量值,点击First Scan。例如角色当前血量为 100,就输入 100。首次扫描结果可能是几千到几万个地址,这都很正常。
5.2 变化后再次扫描
让角色掉血一次,比如从 100 降到 80。回到 CE,把扫描值改为 80,点击Next Scan。每轮扫描后地址数量会急剧减少。重复“掉血 -> 输入新血量 -> Next Scan”的流程。
如果扫描到只剩几个地址,就全选添加到地址列表。然后手动修改其中一个地址的值,回到游戏观察血量是否变化。如果变化,说明该地址大概率就是血量字段。
-- CE Lua 示例:读取指定进程模块中的整数 local pid = getPid("demo.exe") if pid == nil then print("process not found") return end openProcess(pid) local base = getAddress("demo.exe+0x0123A4B0") local hp = readInteger(base) print("Current HP:", hp)需要注意,这段 LUA 里的地址只是临时示例。实际地址必须从扫描结果中获得。
5.3 常见失败原因
如果几轮扫描后地址数量还很多,或者一个地址都没剩下,优先检查:
- 是否选对了进程。
- 血量的实际存储类型是不是浮点数。
- 游戏是否有加密、混淆、热更新或自定义内存池。
- 是否有多个对象同时拥有“血量”这个字段。
特别是 U3D 项目里 UI 上显示的血量,往往经过 HUD 层二次计算,而不等于底层真实血量。这时不要把 UI 文本里显示的数字当作搜索目标,要优先找角色对象内部的字段,比如maxHp、hp、currentHp。
6. 坐标定位:未知数值与浮点扫描
坐标比血量稍微复杂,因为 U3D 的世界坐标通常是float类型,并且角色肉眼可见的 UI 坐标和Transform.position里的世界坐标不一定直接对应。定位坐标的方法主要有两种。
6.1 精确浮点扫描法
当你能确定角色当前坐标的数值时,可以直接用浮点扫描。
- Value Type 选择
Float - Scan Type 选择
Exact Value - 输入当前 X 坐标值
然后角色沿 X 轴移动,输入新坐标,继续Next Scan。反复几次后,剩余地址会指向角色的 X 坐标字段。
6.2 未知初始值扫描法
更多情况下,玩家看不到精确坐标,或者坐标是像12345.67这样的小数。这时使用未知数值扫描:
- 选择
Float类型,Unknown initial value,First Scan。 - 角色移动后,扫描类型选择
Changed value,Next Scan。 - 角色停止后,选择
Unchanged value,Next Scan。 - 反复执行 2 和 3,缩小范围。
这套流程的本质是把“移动时坐标会变、停止时坐标不变”作为筛选条件。因为场景里的位置类字段远少于全部内存,十几轮之后就能定位到坐标地址。
-- CE Lua 示例:读取浮点坐标 local base = getAddress("demo.exe+0x02A1F0C0") local x = readFloat(base) local y = readFloat(base + 0x4) local z = readFloat(base + 0x8) print(string.format("Pos: %.2f, %.2f, %.2f", x, y, z))坐标字段通常在内存里是连续的:X 在偏移0x00,Y 在偏移0x04,Z 在偏移0x08。如果游戏使用 double 类型存储,则每个字段占 8 字节。
7. 数组化理解与对象列表分析
U3D 场景里的单位数量通常不少。一个怪物列表,30 只怪物不会各自分散在内存的任意位置,而是以数组或列表结构集中存放。理解这个数组结构,才能从“单个单位”扩展到“批量定位”。
7.1 结构体数组原理
假设每个怪物对象在内存里的结构体大小为0x58字节,字段排列如下:
| 字段名 | 类型 | 相对偏移 | 说明 |
|---|---|---|---|
| ID | int | 0x00 | 对象 ID |
| hp | int | 0x04 | 当前血量 |
| maxHp | int | 0x08 | 最大血量 |
| posX | float | 0x0C | 世界坐标 X |
| posY | float | 0x10 | 世界坐标 Y |
| posZ | float | 0x14 | 世界坐标 Z |
| state | int | 0x18 | 状态标志 |
| ... | ... | ... | 其他属性 |
上述表格只是教学用的示意结构,不代表所有 U3D 游戏都这样布局。但数组的访问逻辑是通用的。
如果数组首地址是arrayBase,那么第i个怪物的血量字段地址是:
sizeof(Struct) = 0x58 addr = arrayBase + i * 0x58 + 0x04同理,坐标字段地址是:
addr = arrayBase + i * 0x58 + 0x0C7.2 验证数组排布
实际验证时,可以先定位第一只怪物的血量地址。然后在地址列表里右击该地址,选择 "Browse this memory region"。观察相邻内存区块,如果每隔固定偏移就出现一组相似字段,说明该区域很可能是结构体数组。
比如第一个怪物的血量地址是0x02A1F000,另一个怪物的血量地址是0x02A1F058,两者相差0x58,那么这个0x58就是结构体步长。接下来可以用 CE 的自动汇编或 Lua 脚本循环读取整个数组。
-- CE Lua 示例:批量读取数组前10个单位的血量与坐标 local arrayBase = 0x02A1F000 local step = 0x58 for i = 0, 9 do local addr = arrayBase + i * step local id = readInteger(addr + 0x00) local hp = readInteger(addr + 0x04) local x = readFloat(addr + 0x0C) local y = readFloat(addr + 0x10) print(string.format("[%02d] id=%d hp=%d pos=(%.2f, %.2f)", i, id, hp, x, y)) end这个脚本可以一次性验证“数组索引计算方式”是否正确,也可以为批量导出场景数据打基础。
7.3 List 与 array 的区别
Unity 里常见的有T[]和List<T>。
T[]是连续内存,直接用base + index * size访问。List<T>内部有一个_items字段,指向实际的数组缓冲区,同时有_size字段表示当前数量。
在内存层面,要先定位List对象,读取_items指针,再按数组方式计算单元偏移。如果用 CE 的 Mono 功能定位List<T>,可以直接展开字段查看_items地址,效率很高。
8. 指针与多级偏移固化
直接扫描出来的地址,在程序重启后会失效。原因很简单:那些地址属于运行时堆分配的内存,地址随机变化。为了稳定定位数据,必须做指针扫描和多级偏移分析。
8.1 指针扫描流程
步骤如下:
- 找到当前血量地址,例如
0x02A1F058。 - 在地址列表里,右击该地址,选择
Pointer scan for this address。 - 设置指针扫描参数:最大偏移量、最大层级。
- 保存一次扫描结果。
- 重启游戏,重新附加进程,再次找到血量地址。
- 对第二次找到的地址再做一次指针扫描。
- 对比两次扫描结果,找出完全一致的指针路径。
稳定的指针路径通常长这样:
demo.exe+0x00A1C3F0 -> +0x58 -> +0x18 -> +0x04含义是:从模块基址出发,经过固定的多级偏移,最终指向血量字段。因为这个路径的起点在程序模块内,重启后模块基址虽然可能变化,但偏移链固定,所以只要重新计算基址就能稳定定位。
8.2 用 CE Lua 固化指针偏移
获取到稳定偏移链后,可以直接写入 Lua 脚本,实现“一键定位当前血量”。
-- CE Lua 示例:多级指针读取 local moduleBase = getAddress("demo.exe") local addr = moduleBase + 0x00A1C3F0 addr = readPointer(addr) + 0x58 addr = readPointer(addr) + 0x18 addr = readPointer(addr) + 0x04 print("Current HP:", readInteger(addr))注意:这里的0x00A1C3F0和偏移链只是示例格式。实际数值必须来自你本机的指针扫描结果。
8.3 指针失效的排查
指针扫描结果不稳定很常见,可能原因有:
- 扫描到了栈地址,这类地址生命周期短。
- 目标对象的生命周期变化,对象被销毁重建。
- 锁定指针层级过多,中间环节可能未被保存。
- 游戏使用了内存池和对象池,地址复用规则更复杂。
遇到这种情况,优先缩短对象生命周期,让角色保持在场景中不销毁,再做指针扫描。对于对象池机制,需要找到“空闲列表”或“活跃对象数组”的基址,而不是直接依赖单个对象指针。
9. 批量扫描与自动化验证
当结构体数组和指针路径确认后,下一步就是把“手动搜索”变成“批量自动化”。CE 提供了 Lua 脚本接口和特征码扫描接口,可以替代大量重复操作。
9.1 特征码批量定位
如果游戏有多个地图、多个场景,数组基址往往会变化。这时可以用特征码定位:先找到结构体数组附近的一段固定字节序列,保存该特征码,下次直接扫描特征码拿到基址。
-- CE Lua 示例:特征码扫描(伪代码格式,需按CE API调整) local pattern = "48 8B 0D ?? ?? ?? ?? 48 8B 01 8B 40 04" local foundList = AOBScan(pattern) if foundList then print(foundList[0]) end特征码定位适合 U3D 项目中那些不带地址编码、不参与加密的静态代码段。实际项目中的特征码可能含通配符??,需要从内存窗口手工提取。
9.2 批量数据导出
拿到数组基址后,可以用 Python 或 Lua 脚本把场景内所有单位的数据导出成 JSON,方便后续做数据台账分析。
# Python 示例:读取 CE 导出的地址列表并整理结构 import json units = [] # 假设从CE导出的数据文件格式为: address,value with open("ce_export.txt", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: parts = line.strip().split(",") addr = parts[0] hp = int(parts[1]) pos_x = float(parts[2]) pos_y = float(parts[3]) pos_z = float(parts[4]) units.append({ "address": addr, "hp": hp, "pos": [pos_x, pos_y, pos_z] }) with open("units.json", "w", encoding="utf-8") as f: json.dump(units, f, ensure_ascii=False, indent=2)注意,这个 Python 示例只是通用模板。你要根据 CE 脚本实际输出格式调整字段顺序和类型。
9.3 自动化任务设计建议
批量任务的关键是稳定性。建议:
- 先跑通单只单位的定位流程,再扩大到数组循环。
- 每一轮批量扫描前,用地址有效性校验函数判断目标是否合法。
- 添加日志输出,记录数组基址、偏移链、读取失败次数。
- 设计失败重试:检测到指针断裂时,重新执行特征码扫描获取新基址。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 附加进程失败 | 权限不足、反调试保护 | 用管理员权限启动 CE,检查进程是否处于保护状态 | 关闭保护程序,或使用支持的内核扫描驱动 |
| Mono 面板为空 | 项目使用 IL2CPP 打包 | 查看进程模块是否有GameAssembly.dll | 改用特征码搜索或 IL2CPP 工具链 |
| 血量搜不到完全匹配 | 血量类型不是 Int,可能是 Float 或 Double | 切换扫描类型重试 | 分别尝试 Float、Double、4字节、8字节 |
| 坐标变化但扫描不到 | 坐标存储在托管堆,更新有延迟 | 等待角色完全停止后再扫描 | 使用未知数值扫描法,配合 Changed/Unchanged 筛选 |
| 指针扫描结果不稳定 | 对象被销毁重建 | 保持角色存活,避免场景切换 | 改用对象数组基址定位 |
| 重启后地址失效 | 未做指针扫描或偏移链不完整 | 做两次指针扫描对比 | 保存稳定偏移链,用Lua脚本固化 |
| 批量读取时有空值 | 数组长度大于实际活跃对象数 | 检查 List_size字段或对象有效标志位 | 循环前先读取数量字段,跳过无效索引 |
| 误报反作弊 | 对联网游戏进行内存读写 | 停止操作并检查目标是否有反作弊 | 仅保留单机、离线测试环境 |
11. 最佳实践与合规建议
最后整理一套可以长期复用的工程化经验。
第一,先小范围验证再批量执行。第一次定位血量、坐标时,不要直接写大型脚本,先用 CE 界面手动扫描,确认字段偏移准确,再写 Lua 自动循环。小范围验证能避免把错误偏移带入批量任务。
第二,保留最小可运行配置。把 CE 表、Lua 脚本、特征码、结构体偏移放在单独的目录中,并写一个 README 记录:
- 目标程序名称与版本。
- 主模块基址变化范围。
- 结构体步长和字段偏移表。
- 指针链的长度和层级。
- 每次验证的时间点和操作步骤。
第三,区分 UI 数据与底层数据。U3D 中血条、坐标显示往往经过 UI 层包装,不要直接搜索屏幕上显示的数字。优先从Transform.position、角色状态组件等底层字段入手。
第四,尊重授权边界。对商业游戏和在线游戏做逆向分析前,必须确认是否违反用户协议,是否绕过反作弊系统。本文所述方法仅用于学习 Unity 内存布局、本地单机测试和安全研究。
第五,输出内容要复核。如果是为了做技术笔记或写报告,导出的 JSON 和地址表要重新进入游戏验证,不能只凭一次扫描结果下结论。
第六,注意隐私与数据合规。分析过程中如果涉及用户生成数据、账号信息或敏感内容,不要截图公开发布。不要在博客文章中贴出真实游戏的完整偏移链和可执行脚本,避免被用于非法用途。
从更长远的角度看,U3D 逆向的价值不止于定位血量和坐标。理解这套方法后,你可以继续往这几个方向深入:
- 分析 U3D 的对象生命周期与内存池机制。
- 研究 IL2CPP 模式下 vtable 和 metadata 的解析。
- 使用 Frida 对 Unity 的 native 层函数做动态插桩。
- 结合特征码和结构体数组思想,建立一套通用的 U3D 数据分析工具链。
对于刚开始接触 U3D 逆向的读者,先从血量定位和坐标扫描入手是最合适的路径。这两个字段变化频率高、判断标准清晰,能帮你快速建立“内存数值变化 -> 地址追踪 -> 结构体拟合 -> 数组定位”的完整思路。建议收藏备用,动手时先准备一个自己打包或有权测试的 U3D 单机工程,把流程完整跑一遍。