Hook 机制
调试器的核心能力来源于对 JavaScript 原型链的 Hook 技术。本文档解释其工作原理及如何利用这一机制。
为什么需要 Hook?
Tanki Online 的代码经过高度混淆,变量名被重命名为无意义的字符串(如 uids 变成 Z_1a2b3c)。调试器需要:
- 找到混淆名 - 解析游戏代码,建立映射表
- 拦截属性访问 - 在属性被读/写时触发回调
- 修改返回值 - 在属性被读取时返回自定义值
工作原理
第一步:解析混淆映射
调试器扫描游戏的所有 JavaScript 文件,通过正则表达式识别特定模式:
模式 1:toString 重写
javascript
// 游戏代码中的典型模式
.someProperty.toString = function() {
return "BattleUsers" + this._obfuscatedName
}调试器捕获此模式,提取出:
- 类名:
BattleUsers - 混淆属性名:
_obfuscatedName
模式 2:Getter 函数
javascript
.getSomething = function() {
return this._hiddenVar
}调试器追踪这种间接引用,找到真正的变量名。
模式 3:构造函数参数
javascript
return "BattleUsers" + (function() {
// 参数定义
uids = "..." + this.obf1,
ranks = "..." + this.obf2
})第二步:建立双向映射
解析完成后,调试器维护两个映射表:
| 映射表 | 方向 | 用途 |
|---|---|---|
obfToRealMap | 混淆名 → 真实名 | 翻译显示 |
realToObfMap | 真实名 → 混淆名 | 注入修改 |
javascript
// 示例映射
obfToRealMap = {
"Z_1a2b3c": "uids",
"Y_4d5e6f": "ranks"
}
realToObfMap = {
"uids": "Z_1a2b3c",
"ranks": "Y_4d5e6f"
}第三步:Hook 原型属性
使用 Object.defineProperty 在 Object.prototype 上定义混淆属性:
javascript
Object.defineProperty(Object.prototype, obfuscatedName, {
get: function() {
// 返回修改后的值(如果启用了注入)
// 否则返回原始值
},
set: function(value) {
// 存储原始值
// 触发历史记录
}
})关键点:
- Hook 在
Object.prototype上,所以任何对象的这个属性被访问时都会触发 - 使用
WeakMap存储每个对象的独立值,避免全局污染
第四步:拦截与修改
读取时(get):
javascript
// 用户添加了 BattleUsers.ranks 并设置了注入值 [99,99,99]
// 当游戏读取 this.ranks 时:
get: function() {
const realValue = store.get(this); // 真实值
if (注入已启用 && 注入值有效) {
return 注入值; // 返回 [99,99,99]
}
return realValue; // 返回真实值
}写入时(set):
javascript
set: function(value) {
store.set(this, value); // 保存真实值
// 记录到历史(origValues.push)
// 如果有自动补全配置,更新输入框
}注入值的类型
字面量
| 类型 | 示例 |
|---|---|
| 数字 | 100 |
| 字符串 | "hello" |
| 布尔值 | true |
| 数组 | [1, 2, 3] |
| 对象 | {"x": 100, "y": 200} |
| null | null |
JS 脚本
以 js: 为前缀,$ 代表原始值:
javascript
// 翻倍
js: return $ * 2
// 修改对象属性
js: return { ...$, custom: "value" }
// 条件逻辑
js: if($ > 100) { return 0 } else { return $ }
// 函数包装
js: return (function(x) { return x + 1 })($)脚本在 getter 中执行,返回值将替代原始值。
历史记录机制
每次属性被赋值时,调试器会:
- 将新值存入
origValues数组 - 更新
origVal(最新值) - 触发 UI 刷新
javascript
// runtimeDebugs 结构
runtimeDebugs = {
"Z_1a2b3c": {
key: "BattleUsers.uids",
origVal: [...], // 最新值
origValues: [[...], [...]] // 所有历史值
}
}历史记录存储在内存中,刷新页面会丢失。重要数据请使用「保存全部」功能导出。
性能考虑
影响性能的因素
| 因素 | 影响 | 建议 |
|---|---|---|
| Hook 数量 | 每个 Hook 增加原型链查找开销 | 只 Hook 需要的参数 |
| 历史记录数量 | 大量记录占用内存 | 及时清空不用的记录 |
| 高频变量 | 如鼠标位置频繁变化 | 避免长期监听 |
| 注入脚本复杂度 | 复杂脚本增加 getter 执行时间 | 保持脚本简洁 |
最佳实践
javascript
// ❌ 不好:Hook 过多
Object.keys(allParams).forEach(addTarget);
// ✅ 好:只 Hook 需要的
['BattleUsers.uids', 'BattleUsers.ranks'].forEach(addTarget);
// ❌ 不好:高频变量监听
addTarget('Input.mousePosition');
// ✅ 好:使用内核探针代替
TankiDebug.kernel.openInspector();
// ❌ 不好:复杂注入脚本
js: return (function() { /* 100行代码 */ })($)
// ✅ 好:简洁脚本
js: return $ * 2安全边界
原型链污染风险
Hook 在 Object.prototype 上,如果属性名与内置方法冲突可能产生问题。调试器会:
- 过滤以
__开头的属性(部分) - 避免 Hook 已存在的原生属性
- 使用
WeakMap隔离实例数据
游戏更新适配
游戏更新可能导致:
- 混淆名变化 → 重新扫描即可
- 代码结构变化 → 需要更新解析正则
内核探针的动态发现机制对结构变化的适应性更强。
调试 Hook
查看已 Hook 的属性
javascript
// 控制台输入
console.log(TankiDebug.hookedVars);
// Set { "_obf1", "_obf2", ... }查看映射表
javascript
console.log(TankiDebug.obfToRealMap);
console.log(TankiDebug.realToObfMap);手动触发重新扫描
javascript
TankiDebug.runAutoScan();限制与注意事项
| 限制 | 说明 |
|---|---|
| 仅限客户端 | 只能修改客户端内存,服务端校验的数据无效 |
| 依赖解析 | 如果解析失败(代码结构变化),Hook 不会生效 |
| 性能开销 | 大量 Hook 可能影响游戏流畅度 |
| 无法 Hook 原生属性 | 如 toString、valueOf 等不会被 Hook |
