# 3DTank 逆向工程 把 `main.6469b966.js`(webpack5 打包的游戏客户端)还原成可读、可改的源码工程。 ## 目录 ``` 3DTank/ src/ # 从 bundle 拆出的源码(prettier 美化) module_.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`(可用参数覆盖)。 ## 用法 ```bash 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 条 | ## 命名分层(作用域决定重命名安全性) 1. **模块内局部名**(类名、函数名、变量名):仅在本模块有效,可自由改名 → 用 `classNames`/`enums`/`closures` 打底,其余靠上下文推断。 2. **属性名**(字段/方法):全局共享字符串(如 `a11l_1`、`rym`),改名必须全局一致 → 由 `fieldsGlobal` + 导出表 + 人工词典统一驱动。 3. **不可改名**:宿主/浏览器 API、`equals`/`toString`/`h1`/`e1`/`g1` 等工具链与引擎已依赖的名字。 ## 验证闭环 重命名后必须能重新组装成可加载的 bundle 并通过加载测试(126 模块 + 1218 资源全注册), 避免"改坏语义",特别是属性名替换。 ## 本地可运行客户端 ```bash 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|高6位, 低8位`);<4194304 → 3B | 字段表的来源(`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..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/`) ```bash 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/