工具链(3DTank/tools/):extract(模块提取)、mine_evidence/apply_names(命名证据与别名回填)、extract_codec_fields(字段表 + 全库槽位类型 + PrimClassWire/ResourceClasses)、mine_downlink(下行字段表)、extract_space_init/rebase_space_init/audit_space_init(空间模板与记录切分审计)、extract_space_session(把真实会话切成按上行触发的回放模板)、pair_flow(请求→应答配对)、probe_containers、ingest/decrypt_frames(抓包入库与解密)、serve/fetch_assets(本地站点镜像)、import_db(抓包对象仓库导入 MySQL)。 服务器(3DTank/tools/server/):link/entry/codec/container/dispatch/world/space/semantic(协议层与 lt/pt/zt 三个容器布局)、session(会话回放,顺序推进)、db + schema.sql(MySQL 连接层与对象仓库表结构)、store/responder(查库组帧与定向应答;接管范围默认只开 ba.lcr)。 同时修 pair_flow 的时间轴偏移:timeline_c2s 与 timeline_s2c 的 0 点相差 144~4453ms,改前配对整体偏约 2 秒(实测 space-100073070 偏 2084ms),改后 22.4% 的触发者被纠正。 src/、src_named/、packets/、3DTank/local/(含数据库口令)不进库,可用上述工具重新生成。
13 KiB
3DTank 逆向工程
把 main.6469b966.js(webpack5 打包的游戏客户端)还原成可读、可改的源码工程。
目录
3DTank/
src/ # 从 bundle 拆出的源码(prettier 美化)
module_<id>.js # 126 个代码模块
resources.json # 1218 个资源模块(ID → 路径)
index.json # 模块索引
names/
evidence.json # 命名证据(见下)
command_alias.json # 派生命名词典(读源码证实语义后起的别名,不是原始名;含 Classes/Methods/Fields/MethodIds/Spaces)
rename_report.json # apply_names.js 的产出报告(含 Aliases 段)
tools/
extract.js # bundle → src/
mine_evidence.js # src/ → names/evidence.json
apply_names.js # src/ → src_named/(证据命名;加 --aliases 时附带回填 command_alias.json 的别名)
src_named/ # 应用重命名后的源码(126 个模块,三道校验:node --check / 字符串集合一致 / 标识符计数)
(待建) tools/build_bundle.js
原始 bundle 不复制进本工程,默认引用 ../main.6469b966.js(可用参数覆盖)。
用法
cd tools
npm install
node extract.js # 拆模块 → ../src
node mine_evidence.js # 挖命名证据 → ../names/evidence.json
命名证据(逆向的关键抓手)
编译产物里保留了相当多"原始名字",这是还原命名的地基:
| 证据 | 例子 | 数量 |
|---|---|---|
| 组件/服务懒加载 getter | var x = t.a11l_1; if (null != x) return x; vj("gameMode") → 字段 a11l_1 就是 gameMode |
1718 个字段 |
数据类 toString |
Q(r7).toString = function () { return "Data(" + ... } → 类 r7 即 Data |
921 个类 |
| 枚举构造 | E = new Qc("Shadows", 0) → 类 Qc、成员 Shadows=0 |
337 个枚举 / 2633 个成员 |
| 闭包原名 | fn.callableName = "addToBattle" |
477 处 |
| 导出表 | t.$_$.ac = Tat → 模块 87072 的 ac 导出 |
6249 条 |
| 字符串常量 | 错误信息、协议动作名等 | 21792 条 |
命名分层(作用域决定重命名安全性)
- 模块内局部名(类名、函数名、变量名):仅在本模块有效,可自由改名 → 用
classNames/enums/closures打底,其余靠上下文推断。 - 属性名(字段/方法):全局共享字符串(如
a11l_1、rym),改名必须全局一致 → 由fieldsGlobal+ 导出表 + 人工词典统一驱动。 - 不可改名:宿主/浏览器 API、
equals/toString/h1/e1/g1等工具链与引擎已依赖的名字。
验证闭环
重命名后必须能重新组装成可加载的 bundle 并通过加载测试(126 模块 + 1218 资源全注册), 避免"改坏语义",特别是属性名替换。
本地可运行客户端
cd tools
node fetch_assets.js --concurrency 10 # 镜像 1223 个文件(≈70MB)到 web/
node serve.js --port 8080 # 起本地站点
# 浏览器打开 http://localhost:8080/
目录/挂载:
web/:index.html(源站首页)+play/static/**(1218 个资源、字体、图标、manifest)/play/static/js/**直接挂../3dtank.com/play/static/js/(切割版 JS,单一来源,含 hornet 注入)- 404 会记到
names/missing.txt;代理响应会缓存到cache/
域名守卫与代理(关键)
src/module_13431.js:1656-1716:游戏按 location.host 决定配置端点;非 *.3dtank.com 域名(如 localhost)会退化成
resources = http(s)://<本机>/resources
config-template = http(s)://<本机>/config.xml
这两条在本机原本 404,config.xml 取不到就重试并重载页面 → 表现为"一直刷新"。
serve.js 因此内置代理:
| 本机路径 | 转发到 | 说明 |
|---|---|---|
/resources/** |
https://res.3dtank.com/** |
CDN 资源基址 |
/config.xml |
https://c1.3dtank.com/config.xml |
服务器配置(指向 wss://c1.3dtank.com:9090) |
/s/status.js |
https://3dtank.com/s/status.js |
balancer 节点状态 |
代理会强制带上 origin: https://3dtank.com 与 referer(CDN 有来源校验),并落盘缓存。
另外 URL 查询参数优先级高于上述默认值,也可用
?resources=…&config-template=…&balancer=… 直接覆盖(值需 URL 编码)。
协议逆向与离线模拟(tools/ 协议工具链)
把 wss://c1.3dtank.com:9090/ 的握手、加解密、空间帧结构、命令表全部还原,并能在本地起一个
"假服务器"离线跑客户端。
帧格式
帧 = [2 或 4 字节大端长度][帧体] b0&0x80 → 4 字节头;b0&0x40 → 帧体 zlib 压缩
帧体 = [nullmap][payload] payload = [1 字节命令 id][body]
入口层命令 id:c2s 1=Hash Request 3=Auth/EnterSpace 32=ClientLog;s2c 2=Hash Response 32=Open Space
进入空间后链路切到加密(变体 XOR 流密码,见 wss_server.js 的 Cipher:p = c ^ key[idx]; key[idx] = p; idx ^= p & 7)。
空间帧(两个方向,形状不同)
| 方向 | 形状 |
|---|---|
| s2c | [nullmap][spaceId i64 8B][ (methodId i64 8B)(负载) … ] |
| c2s | [nullmap][objectId i64 8B][ (methodId i64 8B)(负载) … ](没有命令 id 字节层) |
上行形状的出处是 src/module_61860.js 的 SpaceCommand codec(fr.o7g 依次写 objectId、methodId
再写命令自身负载),并与抓包字节互相验证(171/171 帧 methodId 命中、G.q1wd 的 i32=1 恰好吃满 4 字节)。
字段级解码(names/codec_fields.json)
lib_space_codec.js 按下面的规则逐字段解码,规则全部来自 src/module_24635.js 的序列化器实现:
| 类型 | 线上宽度 |
|---|---|
| bool/i8 / i16 / i32,f32 / i64,f64 | 1 / 2 / 4 / 8 字节(大端) |
| string,bytes | varint 长度 + 原始字节 |
| list<T> | varint 元素数 + 元素… |
| opt<T> | 1 字节标志(非 0 = 空)+ 有值时紧跟 T |
| enum<T> | 4 字节(库里 o7g 是空实现,但游戏自注册的枚举以 i32 上线) |
| 模型类 | 就地展开该模型 codec 的字段(无长度前缀) |
| varint | <128 → 1B;<16384 → 2B(`0x80 |
字段表的来源(tools/extract_codec_fields.js):每个 codec 有三段式结构 ——
getter(v("codec_名字") 的报错串就是字段名)/ q7g(slot → 类型描述符)/ p7g(字段线上顺序,调用次序即顺序)。
类型描述符三兄弟在 module_24635:$_$.e=单值、$_$.c=list、$_$.b=enum,构造器第 2 参 !0 = optional。
两条"没有名字也能救回来"的兜底(都不是猜宽度,而是从源码取的证据):
- WireClasses:协议核心类用
m7f(kClass(类), new Codec())注册,这些 codec 的o7g只调一次写入器 → 类 → 线上基础类型可反推(实体引用xr→ i64、R→ i64)。只认「恰好一次写入」的标量 codec,向量/嵌套命令自动跳过。 - CodecByModelName:字段只写了类名、没带 codec 链接时按模型真名找 codec(如
ji= module_24635::Xt 单 i64)。
命令表
| 产物 | 内容 | 来源 |
|---|---|---|
names/method_table.json |
s2c methodId → 模型/codec | names/regdump.json(运行时 dump 的注册表) |
names/uplink_table.json |
c2s methodId → 命令类 / 发送方法 / 负载字段 | 源码里的 Long 字面量(new $(lo, hi))+ 发送点前的 this.<codec>.o7g( 次序 |
names/flow.json / flow_s2c.tsv |
请求 → 应答配对 | tools/pair_flow.js(按时间就近配对) |
Long 合成:value = hi*2^32 + (lo>>>0),已用源码 h7n() 对客户端 dump 的注册表 680/680 标定。
上行字段没有可读名:命令类不像模型 codec 那样保留 codec_<名字> getter,555 个上行字段在模块内
没有任何属性访问点,压缩后原可读名已不可恢复,只能人工标注或靠调用点语义推断。
服务器:中继(分析)与真服务器(server/)
cd tools
node wss_server.js # 中继:客户端 → 本地 → 真实服务器(抓包 + 实时解析)
node server/index.js --port 9091 # 真服务器(M1):自己握手 / 自己管空间 / 把未实现命令记全
node server/selftest.js # MakeWriter 往返自检(拿真实抓包逐字节比对)
node decode_space_frames.js --dir ../packets/plain/space-100045720 --fields # 事后逐字段解一包
node pair_flow.js # 重新生成请求→应答配对表
node ingest.js --label mine-01 # 把一次抓包入库到 packets/plain/
回放(fake 模式)方案已废弃(2026-09-16 实测结论)。原因:客户端一次会话是「大厅 + 6 个空间轮流连」
的链(mine-01 的 session_001…007:大厅 → 100045260 → 920007382011 → 100119080 → 100072911 →
100045720 → 车库 100073070),导航全部由大厅那几帧 Open Space 驱动,且每个连接都会往客户端的
全局注册表(gameType / model / resource / space)里塞数据。只回放单条连接必然缺状态,实测表现为
进车库后抛 Class not found exception class=-2147482689(src/module_45686.js:117 的 ft() 查
gameTypeRegistry 落空),弹错后页面自动刷新,死循环。空间层是问答式的,只能真服务器。
wss_server.js 只保留 relay(协议分析);真服务器在 tools/server/:
| 文件 | 职责 |
|---|---|
link.js |
帧边界 / nullmap / 明文协商(握手回帧里把「启用链路加密」置 0,整条链路明文) |
entry.js |
Hash Request·Response / EnterSpace / Open Space / ClientLog |
codec.js |
MakeWriter(写包)+ ResolveFields(按 methodId 取字段表,服务端与自检同一口径) |
dispatch.js |
空间帧识别(两道门)→ 逐条记录解码 → 落地日志 → 记「未实现」 |
world.js |
空间实例表 / 空间分类 / 未实现命令清单 |
index.js |
启动、连接生命周期、日志落盘(server-logs/<label>/session_00N/) |
selftest.js |
MakeWriter 往返自检 |
M1 现状:入口层全量(握手、鉴权、ClientLog、保活);空间层只记录、不伪造,一个字节都不回放。
未实现清单就是 M2 的施工图。M2 要做的是按 src/module_29554.js 的 modelService 协议
(_loadDependencies / _loadObjectsData / _setModelCommandAlias / _unloadObjects)与
src/module_45686.js 的容器三编码(lt/pt/zt)把四张注册表填起来,让客户端进车库不报错。
写包能力验收(node server/selftest.js:13621 个抓包文件 / 1283 个空间帧 / 1206 条记录):
逐字节一致 1108 条(92%)、不一致 9 条、解码中断 89 条、写侧不支持 0 条。
残留的 9 + 89 条全部落在 ChatCC / ClanNotifierData 两个命令上(字段表漂移),不是写包逻辑问题。
容器(无 codec 的记录)—— 三个布局已闭合
容器命令没有普通 codec(客户端只读不写:src/module_45686.js 里 lt.o7g 是空实现、pt.o7g/zt.o7g 直接
throw),它的负载由三个「容器 codec」描述。三个布局已逐字节标定完成,实现在 tools/server/container.js
(读写都有,写侧由读侧反推,用真实抓包往返验证):
lt : [i64 id] id == 0 → 值 = 紧跟的那个 i64;否则按 modelRegister[id] 的 codec 就地展开
pt : [i32 C][C × (i64 objId, i64 gameTypeId)][i32 D][D × lt项]
zt : [i32 X][i32 C][C × { i64 ObjId, i32 N, N × i64 ModelId }] ← 模型归属表(原「模式表」)
[i32 D][D × { i64 R, i16 E, i16 U, i64 S, u8 O, varint Skip, Skip × i64 }]
- 宽度依据:
zt项的字段顺序来自源码$t()(q2u, o2u, o2u, q2u, bool+zt的n2u跳过数)。 实测每项恰好 22 字节 ⇒8 + 2w + 8 + 1 + s = 22⇒o2u= i16、n2u= 1 字节(按 varint 读)。 - 标定证据:
space-100045260/s2c_0000.bin(915B,解压 1799B)用zt布局 1782/1782 字节精确吃满。 - 计数一律 i32(4 字节)大端,不是 varint(这一点此前被误判过)。
- 模型归属表里的 N 个 modelId 语义与旧结论一致,例如
space-100045260(30 个)= CaptchaCC / RegistrationModelCC / EntranceResourcesCC …(登录+大厅初始化批次)、space-100045720(5 个)= _t / Kz / ChatCC / ClanChatCC / NewsShowingCC(聊天批次)、space-100072911(2 个)= Jj / ShopCategoryCC(商店)。 - 布局判定以「哪个布局恰好吃到帧尾」为准,不要信
names/method_table.json的Codec列 —— 实测它把_t那类行标成lt,而真实负载是zt形状。 - 验收:
node server/selftest.js里容器帧 11/11 逐字节一致(10 个zt+ 1 个pt)。 仍有 2800 余个帧的 methodId 不在普通 codec / 上行表里且三布局都判不出——那些是尚未拿到 codec 的其它命令, 不是容器,属于后续要补的表。