文章目录
-
- 概述
- [C 语言的内存模型:CE 的假设](#C 语言的内存模型:CE 的假设)
- [Python 的内存模型:三层间接](#Python 的内存模型:三层间接)
-
- 第一层:PyFloatObject
- [第二层:dict 存指针,不存值](#第二层:dict 存指针,不存值)
- [第三层:不可变对象 = 每次赋值都换地址](#第三层:不可变对象 = 每次赋值都换地址)
- [CE 搜索失败的完整原因](#CE 搜索失败的完整原因)
- 哪些游戏有这个问题
- 总结
概述
在逆向某游戏(Cocos + Python 2.7)时遇到一个现象:游戏中"当前年剩余时间"(LevelRestTime)是一个不断变化的 float 值(从120倒计时到0),但用 Cheat Engine 无论怎么搜都搜不到。
值没有加密,问题出在 Python 的对象内存模型与 CE 的搜索假设根本不兼容。这个问题对所有内嵌 Python/Lua/JS 等脚本引擎的游戏都适用。
C 语言的内存模型:CE 的假设
CE 的设计基于 C/C++ 的内存模型------变量是一块固定地址 上的裸值:
c
// C 语言
float LevelRestTime = 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 这行代码实际执行的是:
python
# 不是 "修改原地的值"
# 而是 "创建新对象, 替换指针"
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 读写数据
- 操作对象指针而非裸内存值