文章目录
- 概述
- C 语言的内存模型:CE 的假设
- Python 的内存模型:三层间接
- 第一层:PyFloatObject
- 第二层:dict 存指针,不存值
- 第三层:不可变对象 = 每次赋值都换地址
- CE 搜索失败的完整原因
- 哪些游戏有这个问题
- 总结
概述
在逆向某游戏(Cocos + Python 2.7)时遇到一个现象:游戏中"当前年剩余时间"(LevelRestTime)是一个不断变化的 float 值(从120倒计时到0),但用 Cheat Engine 无论怎么搜都搜不到。
值没有加密,问题出在Python 的对象内存模型与 CE 的搜索假设根本不兼容。这个问题对所有内嵌 Python/Lua/JS 等脚本引擎的游戏都适用。
C 语言的内存模型:CE 的假设
CE 的设计基于 C/C++ 的内存模型——变量是一块固定地址上的裸值:
// C 语言floatLevelRestTime=114.99f;// 4字节, 固定地址 0x12345678// 下一帧LevelRestTime-=0.016f;// 同一个地址, 值变了内存 @ 0x12345678: 帧1: [42 F9 E6 C2] → 114.99 帧2: [9A F9 E6 C2] → 114.97 帧3: [F2 F8 E6 C2] → 114.95 地址不变, 值在原地递减CE 的"首次扫描 → 再次扫描"流程完美契合这个模型:同一个地址上的值在变化,筛几轮就锁定了。
Python 的内存模型:三层间接
Python 中一切皆对象,没有"裸值"。一个 float 变量实际上是这样的结构:
第一层:PyFloatObject
PyFloatObject (Python 2.7, 64位, 共24字节): ┌──────────────────────────────┐ │ +0x00 ob_refcnt (8字节) │ 引用计数 │ +0x08 ob_type (8字节) │ → PyFloat_Type 类型指针 │ +0x10 ob_fval (8字节) │ ← 实际的 double 值 └──────────────────────────────┘注意1:Python 用double(8字节),不是float(4字节)。CE 默认搜 4 字节 float,类型就不对。
注意2:实际值在对象偏移+0x10处,前面有16字节的对象头。
第二层:dict 存指针,不存值
LevelRestTime存在m_dctProp字典中。Python dict 的条目结构:
dict entry (24字节): ┌──────────────────────────────────────────────┐ │ +0x00 hash (8字节) │ │ +0x08 key (8字节) → PyString "LevelRest…"│ │ +0x10 value (8字节) → PyFloatObject* │ ← 指针! └──────────────────────────────────────────────┘dict 里存的是指向 PyFloatObject 的指针,不是 float 值本身。
第三层:不可变对象 = 每次赋值都换地址
Python 的 float 是不可变对象(immutable)。LevelRestTime -= dt这行代码实际执行的是:
# 不是 "修改原地的值"# 而是 "创建新对象, 替换指针"LevelRestTime=LevelRestTime-dt等价于:
1. old = PyFloatObject @ 0xAAAA0000, ob_fval = 114.99 2. new = PyFloat_FromDouble(114.99 - 0.016) → 分配新的 PyFloatObject @ 0xBBBB0000, ob_fval = 114.97 3. dict["LevelRestTime"] 的 value 指针从 0xAAAA0000 改为 0xBBBB0000 4. old 对象引用计数归零 → 被垃圾回收(内存释放或回收到 free list)CE 搜索失败的完整原因
把三层叠加起来,CE 的每一步都踩坑:
帧1: 搜 Double 114.99 → 找到地址 0xAAAA0010 (PyFloatObject.ob_fval) ✓ 第一次能搜到 帧2: 再次扫描, 期望 0xAAAA0010 处的值变为 ~114.97 → 但 0xAAAA0010 处的对象已被回收! → 该地址的内存可能已被其他对象占用, 值是垃圾 → CE: "值不匹配, 排除" ✗ 筛掉了 → 真正的 114.97 在新地址 0xBBBB0010 → 但这个地址不在 CE 的候选列表里 ✗ 找不到| CE 假设 | Python 现实 | 结果 |
|---|---|---|
| 值是4字节float | 值是8字节double | 类型不匹配 |
| 值在固定地址 | 每帧创建新对象,地址变化 | 再次扫描失败 |
| 修改地址处的值即可 | dict存的是指针,需要替换指针 | 即使找到也改不动 |
哪些游戏有这个问题
不只是 Python 游戏。所有使用带 GC 的脚本引擎的游戏都有类似问题:
| 引擎 | 值对象类型 | CE 能直接搜吗 |
|---|---|---|
| CPython 2/3 | PyFloatObject (不可变) | 搜不到,地址每次变 |
| Lua 5.x | TValue (tagged union) | 有时能搜到(Lua 用原地 TValue,较友好) |
| LuaJIT | GCobj / TValue | 取决于 JIT 优化,可能搜到 |
| V8 (JS) | HeapNumber (不可变) | 搜不到,和 Python 一样 |
| Mono (C#) | 值类型在栈/堆 | struct 类型通常能搜到 |
| IL2CPP | 编译为 C++,裸值 | 能搜到(最友好) |
经验法则:如果脚本语言的数值类型是不可变对象(Python float、JS Number),CE 基本搜不到。如果是原地修改的值类型(Lua TValue、C# struct),CE 通常能搜到。
总结
CE 搜不到 Python 游戏的值,不是因为加密,而是因为内存模型的根本差异:
C/C++ 游戏: 地址固定 → 值原地变化 → CE 完美匹配 Python 游戏: 每次赋值 → 新建对象 → 换指针 → 旧地址失效 → CE 跟丢面对脚本引擎游戏,放弃 CE 的"扫描-筛选"流程,转向:
- 理解脚本引擎的对象模型(PyObject、TValue 等)
- 用 Frida 注入到引擎内部,通过引擎自己的 API 读写数据
- 操作对象指针而非裸内存值