feat(collect): 按 docs/tdc-案例.md 重做主链路 —— 四段加密 + 字符串指纹识别 37/37
做的什么:
1. 判决并修正 collect 结构: 不是「整体一次加密」, 而是
chunk1 / trajectory / chunk2 / sd **四段独立加密**, 前 3 段
空格 pad 到 24 字节对齐、sd 不 pad, 4 段 base64 直接拼接。
2. 按案例 §5.3 实现字符串+结构指纹的模块识别器, 把 37 个采集器
全部识别且跨构建唯一。
3. 修 reorder 的恒等映射 bug, 并补强断言。
4. 在线抓取全新样本作为唯一数据源, 清理探索性脚本。
为什么:
* 旧「整体加密 + \x00 补齐」与真机数据矛盾 —— 导致多轮实验
建立在结构错误的 collect 上(这正是此前反复"乱碰"的根因)。
* 模块 id 每份构建重排, 不能当跨构建锚点; 但模块的**代码**
(字符串集合、结构)不变 —— 这是案例指明的不变量。
* reorder 旧实现用「目标槽下标」切「源数组」, 顺序不同即全盘错位;
原测试只断言"是置换", 恒等函数也满足, 所以 bug 被放过了。
怎么验证:
* 四段结构(3 条独立证据, 其中一条不需密钥):
- 真机明文 3 处连续空格串 [+5,+10,+24], 且 pad == 24-len%24 全中
- 密文长度算术 480+96+416+56 = 1048 == 真实 1048 字符
- '=' 只出现在 1046/1048, 对应 sd 段 33B→补 7→40B
* 指纹: 4 份全新在线构建 37/37 识别、0 unknown、跨构建语义名集合
完全一致、单构建内 0 重名
* 配对正确性: 独立证据 37/37 一致; 负对照(清单下标 / 模块 id /
随机配对)均只有 2-3/37 —— 证明归一化没有过拟合
* 端到端重排 3/3 正确且非恒等
* selftest 全绿; 强断言自查: 旧实现过不了新断言
新增:
tools/tdc_modules.py 字符串+结构指纹识别(案例 §5.3)
scripts/fetch_online_samples.py 在线抓取全新 tdc.js
scripts/verify/*.py 7 个可复现的验证脚本
docs/tdc-案例.md 案例原文(看雪, 作者执着的猫)
修改:
tcaptcha/crypto.py +build_collect / split_collect_segments(四段)
tools/tdc_collectgen.py reorder 改为跨构建锚点对齐 + spec_from_seq
tools/tdc_layout.py +static_fingerprint / opcode_histogram
tools/tdc_program.py 修符号求值插值类型崩溃
tcaptcha/tests/test_collectgen.py 补「非恒等」强断言 + 在线新样本端到端
tcaptcha/client.py debug 目录改到系统临时目录(不再写仓库内 scratch/)
har/1ms_dianxuan/PROGRESS.md 新增已知 #41~#49, 显式修正被推翻的旧结论
.gitignore +scratch/ +har/online/
删除:
scratch/(含 10-11 遗留的一次性脚本, 已确认不可恢复后清理)
注意:
* 账本记录了一次自我纠错: 曾据案例 §5.5 误判 kind=2「不进 cd」,
实测(cd 38 槽、轨迹在第 10 槽)否定后已撤回。
* 仍未打通: 纯离线生成需要每个语义名的取值来源(已知 #38)。
This commit is contained in:
@@ -38,4 +38,11 @@ har/**
|
||||
*.har
|
||||
*.zip
|
||||
|
||||
# 探索性脚本与临时诊断产物(一次性、不入版本控制;可复现的验证脚本放 scripts/verify/)
|
||||
scratch/
|
||||
|
||||
# 在线抓取的原始 tdc.js 样本(每份 ~78KB、每次请求都不同,属可再生数据)
|
||||
# 需要时用 `scripts/fetch_online_samples.py --n 4` 重新抓取即可。
|
||||
har/online/
|
||||
|
||||
|
||||
|
||||
+760
@@ -0,0 +1,760 @@
|
||||
# 使用 AI 还原腾讯点选验证码算法——动态 jsvmp
|
||||
|
||||
> **原文来源**:看雪论坛(https://bbs.kanxue.com/thread-290124.htm)
|
||||
> **作者**:执着的猫
|
||||
> **发布时间**:2026-2-28 15:17
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
### 1.1 腾讯防水墙(TCaptcha)简介
|
||||
|
||||
腾讯防水墙是腾讯自研的验证码服务,广泛应用于 QQ、微信生态以及大量第三方接入站点。它提供多种验证类型:滑动拼图、文字点选、空间推理等。
|
||||
|
||||
本文以腾讯防水墙的**文字点选验证码**为目标。当用户触发需要验证的操作后,页面会弹出一个验证码 iframe,要求用户"请依次点击"指定的三个汉字。验证通过后返回 ticket,前端携带 ticket 完成业务请求。
|
||||
|
||||
验证码运行在跨域 iframe `turing.captcha.gtimg.com` 中,核心逻辑由一个名为 `tdc.js` 的脚本驱动。这个脚本负责采集用户的浏览器指纹、鼠标轨迹等行为数据,加密后生成一个 `collect` 字段提交给服务端验证。
|
||||
|
||||
### 1.2 为什么难:一切都在变
|
||||
|
||||
`tdc.js` 只有几行代码——但这几行代码定义了一个完整的栈式字节码虚拟机,腾讯称之为 **CHAOS VM**。所有真正的业务逻辑(指纹采集、加密算法、数据组装)都编译成了字节码,运行在这个 VM 内部。看不到任何业务代码——没有 `function encrypt()`,没有 `key = [...]`,一切都藏在一个巨大的 Base64 字符串里。
|
||||
|
||||
但 VM 保护本身不是最难的部分。真正的核心难点是:**`tdc.js` 每次加载都会变。**
|
||||
|
||||
每次请求 `https://turing.captcha.qcloud.com/tdc.js`,服务端都会动态生成一份全新的 `tdc.js`。变化的内容包括:
|
||||
|
||||
| 变化项 | 说明 | 影响 |
|
||||
|---|---|---|
|
||||
| XTEA 加密密钥 | 4 个 int32 的 key,每次完全不同 | 无法硬编码 key |
|
||||
| key 的运行时偏移 | 加密时给特定 key 索引加的偏移值,每次不同 | 必须动态提取 |
|
||||
| 操作码索引 | VM 的 58 个操作码在 handler 数组中的位置打乱 | 无法硬编码 opcode 映射 |
|
||||
| 37 个模块的 ID | 每个采集器模块的注册 ID 随机重新分配 | 无法按 ID 判断模块类型 |
|
||||
| 模块的调用顺序 | cd 数组中模块的排列顺序随机打乱 | 无法硬编码字段位置 |
|
||||
| key 构建函数的 scope | 真实/诱饵 key buffer 的索引每次不同 | 无法硬编码判别规则 |
|
||||
|
||||
这意味着不能做一次分析然后硬编码结果——上一秒分析出来的 key、模块顺序、opcode 映射,下一秒全部作废。
|
||||
|
||||
### 1.3 为什么"补环境"不够
|
||||
|
||||
面对 `tdc.js` 的 VM 保护,一个常见的思路是"补环境":用 Node.js 或 jsdom 搭建一个模拟的浏览器环境,直接执行 `tdc.js`,调用 `TDC.getData(true)` 拿到结果。
|
||||
|
||||
这个思路确实能跑通——但它是一个**黑盒方案**:
|
||||
|
||||
- **指纹不可控**。`tdc.js` 内部有 37 个模块采集浏览器指纹(Canvas 指纹、WebGL 渲染器、屏幕分辨率、CPU 核心数等)。补环境方案中,这些值要么是 Node.js 的默认值(明显异常),要么需要逐一伪造——但不知道它到底采集了哪些项。
|
||||
- **检测风险高**。`tdc.js` 有 27 项自动化检测(`navigator.webdriver`、ChromeDriver 标记等)。补环境稍有遗漏就会被标记。
|
||||
- **维护成本高**。腾讯一旦更新 `tdc.js` 的采集项或检测逻辑,补环境方案就可能失效,而你无法快速定位问题。
|
||||
|
||||
最终选择了另一条路:**白盒还原**。完全理解 `tdc.js` 的内部逻辑,用 Python 从零构造 `collect`。这条路更难,但一旦走通,每个参数都在掌控之中。
|
||||
|
||||
### 1.4 最终目标
|
||||
|
||||
用纯 Python 还原 `TDC.getData(true)` 生成的 `collect` 加密字段,实现端到端的自动化流程:
|
||||
|
||||
```
|
||||
prehandle 获取会话 → 动态下载并解析 tdc.js → OCR 识别验证码文字
|
||||
→ 生成鼠标轨迹 → 构造 collect → 求解 PoW → 提交 verify → 获取 ticket
|
||||
```
|
||||
|
||||
整个过程不需要运行任何 JavaScript,不需要浏览器环境,完全在 Python 中完成。
|
||||
|
||||
---
|
||||
|
||||
## 二、请求流程分析
|
||||
|
||||
### 2.1 验证码触发与交互流程
|
||||
|
||||
用户触发需要验证的操作后,服务端返回验证码弹出指令,前端随即加载腾讯验证码 SDK(`TCaptcha.js`),弹出一个跨域 iframe。
|
||||
|
||||
这个 iframe 的域名是 `turing.captcha.gtimg.com`,与宿主页面跨域隔离。验证码的所有逻辑——图片展示、用户交互、行为采集、数据加密——都在这个 iframe 内部完成。这也给逆向分析带来了额外的挑战:不能在主页面的 console 中直接访问验证码的变量和函数,必须切换到 iframe 上下文才行。
|
||||
|
||||
### 2.2 五阶段请求流程
|
||||
|
||||
**阶段 1:预处理(prehandle)**
|
||||
|
||||
```
|
||||
GET https://turing.captcha.qcloud.com/cap_union_prehandle
|
||||
参数: aid=xxx, ua=Base64(UserAgent), ...
|
||||
```
|
||||
|
||||
返回内容包括:
|
||||
- `sess`:会话标识,后续所有请求都要携带
|
||||
- `pow_cfg`:PoW(工作量证明)配置,包含 prefix 和目标 MD5
|
||||
- `dyn_show_info`:验证码展示信息,包含文字指令("请依次点击:X X X")和图片 URL
|
||||
|
||||
**阶段 2:tdc.js 加载**
|
||||
|
||||
```
|
||||
GET https://turing.captcha.qcloud.com/tdc.js?app_data={sid}&t={random}
|
||||
```
|
||||
|
||||
`tdc.js` 下载后通过 blob URL 加载到 iframe 中。加载完成后,iframe 的 `window` 上会出现一个 `TDC` 对象,暴露 4 个方法:
|
||||
|
||||
```javascript
|
||||
window.TDC = {
|
||||
setData: core.mSet, // 设置额外数据(如 ft 字段)
|
||||
getData: core.mGetData, // 获取加密后的 collect ← 核心入口
|
||||
getInfo: getInfoFunc, // 获取 eks 环境信息
|
||||
clearTc: core.mClear // 清除采集数据
|
||||
}
|
||||
```
|
||||
|
||||
`tdc.js` 加载后立即调用 `core.mInit()` 开始采集行为数据(鼠标移动、键盘事件等)。
|
||||
|
||||
**阶段 3:用户交互**
|
||||
|
||||
用户看到验证码图片,依次点击指定的汉字。每次点击的坐标被记录为 `ans` 参数。与此同时,TDC 持续采集鼠标移动轨迹。
|
||||
|
||||
**阶段 4:提交验证**
|
||||
|
||||
用户点击完成后,前端脚本 `dy-ele.js` 执行以下操作:
|
||||
|
||||
```javascript
|
||||
TDC.setData({ft: "qf_7Pf__H"}); // 设置额外数据
|
||||
collect = decodeURIComponent(TDC.getData(true)); // 获取加密行为数据
|
||||
eks = TDC.getInfo().info; // 获取环境信息
|
||||
pow_answer = solvePow(pow_cfg); // 求解 PoW
|
||||
```
|
||||
|
||||
```
|
||||
POST /cap_union_new_verify
|
||||
{
|
||||
collect, eks, tlg: collect.length, ans: JSON.stringify(clicks),
|
||||
sess, pow_answer, pow_calc_time, ...
|
||||
}
|
||||
```
|
||||
|
||||
**阶段 5:响应处理**
|
||||
|
||||
- 成功:返回 `ticket` + `randstr`,前端携带这两个值重新发起登录请求
|
||||
- 失败:返回 `errorCode`(如 "50" 表示验证失败),同时返回新的 `sess`
|
||||
|
||||
### 2.3 参数分类
|
||||
|
||||
提交验证时有 8 个参数,但需要逆向的实际上只有 2 个:
|
||||
|
||||
| 参数 | 难度 | 说明 |
|
||||
|---|---|---|
|
||||
| collect | 核心难点 | `TDC.getData(true)` 生成的加密行为数据 |
|
||||
| eks | 简单提取 | `tdc.js` 源码中的硬编码常量,正则提取即可 |
|
||||
| ans | OCR 问题 | 用户点击坐标,明文 JSON |
|
||||
| pow_answer | 简单 | MD5 碰撞,几行代码 |
|
||||
| sess | 透传 | 从上一次响应中获取 |
|
||||
| tlg | 无需逆向 | collect 字符串长度 |
|
||||
| pow_calc_time | 无需逆向 | PoW 计算耗时 |
|
||||
| aid | 固定值 | 接入方分配的 ID |
|
||||
|
||||
### 2.4 无状态架构
|
||||
|
||||
一个有意思的设计:服务端如何知道 XTEA 加密的密钥?
|
||||
|
||||
答案藏在 `eks` 字段里。`eks` 是一个 Base64 编码的 176 字节数据,实质上是 **XTEA key + offsets + 元数据的加密信封**。服务端生成 `tdc.js` 时,随机生成 XTEA key,用服务端主密钥加密后写入 `tdc.js` 源码中的一个全局变量。客户端原样回传 `eks`,服务端用主密钥解密即可还原 key。
|
||||
|
||||
这种设计实现了完全无状态的验证架构——服务端不需要存储任何 key-session 映射关系。
|
||||
|
||||
key 的生命周期与 `tdc.js` 绑定:页面加载时生成新 key,整个验证会话内不变(包括多次重试和刷新题目)。只有页面刷新重新加载 `tdc.js` 才会产生新 key。
|
||||
|
||||
---
|
||||
|
||||
## 三、CHAOS VM 逆向
|
||||
|
||||
### 3.1 VM 架构概览
|
||||
|
||||
打开 `tdc.js`,格式化后只有约 226 行。前几行是普通 JS 赋值(Date 代理函数、`eks` 常量),核心是一个名为 `__TENCENT_CHAOS_VM` 的函数,它定义了一个完整的栈式字节码虚拟机。
|
||||
|
||||
VM 的主循环极其简洁:
|
||||
|
||||
```javascript
|
||||
for (var E = !1; !E;) E = U[k[N++]]();
|
||||
```
|
||||
|
||||
三个核心变量:
|
||||
- `N`:程序计数器(PC)
|
||||
- `k`:指令流数组(操作码 + 操作数)
|
||||
- `U`:操作码处理器表(handler 数组,约 70 个 slot,58 个有效)
|
||||
|
||||
每次循环取出 `k[N]` 作为操作码索引,调用 `U` 中对应的 handler 函数执行操作。handler 返回 `true` 时虚拟机停止。
|
||||
|
||||
字节码和字符串表的编码方式也很巧妙:
|
||||
|
||||
```javascript
|
||||
// tdc.js 最后的调用
|
||||
__TENCENT_CHAOS_VM(0, function(Q) {
|
||||
var B = Q[0], // Base64 编码的字节码字符串
|
||||
F = Q[1], // 字符串/常量表(数组)
|
||||
U = P(B), // Base64 解码
|
||||
...
|
||||
// 将字节码 charCode 和字符串表合并成指令流 k
|
||||
})
|
||||
```
|
||||
|
||||
`Q` 参数是一个大数组:`["NgMBAjQ0NgYB...", 偏移1, 值1, 偏移2, 值2, ...]`。Base64 解码后,每个字符的 charCode 作为操作码或操作数,字符串表按偏移位置插入指令流。最终产出一个约 **45000 个元素**的指令数组。
|
||||
|
||||
### 3.2 操作码自动识别
|
||||
|
||||
VM 的操作码表 `U` 是一个数组,每个元素是一个 handler 函数。问题是:**每次加载 `tdc.js`,操作码在数组中的索引位置会变**。比如"加法"操作这次在 `U[19]`,下次可能在 `U[37]`。
|
||||
|
||||
但 handler 函数的代码本身不变。通过分析每个 handler 的代码特征来识别它的语义:
|
||||
|
||||
```python
|
||||
# opcode_mapper.py 中的部分匹配规则
|
||||
# 每个 handler 的 JS 代码有唯一的特征签名
|
||||
patterns = {
|
||||
'ADD': lambda code: 'b+a' in code or 'a+b' in code,
|
||||
'SUB': lambda code: 'b-a' in code,
|
||||
'MUL': lambda code: 'b*a' in code,
|
||||
'SHL': lambda code: '<<' in code and '>>>' not in code,
|
||||
'USHR': lambda code: '>>>' in code,
|
||||
'PUSH_IMM': lambda code: 'k[N++]' in code and len(code) < 50,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
58 个操作码涵盖了一个完整语言的基本操作:算术运算、位运算、比较、跳转、函数定义与调用、属性访问、数组操作等。每次拿到新的 `tdc.js`,自动识别一遍即可。
|
||||
|
||||
### 3.3 反汇编器实现
|
||||
|
||||
有了操作码映射,就可以将指令数组反汇编成可读的汇编代码。
|
||||
|
||||
反汇编器采用**递归下降**策略:
|
||||
- **字节码解码**:解析 `tdc.js` 源码,提取 Base64 字节码和字符串表,合并为指令数组
|
||||
- **函数边界识别**:`FUNC_DEF` 指令标记函数的起始 PC 和参数个数,据此拆分函数
|
||||
- **跳转目标标注**:`GOTO`、`JMP_IF`、`JMP_UNLESS` 指令的目标地址标注为 label
|
||||
- **按函数输出**:每个函数输出为独立的 `.asm` 文件
|
||||
|
||||
以 demo01 版本为例,反汇编产出 **214 个函数、24000+ 条指令**。一个典型的反汇编片段:
|
||||
|
||||
```asm
|
||||
; func_15662.asm — module 13 的 .get 函数(18 条指令)
|
||||
15662 PUSH_STR "Array" ; 压入 "Array"
|
||||
15664 LOAD_GLOBAL ; window.Array
|
||||
15665 CALL_NEW 0 ; new Array()
|
||||
15667 STORE_VAR 5 ; M[5] = arr
|
||||
15669 INIT_SLOT 5 ;
|
||||
15671 PUSH_IMM 0 ;
|
||||
15673 VAR_PROP ; arr[0]
|
||||
15674 PUSH_IMM 537691106 ; 立即数
|
||||
15676 SET_PROP ; arr[0] = 537691106
|
||||
15677 PUSH_ARRAY 5 ;
|
||||
15679 RETURN ; return arr
|
||||
```
|
||||
|
||||
### 3.4 AI 在 VM 逆向中的角色
|
||||
|
||||
反汇编只是第一步。24000 条指令中,大部分是 37 个采集器模块的代码,真正的加密核心只有几百条。AI 在这一步的价值是快速定位和理解:
|
||||
|
||||
- 给 AI 一个函数的反汇编代码,它能直接还原为伪代码。比如看到 `PUSH_STR "charCodeAt"` → `VAR_PROP` → `LOAD_VAR 4` → `CALL_METHOD 1`,AI 可以立即识别这是 `string.charCodeAt(i)` 调用
|
||||
- 面对 214 个函数,AI 能通过关键字搜索(如搜索包含 `2654435769` 的函数)快速定位 XTEA 加密核心
|
||||
- 逐层深入:先理解顶层调用链(`mGetData` → `get_collected` → `encrypt`),再逐个分析子函数
|
||||
|
||||
整个 VM 逆向过程是一个 **"AI 精读 + 人工验证"** 的循环:AI 提出伪代码假设,人在浏览器中打断点验证。
|
||||
|
||||
---
|
||||
|
||||
## 四、XTEA 加密还原
|
||||
|
||||
### 4.1 算法定位
|
||||
|
||||
在 45000 个指令元素中,有一个数字极具辨识度:**2654435769**,即 `0x9E3779B9`——这是 TEA 系列加密算法的标志性 delta 常量,来源于黄金分割比。
|
||||
|
||||
在指令数组中搜索这个值,直接定位到 XTEA 核心函数(demo01 中是 `func_9181`,约 255 条指令)。从这个函数向上追溯调用链:
|
||||
|
||||
```
|
||||
func_9181 (XTEA 核心)
|
||||
← 被 func_7313 (主加密函数) 调用
|
||||
← 被 func_10005 (导出包装) 调用
|
||||
← 被 func_6186 (mGetData) 调用
|
||||
func_6186 = TDC.getData 的实际实现
|
||||
```
|
||||
|
||||
### 4.2 变化中的不变:Key 提取规则
|
||||
|
||||
Key 每次都变,但**提取 key 的方法是不变的**。这是"变化中找不变"这条主线在加密层面的体现。
|
||||
|
||||
XTEA 的 key 是 4 个 int32,由 4 个字符串通过小端序打包生成。每次 `tdc.js` 变化的是字符串内容和偏移值,但打包公式的代码结构不变:
|
||||
|
||||
```python
|
||||
# 每个 key 元素的生成公式(不变)
|
||||
key[index] = Σ (charCodeAt(string[i]) - offset) << (8 * i) # i = 0..3
|
||||
# 变化的是具体的 string、offset、index
|
||||
```
|
||||
|
||||
难点在于**真假混淆**。主加密函数有 14 个子函数参与 key 构建,其中只有 4 个写入真实 key buffer(`buf_A`),其余 10 个写入诱饵 buffer(`buf_B`)。真实和诱饵使用完全相同的字符串(如"SUGU"同时出现在 `buf_A` 和 `buf_B`),仅凭字符串无法区分。
|
||||
|
||||
而且,真实 buffer 的 scope 索引每个版本都不同——版本 A 中 `M[9]` 是真实 key,版本 B 中 `M[8]` 才是。不能硬编码。
|
||||
|
||||
**不变的判别规则**:虽然索引在变,但 XTEA 函数必须引用真实的 key buffer 才能加密。所以从 XTEA 函数的 scope 声明出发,逆向追踪它引用的是哪个 buffer,再映射到父函数中找到写入该 buffer 的子函数——这条 **scope chain 追踪逻辑是跨版本稳定的**。
|
||||
|
||||
用同样的思路处理 XTEA 的运行时偏移值(每次加密时给特定 key 索引加的额外偏移):偏移值本身每次不同,但它在字节码中的**位置模式不变**——总是作为 `PUSH_IMM` 指令嵌入在 XTEA 循环的条件分支中。搜索 XTEA 函数范围内符合特征的立即数,即可自动提取。
|
||||
|
||||
用 6 个不同版本的 `tdc.js` 验证了这套自动提取方案的正确性。
|
||||
|
||||
### 4.3 互联网调研
|
||||
|
||||
在动手实现之前,搜索了中英文社区(看雪、CSDN、知乎、GitHub、cn-sec.com 等),确认没有人公开过从字节码中自动静态提取 key 的方法。现有方案要么需要手动断点抓取(每次操作浏览器),要么走补环境路线(不提取 key,直接跑 `tdc.js`)。
|
||||
|
||||
本方案是:**纯静态分析字节码,自动提取 key**。不需要浏览器,不需要 Node.js,从 `tdc.js` 文本直接计算出 key 值。
|
||||
|
||||
---
|
||||
|
||||
## 五、collect 动态构造
|
||||
|
||||
### 5.1 collect 整体结构
|
||||
|
||||
`collect` 不是简单地"加密一段 JSON"。它由 **4 段密文直接拼接**而成:
|
||||
|
||||
```
|
||||
collect = base64(xtea(chunk1_padded)) + base64(xtea(trajectory_padded))
|
||||
+ base64(xtea(chunk2_padded)) + base64(xtea(sd_str))
|
||||
```
|
||||
|
||||
- `chunk1`:`{"cd":[` + 轨迹前的指纹值(逗号分隔)+ `,`
|
||||
- `trajectory`:鼠标轨迹数据 `[[x,y,t],[dx,dy,dt],...]`
|
||||
- `chunk2`:`,` + 轨迹后的指纹值 + `],`
|
||||
- `sd`:`"sd":{"od":"C","ft":"xxx"}}`
|
||||
|
||||
`chunk1`、`trajectory`、`chunk2` 在加密前用空格 pad 到 **24 字节对齐**(确保 Base64 编码后没有 `=` 填充符),`sd` 不 pad。4 段密文的 Base64 之间没有分隔符,直接拼接。
|
||||
|
||||
这个设计很聪明:因为每段都 pad 到 24 字节对齐,Base64 编码后没有 `=`,4 段拼起来看起来就是一个完整的 Base64 字符串。但解密时也可以整体 decode + decrypt,因为 XTEA 是 ECB 模式(每 8 字节独立加密)。
|
||||
|
||||
### 5.2 cd 数组与 37 个模块
|
||||
|
||||
`cd` 数组是 `collect` 的主体,包含了 37 个采集器模块的输出。这些模块采集各种浏览器指纹和环境信息:
|
||||
|
||||
| 类型 | 采集内容 | 值示例 |
|
||||
|---|---|---|
|
||||
| screen_fingerprint | 屏幕综合信息 | `"1512-982-900-30-*-*-|-*"` |
|
||||
| user_agent | `navigator.userAgent` | `"Mozilla/5.0..."` |
|
||||
| canvas_fingerprint | Canvas 2D 指纹 | Base64 字符串 |
|
||||
| webgl_renderer | WebGL 渲染器 | `"ANGLE..."` |
|
||||
| hardwareConcurrency | CPU 核心数 | 14 |
|
||||
| timezone | 时区偏移 | `"+08"` |
|
||||
| webdriver_detect | 自动化检测 | 0 |
|
||||
| trajectory | 鼠标轨迹 | 轨迹数组(加密后的密文) |
|
||||
| ... | ... | ... |
|
||||
|
||||
37 个模块中:
|
||||
- 31 个有标准 `.get` 导出,通过 `module.get()` 返回 `[value, secondary, type_flag]`
|
||||
- 6 个无 `.get`(`webdriver_detect`、`template_url`、`webrtc_ip`、`console_detect`、`timezone`、`dom_compliance`),但仍产生 `cd` token
|
||||
- `guid_token` 是 RAW 模式,一次产生多个逗号分隔的 token
|
||||
|
||||
### 5.3 核心挑战:模块 ID 每次都变
|
||||
|
||||
这是整个项目中最核心也最曲折的部分——它完美体现了"变化中找不变"的主题。
|
||||
|
||||
**问题**:`tdc.js` 每次加载时,37 个模块的 ID 随机重新分配。上一秒"模块 13"是 `user_agent`,下一秒"模块 13"可能变成 `timezone`。要正确构造 `cd` 数组,必须知道每个模块 ID 这次对应什么类型——而这个映射关系每次都不同。
|
||||
|
||||
这是补环境方案完全无法解决的问题。补环境可以跑出一个 `collect`,但它不知道 `cd` 数组中第 5 个位置是 `user_agent` 还是 `screen_width`。一旦需要控制指纹值(比如随机化 UA 或屏幕分辨率来避免被检测),就必须知道每个位置填什么。
|
||||
|
||||
**失败的尝试 v1-v3**
|
||||
|
||||
尝试了 3 个版本的静态分析器,思路是"追踪每个模块 `.get` 函数的返回值来源"。从字节码中确实能找到正确的 JS API 字符串(如 `"userAgent"`、`"characterSet"`),但位置映射全部错误——类型识别是对的,分配给了错误的模块。
|
||||
|
||||
根因是 VM 的**动态语义**(变量自增、共享引用、运行时 scope 修改)无法通过纯静态分析推断。
|
||||
|
||||
**突破:代码变了位置,但没变内容**
|
||||
|
||||
失败三次后,重新审视了问题,发现了一个关键洞察:
|
||||
|
||||
**`tdc.js` 的"动态化"是重新排列,不是重新生成。**
|
||||
|
||||
模块的 ID 变了、位置变了,但每个模块的**代码逻辑完全不变**。采集 `userAgent` 的模块,不管被分配到 ID 13 还是 ID 57,它的字节码里一定包含字符串 `"userAgent"`。采集 `timezone` 的模块,一定包含 `"getTimezoneOffset"`。
|
||||
|
||||
这就是**不变量**。
|
||||
|
||||
基于这个洞察,放弃了"追踪返回值"的思路,转而用**字符串集合 + 结构特征**作为指纹来识别模块类型:
|
||||
|
||||
```python
|
||||
# module_fingerprint.py — 每个模块类型有唯一的字符串指纹
|
||||
fingerprint_rules = {
|
||||
'user_agent': {'userAgent'},
|
||||
'canvas_fingerprint': {'getContext', '2d'},
|
||||
'webgl_renderer': {'UNMASKED_RENDERER_WEBGL'},
|
||||
'timezone': {'getTimezoneOffset'},
|
||||
'webdriver_detect': {'$cdc_asdjflasutopfhvcZLmcfl_'},
|
||||
'webrtc_ip': {'RTCPeerConnection'},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
对于没有区分性字符串的通用模块(如两个都只有简单赋值的模块),用 `.get` 函数的指令数 + entry 函数的指令数 + 是否包含特定指令来区分。
|
||||
|
||||
**结果:3 个 `tdc.js` 版本、37 个模块、全部正确识别,无一 unknown。** 从 v1 的全部错位到指纹方案的全部正确,关键不是更复杂的分析,而是换了一个更好的问题——不再问"这个模块返回什么值",而是问"这个模块的代码长什么样"。
|
||||
|
||||
### 5.4 模块顺序还原
|
||||
|
||||
知道了每个模块 ID 对应的类型后,还需要知道它们在 `cd` 数组中的排列顺序。
|
||||
|
||||
这个顺序由**编排函数**决定。编排函数的字节码结构是固定的:按 `require_seq` 顺序依次调用每个模块,将模块对象 push 到数组中。从编排函数的字节码中提取所有 `PUSH_IMM mid` 指令,就得到了完整的模块调用顺序。
|
||||
|
||||
```python
|
||||
# 从编排函数提取 require_seq
|
||||
# demo01: [8,9,10,13,14,15,16,19,20,22,23(traj),1,28,29,32,35,36,37,38,39,40,41,48,49,50,51,53,55,56,57,58]
|
||||
```
|
||||
|
||||
结合指纹识别结果,每个位置的类型就确定了。再根据类型查表填入对应的指纹值,就能生成正确的 `cd` JSON。
|
||||
|
||||
### 5.5 func_4310 构建逻辑
|
||||
|
||||
`func_4310` 是将 37 个模块的输出组装成 JSON 字符串并分段加密的核心函数,逻辑如下:
|
||||
|
||||
```python
|
||||
output = '{"cd":['
|
||||
first = True
|
||||
for module in cd_array:
|
||||
if not module:
|
||||
continue
|
||||
value, secondary, type_flag = module.get()
|
||||
if not first:
|
||||
output += ","
|
||||
first = False
|
||||
if type_flag == 2:
|
||||
# 轨迹分界:加密当前累积的 chunk1
|
||||
encrypted = base64(xtea(pad_to_24(output)))
|
||||
traj_output = encrypted + value # value 是模块内部已加密的轨迹
|
||||
output = "" # 重置,开始 chunk2
|
||||
elif value is None:
|
||||
output += "null"
|
||||
elif isinstance(value, (int, float)):
|
||||
output += str(value or 0) # falsy 数字变 0
|
||||
elif type_flag == 1:
|
||||
output += value # RAW:不加引号
|
||||
else:
|
||||
output += '"' + value + '"' # 字符串:加引号
|
||||
if isinstance(secondary, (int, float)):
|
||||
output += "," + str(secondary or 0)
|
||||
output += "],"
|
||||
traj_output += base64(xtea(pad_to_24(output))) # 加密 chunk2
|
||||
```
|
||||
|
||||
几个细节:
|
||||
- `type_flag == 2` 只有 trajectory 模块会触发,是分段加密的边界标记
|
||||
- `type_flag == 1` 表示 RAW 输出(如 `[360,360]`),不加引号直接拼接
|
||||
- `value || 0`:JS 中 `0 || 0` 结果为 0,但 `undefined || 0` 也是 0
|
||||
- `secondary` 是部分模块的第二个返回值,作为额外的逗号分隔值输出
|
||||
|
||||
---
|
||||
|
||||
## 六、全流程打通
|
||||
|
||||
### 6.1 轨迹生成
|
||||
|
||||
验证码要求用户依次点击 3 个汉字,过程中 TDC 会采集完整的鼠标移动轨迹。轨迹格式:
|
||||
|
||||
```
|
||||
[[x, y, t], // 第一个点:绝对坐标 + 时间偏移(毫秒)
|
||||
[dx, dy, dt], // 后续点:相对上一个点的偏移
|
||||
[dx, dy, dt],
|
||||
...
|
||||
[1, 1, 12]] // 固定结尾标记
|
||||
```
|
||||
|
||||
坐标系是相对于验证码容器 `tCaptchaDyContent`(360×360 像素),使用 `event.offsetX/Y`。注意这与 `ans` 参数的坐标系不同——`ans` 使用的是原始图片坐标(672×480)。
|
||||
|
||||
轨迹生成用贝塞尔曲线模拟真实鼠标移动:
|
||||
|
||||
```python
|
||||
def generate_trajectory(click_points):
|
||||
trajectory = []
|
||||
last_x, last_y, last_t = 0, 0, 0
|
||||
for i, (target_x, target_y) in enumerate(click_points):
|
||||
# 贝塞尔曲线生成从当前位置到目标的移动路径
|
||||
points = bezier_move(last_x, last_y, target_x, target_y)
|
||||
for x, y, t in points:
|
||||
if not trajectory:
|
||||
trajectory.append([x, y, t]) # 第一个点用绝对值
|
||||
else:
|
||||
trajectory.append([x - last_x, y - last_y, t - last_t])
|
||||
last_x, last_y, last_t = x, y, t
|
||||
trajectory.append([1, 1, 12]) # 结尾标记
|
||||
return trajectory
|
||||
```
|
||||
|
||||
### 6.2 OCR 文字识别
|
||||
|
||||
验证码图片中,需要点击的汉字以黄色高亮显示在背景图上。识别流程:
|
||||
|
||||
- **HSV 颜色空间分割**:将图片转为 HSV,提取黄色区域(H: 20-40, S: 100-255, V: 200-255)
|
||||
- **连通域分析**:找到独立的黄色文字区域,按面积过滤噪点
|
||||
- **PaddleOCR 识别**:对每个区域裁剪后送入 PaddleOCR v5 进行汉字识别
|
||||
- **匹配排序**:将识别结果与指令中的目标汉字匹配,按要求顺序排列
|
||||
|
||||
当前瓶颈是形近字的误识别。例如"边"和"芭"、"笆"在低分辨率下容易混淆。PaddleOCR 的识别准确率在 85% 左右,这直接影响了整体验证通过率。后续可以换用更强的 OCR 模型(如基于 Transformer 的方案)来优化。
|
||||
|
||||
### 6.3 指纹随机化
|
||||
|
||||
如果每次请求都用相同的浏览器指纹,很容易被服务端检测到异常。实现了指纹随机化:
|
||||
|
||||
```json
|
||||
// fingerprint/profiles.json 定义了 6 款 Mac 设备画像
|
||||
{
|
||||
"MacBook Pro 14 M3 Pro": {
|
||||
"screen": {"width": 1512, "height": 982, "colorDepth": 30},
|
||||
"webgl_renderer": "ANGLE (Apple, Apple M3 Pro, OpenGL 4.1)",
|
||||
"webgl_vendor": "Google Inc. (Apple)",
|
||||
"hardwareConcurrency": 12,
|
||||
"canvas_hash": "GgoAAAA...base64..."
|
||||
},
|
||||
"MacBook Air 13 M2": { ... },
|
||||
"iMac 24 M3": { ... },
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
每次请求随机选择一款设备画像,再随机组合 Chrome 版本(130-145)和 macOS 版本,生成一致的 User-Agent、`sec-ch-ua` headers 和环境变量。所有相关的指纹值(屏幕尺寸、WebGL 渲染器、CPU 核心数等)都从同一个画像中取,确保自洽。
|
||||
|
||||
### 6.4 PoW 求解
|
||||
|
||||
验证码提交前需要解决一个 PoW(工作量证明):给定 `prefix` 和目标 MD5 前缀,找到一个 `nonce` 使得 `MD5(prefix + nonce)` 以目标前缀开头。
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
def solve_pow(prefix, target):
|
||||
nonce = 0
|
||||
while True:
|
||||
candidate = f"{prefix}{nonce}"
|
||||
if hashlib.md5(candidate.encode()).hexdigest().startswith(target):
|
||||
return f"{prefix}#{nonce}"
|
||||
nonce += 1
|
||||
```
|
||||
|
||||
通常几千到几万次尝试即可完成,耗时几十毫秒。
|
||||
|
||||
### 6.5 端到端编排
|
||||
|
||||
`full_flow.py` 是整个流程的编排器:
|
||||
|
||||
```python
|
||||
def main():
|
||||
# 1. 生成随机指纹
|
||||
profile = random_profile()
|
||||
# 2. prehandle 获取会话
|
||||
sess, pow_cfg, captcha_info = prehandle(profile)
|
||||
# 3. 下载并解析 tdc.js
|
||||
tdc_js = download_tdc(sess)
|
||||
key, offsets, eks, module_types, require_seq, traj_index = parse_tdc(tdc_js)
|
||||
# 4. 下载验证码图片 + OCR
|
||||
image = download_image(captcha_info)
|
||||
chars = locate_chars(image) # HSV 分割 + PaddleOCR
|
||||
click_points = match_click_points(chars, captcha_info['instruction'])
|
||||
# 5. 生成轨迹
|
||||
trajectory = generate_trajectory(click_points)
|
||||
# 6. 构建 collect
|
||||
collect = build_collect(
|
||||
key, offsets, module_types, require_seq,
|
||||
traj_index, trajectory, profile, eks
|
||||
)
|
||||
# 7. 求解 PoW
|
||||
pow_answer = solve_pow(pow_cfg)
|
||||
# 8. 生成 ans
|
||||
ans = generate_ans(click_points)
|
||||
# 9. 提交 verify
|
||||
result = verify(sess, collect, eks, ans, pow_answer)
|
||||
if result.get('ticket'):
|
||||
print(f"验证通过!ticket: {result['ticket']}")
|
||||
```
|
||||
|
||||
整个流程纯 Python 实现,不依赖浏览器环境,单次执行约 **2-3 秒**(主要耗时在网络请求和 OCR)。
|
||||
|
||||
---
|
||||
|
||||
## 七、工具介绍:JS Reverse MCP
|
||||
|
||||
### 7.1 为什么需要这个工具
|
||||
|
||||
传统 JS 逆向的工作流是:打开 DevTools → 搜索代码 → 手动打断点 → 触发操作 → 观察变量 → 记录到笔记。这个过程效率低、容易遗漏,且完全依赖人的经验。
|
||||
|
||||
AI 编码助手(如 Claude Code、Cursor、Copilot)擅长分析代码、识别模式、快速迭代方案。但它们有一个致命短板:**无法直接操控浏览器调试器**。AI 可以读反汇编代码并还原伪代码,但无法自己打个断点去验证。
|
||||
|
||||
市面上也有一些浏览器自动化的 MCP 工具(如 Playwright MCP、Puppeteer MCP),但它们的设计目标是 **UI 自动化测试,不是逆向分析**。它们能点击按钮、填写表单、截取截图,但不能设断点、不能查看调用栈、不能 Hook 函数、不能在断点处求值表达式——而这些恰恰是 JS 逆向最核心的操作。
|
||||
|
||||
**JS Reverse MCP** 是专门为 JS 逆向分析设计的 MCP 服务器。它将 Chrome DevTools Protocol(CDP)的完整调试能力暴露为标准化的 MCP 工具,让 AI 能像经验丰富的逆向工程师一样操作 DevTools。
|
||||
|
||||
### 7.2 核心能力
|
||||
|
||||
JS Reverse MCP 提供了 **30+ 个工具**,覆盖 JS 逆向分析的完整链路:
|
||||
|
||||
- **脚本分析**:列出页面所有 JS 脚本、在所有源码中搜索关键字、获取脚本源码(支持压缩代码的字符偏移定位)
|
||||
- **断点调试**:设置行断点、条件断点、XHR/Fetch 断点。支持 `set_breakpoint_on_text`——直接搜索代码文本自动设置断点,特别适合压缩代码(整个文件在一行上)
|
||||
- **执行控制**:暂停/恢复执行、step over / step into / step out。在断点处用 `evaluate_on_callframe` 求值任意表达式
|
||||
- **函数追踪**:`hook_function` 拦截全局函数或对象方法,记录每次调用的参数和返回值。`trace_function` 更强大——它能追踪 webpack/rollup 打包的模块内部函数,使用条件断点(logpoint)实现零侵入式日志
|
||||
- **网络分析**:查看请求详情和响应内容、获取请求发起的 JavaScript 调用栈(快速定位哪段代码触发了某个 API 请求)
|
||||
- **运行时检查**:深度检查对象结构和原型链、获取 `cookies`/`localStorage`/`sessionStorage`、监控 DOM 事件、截取页面截图
|
||||
|
||||
### 7.3 一般 AI 工具做不到的事
|
||||
|
||||
JS Reverse MCP 有几个在逆向场景中至关重要、但其他 AI 工具完全不具备的能力:
|
||||
|
||||
**跨域 iframe 切换**
|
||||
|
||||
这是本项目中最关键的能力之一。腾讯验证码运行在跨域 iframe `turing.captcha.gtimg.com` 中,与宿主页面完全隔离。普通的浏览器自动化工具(Playwright MCP、Puppeteer MCP)只能操作主页面上下文,无法进入跨域 iframe 的 JS 执行环境。
|
||||
|
||||
JS Reverse MCP 通过 CDP 的 `Runtime.executionContextCreated` 事件追踪所有 frame 的执行上下文,支持自由切换:
|
||||
|
||||
```
|
||||
AI: list_frames
|
||||
→ [0] main frame: https://login.example.com
|
||||
→ [1] tcaptcha_iframe_dy: https://turing.captcha.gtimg.com/...
|
||||
|
||||
AI: select_frame(1) ← 切换到验证码 iframe
|
||||
AI: evaluate_script(() => window.TDC)
|
||||
→ {getData: ƒ, getInfo: ƒ, setData: ƒ, clearTc: ƒ} ← 直接访问 TDC 对象!
|
||||
|
||||
AI: evaluate_script(() => TDC.getData(true))
|
||||
→ "b4lpdxdUNOU90e5D..." ← 拿到加密后的 collect
|
||||
```
|
||||
|
||||
如果没有这个能力,AI 根本无法接触到验证码的核心对象 `TDC`,更无法验证加密逻辑的正确性。在整个项目的动态验证阶段,几乎每一次浏览器操作都需要先 `select_frame(1)` 切换到 iframe。
|
||||
|
||||
**过检测能力**
|
||||
|
||||
现代网站普遍使用 `navigator.webdriver`、`window.chrome` 等属性检测自动化浏览器。Puppeteer 和 Playwright 默认会被检测到,导致页面行为异常(验证码无限弹出、数据采集结果异常等)。
|
||||
|
||||
JS Reverse MCP 支持**连接到用户已运行的 Chrome 实例**,而不是自己启动一个受控的浏览器:
|
||||
|
||||
```bash
|
||||
# 用户自己启动 Chrome(完全正常的浏览器环境)
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
|
||||
|
||||
# MCP 连接到这个已运行的 Chrome
|
||||
claude mcp add js-reverse node /路径/build/src/index.js \
|
||||
-- --browser-url=http://127.0.0.1:9222
|
||||
```
|
||||
|
||||
这意味着:
|
||||
- **零检测风险**:连接的是用户正常使用的 Chrome,没有任何自动化标记。`navigator.webdriver` 为 false,`window.chrome` 正常存在,所有指纹都是真实的
|
||||
- **保留登录状态**:已登录的 cookie、localStorage 都在,不需要重新登录
|
||||
- **真实环境调试**:在真实的、未被篡改的浏览器环境中调试,观察到的行为和数据与用户完全一致
|
||||
|
||||
这对于分析验证码这类场景至关重要——腾讯防水墙的 `tdc.js` 有 27 项自动化检测(包括 `navigator.webdriver`、`document.$cdc_asdjflasutopfhvcZLmcfl_` ChromeDriver 标记等),如果被检测到,采集到的行为数据会包含异常标记,服务端直接拒绝。
|
||||
|
||||
**断点处执行任意表达式**
|
||||
|
||||
Playwright/Puppeteer MCP 只能执行页面级脚本(相当于在 console 中输入代码)。但 JS 逆向中最有价值的操作是在**断点处检查局部变量和闭包变量**——这些变量在全局作用域中不可见。
|
||||
|
||||
JS Reverse MCP 的 `evaluate_on_callframe` 可以在暂停的调用帧上下文中求值任意表达式:
|
||||
|
||||
```
|
||||
AI: set_breakpoint(url="tdc.js", line=178, condition="H && H.find(...)")
|
||||
用户: 触发验证
|
||||
AI: get_paused_info ← 获取调用栈和 scope 变量
|
||||
AI: evaluate_on_callframe("H[9][0]") ← 提取闭包中的 XTEA key
|
||||
→ [1466650466, 1112888626, 1363366223, 1112430392]
|
||||
```
|
||||
|
||||
在本项目中,XTEA 的 key buffer 存在于 VM 的闭包 scope 中,从全局作用域完全不可见。没有断点处求值的能力,就无法验证从字节码中静态提取的 key 是否正确。
|
||||
|
||||
### 7.4 在本项目中的实际应用
|
||||
|
||||
这个工具贯穿了整个逆向分析过程:
|
||||
|
||||
- **请求分析阶段**:用 `list_network_requests` 过滤 XHR/Fetch 请求,找到 `cap_union_new_verify` 接口。用 `get_network_request` 查看请求参数,识别出 `collect`、`eks` 等加密字段。
|
||||
- **入口定位阶段**:用 `break_on_xhr("cap_union_new_verify")` 设置 XHR 断点 → 触发验证 → `get_paused_info()` 获取完整的 JavaScript 调用栈,直接从请求倒推到加密函数的入口。
|
||||
- **Key 验证阶段**:在 XTEA 函数入口设置条件断点 → 用 `evaluate_on_callframe` 在断点处提取运行时的 key 和 scope 变量 → 与 Python 静态提取的结果对比验证。
|
||||
- **iframe 切换**:验证码运行在跨域 iframe 中。用 `list_frames` 列出所有 frame → `select_frame(1)` 切换到 `tcaptcha_iframe_dy` → 然后就可以在 iframe 上下文中执行脚本,直接调用 `TDC.getData(true)`。
|
||||
- **Hook 拦截**:用 `hook_function("encodeURIComponent")` 拦截 URL 编码函数,抓取 `collect` 加密后、URL 编码前的原始数据。用 `hook_function("String.fromCharCode")` 捕获 XTEA 加密输出的字节拆分过程(298 次调用 = 298 个 int32 = 1192 字节密文)。
|
||||
- **源码搜索**:用 `search_in_sources("2654435769")` 在所有加载的脚本中搜索 XTEA delta 常量,虽然 `tdc.js` 通过 blob URL 加载(CDP 无法直接列出),但搜索功能仍能覆盖。
|
||||
|
||||
### 7.5 安装与配置
|
||||
|
||||
**GitHub 地址**:https://github.com/zhizhuodemao/js-reverse-mcp
|
||||
|
||||
```bash
|
||||
# 克隆并构建
|
||||
git clone https://github.com/zhizhuodemao/js-reverse-mcp.git
|
||||
cd js-reverse-mcp
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
配置到 AI 编码助手:
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add js-reverse node /路径/js-reverse-mcp/build/src/index.js
|
||||
|
||||
# 连接已运行的 Chrome(推荐,保留登录状态和 cookie)
|
||||
# 先启动 Chrome:
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
|
||||
|
||||
# 再配置 MCP 连接:
|
||||
claude mcp add js-reverse node /路径/js-reverse-mcp/build/src/index.js \
|
||||
-- --browser-url=http://127.0.0.1:9222
|
||||
```
|
||||
|
||||
Cursor 和 VS Code Copilot 也支持,在 MCP 设置中添加相同的配置即可。
|
||||
|
||||
### 7.6 典型工作流
|
||||
|
||||
一个完整的 AI 驱动逆向分析流程:
|
||||
|
||||
```
|
||||
你: "帮我分析这个页面的登录请求是怎么加密的"
|
||||
|
||||
AI: list_network_requests → 找到 POST /api/login,发现 header 中有 x-sign 加密参数
|
||||
AI: get_request_initiator → 获取调用栈,定位到 encrypt() 函数
|
||||
AI: search_in_sources("x-sign") → 找到 x-sign 赋值的代码位置
|
||||
AI: set_breakpoint_on_text("x-sign") → 在赋值处设断点
|
||||
|
||||
你: 刷新页面,点击登录
|
||||
|
||||
AI: get_paused_info → 查看当前作用域变量,发现 sign = hmacSHA256(body, key)
|
||||
AI: evaluate_on_callframe("key") → 提取密钥值
|
||||
AI: step_into → 进入 hmacSHA256 函数,确认算法细节
|
||||
AI: resume → 继续执行
|
||||
|
||||
AI: "分析完成。x-sign = HMAC-SHA256(请求体, 固定密钥),密钥是 ..."
|
||||
```
|
||||
|
||||
整个过程中,你只需要操作浏览器触发请求,AI 负责所有的调试和分析工作。
|
||||
|
||||
---
|
||||
|
||||
## 八、总结与思考
|
||||
|
||||
### 8.1 AI 的实际贡献
|
||||
|
||||
回顾整个项目,AI 在以下方面提供了实质性帮助:
|
||||
|
||||
- **字节码精读与伪代码还原**。面对 24000+ 条反汇编指令,AI 能快速将指令序列还原为可读的伪代码。一个 300 条指令的函数,人工分析可能需要半天,AI 几分钟就能给出伪代码初稿。
|
||||
- **模式识别**。从 14 个 key 构建子函数中识别真实 vs 诱饵的判别规则,从 37 个模块的字节码中提取指纹特征——这类"在大量相似代码中找差异"的任务,AI 比人高效得多。
|
||||
- **方案迭代速度**。模块分析器从 v1 到 v2 到 v3 再到最终的指纹方案,每次迭代都涉及重构核心逻辑。AI 让每次迭代的成本从"几天"降到了"几小时"。
|
||||
- **跨文件关联**。追踪 scope chain(从 XTEA 函数的 `M[7]` → parent 的 `M[X]` → encrypt 的 `M[Y]`)、函数调用链(`mGetData` → `get_collected` → `encrypt` → XTEA)、模块注册映射(`PUSH_IMM mid` → `FUNC_DEF`)——这类需要在多个文件间跳转关联的任务,AI 的工作记忆是关键优势。
|
||||
|
||||
### 8.2 AI 做不好的部分
|
||||
|
||||
- **JS 位运算语义**。`+` 不截断但 `<<` 截断的差异,AI 在第一次实现时没有正确处理。这类"语言规范的细微之处"需要人工提醒和验证。
|
||||
- **VM 动态行为推断**。`LOAD_DYN`(动态加载变量)、共享引用赋值、运行时 scope 修改——这些只有在 VM 实际执行时才能确定的行为,AI 的静态分析无法推断。模块分析器 v1-v3 的失败就是因为试图用静态分析解决本质上是动态的问题。
|
||||
- **创造性突破**。从"逐个分析每个模块返回什么值"到"用指纹识别模块类型"的思路转换,是一个关键的创造性跳跃。AI 在已有思路上迭代很快,但提出全新思路的能力仍有限。这个转换是人工观察到"模块代码语义不变,只是 ID 变了"后提出的。
|
||||
|
||||
### 8.3 变化中找不变:方法论回顾
|
||||
|
||||
回顾整个项目,核心方法论可以概括为一句话:
|
||||
|
||||
> **在每次都变的代码中,找到跨版本不变的结构特征,然后基于这些不变量构建自动化提取管线。**
|
||||
|
||||
| 层面 | 每次变化的 | 跨版本不变的 | 提取方法 |
|
||||
|---|---|---|---|
|
||||
| 操作码 | handler 在数组中的索引 | handler 函数的代码特征 | 代码模式匹配 |
|
||||
| XTEA key | 4 个 int32 的值 | 打包公式的字节码结构 | scope chain 追踪 |
|
||||
| key 偏移 | 偏移值的大小 | 偏移值在字节码中的位置模式 | 特征搜索 |
|
||||
| 真/假 key | buffer 的 scope 索引 | XTEA 必须引用真实 buffer | scope 追踪 |
|
||||
| 模块 ID | 37 个模块的 ID 分配 | 每个模块的代码语义和字符串集合 | 字符串指纹 |
|
||||
| 模块顺序 | cd 数组中的排列 | 编排函数的字节码结构 | 提取 require_seq |
|
||||
|
||||
这条路比补环境难得多,但收益也大得多:每个参数都在掌控中,可以精确控制指纹值、随机化设备画像、构造逼真的行为轨迹。
|
||||
|
||||
### 8.4 安全对抗趋势
|
||||
|
||||
腾讯 CHAOS VM 的动态化设计确实增加了逆向成本——不能做一次分析就硬编码结果,必须实现自动化的解析管线。但它的软肋在于:**动态化是"重新排列",不是"重新生成"**。模块代码不变,只是换了 ID 和位置,这给了指纹识别的机会。
|
||||
|
||||
如果未来对模块代码本身也进行变换(如指令替换、插入花指令、变量名混淆),指纹识别的难度会大幅增加。但这也会增加服务端的生成成本和 JS 体积。攻防永远是成本的博弈。
|
||||
|
||||
AI 工具的介入正在改变这个博弈的平衡点。过去需要一个经验丰富的逆向工程师花几周完成的工作,现在一个了解基本原理的开发者配合 AI 和专用工具(如 JS Reverse MCP)可以在几天内完成。
|
||||
|
||||
---
|
||||
|
||||
*本文首发于看雪论坛(https://bbs.kanxue.com/thread-290124.htm),作者:执着的猫,发布时间:2026-2-28。*
|
||||
@@ -4,7 +4,31 @@
|
||||
> 全部在 Python 内完成; ~~Node 只保留一个用途: 从当前 tdc.js 提取本次构建的 XTEA 密钥字~~
|
||||
> **2026-10-11 起 Node 连提取都不再需要**(纯 Python 静态还原,见已知 #19~#22;Node 仅作兜底)。
|
||||
> 依据: AGENTS.md §2.3(拒绝补环境) / §2.4(分层交付) / §3.3(字节码 VM) / §4.3(运行时捕获) / §5.8(标定式交付)。
|
||||
> 最后更新: 2026-10-11 (纯 Python XTEA 密钥静态提取落地 + opcode 表按构建推导)
|
||||
> 最后更新: 2026-10-12 (**按 docs/tdc-案例.md 重做**:collect 四段加密判决 + 字符串指纹模块识别 37/37)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 2026-10-12 路线修正(对照 `docs/tdc-案例.md`,全部基于**在线新抓样本**)
|
||||
|
||||
之前多轮「乱碰」的根因,是几条**基础假设错了**:
|
||||
|
||||
| 旧假设(已推翻) | 新结论(已判决) | 证据 |
|
||||
| --- | --- | --- |
|
||||
| collect = **整体 JSON 一次加密**(`\x00` 补齐) | **4 段独立加密**,前 3 段**空格 pad 到 24 字节**,sd 不 pad | 真机明文 3 处连续空格串 `[355:+5, 422:+10, 720:+24]`;密文长度算术 480+96+416+56 = **1048**,与真实 1048 精确相等;`==` 只出现在末尾(sd 段) |
|
||||
| `kind=2` 的项"仍产出 1 槽" | **这条原本就是对的**(真机 cd 38 槽、轨迹在第 10 槽),本轮一度误判为"不进 cd"已撤回。补充一层理解:它**同时**是 chunk1/chunk2 的**分段触发点**(案例 §5.5) | 真机 cd 38 槽 + 轨迹槽位 10 + trajectory 独立成段 |
|
||||
| 模块 id 可当跨构建锚点 | id **每份构建重排**:交集 28 个里 `id→语义` 相同仅 **1/11** | 模块 8 在 A 是 `navigator.platform`,在 B 是 canvas 指纹 |
|
||||
| (自创)指令直方图是够用的锚点 | 字符串+结构指纹语义名 **37/37 唯一**;直方图只有 20/37 | 见已知 #43/#44 |
|
||||
|
||||
现在的代码路径:
|
||||
|
||||
```
|
||||
tdc_modules.analyze() -> 每项的语义名(37/37,跨构建稳定)
|
||||
tdc_collectgen.reorder(s, dst, src) -> 按语义名锚点把源样本对齐到本次构建
|
||||
crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接
|
||||
```
|
||||
|
||||
**数据纪律:本轮起所有新研究只用 `har/online/` 下在线抓取的样本**
|
||||
(`scripts/fetch_online_samples.py --n 4`),不再依赖仓库里的旧抓包。
|
||||
|
||||
---
|
||||
|
||||
@@ -72,6 +96,20 @@
|
||||
| 40 | **"谁在发 HTTP"是 12 的剩余唯一自变量**(结论,待最后一步坐实)| 证据链:① 槽位序证伪(§三);② 包内容证伪(本会话真实 collect 由 Python 发仍 12);③ 已知 #26:宽松 aid 190758559 上 **httpx + 无 cookie + 无头保真** 也能 0 —— 说明"请求侧细节"对宽松 aid 无所谓,但对 195672344 不够。④ 真机同 aid 只出 50/9/0(#25)。→ 剩余差在**会话↔请求方绑定**。**收口实验**:把同一 sess/eks/tkid + 同一 collect,分别由浏览器发与 Python 发,比较 errorCode | 结论(待收口) |
|
||||
| 38 | **纯离线生成还没打通(诚实标注)** | 当前 `--from-sample` 依赖**一份同机的真机明文**做重排;`--values`(语义名→值)需要调用方提供取值字典。**仍缺**:纯算地为每个语义名算出值(多数是静态环境量可配置,但 canvas/WebGL 指纹只能取自真实环境)。这一步完成前,不能声称"纯离线过风控"。 | 状态说明 |
|
||||
|
||||
### 2026-10-12 新增(对照 `docs/tdc-案例.md`,全部基于 `har/online/` 在线新抓样本)
|
||||
|
||||
| # | 结论 | 证据 | 确信度 |
|
||||
| --- | --- | --- | --- |
|
||||
| 41 | **collect 是「4 段独立加密」拼接,不是整体一次加密**:`chunk1 + trajectory + chunk2 + sd`,前 3 段**空格 pad 到 24 字节对齐**,sd 不 pad;4 段 base64 **直接拼接无分隔符** | ① 真机明文 3 处连续空格串 `[355:+5, 422:+10, 720:+24]`,且 `pad == 24 - len%24` 三段全中;② **无需密钥的纯算术判决**:360/72/312/40 → base64 480+96+416+56 = **1048**,与真实 collect 的 1048 字符精确相等;③ `==` 只出现在位置 1046/1048(正是 sd 段 33B→补 7→40B 的产物);④ 段边界与案例 §5.5 逐字吻合(chunk1 以 `,2560,` 结尾、chunk2 以 `,"UTF-8"],` 结尾) | 确证 |
|
||||
| 42 | **`kind=2`(`type_flag==2`)产出 1 槽(就是轨迹),并同时触发**分段加密**——它是 chunk1/chunk2 的分界** | 案例 §5.5;真机明文 cd **38 槽**、轨迹位于第 **10** 槽,trajectory 段 `[360:432]` 独立成段,结尾 `[1,1,12]`(案例 §6.1 的固定标记)。<br>→ 即「产槽」与「分段」**同时成立**:旧已知 #36 的"仍产出 1 槽"**未被推翻**,本轮早期我一度误判为"不进 cd",已用 cd 槽数(38)+ 轨迹槽位(10)纠正 | 确证(含一次自我纠错) |
|
||||
| 43 | **模块 id 不能当跨构建锚点**(每份构建重排) | 两份构建清单交集 28 个模块里 `id→语义` 完全相同仅 **1/11**(模块 8 在 A 是 `navigator.platform`,在 B 是 canvas 指纹);案例 §1.2 亦明确列出"37 个模块的 ID 随机重新分配" | 确证 |
|
||||
| 44 | **字符串+结构指纹可把 37 个采集器全部识别且唯一**(案例 §5.3 方案) | 4 份全新在线构建:`37/37` 识别、**0 unknown**、跨构建语义名集合**完全一致**、单构建内**0 重名**;端到端重排 3/3 正确且非恒等 | 确证 |
|
||||
| 45 | **指纹必须按 specificity 排序 + 支持前缀匹配** | `?rand=` 在真机里是 `?rand=1519713624347`(带会话随机数),精确匹配永远命中不了 → `template_url` 被误判成 `location_href`;改为 `?rand=*` 前缀匹配 + 给 `location_href` 加禁用串后消歧 | 确证(踩坑修正) |
|
||||
| 46 | **fp_salt 的随机串每构建都变**(`AyqhIdr`/`QtDKYSO`/`ryieWP_`/`mYmmgLx`),**不能**用字符串识别 | 4 份新构建各不相同;改用结构规则 `entry≥120 且 get≥100` 抓,每份构建恰好 1 个 | 确证 |
|
||||
| 47 | **结构完全相同的 2 项可用「闭包最后写入什么」消歧** | 这 2 项 entry 40 条、.get 9 条、指令逐条相同(只差 JUMP 绝对地址);差异在**闭包 target**(`CREATE_CLOSURE.target`,不是 JUMP 目标):`new Array()[0] = ""` vs `new Array()[0] = <随机int32>`。实测每份构建恰好 empty×1 + rand×1 | 确证 |
|
||||
| 48 | **配对正确性经独立证据 + 负对照验证** | 独立证据:同一归一化指令序列(抹掉地址/模块号/字符值)下 37/37 全等(唯一例外 fp_salt 的随机 int32,抹掉后 37/37);负对照:清单下标配对 2-3/37、模块 id 配对 2-3/37、随机配对 2/37 —— 对照组极低,证明归一化**没有过拟合** | 确证 |
|
||||
| 49 | **`reorder` 旧实现是恒等映射(真 bug,已修)** | 旧代码 `sample[slot:slot+n]` 里的 `slot` 是**目标**构建自己的槽下标 → 顺序不同时全盘错位、相同时才碰巧对。原测试只断言"是置换",恒等函数也满足 → **弱断言把 bug 放过了**。已补强断言(== 已知正确答案 且 != 原样),并自查:旧实现过不了新断言 | 确证 |
|
||||
|
||||
---
|
||||
|
||||
## 二、未知(附假设 + 确信度)
|
||||
@@ -111,6 +149,17 @@
|
||||
| **H5:1ms 后端用 `identity_context` 把会话标记为可信** | 拿抓包里**签名过的** `identity_context`(599B `emctx1.*`) 先调 `POST /api/v1/captcha/params`(响应与不带时**完全相同**,无额外 token)再解题,仍 12 → H5 排除 |
|
||||
| **"请求侧保真度不够"这一整类** | 直接反证:aid 190758559 上 **未做任何保真的 httpx + 无 cookie** 也拿到 ticket(0);即请求侧细节本就足够,12 与保真度无关 |
|
||||
|
||||
### 2026-10-12 新增推翻的假设(对照 `docs/tdc-案例.md`)
|
||||
|
||||
| 假设 | 否定证据 |
|
||||
| --- | --- |
|
||||
| **"整体加密"是 collect 的结构**(旧 `crypto.encrypt_string` / `tdc_collectgen.encrypt_plain` 的做法) | 判决书据 3 条,互相独立:<br>① 真机明文有 **3 处连续空格串**(`+5 / +10 / +24`),`\x00` 补齐不会产生空格;<br>② **不需要密钥**的纯算术:4 段长度 360/72/312/40 → base64 480+96+416+56 = **1048**,与真实 collect 的 1048 字符精确相等(差一个字节都不行);<br>③ `==` 只出现在 1046/1048,恰好对应 sd 段 33B→XTEA 补 7→40B。<br>→ 改为 `crypto.build_collect()`(4 段独立加密 + 空格 pad 到 24)。旧函数**仍保留但已不是主路径**。<br>⚠️ 注意被推翻的是**加密结构**,不是槽数模型——cd 仍是 38 槽(见已知 #42)。 |
|
||||
| ~~**`kind=2` 的项"仍产出 1 槽"是错的**~~ ⚠️ **本条是我 2026-10-12 的误判,已撤回** | 我曾据案例 §5.5 断言 `type_flag==2`"不进 cd"、并据此认为旧"37 项→38 槽"推导被推翻。**实测否定了这个断言**:真机明文 cd 就是 **38 槽**,轨迹位于**第 10 槽**。→ 真相是「产 1 槽」与「触发分段」**同时成立**,旧已知 #36 仍然正确。教训:引用外部案例时必须用本项目数据复核,不能拿案例文字直接覆盖已有实测(见已知 #42)。 |
|
||||
| **模块 id 可当跨构建对齐锚点** | 清单交集 28 个模块里 `id→语义` 完全相同仅 **1/11**;案例 §1.2 明确"37 个模块的 ID 随机重新分配"。→ 改用**语义名**做锚点(已知 #44)。 |
|
||||
| **自创的「指令直方图」是够用的锚点** | 直方图跨构建交集 37/37 但单构建内唯一仅 **20/37**;语义名方案 **37/37 唯一**。直方图降级为兜底锚点。 |
|
||||
| **fp_salt 可用固定字符串识别**(旧规则写死了 `AyqhIdr`) | 4 份新构建里分别是 `AyqhIdr` / `QtDKYSO` / `ryieWP_` / `mYmmgLx` —— **每构建都变**。改用结构规则识别(已知 #46)。 |
|
||||
| **"按模块 id 配对就能跨构建对齐"** | 同 #43;且我用"模块 id 相等"做校验时**自己误判过一次**(看到 35/37 不一致就以为配对错了)—— 实际上 id 不一致是**预期行为**,不能用来否定配对。正确校验见已知 #48。 |
|
||||
|
||||
---
|
||||
|
||||
## 四、当前实现(纯算单一路径)
|
||||
@@ -128,15 +177,53 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认
|
||||
```
|
||||
|
||||
* 纯 Python 提取器: `tcaptcha/tdc_key.py` (算法) + `tcaptcha/tdc_opcodes.py` (opcode 表推导)
|
||||
* **2026-10-12 新增(按案例重做后的主路径):**
|
||||
* `tools/tdc_modules.py` —— 字符串+结构指纹识别 37 个采集器(**37/37 唯一**,案例 §5.3)
|
||||
* `tcaptcha/crypto.py::build_collect()` —— **4 段独立加密**(前 3 段空格 pad 到 24,sd 不 pad)
|
||||
* `tcaptcha/crypto.py::split_collect_segments()` —— 从已解密明文切回 4 段(对拍/标定用)
|
||||
* `tools/tdc_collectgen.py::reorder(sample, dst, src)` —— 按语义名锚点**跨构建**对齐(旧恒等 bug 已修)
|
||||
* `scripts/fetch_online_samples.py` —— 在线抓取全新 tdc.js(**新研究的数据来源**)
|
||||
* CLI: `tools/tdc_key_pure.py [tdc.js] [-v] [--opcodes]`
|
||||
* 模板: `tcaptcha/models/success_collect_template.b64`(真实成功样本明文)
|
||||
* 加密: `tcaptcha/crypto.py` 中**唯一**的教科书 XTEA
|
||||
* Node 现存用途: 仅 `tdc_worker.js` 兜底提取密钥(主路径已不需要)
|
||||
|
||||
### 2026-10-12 起的新主路径(替代上面 `generate_collect_success` 的模板回填)
|
||||
|
||||
```
|
||||
1. scripts/fetch_online_samples.py -> har/online/<ts>/tdc_*.js(本次会话的构建)
|
||||
2. tdc_key.extract_xtea_key_pure(js) -> 本次构建的 XTEA 密钥(纯 Python,零 Node)
|
||||
3. tdc_modules.analyze(js) -> 37 项的语义名 + require 顺序(37/37 唯一)
|
||||
4. tdc_collectgen.reorder(cd, dst, src) -> 把标定样本对齐到本次构建(跨构建锚点)
|
||||
5. crypto.build_collect(c1,traj,c2,sd,k) -> 4 段独立加密后拼接 = collect
|
||||
```
|
||||
|
||||
⚠️ 第 4 步仍需要一份**同源标定样本**提供取值(已知 #38)。这是当前唯一未打通的环节。
|
||||
|
||||
---
|
||||
|
||||
## 五、下一步(可被下一轮直接执行)
|
||||
|
||||
### 🆕 2026-10-12 优先(按案例重做后的收尾,按成本排序)
|
||||
|
||||
0. **把"纯离线生成"打通(唯一剩余阻塞点)**。当前链路第 4 步仍需一份**同源标定样本**
|
||||
提供每个语义名的取值(已知 #38)。要做的是:为每个语义名写一个**取值生成器**
|
||||
—— 多数是静态环境量(UA / 屏幕 / 时区 / 语言 / 核数 / charset)可直接配置,
|
||||
canvas/WebGL 指纹用 `fingerprint/profiles.json` 画像(案例 §6.3 的做法)。
|
||||
完成标志:不依赖任何真机明文,纯算生成 collect 并拿到 `errorCode=0`。
|
||||
1. **把 4 段结构接进生成链路**(已知 #41 已判决,但 `SlotSpec` 还没产出段边界):
|
||||
`tdc_collectgen` 目前只输出一个扁平 `cd` 列表;需要额外给出
|
||||
`chunk1 / trajectory / chunk2 / sd` 四段(轨迹项所在位置即分界),
|
||||
再交给 `crypto.build_collect()`。改完用真机明文对拍总长度(应 1048 字符量级)。
|
||||
2. **用在线新样本做一次端到端验证**:`fetch_online_samples.py` 抓构建 → 纯算生成
|
||||
collect → 打 verify。先在**宽松 aid 190758559** 上跑(已知 #26 那里 httpx 裸发就能 0),
|
||||
通过后再上 1ms。
|
||||
3. **1ms 的 12 收口实验**(已知 #40):同一 sess/eks/tkid + 同一 collect,
|
||||
分别由浏览器发与 Python 发,比较 errorCode。这一步必须在上面 0~2 完成之后才有意义
|
||||
—— 否则"包内容"这个变量本身还没被正确构造出来,实验没有区分度。
|
||||
|
||||
### 既有项
|
||||
|
||||
0. ~~**静态提取 XTEA 密钥(去掉最后一个 Node 依赖)**~~ —— **已完成**(已知 #19~#22):
|
||||
`tcaptcha/tdc_key.py` 纯 Python 零 Node,6/6 构建与 Node 逐字一致。
|
||||
残留增强项(非阻塞):① 若服务端改了 handler 语义,`tdc_opcodes.derive_opcode_table`
|
||||
@@ -209,3 +296,31 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认
|
||||
.venv/bin/python tools/tdc_key_pure.py --opcodes tcaptcha/tdc_sample.js # 打印按构建推导的 opcode 表
|
||||
.venv/bin/python scripts/analyze_collect_layout.py --json /tmp/cap.json # collect 槽位序对比 (已知 #31)
|
||||
```
|
||||
|
||||
### 2026-10-12 新增的复验入口(按案例重做后)
|
||||
|
||||
```bash
|
||||
# ① 在线抓取全新样本(新研究的数据来源;同一个 URL 每次返回不同构建,见 #29)
|
||||
.venv/bin/python scripts/fetch_online_samples.py --n 4
|
||||
|
||||
# ② 字符串+结构指纹识别 37 个采集器(应 37/37、0 unknown、0 重名)
|
||||
.venv/bin/python tools/tdc_modules.py har/online/<ts>/tdc_01_*.js
|
||||
|
||||
# ③ 四段结构判决(不需要密钥:密文长度算术应精确等于 1048 / 该份的长度)
|
||||
.venv/bin/python scripts/verify/verify_segment.py <已解密的 collect 明文文件>
|
||||
.venv/bin/python scripts/verify/verify_seglen.py <collect 的 base64>
|
||||
|
||||
# ④ 指纹跨构建一致性(语义名集合应完全一致、单构建内 0 重名)
|
||||
.venv/bin/python scripts/verify/verify_fp_consistency.py
|
||||
|
||||
# ⑤ 配对正确性(实验组≈37/37,三个负对照应 2-3/37 —— 证明没过拟合)
|
||||
.venv/bin/python scripts/verify/verify_pair_correct.py
|
||||
.venv/bin/python scripts/verify/verify_control_negative.py
|
||||
|
||||
# ⑥ 端到端重排(新数据,应 3/3 正确且非恒等)
|
||||
.venv/bin/python scripts/verify/verify_reorder.py
|
||||
|
||||
# ⑦ 回归测试(已并入 selftest,含「reorder 非恒等」强断言)
|
||||
.venv/bin/python -m tcaptcha.tests.test_collectgen
|
||||
.venv/bin/python -m tcaptcha.selftest
|
||||
```
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
在线抓取**全新**样本(2026-10-12 起,只使用本脚本产出的数据)。
|
||||
|
||||
抓什么:
|
||||
1. prehandle -> sess / comm_captcha_cfg.tdc_path
|
||||
2. 连抓 N 份 tdc.js(同一个 URL **每次请求都返回不同构建**,账本 #29)
|
||||
3. 每份都算出: XTEA 密钥、eks、37 模块的字符串指纹、槽位序
|
||||
|
||||
产出目录: har/online/<时间戳>/
|
||||
用法:
|
||||
.venv/bin/python scripts/fetch_online_samples.py --n 4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from tcaptcha.client import TencentCaptchaClient # noqa: E402
|
||||
from tcaptcha.crypto import extract_eks # noqa: E402
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
|
||||
OUT = ROOT / "har" / "online"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="在线抓取全新 tdc.js 样本")
|
||||
ap.add_argument("--n", type=int, default=4, help="抓几份 tdc.js")
|
||||
ap.add_argument("--aid", default="2039519451", help="雨云 aid(宽松,适合取样)")
|
||||
ap.add_argument("--tag", default=None, help="输出子目录名(默认时间戳)")
|
||||
ap.add_argument("--gap", type=float, default=1.0, help="两次抓取间隔秒")
|
||||
args = ap.parse_args()
|
||||
|
||||
tag = args.tag or time.strftime("%Y%m%d-%H%M%S")
|
||||
d = OUT / tag
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
print("输出目录:", d)
|
||||
|
||||
client = TencentCaptchaClient(aid=args.aid,
|
||||
entry_url="https://app.rainyun.com/account/reward/earn#")
|
||||
try:
|
||||
tkid = str(random.randint(100000000, 999999999))
|
||||
ch, cfg, sid = client.prehandle(tkid)
|
||||
print("prehandle: sess=%s sid=%s kind=%s" % (ch.sess[:16], sid, ch.kind))
|
||||
tdc_path = cfg.get("tdc_path", "")
|
||||
if not tdc_path:
|
||||
print("❌ prehandle 没给 tdc_path")
|
||||
return 1
|
||||
print("tdc_path:", tdc_path[:90])
|
||||
(d / "meta.json").write_text(json.dumps({
|
||||
"aid": args.aid, "sess": ch.sess, "sid": sid,
|
||||
"tdc_path": tdc_path, "kind": ch.kind,
|
||||
"fetched_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
|
||||
seen = set()
|
||||
got = 0
|
||||
for i in range(args.n * 3): # 允许重试,直到拿到 n 份不同构建
|
||||
if got >= args.n:
|
||||
break
|
||||
body = client.fetch_tdc(tdc_path)
|
||||
md5 = hashlib.md5(body.encode("utf-8")).hexdigest()
|
||||
if md5 in seen:
|
||||
time.sleep(args.gap)
|
||||
continue
|
||||
seen.add(md5)
|
||||
got += 1
|
||||
name = "tdc_%02d_%s.js" % (got, md5[:8])
|
||||
(d / name).write_text(body, encoding="utf-8")
|
||||
|
||||
info = {"md5": md5, "bytes": len(body), "index": got}
|
||||
try:
|
||||
info["key"] = extract_xtea_key_pure(body)["key"]
|
||||
except Exception as e:
|
||||
info["key_error"] = "%s: %s" % (type(e).__name__, str(e)[:120])
|
||||
info["eks"] = extract_eks(body)[:40]
|
||||
print(" [%d] %s %6d B md5=%s key=%s"
|
||||
% (got, name, len(body), md5[:12], info.get("key")))
|
||||
(d / (name[:-3] + ".json")).write_text(
|
||||
json.dumps(info, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
time.sleep(args.gap)
|
||||
print()
|
||||
print(">>> 拿到 %d 份互不相同的新构建 -> %s" % (got, d))
|
||||
return 0 if got else 1
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证 struct_40_9 的消歧特征是否跨构建稳定。
|
||||
|
||||
判别:
|
||||
A 组: 闭包体最后写入 **空串** (PUSH_EMPTY_STR; SET_PROP)
|
||||
B 组: 闭包体最后写入 **随机 int32** (PUSH_IMM <big>; SET_PROP)
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
|
||||
|
||||
def closure_body(p, entry):
|
||||
"""取模块唯一闭包的 target 体。"""
|
||||
cls = [c for c in p.closures if p.owner(c.pc) == entry]
|
||||
if not cls:
|
||||
return None
|
||||
return p.function_code(cls[0].target)
|
||||
|
||||
|
||||
def disamb(p, entry):
|
||||
"""返回 'empty' | 'rand' | None"""
|
||||
body = closure_body(p, entry)
|
||||
if body is None:
|
||||
return None
|
||||
imms = [x["operands"][0] for x in body
|
||||
if x["name"] == "PUSH_IMM" and x["operands"]]
|
||||
big = [v for v in imms if v > 1_000_000]
|
||||
if big:
|
||||
return "rand"
|
||||
return "empty"
|
||||
|
||||
|
||||
stats = defaultdict(lambda: defaultdict(list))
|
||||
for f in BATCH:
|
||||
p = tp.load(f)
|
||||
reg = dict(p.module_registry(0))
|
||||
rows = tm.analyze(f)
|
||||
for r in rows:
|
||||
if r["name"] != "struct_40_9":
|
||||
continue
|
||||
d = disamb(p, reg[r["module"]])
|
||||
stats[f.split("/")[-1]][d or "?"].append((r["item"], r["module"]))
|
||||
|
||||
print("%-24s %-8s %s" % ("构建", "判别", "项(模块)"))
|
||||
print("-" * 70)
|
||||
ok = True
|
||||
for f, groups in stats.items():
|
||||
for d, members in sorted(groups.items()):
|
||||
print("%-24s %-8s %s" % (f, d, members))
|
||||
# 每份构建必须 empty 1 个 + rand 1 个
|
||||
if len(groups.get("empty", [])) != 1 or len(groups.get("rand", [])) != 1:
|
||||
ok = False
|
||||
print()
|
||||
print(">>> 每份构建都是 empty×1 + rand×1 :", ok)
|
||||
print(">>> 结论:", "可静态消歧(写入空串 vs 写入随机 int32)" if ok else "仍不可分")
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""对照实验:证明「按语义名配对」不是归一化过拟合出来的。
|
||||
|
||||
风险: 归一化若抹掉太多,任何配对都会"全对",校验就失去意义。
|
||||
|
||||
做法(负对照,同一套归一化 + 同一套比对):
|
||||
① 按语义名配对(实验组)
|
||||
② 按清单下标配对(负对照:槽位序随机,必然错)
|
||||
③ 按模块 id 配对(负对照:id 每次重排,必然错)
|
||||
④ 随机配对(负对照基线)
|
||||
|
||||
若①接近 37/37 而 ②③④ 明显更低 => 归一化没有过拟合,配对确实有效。
|
||||
"""
|
||||
import glob
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
# 带"代码地址"的操作数:每份构建的布局不同,必须抹掉
|
||||
ADDR_OPS = {"JUMP", "JUMP_IF_TRUE", "JUMP_IF_FALSE", "PUSH_TRY"}
|
||||
|
||||
|
||||
def norm_code(p, entry, maxlen=400):
|
||||
"""归一化指令序列(与 verify_pair_correct2.py 同一套规则,此处内联避免导入副作用)。
|
||||
|
||||
抹掉三类**会重排**的操作数,保留指令骨架与字符串的位置:
|
||||
1. 代码地址(JUMP / JUMP_IF_* / PUSH_TRY)
|
||||
2. require(N) 的模块号 N
|
||||
3. PUSH_STR_CHAR 的具体字符值(fp_salt 随机盐串、混淆变量名每次都变)
|
||||
"""
|
||||
code = p.function_code(entry)
|
||||
out = []
|
||||
for k, inst in enumerate(code):
|
||||
nm, ops = inst["name"], tuple(inst["operands"] or [])
|
||||
if nm in ADDR_OPS:
|
||||
ops = ("ADDR",) * len(ops)
|
||||
elif nm == "PUSH_STR_CHAR":
|
||||
ops = ("CH",)
|
||||
elif nm == "PUSH_IMM" and ops:
|
||||
if (k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM"
|
||||
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC"):
|
||||
ops = ("MOD",) * len(ops)
|
||||
out.append((nm, ops))
|
||||
return tuple(out[:maxlen])
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
|
||||
|
||||
def load(f):
|
||||
p = tp.load(f)
|
||||
rows = tm.analyze(f)
|
||||
reg = dict(p.module_registry(0))
|
||||
sig = {r["name"]: norm_code(p, reg[r["module"]]) for r in rows}
|
||||
return rows, sig
|
||||
|
||||
|
||||
A, fa = load(BATCH[0])
|
||||
print("基准 %s (归一化:抹掉地址/模块号/字符值,保留指令骨架与串的位置)" % BATCH[0].split("/")[-1])
|
||||
print()
|
||||
print("%-22s %-22s %-8s %-8s" % ("对比构建", "配对方式", "一致", "不一致"))
|
||||
print("-" * 66)
|
||||
|
||||
for f in BATCH[1:]:
|
||||
B, fb = load(f)
|
||||
names = sorted(set(fa) & set(fb))
|
||||
|
||||
def score(pairs):
|
||||
return sum(1 for a, b in pairs if a == b)
|
||||
|
||||
# ① 实验组:语义名
|
||||
p1 = [(fa[n], fb[n]) for n in names]
|
||||
# ② 负对照:清单下标
|
||||
p2 = [(fa[A[i]["name"]], fb[B[i]["name"]]) for i in range(min(len(A), len(B)))]
|
||||
# ③ 负对照:模块 id(用 id 当 key 去查另一边同名集合的任意一项)
|
||||
fb_by_id = {r["module"]: fb[r["name"]] for r in B}
|
||||
p3 = [(fa[A[i]["name"]], fb_by_id.get(A[i]["module"], ())) for i in range(len(A))]
|
||||
# ④ 负对照:随机配对
|
||||
rnd = random.Random(7)
|
||||
shuf = names[:]
|
||||
rnd.shuffle(shuf)
|
||||
p4 = [(fa[n], fb[m]) for n, m in zip(names, shuf)]
|
||||
|
||||
for label, pairs in (("①语义名(实验组)", p1), ("②清单下标(负对照)", p2),
|
||||
("③模块id(负对照)", p3), ("④随机配对(负对照)", p4)):
|
||||
s = score(pairs)
|
||||
print("%-22s %-22s %-8d %-8d" % (f.split("/")[-1], label, s, len(pairs) - s))
|
||||
print()
|
||||
|
||||
print(">>> 判读: ①应≈37/37;②③④应明显更低 => 归一化未过拟合,配对有效")
|
||||
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证字符串+结构指纹方案的**跨构建一致性** —— 只基于 har/online/ 全新样本。
|
||||
|
||||
案例 §5.3 的验收标准: "3 个 tdc.js 版本、37 个模块、全部正确识别,无一 unknown"。
|
||||
本脚本再进一步:不仅要求"无 unknown",还要求
|
||||
① 每份构建内部 37 项都有名字
|
||||
② 各构建之间的**语义名集合**一致(说明识别的是同一批采集器)
|
||||
③ 语义名 -> 每项在 cd 里的**相对位置**可交叉核对
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_modules as tm # noqa: E402
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
print("全新在线样本 %d 份(只使用这批数据)" % len(BATCH))
|
||||
print()
|
||||
|
||||
results = {}
|
||||
for f in BATCH:
|
||||
rows = tm.analyze(f)
|
||||
names = [r["name"] for r in rows]
|
||||
results[f] = rows
|
||||
unk = sum(1 for n in names if n == "unknown")
|
||||
via_struct = sum(1 for r in rows if r.get("via_struct"))
|
||||
via_deleg = sum(1 for r in rows if r.get("via_delegate"))
|
||||
print(" %-24s 项%2d unknown %d | 字符串直判 %2d 委派链 %d 结构兜底 %d"
|
||||
% (f.split("/")[-1], len(rows), unk,
|
||||
len(rows) - via_struct - via_deleg, via_deleg, via_struct))
|
||||
|
||||
print()
|
||||
keys = list(results)
|
||||
base = Counter(r["name"] for r in results[keys[0]])
|
||||
print("以 %s 为基准:%d 个语义名(去重 %d)"
|
||||
% (keys[0].split("/")[-1], len(base), len(set(base))))
|
||||
print()
|
||||
for f in keys[1:]:
|
||||
c = Counter(r["name"] for r in results[f])
|
||||
same = sum((base & c).values())
|
||||
only_base = sum((base - c).values())
|
||||
only_it = sum((c - base).values())
|
||||
print(" vs %-22s 相同 %2d | 仅基准有 %2d | 仅有它有 %2d"
|
||||
% (f.split("/")[-1], same, only_base, only_it))
|
||||
|
||||
print()
|
||||
common = set(base)
|
||||
for f in keys[1:]:
|
||||
common &= set(Counter(r["name"] for r in results[f]))
|
||||
allnames = set()
|
||||
for f in keys:
|
||||
allnames |= set(r["name"] for r in results[f])
|
||||
print(">>> 所有构建**共同**识别出的语义名: %d 个" % len(common))
|
||||
print(">>> 出现过的语义名总数 : %d 个" % len(allnames))
|
||||
print(">>> 差异名:", sorted(allnames - common) if allnames - common else "无(完全一致)")
|
||||
print()
|
||||
|
||||
# 唯一性检查:单构建内是否有重名(重名 = 锚点有歧义)
|
||||
print("单构建内重名检查(重名说明该锚点有歧义):")
|
||||
for f in keys:
|
||||
c = Counter(r["name"] for r in results[f])
|
||||
dup = {k: v for k, v in c.items() if v > 1}
|
||||
print(" %-24s 重名 %d 组 %s"
|
||||
% (f.split("/")[-1], len(dup), dict(sorted(dup.items()))))
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""判决「按语义名配对」是否**正确** —— 用独立于指纹的方法。
|
||||
|
||||
⚠️ 上一轮我用「模块 id 相等」做校验,那个假设是**错的**:
|
||||
案例 §1.2 与账本 #29 都明确说「37 个模块的 ID 每次构建随机重新分配」。
|
||||
registry 全集交集 62/62 只说明编号**范围**相同(都是 0..61),
|
||||
不代表 id -> 语义 的映射相同。id 不一致是**预期行为**,不能用来否定配对。
|
||||
|
||||
本轮改用真正独立的证据:**函数体的归一化指令序列**。
|
||||
指纹规则只用到「字符串集合」和「结构统计(指令数/直方图)」,
|
||||
没有用到「指令的完整顺序 + 操作数」。所以拿它做校验是独立的。
|
||||
|
||||
归一化: 抹掉 JUMP 的绝对地址(每构建不同),保留其余操作数。
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_layout # noqa: E402
|
||||
import tdc_modules as tm # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
|
||||
|
||||
# 带"代码地址"的操作数:每份构建的布局不同,必须抹掉
|
||||
ADDR_OPS = {"JUMP", "JUMP_IF_TRUE", "JUMP_IF_FALSE", "PUSH_TRY"}
|
||||
|
||||
|
||||
def norm_code(p, entry, maxlen=400):
|
||||
"""归一化指令序列。
|
||||
|
||||
必须抹掉两类**会重排**的操作数,否则校验必然失败:
|
||||
1. 代码地址:JUMP / JUMP_IF_TRUE / JUMP_IF_FALSE / PUSH_TRY
|
||||
2. require(N) 的模块号 N:`LOAD_REG_IMM; PUSH_IMM N; CALL_FUNC`
|
||||
(案例 §1.2:37 个模块的 ID 每次构建随机重新分配)
|
||||
其余操作数(字符串 charCode、小常量、寄存器号)跨构建稳定,保留。
|
||||
"""
|
||||
code = p.function_code(entry)
|
||||
out = []
|
||||
for k, inst in enumerate(code):
|
||||
nm, ops = inst["name"], tuple(inst["operands"] or [])
|
||||
if nm in ADDR_OPS:
|
||||
ops = ("ADDR",) * len(ops)
|
||||
elif nm == "PUSH_STR_CHAR":
|
||||
# 只保留"这是一个字符"这件事,**不看具体是哪个字符**。
|
||||
# 这样 fp_salt 的随机盐串、混淆变量名这些「会变的内容」就不会干扰校验,
|
||||
# 而"字符串的长度/位置"仍然参与比对 —— 足够独立,且仍然很强。
|
||||
ops = ("CH",)
|
||||
elif nm == "PUSH_IMM" and ops:
|
||||
# require(N) 形状:前一个 LOAD_REG_IMM,后一个 CALL_FUNC
|
||||
if (k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM"
|
||||
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC"):
|
||||
ops = ("MOD",) * len(ops)
|
||||
out.append((nm, ops))
|
||||
return tuple(out[:maxlen])
|
||||
|
||||
|
||||
print("构建 : 每个模块的函数体归一化指令序列 -> 作为独立指纹")
|
||||
print()
|
||||
|
||||
A = tm.analyze(BATCH[0])
|
||||
pa = tp.load(BATCH[0])
|
||||
ra = dict(pa.module_registry(0))
|
||||
fa = {r["name"]: norm_code(pa, ra[r["module"]]) for r in A}
|
||||
|
||||
print("基准 %s (%d 项)" % (BATCH[0].split("/")[-1], len(A)))
|
||||
print()
|
||||
print("%-24s %-8s %-8s %-8s %s" % ("对比构建", "配对", "序列一致", "不一致", "判决"))
|
||||
print("-" * 66)
|
||||
all_ok = True
|
||||
for f in BATCH[1:]:
|
||||
B = tm.analyze(f)
|
||||
pb = tp.load(f)
|
||||
rb = dict(pb.module_registry(0))
|
||||
fb = {r["name"]: norm_code(pb, rb[r["module"]]) for r in B}
|
||||
common = set(fa) & set(fb)
|
||||
same = [n for n in common if fa[n] == fb[n]]
|
||||
diff = [n for n in common if fa[n] != fb[n]]
|
||||
all_ok &= not diff
|
||||
print("%-24s %-8d %-8d %-8d %s"
|
||||
% (f.split("/")[-1], len(common), len(same), len(diff),
|
||||
"✓" if not diff else "✗"))
|
||||
for n in sorted(diff)[:3]:
|
||||
a, b = fa[n], fb[n]
|
||||
hit = next((k for k in range(min(len(a), len(b))) if a[k] != b[k]), -1)
|
||||
print(" %-26s 首个分歧@%d A=%s B=%s" % (n, hit,
|
||||
a[hit] if hit >= 0 else "-", b[hit] if hit >= 0 else "-"))
|
||||
|
||||
print()
|
||||
print(">>> 全部配对的函数体序列一致:", all_ok)
|
||||
print(">>> 判决:", "语义名配对**正确**(独立证据)" if all_ok else "配对有误,需检查")
|
||||
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用**全新在线样本**做端到端重排测试(不需要真机明文,自证)。
|
||||
|
||||
做法:
|
||||
给每个语义名编一个**假值** value_i,按构建 A 的清单顺序排成 cd_A。
|
||||
再用锚点把它重排到构建 B 的顺序,得到 cd_B。
|
||||
正确答案 = 按 B 的清单顺序、取各自语义名的假值。
|
||||
|
||||
这个测试能抓住 reorder 的核心 bug(恒等映射):
|
||||
若 B 的顺序 ≠ A 的顺序,恒等映射必然错;只有真对齐才对。
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import tdc_collectgen as cg # noqa: E402
|
||||
import tdc_modules as tm # noqa: E402
|
||||
|
||||
BATCH = sorted(glob.glob(str(ROOT / "har" / "online" / "*" / "tdc_*.js")))
|
||||
print("新样本 %d 份" % len(BATCH))
|
||||
print()
|
||||
|
||||
|
||||
A = BATCH[0]
|
||||
rowsA = tm.analyze(A)
|
||||
namesA = [r["name"] for r in rowsA]
|
||||
seqA = [r["module"] for r in rowsA]
|
||||
n = len(namesA)
|
||||
print("基准 %s 项 %d 唯一名 %d" % (A.split("/")[-1], n, len(set(namesA))))
|
||||
|
||||
# 假值:每项一个可区分的值
|
||||
fake = {nm: "V<%s>" % nm for nm in namesA}
|
||||
cd_A = [fake[nm] for nm in namesA]
|
||||
|
||||
src = cg.spec_from_seq(seqA, [None] * n, namesA, sample_cd=cd_A)
|
||||
|
||||
print()
|
||||
for f in BATCH[1:]:
|
||||
rowsB = tm.analyze(f)
|
||||
namesB = [r["name"] for r in rowsB]
|
||||
seqB = [r["module"] for r in rowsB]
|
||||
dst = cg.spec_from_seq(seqB, [None] * len(rowsB), namesB)
|
||||
|
||||
# 正确答案:按 B 的顺序取各自语义名的假值
|
||||
truth = [fake[nm] for nm in namesB]
|
||||
|
||||
try:
|
||||
out = cg.reorder(cd_A, dst, src)
|
||||
ok = out == truth
|
||||
identical = out == cd_A
|
||||
print(" vs %-24s" % f.split("/")[-1])
|
||||
print(" 重排结果 == 正确答案 : %s" % ok)
|
||||
print(" 重排结果 == 原样(说明没重排): %s" % identical)
|
||||
if not ok:
|
||||
bad = [i for i, (a, b) in enumerate(zip(out, truth)) if a != b]
|
||||
print(" 错位 %d 处, 前5: %s" % (len(bad), bad[:5]))
|
||||
except Exception as e:
|
||||
print(" vs %-24s %s: %s" % (f.split("/")[-1], type(e).__name__, str(e)[:110]))
|
||||
print()
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用真机 collect 密文的**长度与填充**判决「四段加密」结构。
|
||||
|
||||
不需要密钥:段结构在 base64 的长度与 '=' 分布上留下指纹。
|
||||
|
||||
判决依据(docs/tdc-案例.md §5.1):
|
||||
chunk1/trajectory/chunk2 各 pad 到 24 字节 -> 密文长度是 24 的倍数 -> base64 无 '='
|
||||
sd 不 pad,只被 XTEA 补到 8 的倍数 -> 长度%3 != 0 -> base64 带 '='
|
||||
|
||||
用法:
|
||||
.venv/bin/python scripts/verify/verify_seglen.py <collect 的 base64>
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: verify_seglen.py <collect 的 base64(文件路径或直接给串)>")
|
||||
raise SystemExit(2)
|
||||
_arg = sys.argv[1]
|
||||
_p = Path(_arg)
|
||||
b64 = (_p.read_text(encoding="ascii") if _p.exists() else _arg).strip()
|
||||
print("真机 collect: %d 字符, 含 '=' : %s" % (len(b64), "=" in b64))
|
||||
print()
|
||||
|
||||
raw = base64.b64decode(b64)
|
||||
print("解码后密文: %d 字节" % len(raw))
|
||||
print()
|
||||
|
||||
# 真机明文(另一份同会话样本)给出的各段长度
|
||||
segs = [("chunk1", 355, 5), ("trajectory", 62, 10), ("chunk2", 288, 24), ("sd", 33, None)]
|
||||
print("%-11s %8s %6s %10s %12s %10s" % ("段", "内容", "pad", "密文字节", "base64字符", "含'='"))
|
||||
print("-" * 66)
|
||||
total = 0
|
||||
total_b64 = 0
|
||||
for name, content, pad in segs:
|
||||
if pad is not None:
|
||||
n = content + pad # 空格 pad 到 24
|
||||
assert n % 24 == 0
|
||||
else:
|
||||
n = content + ((8 - content % 8) % 8) # sd: 只被 XTEA 补到 8 的倍数
|
||||
enc = base64.b64encode(b"\x00" * n).decode()
|
||||
total += n
|
||||
total_b64 += len(enc)
|
||||
print("%-11s %8d %6s %10d %12d %10s"
|
||||
% (name, content, pad if pad is not None else "(xtea)",
|
||||
n, len(enc), "=" in enc))
|
||||
print("-" * 66)
|
||||
print("%-11s %8s %6s %10d %12d" % ("合计", "", "", total, total_b64))
|
||||
print()
|
||||
print(">>> 推算总密文字节 %d == 实际 %d : %s" % (total, len(raw), total == len(raw)))
|
||||
print(">>> 推算 base64 字符 %d == 实际 %d : %s" % (total_b64, len(b64), total_b64 == len(b64)))
|
||||
print()
|
||||
|
||||
# 反过来看:把整串按 24 字节切,前三段应能整除
|
||||
print("[反向校验] 把密文按 24 字节切分:")
|
||||
off = 0
|
||||
for name, content, pad in segs:
|
||||
if pad is None:
|
||||
print(" %-11s 剩余 %3d 字节 (sd, 非 24 倍数)" % (name, len(raw) - off))
|
||||
break
|
||||
n = content + pad
|
||||
print(" %-11s [%4d:%4d] %3d 字节 24倍数=%s"
|
||||
% (name, off, off + n, n, n % 24 == 0))
|
||||
off += n
|
||||
print()
|
||||
|
||||
# 关键指纹: '=' 只应出现在最后(来自 sd 段)
|
||||
idx = b64.find("=")
|
||||
print("'=' 首次出现位置: %d / 总长 %d -> %s"
|
||||
% (idx, len(b64), "在末尾区域(符合 sd 段不 pad)"
|
||||
if idx >= len(b64) - 4 else "⚠️ 在中间(与四段结构矛盾)"))
|
||||
if idx >= 0:
|
||||
print(" 尾部填充: %r" % b64[idx:])
|
||||
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""按 docs/tdc-案例.md §5.1 验证 collect 的「四段加密」结构(真机明文判决)。
|
||||
|
||||
要判决的问题:
|
||||
项目现有 crypto.encrypt_string / tdc_collectgen.encrypt_plain 都是
|
||||
「整体 JSON -> \x00 补齐到 8 -> 一次加密 -> 一个 base64」。
|
||||
案例说「4 段独立加密,前 3 段空格 pad 到 24 字节对齐,sd 不 pad」。
|
||||
两者只能有一个对 —— 用真机明文里的**空格填充串**来判。
|
||||
|
||||
用法:
|
||||
.venv/bin/python scripts/verify/verify_segment.py <已解密的 collect 明文文件>
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: verify_segment.py <已解密的 collect 明文文件>")
|
||||
print(" (明文来源: 用同会话 tdc.js 的密钥解密 TDC.getData(true) 的产出)")
|
||||
raise SystemExit(2)
|
||||
RAW = sys.argv[1]
|
||||
|
||||
data = open(RAW, encoding="latin1").read()
|
||||
print("原始(含尾部 \\x00):", len(data), "字节")
|
||||
stripped = data.rstrip("\x00")
|
||||
print("去掉尾部 \\x00 后 :", len(stripped), "字节 (尾部 \\x00 个数 = %d)" % (len(data) - len(stripped)))
|
||||
print()
|
||||
|
||||
runs = [(m.start(), m.end()) for m in re.finditer(r" {2,}", stripped)]
|
||||
print("[1] 连续空格串(只能来自「空格 pad」):")
|
||||
for a, b in runs:
|
||||
print(" 位置 %3d..%3d 长度 %2d" % (a, b, b - a))
|
||||
print()
|
||||
|
||||
# 段边界 = 每个空格串的结束位置(若空格串在段尾)
|
||||
bounds = [0] + [b for _a, b in runs[:-1]] if len(runs) > 1 else [0]
|
||||
# 更稳: 段边界取每个空格串的"起点所在段"的末尾 -> 直接用空格串终点
|
||||
b1, b2, b3 = runs[0][1], runs[1][1], runs[2][1]
|
||||
segs = [("chunk1", 0, b1), ("trajectory", b1, b2), ("chunk2", b2, b3), ("sd", b3, len(stripped))]
|
||||
print("[2] 按空格串切出的 4 段:")
|
||||
for name, a, b in segs:
|
||||
s = stripped[a:b]
|
||||
print(" %-11s [%3d:%3d] len=%3d 24倍数=%-5s 8倍数=%-5s"
|
||||
% (name, a, b, len(s), len(s) % 24 == 0, len(s) % 8 == 0))
|
||||
print()
|
||||
|
||||
print("[3] 空格 pad 公式校验 (pad_len = 24 - len%24,总是补 1..24 个):")
|
||||
for name, a, b in segs[:3]:
|
||||
s = stripped[a:b]
|
||||
content = s.rstrip(" ")
|
||||
pad = len(s) - len(content)
|
||||
print(" %-11s 内容 %3d + pad %2d = %3d 公式给出 %2d -> %s"
|
||||
% (name, len(content), pad, len(s), 24 - len(content) % 24,
|
||||
"OK" if pad == 24 - len(content) % 24 else "FAIL"))
|
||||
print()
|
||||
|
||||
print("[4] sd 段不 pad(只被 XTEA 的 8 字节块 \x00 补齐):")
|
||||
sd = stripped[b3:]
|
||||
print(" sd = %r" % sd)
|
||||
print(" sd 长度 %d (非 8 的倍数 -> 加密时被补 %d 个 \\x00)"
|
||||
% (len(sd), (8 - len(sd) % 8) % 8))
|
||||
print(" 实际尾部 \\x00 个数 = %d" % (len(data) - len(stripped)))
|
||||
print()
|
||||
|
||||
print("[5] 逻辑整体是否是合法 JSON,以及轨迹在 cd 里的位置:")
|
||||
obj = json.loads(stripped)
|
||||
cd = obj["cd"]
|
||||
traj_idx = [i for i, v in enumerate(cd)
|
||||
if isinstance(v, list) and v and all(isinstance(x, list) and len(x) == 3 for x in v)]
|
||||
print(" cd 槽数 = %d" % len(cd))
|
||||
print(" 轨迹槽下标 = %s" % traj_idx)
|
||||
if traj_idx:
|
||||
print(" 轨迹内容前 3 点 = %s" % cd[traj_idx[0]][:3])
|
||||
print()
|
||||
|
||||
print("[6] 去 pad 后 4 段拼接 == 逻辑明文(应能解析成同一对象):")
|
||||
rebuilt = "".join(stripped[a:b].rstrip(" ") for _name, a, b in segs[:3]) + stripped[b3:]
|
||||
obj2 = json.loads(rebuilt)
|
||||
print(" 可解析为 JSON :", True)
|
||||
print(" 与原对象逐项相同 :", obj2 == obj)
|
||||
print(" 重建长度 %d (原文去尾 \\x00 后 %d, 差 = pad 总量 %d)"
|
||||
% (len(rebuilt), len(stripped), len(stripped) - len(rebuilt)))
|
||||
print()
|
||||
print("[7] 三段之间的连接处(验证 chunk1 以逗号结尾 / chunk2 以 '],' 结尾):")
|
||||
print(" chunk1 内容末尾 : %r" % stripped[0:b1].rstrip(" ")[-24:])
|
||||
print(" trajectory 内容 : %r" % stripped[b1:b2].rstrip(" ")[:40])
|
||||
print(" chunk2 内容开头 : %r" % stripped[b2:b3].rstrip(" ")[:24])
|
||||
print(" chunk2 内容末尾 : %r" % stripped[b2:b3].rstrip(" ")[-24:])
|
||||
print(" sd 内容 : %r" % stripped[b3:])
|
||||
+4
-2
@@ -26,6 +26,7 @@ import logging
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -405,9 +406,10 @@ class TencentCaptchaClient:
|
||||
if not eks:
|
||||
logger.warning("未从 tdc.js 中提取到 eks, 继续尝试")
|
||||
|
||||
# 无论是预期的还是非预期题型,第一时间下载原图落盘到本地 scratch/captcha_debug/,辅助用户肉眼判断
|
||||
# 无论是预期的还是非预期题型,第一时间下载原图落盘到系统临时目录,辅助用户肉眼判断。
|
||||
# 注意: 别再写回仓库内的 scratch/ —— 该目录已清理且不入库(.gitignore)。
|
||||
bg_bytes: Optional[bytes] = None
|
||||
debug_dir = Path("scratch/captcha_debug")
|
||||
debug_dir = Path(tempfile.gettempdir()) / "captcha_debug"
|
||||
debug_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
safe_inst = re.sub(r"[^\w\u4e00-\u9fff]+", "_", challenge.instruction).strip("_") or "no_inst"
|
||||
|
||||
@@ -107,6 +107,7 @@ def xtea_decrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
|
||||
|
||||
|
||||
def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
|
||||
"""XTEA 加密一段字节;不足 8 字节用 \\x00 补齐(XTEA 块对齐所需)。"""
|
||||
data = data + b"\x00" * ((8 - len(data) % 8) % 8)
|
||||
out = bytearray()
|
||||
for i in range(0, len(data), 8):
|
||||
@@ -116,6 +117,86 @@ def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# collect 的「四段加密」结构(按 docs/tdc-案例.md §5.1,真机明文已判决) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
SEG_ALIGN = 24
|
||||
SEG_PAD = b" "
|
||||
|
||||
_collect_seg_re = re.compile(rb" {2,}")
|
||||
|
||||
|
||||
def xtea_encrypt_segment(text: str, key: List[int],
|
||||
align: int = SEG_ALIGN, pad: bytes = SEG_PAD) -> bytes:
|
||||
"""加密 collect 的一个**数据段**:空格 pad 到 align 字节对齐后 XTEA + Base64。
|
||||
|
||||
为什么要 pad 到 24(而不是 8):24 字节 -> 32 个 Base64 字符,且**不会产生 `=` 填充**。
|
||||
4 段密文的 Base64 直接拼接后看起来就是一个完整的 Base64 串,没有分隔符。
|
||||
(真机明文里能直接看到这些空格:chunk1 +5、trajectory +10、chunk2 +24。)
|
||||
"""
|
||||
data = text.encode("latin1")
|
||||
# 注意:即使 len%align==0 也要补一整个 align(真机 chunk2 内容 288 字节、
|
||||
# 288%24==0,却补了 24 个空格 -> pad 长度恒为 align - len%align,落在 1..align)
|
||||
data += pad * (align - len(data) % align)
|
||||
return base64.b64encode(xtea_encrypt_bytes(data, key))
|
||||
|
||||
|
||||
def xtea_encrypt_segment_raw(text: str, key: List[int]) -> bytes:
|
||||
"""加密 **sd 段**:不 pad 到 24,只由 XTEA 的 8 字节块补齐(真机实测如此)。"""
|
||||
data = text.encode("latin1")
|
||||
return base64.b64encode(xtea_encrypt_bytes(data, key))
|
||||
|
||||
|
||||
def build_collect(chunk1: str, trajectory: str, chunk2: str, sd: str,
|
||||
key: List[int], url_encode: bool = False) -> str:
|
||||
"""按案例 §5.1 组装 collect —— **4 段密文直接拼接,无分隔符**。
|
||||
|
||||
collect = b64(xtea(chunk1 + 空格pad到24))
|
||||
+ b64(xtea(traj + 空格pad到24))
|
||||
+ b64(xtea(chunk2 + 空格pad到24))
|
||||
+ b64(xtea(sd)) # sd 不 pad
|
||||
|
||||
段的内容边界(案例 §5.5,与真机明文逐字吻合):
|
||||
chunk1 = '{"cd":[' + 轨迹之前的指纹值 + ','
|
||||
trajectory = '[[x,y,t],[dx,dy,dt],...,[1,1,12]]'
|
||||
chunk2 = ',' + 轨迹之后的指纹值 + '],'
|
||||
sd = '"sd":{"od":"C","ft":"..."}}'
|
||||
"""
|
||||
parts = [
|
||||
xtea_encrypt_segment(chunk1, key),
|
||||
xtea_encrypt_segment(trajectory, key),
|
||||
xtea_encrypt_segment(chunk2, key),
|
||||
xtea_encrypt_segment_raw(sd, key),
|
||||
]
|
||||
b64 = b"".join(parts).decode("ascii")
|
||||
if url_encode:
|
||||
return urllib.parse.quote(b64, safe="")
|
||||
return b64
|
||||
|
||||
|
||||
def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
|
||||
"""把一份**已解密**的 collect 明文按空格 pad 切回 4 段(去 pad)。
|
||||
|
||||
用于从真机样本反解出 chunk1/trajectory/chunk2/sd,做对拍或标定。
|
||||
"""
|
||||
data = plain.encode("latin1")
|
||||
bounds = [0]
|
||||
for m in _collect_seg_re.finditer(data):
|
||||
bounds.append(m.end())
|
||||
bounds.append(len(data))
|
||||
if len(bounds) != 5:
|
||||
raise ValueError(
|
||||
f"期望 3 个空格填充串(4 段结构),实际切出 {len(bounds) - 1} 段;"
|
||||
f"这份明文可能不是「四段加密」格式")
|
||||
out = []
|
||||
for i in range(4):
|
||||
seg = data[bounds[i]:bounds[i + 1]]
|
||||
if i < 3:
|
||||
seg = seg.rstrip(SEG_PAD) # 前三段去掉空格 pad
|
||||
out.append(seg.decode("latin1"))
|
||||
return out[0], out[1], out[2], out[3]
|
||||
|
||||
|
||||
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
|
||||
out = bytearray()
|
||||
for i in range(0, len(data) - len(data) % 8, 8):
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
4. 加密/解密**回环一致**(明文逐字节还原);
|
||||
5. 槽数不匹配的样本**必须报错**,不能静默错位。
|
||||
|
||||
样本: tcaptcha/tests/fixtures/tdc_slotmap_case.json
|
||||
= {seq, kinds, v0s, sample_cd},取自 2026-10-11 真机会话(37 项 -> 38 槽)。
|
||||
样本:
|
||||
* tcaptcha/tests/fixtures/tdc_slotmap_case.json —— 合成用例(已知正确答案)
|
||||
* har/online/<时间戳>/tdc_*.js —— **在线抓取的全新构建**(真机数据)
|
||||
|
||||
运行:
|
||||
.venv/bin/python -m tcaptcha.tests.test_collectgen
|
||||
@@ -88,11 +89,85 @@ def main() -> int:
|
||||
print("\n[5] 槽数不匹配必须报错")
|
||||
raised = False
|
||||
try:
|
||||
cg.reorder(sample_cd[:-1], spec)
|
||||
cg.reorder(sample_cd[:-1], spec) # src=None: 契约是"同序"
|
||||
except ValueError:
|
||||
raised = True
|
||||
check("少一槽 -> ValueError", raised)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# [6] 强断言:重排**必须真的重排**(旧实现的 bug 就是被"是置换"放过的)
|
||||
# ---------------------------------------------------------------- #
|
||||
print("\n[6] reorder 不是恒等映射(抓旧 bug)")
|
||||
print(" 旧实现的循环是 sample[slot:slot+n],其中 slot 是**目标**槽号,")
|
||||
print(" 两份构建顺序不同时全盘错位;而'值集合不变'这个弱断言抓不住它。")
|
||||
# 把清单顺序打乱,构造"另一份构建"
|
||||
import random
|
||||
idx = list(range(len(seq)))
|
||||
random.Random(11).shuffle(idx)
|
||||
seq2 = [seq[i] for i in idx]
|
||||
kinds2 = [kinds[i] for i in idx]
|
||||
v0s2 = [v0s[i] for i in idx]
|
||||
# 给每项一个唯一语义名(真实场景由 tdc_modules 的指纹提供)
|
||||
names1 = ["n%d" % i for i in range(len(seq))]
|
||||
names2 = ["n%d" % i for i in idx]
|
||||
src = cg.spec_from_seq(seq, kinds, names1, sample_cd=sample_cd, v0s=v0s)
|
||||
dst = cg.spec_from_seq(seq2, kinds2, names2, v0s=v0s2)
|
||||
# 正确答案:按 src 的 assign 把每项的值搬到目标顺序
|
||||
truth: list = []
|
||||
for i in idx:
|
||||
_i, _m, sl, nn = src.assign[i]
|
||||
truth.extend(sample_cd[sl:sl + nn])
|
||||
try:
|
||||
out2 = cg.reorder(sample_cd, dst, src)
|
||||
check("重排结果 == 已知正确答案", out2 == truth)
|
||||
check("重排结果 != 原样(证明真的重排了)", out2 != list(sample_cd))
|
||||
except cg.UnmappedSlotsError as exc:
|
||||
check("重排结果 == 已知正确答案", False, f"抛错: {str(exc)[:80]}")
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# [7] 用在线抓取的**全新构建**做端到端重排(真机数据,非合成)
|
||||
# ---------------------------------------------------------------- #
|
||||
online = sorted((ROOT / "har" / "online").glob("*/tdc_*.js")) if (
|
||||
ROOT / "har" / "online").exists() else []
|
||||
print("\n[7] 在线新构建 + 语义名锚点的端到端重排")
|
||||
if len(online) < 2:
|
||||
print(" (跳过: 需要 ≥2 份 har/online/*/tdc_*.js;"
|
||||
"用 scripts/fetch_online_samples.py --n 4 抓取)")
|
||||
else:
|
||||
try:
|
||||
import tdc_modules
|
||||
except Exception as exc:
|
||||
check("导入 tdc_modules", False, str(exc)[:80])
|
||||
online = []
|
||||
if len(online) >= 2:
|
||||
def names_of(f):
|
||||
return [r["name"] for r in tdc_modules.analyze(f)]
|
||||
na = names_of(online[0])
|
||||
n = len(na)
|
||||
check("新构建清单 37 项", n == 37, "实际 %d" % n)
|
||||
check("语义名全部唯一(37/37)", len(set(na)) == n,
|
||||
"唯一 %d/%d" % (len(set(na)), n))
|
||||
check("无 unknown", not any(x == "unknown" for x in na))
|
||||
# 给每个语义名一个假值,按 A 顺序排成 cd_A,重排到 B 顺序
|
||||
fake = {nm: "V<%s>" % nm for nm in na}
|
||||
cd_a = [fake[nm] for nm in na]
|
||||
seqa = [r["module"] for r in tdc_modules.analyze(online[0])]
|
||||
srca = cg.spec_from_seq(seqa, [None] * n, na, sample_cd=cd_a)
|
||||
all_ok = True
|
||||
for f in online[1:]:
|
||||
nb = names_of(f)
|
||||
seqb = [r["module"] for r in tdc_modules.analyze(f)]
|
||||
dstb = cg.spec_from_seq(seqb, [None] * len(nb), nb)
|
||||
truthb = [fake[nm] for nm in nb]
|
||||
try:
|
||||
got = cg.reorder(cd_a, dstb, srca)
|
||||
good = got == truthb and got != cd_a
|
||||
except Exception:
|
||||
good = False
|
||||
all_ok &= good
|
||||
print(" %-28s 重排正确=%s" % (f.parent.name + "/" + f.name[:12], good))
|
||||
check("全部新构建重排正确且非恒等", all_ok)
|
||||
|
||||
print(f"\n{'=' * 62}")
|
||||
if _fails:
|
||||
print(f"❌ collectgen 测试失败: {_fails}")
|
||||
|
||||
+268
-19
@@ -31,6 +31,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
@@ -50,6 +51,202 @@ import tdc_program as tp # noqa: E402
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 槽位映射 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def value_sig(v: Any) -> str:
|
||||
"""值的**结构签名**(用于在没有环境指纹时当次级锚点)。
|
||||
|
||||
只描述"形状 + 常量",抹掉随时间/会话变化的具体数值:
|
||||
1791736322 -> "int:epochish" [[34,96,3284],...] -> "list:pts"
|
||||
"MacIntel" -> "str:MacIntel" [360,360] -> "list:2"
|
||||
易变槽(时间戳/轨迹/随机串)的签名在两份样本里**类型一致**,数值不同 —— 正好
|
||||
用来把它们彼此对应起来,再由调用方决定"沿用样本值"还是"重新生成"。
|
||||
"""
|
||||
if v is None:
|
||||
return "null"
|
||||
if isinstance(v, bool):
|
||||
return f"bool:{v}"
|
||||
if isinstance(v, int):
|
||||
return "int:epochish" if 1_700_000_000 <= v <= 2_000_000_000 else f"int:{v}"
|
||||
if isinstance(v, float):
|
||||
return "float"
|
||||
if isinstance(v, str):
|
||||
if len(v) > 200:
|
||||
return "str:big"
|
||||
if _RANDSTR_RE.fullmatch(v):
|
||||
return "str:rand"
|
||||
return f"str:{v[:24]}"
|
||||
if isinstance(v, list):
|
||||
if v and all(isinstance(x, list) and len(x) == 3 for x in v):
|
||||
return "list:pts"
|
||||
return f"list:{len(v)}"
|
||||
return type(v).__name__
|
||||
|
||||
|
||||
_RANDSTR_RE = re.compile(r"[A-Za-z0-9+/=]{20,}")
|
||||
|
||||
|
||||
class _Unmapped:
|
||||
"""哨兵:标记"这个目标槽没找到源槽"。
|
||||
|
||||
为什么不能用 None:真机 cd 里本身就有 null 值(`out += "null"` 分支,
|
||||
见 PROGRESS.md #36),用 None 当占位符会把合法的 null 槽误判成"没对齐"。
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - 仅用于调试输出
|
||||
return "<unmapped>"
|
||||
|
||||
|
||||
_UNMAPPED = _Unmapped()
|
||||
|
||||
|
||||
def build_anchor_map(src: "SlotSpec", dst: "SlotSpec") -> List[Tuple[int, int]]:
|
||||
"""把「源构建的槽」对齐到「目标构建的槽」,返回 [(src_slot, dst_slot), ...]。
|
||||
|
||||
这是重排的核心。三层锚点,逐层降级(都必须**双向唯一**,否则宁可不对齐):
|
||||
|
||||
① **环境语义指纹**(主锚点,跨构建稳定,实测多重集交集 23/24)
|
||||
源项 i 与目标项 j 的 env_fingerprint 相同 -> 对齐。
|
||||
② **结构签名**(次锚点,用于常量槽 / 读不到环境的项)
|
||||
value_sig 相同 -> 对齐。
|
||||
③ 剩余未对齐的 -> 由调用方显式处理(不猜)。
|
||||
|
||||
为什么不能按项下标或模块 id 对齐(两个都已实测证伪):
|
||||
* 项下标:两次构建的清单顺序**就是**不同的东西,按下标对齐 = 恒等映射;
|
||||
* 模块 id:编译器每次重新分配,两份构建交集 28 个模块里 id->语义 相同的
|
||||
只有 1/11(2026-10-12 实测)。
|
||||
"""
|
||||
|
||||
def bipartite(keys_src: List[Any], keys_dst: List[Any]) -> Dict[int, int]:
|
||||
"""只在**双向唯一**时配对,避免多对一的歧义错位。"""
|
||||
from collections import defaultdict
|
||||
|
||||
by_dst: Dict[Any, List[int]] = defaultdict(list)
|
||||
by_src: Dict[Any, List[int]] = defaultdict(list)
|
||||
for i, k in enumerate(keys_src):
|
||||
if k:
|
||||
by_src[k].append(i)
|
||||
for j, k in enumerate(keys_dst):
|
||||
if k:
|
||||
by_dst[k].append(j)
|
||||
pair: Dict[int, int] = {}
|
||||
for k, srcs in by_src.items():
|
||||
dsts = by_dst.get(k) or []
|
||||
if len(srcs) == 1 and len(dsts) == 1:
|
||||
pair[srcs[0]] = dsts[0]
|
||||
return pair
|
||||
|
||||
pairs: Dict[int, int] = {}
|
||||
# 锚点优先级(2026-10-12 实测,4 份全新在线构建):
|
||||
# 锚点 跨构建唯一可配对
|
||||
# ① 语义名(案例§5.3 指纹) 35/37 <- 主锚点
|
||||
# ② 静态语义指纹 fp 29/37
|
||||
# ③ 指令直方图 hist 20/37
|
||||
# ④ 结构签名 value_sig (仅双方都有实测值时)
|
||||
# 所以:语义名先用,再逐级降级。每一级都要求**双向唯一**才配对。
|
||||
|
||||
# ① 语义名(案例 §5.3:字符串指纹 + 结构兜底 + 重名消歧)
|
||||
pairs.update(bipartite([n or "" for n in src.names],
|
||||
[n or "" for n in dst.names]))
|
||||
# ②③ 降级:静态语义指纹 -> 指令直方图
|
||||
for key in (lambda s: [f or "" for f in s.fps],
|
||||
lambda s: [h or "" for h in s.hists]):
|
||||
for i, j in bipartite(key(src), key(dst)).items():
|
||||
pairs.setdefault(i, j)
|
||||
# ④ 末级:结构签名(仅在双方都有实测值时可用)
|
||||
sig_pair = bipartite([value_sig(v) for v in src.sample_values],
|
||||
[value_sig(v) for v in dst.sample_values])
|
||||
for i, j in sig_pair.items():
|
||||
pairs.setdefault(i, j)
|
||||
|
||||
out: List[Tuple[int, int]] = []
|
||||
for i, (_i, _m, sslot, n) in enumerate(src.assign):
|
||||
j = pairs.get(i)
|
||||
if j is None or j >= len(dst.assign):
|
||||
continue
|
||||
_dj, _dm, dslot, dn = dst.assign[j]
|
||||
if dn != n:
|
||||
continue # 槽数形状不一致 -> 拒绝猜,跳过
|
||||
for k in range(n):
|
||||
out.append((sslot + k, dslot + k))
|
||||
return out
|
||||
|
||||
|
||||
def reorder(sample: Sequence[Any], dst: "SlotSpec",
|
||||
src: Optional["SlotSpec"] = None,
|
||||
fill: Optional[Dict[int, Any]] = None) -> List[Any]:
|
||||
"""把「源样本」的 cd 值重排成**目标构建**的槽位序。
|
||||
|
||||
⚠️ 旧实现的 bug(2026-10-12 修复):
|
||||
旧版签名是 `reorder(sample, spec)`,其中 `spec` 只是**目标构建**的规格,
|
||||
而循环里 `sample[slot:slot+n]` 用的 `slot` 恰恰是**目标构建自己的槽下标**。
|
||||
两次构建的槽位序不同(#31),所以这等价于"按目标下标切源数组" —— 顺序不同
|
||||
时全盘错位,顺序相同时才碰巧对。**必须同时给出源构建的规格**才能对齐。
|
||||
|
||||
参数:
|
||||
sample - 源构建产出的 cd 值数组(真机明文)
|
||||
dst - 目标构建的槽位规格(本次要发包的那个构建)
|
||||
src - 源构建的槽位规格。**不传则退化为旧行为**(仅当两份构建同序时才正确,
|
||||
此时本函数会先校验再放行,不再静默错位)。
|
||||
fill - {目标槽号: 值},用于补齐锚点匹配不上的槽。
|
||||
|
||||
锚点能力上限(2026-12 实测,见 tdc_layout.static_fingerprint / opcode_histogram):
|
||||
静态锚点组合 `fp+hist` 单构建内唯一 **29/37**,跨构建交集 35。
|
||||
剩下 8 项(分成 2 组)**静态不可区分**:它们的函数体只有一处 JUMP 目标不同,
|
||||
语义藏在跳转目标里,符号求值取不到。这些槽必须由调用方用 `fill` 显式给值。
|
||||
|
||||
未给 fill 且仍对不齐的槽 -> 抛 `UnmappedSlotsError`(宁可暴露"不知道",
|
||||
也不要静默填错值 —— 旧实现正是静默填错,导致多轮实验建立在坏数据上)。
|
||||
"""
|
||||
if src is None:
|
||||
# 契约:不提供源构建规格 = 调用方**承诺**样本与目标构建同序。
|
||||
# 此时重排只能是恒等映射;先把槽数校验掉,再直接返回副本。
|
||||
if len(sample) != dst.n_slots:
|
||||
raise ValueError(
|
||||
f"样本槽数 {len(sample)} != 目标构建槽数 {dst.n_slots};"
|
||||
f"未提供源构建规格时不跨构建重排(见 PROGRESS.md #31)")
|
||||
out: List[Any] = [_UNMAPPED] * dst.n_slots
|
||||
for d in range(dst.n_slots):
|
||||
out[d] = sample[d]
|
||||
for d, v in (fill or {}).items():
|
||||
if 0 <= d < dst.n_slots:
|
||||
out[d] = v
|
||||
return out
|
||||
|
||||
if len(sample) != src.n_slots:
|
||||
raise ValueError(
|
||||
f"样本槽数 {len(sample)} != 源构建槽数 {src.n_slots};"
|
||||
f"样本必须来自 `src` 描述的那个构建")
|
||||
|
||||
pairs = build_anchor_map(src, dst)
|
||||
# 用哨兵而不是 None 标记"未映射":真机 cd 里本身就有 null 值,
|
||||
# 拿 None 当占位符会把合法的 null 槽误判成"没对齐"。
|
||||
out: List[Any] = [_UNMAPPED] * dst.n_slots
|
||||
for s_slot, d_slot in pairs:
|
||||
out[d_slot] = sample[s_slot]
|
||||
unmapped = [d for d, v in enumerate(out) if v is _UNMAPPED]
|
||||
for d, v in (fill or {}).items():
|
||||
if 0 <= d < dst.n_slots:
|
||||
out[d] = v
|
||||
if d in unmapped:
|
||||
unmapped.remove(d)
|
||||
if unmapped:
|
||||
raise UnmappedSlotsError(unmapped, dst, src)
|
||||
return out
|
||||
|
||||
|
||||
class UnmappedSlotsError(RuntimeError):
|
||||
"""有目标槽找不到源槽与之对应(锚点匹配失败)。"""
|
||||
|
||||
def __init__(self, slots: List[int], dst: "SlotSpec", src: "SlotSpec") -> None:
|
||||
self.slots = slots
|
||||
super().__init__(
|
||||
f"目标构建有 {len(slots)} 个槽无法与源样本对齐(槽号 {slots[:12]}"
|
||||
f"{'...' if len(slots) > 12 else ''})。"
|
||||
f"锚点不足时宁可报错也不要静默填错值 —— 请用 `--values` 显式提供这些槽的取值,"
|
||||
f"或检查两份构建是否来自同一采集器集合。")
|
||||
|
||||
|
||||
def outer_commas(s: str) -> int:
|
||||
"""数**不在**任何 `[]` / `{}` 内的逗号个数(方括号内的逗号不构成顶层分隔)。"""
|
||||
depth = 0
|
||||
@@ -69,11 +266,27 @@ class SlotSpec:
|
||||
|
||||
def __init__(self, seq: Sequence[int],
|
||||
kinds: Optional[Sequence[Any]] = None,
|
||||
v0s: Optional[Sequence[str]] = None) -> None:
|
||||
v0s: Optional[Sequence[str]] = None,
|
||||
fps: Optional[Sequence[Optional[str]]] = None,
|
||||
hists: Optional[Sequence[str]] = None,
|
||||
names: Optional[Sequence[Optional[str]]] = None) -> None:
|
||||
self.seq = list(seq)
|
||||
self.kinds = list(kinds) if kinds is not None else [None] * len(seq)
|
||||
# v0s[i] = 该项 get()[0] 的字符串化结果;kind=1 时按它的逗号数切出额外槽
|
||||
self.v0s = list(v0s) if v0s is not None else [""] * len(seq)
|
||||
# fps[i] = 该项的**环境语义指纹**(跨构建稳定, 见 tdc_layout.env_fingerprint);
|
||||
# None/空串 表示该项读不到环境路径(常量槽 / 轨迹槽),需退到值签名或直接填值
|
||||
self.fps: List[Optional[str]] = list(fps) if fps is not None else [None] * len(seq)
|
||||
# hists[i] = 该项的**指令直方图**(兜底锚点,覆盖 37/37,见 tdc_layout.opcode_histogram)
|
||||
self.hists: List[str] = list(hists) if hists is not None else [""] * len(seq)
|
||||
# names[i] = 该项的**语义名**——案例 §5.3 的字符串+结构指纹(主锚点)。
|
||||
# 实测 4 份全新在线构建:37/37 识别、0 unknown、跨构建完全一致、**35/37 唯一可配对**。
|
||||
# 明显优于本项目早期自创的 fp/hist 锚点(29/37),所以放在最前面用。
|
||||
self.names: List[Optional[str]] = (
|
||||
list(names) if names is not None else [None] * len(seq))
|
||||
# 该构建下的一份**实测 cd 值**(源侧重排时要用它算结构签名)。
|
||||
# 目标侧可以留空;只有当成"源规格"参与 build_anchor_map 时才必须给。
|
||||
self.sample_values: List[Any] = []
|
||||
self.assign: List[Tuple[int, int, int, int]] = [] # (item, module, first_slot, n_slots)
|
||||
self._build()
|
||||
|
||||
@@ -130,20 +343,40 @@ def load_sample_cd(path: str) -> List[Any]:
|
||||
return raw["cd"] if isinstance(raw, dict) else raw
|
||||
|
||||
|
||||
def reorder(sample: Sequence[Any], spec: SlotSpec) -> List[Any]:
|
||||
"""把"上一份 cd"的值按**本次构建**的槽位序重排。
|
||||
def spec_from_js(path: str, kinds_path: Optional[str] = None,
|
||||
sample_cd: Optional[Sequence[Any]] = None) -> "SlotSpec":
|
||||
"""从一份 tdc.js 直接构造该构建的槽位规格(含环境语义指纹)。"""
|
||||
prog = tp.load(path)
|
||||
_mid, _entry, seq = tdc_layout.find_manifest_module(prog)
|
||||
kinds, v0s = load_shape(kinds_path, seq) if kinds_path else (None, None)
|
||||
fps = tdc_layout.item_fingerprints(prog, seq)
|
||||
hists = tdc_layout.item_histograms(prog, seq)
|
||||
# 主锚点:案例 §5.3 的字符串+结构指纹语义名(实测 35/37 唯一可配对)
|
||||
# 复用已加载的 prog(analyze_prog),避免把同一份 tdc.js 再解析一遍。
|
||||
# 不做静默降级(AGENTS.md 附录 A#8):锚点算不出来就抛错,
|
||||
# 让调用方明确知道"这次没有主锚点",而不是悄悄退化到较弱的锚点上。
|
||||
import tdc_modules
|
||||
names = [r["name"] for r in tdc_modules.analyze_prog(prog)]
|
||||
spec = SlotSpec(seq, kinds, v0s, fps, hists, names)
|
||||
if sample_cd is not None:
|
||||
spec.sample_values = list(sample_cd)
|
||||
return spec
|
||||
|
||||
原理(#31):同一台机器上的多份样本,**取值集合相同**、只有顺序不同;
|
||||
所以只要样本与本次构建的"槽位数一致",就能按"项 -> 槽"的对应关系做置换。
|
||||
|
||||
def spec_from_seq(seq: Sequence[int],
|
||||
kinds: Optional[Sequence[Any]] = None,
|
||||
names: Optional[Sequence[Optional[str]]] = None,
|
||||
sample_cd: Optional[Sequence[Any]] = None,
|
||||
v0s: Optional[Sequence[str]] = None) -> "SlotSpec":
|
||||
"""只用清单序列构造规格(**不需要 tdc.js**,供测试/合成用例使用)。
|
||||
|
||||
纯算重排只需要「每一项占几槽」+「每项的锚点名」;
|
||||
tdc.js 只用来**导出**这两样东西,本身不是重排的输入。
|
||||
"""
|
||||
if len(sample) != spec.n_slots:
|
||||
raise ValueError(
|
||||
f"样本槽数 {len(sample)} != 本次构建槽数 {spec.n_slots};"
|
||||
f"两者必须来自同一环境(见 PROGRESS.md #31)")
|
||||
out: List[Any] = []
|
||||
for (_i, _m, slot, n) in spec.assign:
|
||||
out.extend(sample[slot:slot + n])
|
||||
return out
|
||||
spec = SlotSpec(seq, kinds, v0s, names=names)
|
||||
if sample_cd is not None:
|
||||
spec.sample_values = list(sample_cd)
|
||||
return spec
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -162,7 +395,10 @@ def encrypt_plain(plain: str, key: Sequence[int]) -> str:
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="按本次构建生成 collect")
|
||||
ap.add_argument("path", help="本次会话的 tdc.js")
|
||||
ap.add_argument("path", help="本次会话的 tdc.js(**目标构建**)")
|
||||
ap.add_argument("--src-build", default=None,
|
||||
help="源样本所属的那份 tdc.js。**跨构建重排必须给**;"
|
||||
"不给则假定源样本与目标构建同序(并会校验槽数)")
|
||||
ap.add_argument("--from-sample", default=None, help="一份已解密的 cd 数组(json)")
|
||||
ap.add_argument("--values", default=None, help="语义名->值的 json 字典(实验性)")
|
||||
ap.add_argument("--out", default=None, help="输出 collect(base64) 的文件")
|
||||
@@ -176,24 +412,37 @@ def main() -> int:
|
||||
key = extract_xtea_key_pure(open(args.path, encoding="utf-8", errors="replace").read())["key"]
|
||||
|
||||
kinds, v0s = load_shape(args.kinds, seq) if args.kinds else (None, None)
|
||||
spec = SlotSpec(seq, kinds, v0s)
|
||||
fps = tdc_layout.item_fingerprints(prog, seq)
|
||||
hists = tdc_layout.item_histograms(prog, seq)
|
||||
names = [r["name"] for r in tdc_modules.analyze_prog(prog)]
|
||||
dst = SlotSpec(seq, kinds, v0s, fps, hists, names)
|
||||
anchored = sum(1 for f in fps if f)
|
||||
print(f"文件 : {args.path}")
|
||||
print(f"清单模块 : registry[{_mid}] 项数 {len(seq)}")
|
||||
print(f"槽位规格 : {spec.describe()}")
|
||||
print(f"槽位规格 : {dst.describe()}")
|
||||
print(f"锚点覆盖 : 语义名 {sum(1 for n in names if n and n != 'unknown')}/{len(seq)}"
|
||||
f" 静态指纹 {anchored}/{len(seq)}")
|
||||
print(f"XTEA 密钥 : {key}")
|
||||
|
||||
if args.from_sample:
|
||||
sample = load_sample_cd(args.from_sample)
|
||||
if args.src_build:
|
||||
src = spec_from_js(args.src_build, args.kinds, sample)
|
||||
else:
|
||||
src = None
|
||||
try:
|
||||
cd = reorder(sample, spec)
|
||||
except ValueError as exc:
|
||||
cd = reorder(sample, dst, src)
|
||||
except (ValueError, UnmappedSlotsError) as exc:
|
||||
print(f"❌ {exc}")
|
||||
return 1
|
||||
print(f"样本槽数 : {len(sample)} -> 重排后 {len(cd)}")
|
||||
if args.src_build:
|
||||
# 自证:锚点对齐后,值集合应当守恒(易变槽数值允许不同)
|
||||
print(f"对齐方式 : 环境语义指纹 + 结构签名(源={args.src_build})")
|
||||
elif args.values:
|
||||
vals = json.load(open(args.values, encoding="utf-8"))
|
||||
cd = []
|
||||
for (_i, m, _slot, n) in spec.assign:
|
||||
for (_i, m, _slot, n) in dst.assign:
|
||||
v = vals[str(m)]
|
||||
cd.extend(v[:n] if isinstance(v, list) else [v])
|
||||
print(f"按语义字典 : {len(cd)} 槽")
|
||||
|
||||
+82
-1
@@ -35,8 +35,9 @@ import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
@@ -152,6 +153,86 @@ def slot_semantics(p: tp.Program, reg: Dict[int, int], mid: int, depth: int = 2)
|
||||
return out
|
||||
|
||||
|
||||
_ENV_RE = re.compile(r"c\.|screen|navigator|document|window")
|
||||
_DELEG_RE = re.compile(r"r\d+\(\d*\)\s*$")
|
||||
|
||||
|
||||
def _is_delegation(e: str) -> bool:
|
||||
"""纯模块委派 `r5(22)` / `r5()` —— 括号里的模块号每份构建重排,不能进指纹。"""
|
||||
return bool(_DELEG_RE.fullmatch(e))
|
||||
|
||||
|
||||
def static_fingerprint(p: tp.Program, reg: Dict[int, int], mid: int,
|
||||
depth: int = 4, keep: int = 3) -> str:
|
||||
"""模块的**静态语义指纹** —— 跨构建对齐的主锚点(2026-10-12 实测)。
|
||||
|
||||
为什么不用模块 id(实测证伪):模块 id 是编译器序号,每份构建重新洗牌。
|
||||
两份构建清单交集的 28 个模块里 `id -> 语义` 完全相同的只有 **1/11**
|
||||
(模块9 在 A 是 canvas,在 B 是 webkitRTCPeerConnection)。按 id 对齐必错位。
|
||||
|
||||
构造规则(两条,都是为了抹掉"会重排"的部分、留下"不会变"的部分):
|
||||
1. 丢掉纯模块委派 `r5(22)` —— 括号里的模块号会重排;
|
||||
2. 其余符号表达式**排序**后取前 keep 条 —— 排序消除遍历顺序差异。
|
||||
|
||||
实测(har/1ms_dianxuan/tdc.js vs live/tdc_fresh.js):
|
||||
* 覆盖 29/37 项(比只取"环境路径"的 24/37 高不少 —— 常量槽、结构槽也有稳定形状)
|
||||
* 两份构建指纹**多重集交集 27/29**
|
||||
* 单构建内 **27/29 唯一**(只有 "Not supported | createElement(canvas)" 这一组重复)
|
||||
"""
|
||||
keepers: List[str] = []
|
||||
for e in slot_semantics(p, reg, mid, depth=depth):
|
||||
e = e.strip()
|
||||
if not e or _is_delegation(e):
|
||||
continue
|
||||
keepers.append(e)
|
||||
return " | ".join(sorted(set(keepers))[:keep])
|
||||
|
||||
|
||||
def env_fingerprint(p: tp.Program, reg: Dict[int, int], mid: int,
|
||||
depth: int = 4, keep: int = 3) -> str:
|
||||
"""只保留**读环境**的表达式(`c.`/screen/navigator/document/window)。
|
||||
|
||||
覆盖比 `static_fingerprint` 低(24/37 vs 29/37),仅在需要"这一槽读什么环境"
|
||||
这种**人类可读标签**时使用。做**对齐**请用 static_fingerprint。
|
||||
"""
|
||||
keepers: List[str] = []
|
||||
for e in slot_semantics(p, reg, mid, depth=depth):
|
||||
e = e.strip()
|
||||
if not e or _is_delegation(e) or not _ENV_RE.search(e):
|
||||
continue
|
||||
keepers.append(e)
|
||||
return " | ".join(sorted(set(keepers))[:keep])
|
||||
|
||||
|
||||
def opcode_histogram(p: tp.Program, reg: Dict[int, int], mid: int) -> str:
|
||||
"""模块的**指令直方图** —— 覆盖 37/37、跨构建多重集交集 37/37 的兜底锚点。
|
||||
|
||||
有些采集器(常量槽、纯闭包槽)的符号求值拿不到表达式(实测 8/37 项返回空串),
|
||||
但它们**函数体的 opcode 分布**是稳定的:编译器把同一份源码编成同一串指令,
|
||||
只是模块 id 换了。实测两份构建 37/37 全部匹配。
|
||||
|
||||
缺点:唯一性差一些(单构建内 20/37 唯一),所以只能当**第三级**锚点,
|
||||
在静态指纹匹配不上时兜底,并且同样要求"双向唯一"才配对。
|
||||
"""
|
||||
entry = reg.get(mid)
|
||||
if entry is None:
|
||||
return ""
|
||||
counts = Counter(inst["name"] for inst in p.function_code(entry))
|
||||
return "|".join(f"{k}:{v}" for k, v in sorted(counts.items()))
|
||||
|
||||
|
||||
def item_fingerprints(p: tp.Program, seq: Sequence[int]) -> List[str]:
|
||||
"""按清单顺序, 给每一项算出静态语义指纹(与 seq 等长)。"""
|
||||
reg = dict(p.module_registry(0))
|
||||
return [static_fingerprint(p, reg, m) for m in seq]
|
||||
|
||||
|
||||
def item_histograms(p: tp.Program, seq: Sequence[int]) -> List[str]:
|
||||
"""按清单顺序, 给每一项算出指令直方图(与 seq 等长, 兜底锚点)。"""
|
||||
reg = dict(p.module_registry(0))
|
||||
return [opcode_histogram(p, reg, m) for m in seq]
|
||||
|
||||
|
||||
def find_manifest_module(p: tp.Program) -> Tuple[int, int, List[int]]:
|
||||
"""找"清单模块": 体内 `push(require(N))` 次数最多者。
|
||||
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
tdc_modules.py —— **按案例 §5.3 的字符串指纹识别 37 个采集器模块**。
|
||||
|
||||
> "从 v1 的全部错位到指纹方案的全部正确,关键不是更复杂的分析,
|
||||
> 而是换了一个更好的问题 —— 不再问『这个模块返回什么值』,
|
||||
> 而是问『这个模块的代码长什么样』。" —— docs/tdc-案例.md §5.3
|
||||
|
||||
为什么必须做这件事(2026-10-12 实测,推翻项目早期假设):
|
||||
* **模块 id 每份构建重新分配**。两份构建清单交集的 28 个模块里,
|
||||
`id -> 语义` 完全相同的只有 **1/11**(模块 8 在 A 是 `navigator.platform`,
|
||||
在 B 是 canvas 指纹)。按 id 对齐必然错位。
|
||||
* 但**模块代码里的字符串集合跨构建完全不变**:采集 userAgent 的模块
|
||||
一定含 `'userAgent'`;采集时区的一定含 `'getTimezoneOffset'`。这就是不变量。
|
||||
|
||||
本模块做三件事:
|
||||
1. `module_strings()` —— 取一个模块的**可达**字符串集合(含内部闭包,递归委派)
|
||||
2. `resolve_delegate()` —— 解析**委派链**(见下)
|
||||
3. `identify()` —— 用指纹规则表把模块判成语义名
|
||||
|
||||
委派链(本项目新发现,案例未提但实测必需):
|
||||
清单里有 6 个模块**自身不采集任何东西**,身体只是 `exports.get = require(N)`:
|
||||
项8 模块21 -> require(22)
|
||||
项9 模块24 -> require(25)
|
||||
项27 模块49 -> require(31)
|
||||
项31 模块53 -> require(0)
|
||||
它们的字符串集合只有 {'Array','Object','exports','get'} —— **零区分性**,
|
||||
必须顺着委派链跟到真正的采集器才能识别。这 6 个就是案例 §5.2 说的
|
||||
"6 个无 .get 模块"的来源之一。
|
||||
|
||||
用法:
|
||||
from tdc_modules import analyze
|
||||
info = analyze("har/1ms_dianxuan/tdc.js")
|
||||
for row in info:
|
||||
print(row["item"], row["module"], row["name"], row["strings"][:4])
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
if str(ROOT / "tools") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tdc_layout # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 通用字符串(每个模块都有,无区分性,进指纹前必须剔除) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
NOISE: Set[str] = {
|
||||
"Array", "Object", "exports", "get", "Math", "RegExp", "String", "Number",
|
||||
"JSON", "set", "reset", "on", "length", "slice", "prototype", "constructor",
|
||||
"push", "apply", "call", "bind", "toString", "valueOf", "hasOwnProperty",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 指纹规则表(案例 §5.3)—— 每个语义名对应一组「必须出现的字符串」 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 形式: 语义名 -> (必需串集合, 权重提示)
|
||||
# 判定: 模块的区分串集合 **包含** 某个规则的必需串 -> 命中该规则
|
||||
# 形式: (语义名, 必需串, 禁用串)
|
||||
# 判定: required ⊆ strings 且 forbidden ∩ strings == ∅
|
||||
#
|
||||
# ⚠️ 规则必须**按 specificity 从高到低排**(必需串多的在前)。
|
||||
# 早期版本只按列表顺序取第一个命中,结果 webdriver_detect 被误判成 user_agent
|
||||
# —— 两者都只要求 1 个串,而 user_agent 恰好排在前面。
|
||||
# 给特征明显的规则补上更多必需串(如 webdriver 要求 2 个自动化标记),
|
||||
# 它们就会天然压过 user_agent 这类单串规则。
|
||||
#
|
||||
# fp_salt 之所以需要 forbidden: 它和 iframe_detect 的串集合几乎相同
|
||||
# (都是 appendChild/body/createElement/document),唯一区别是 iframe_detect
|
||||
# 多一个 contentWindow。用禁用串把两者分开。
|
||||
FINGERPRINTS: List[Tuple[str, Set[str], Set[str]]] = [
|
||||
# ---- 复合指纹(多必需串,天然压过单串规则)----
|
||||
("screen_fingerprint", {"screen", "width", "height", "availHeight", "colorDepth"}, set()),
|
||||
("webrtc_ip", {"RTCPeerConnection", "createDataChannel", "createOffer"}, set()),
|
||||
("webgl_renderer", {"UNMASKED_RENDERER_WEBGL", "WEBGL_debug_renderer_info"}, set()),
|
||||
("webgl_vendor", {"UNMASKED_VENDOR_WEBGL", "WEBGL_debug_renderer_info"}, set()),
|
||||
("webdriver_detect", {"$cdc_asdjflasutopfhvcZLmcfl_", "__webdriver_script_fn"}, set()),
|
||||
("canvas_fingerprint", {"ClientJS,org <canvas> 1.0", "#069"}, set()),
|
||||
("dom_compliance", {"::marker", "CSS"}, set()),
|
||||
("template_url", {"?rand=*", "URL"}, set()),
|
||||
("charset", {"characterSet", "charset"}, set()),
|
||||
("viewport_size", {"clientWidth", "clientHeight"}, set()),
|
||||
("time_since_load", {"getTime", "load"}, set()),
|
||||
# ---- 单串规则(放最后,避免压过上面的复合规则)----
|
||||
("user_agent", {"userAgent"}, set()),
|
||||
("platform", {"platform"}, set()),
|
||||
("languages", {"languages"}, set()),
|
||||
("hardware_concurrency", {"hardwareConcurrency"}, set()),
|
||||
("cookie_enabled", {"cookieEnabled"}, set()),
|
||||
("timezone_offset", {"getTimezoneOffset"}, set()),
|
||||
("itoken", {"TDC_itoken"}, set()),
|
||||
("guid_token", {r"^\d+:\d+$"}, set()),
|
||||
("referrer", {"TCaptchaReferrer"}, set()),
|
||||
("location_href", {"href"}, {"?rand=*", "TCaptchaReferrer"}),
|
||||
("iframe_detect", {"contentWindow"}, set()),
|
||||
("console_detect", {"console"}, set()),
|
||||
("touch_detect", {"ontouchstart"}, set()),
|
||||
("window_time", {"getTime"}, set()),
|
||||
("constant_98k", {"98k"}, set()),
|
||||
("trajectory", {"stringify"}, set()),
|
||||
("screen_width", {"screen", "width"}, set()),
|
||||
("screen_height", {"screen", "height"}, set()),
|
||||
("screen_color_depth", {"colorDepth"}, set()),
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 结构兜底规则(案例 §5.3: "对于没有区分性字符串的通用模块,
|
||||
# 用 .get 函数的指令数 + entry 函数的指令数 + 特定指令来区分")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 形式: (语义名, 判定函数) —— 只在**字符串规则全落空**时启用。
|
||||
#
|
||||
# ⚠️ 命名纪律:下面这些名字**只描述「观测到的结构分组」,不猜测业务语义**。
|
||||
# 早期版本把 entry48/get9/call1 那组臆断成 "itoken_relay" —— 那是没有证据的猜测。
|
||||
# 现在统一用 "struct_<entry>_<get>[_call]" 这种**纯结构标签**:
|
||||
# 它诚实地表达「我们只知道它长这样,还不知道它采集什么」,
|
||||
# 同时又因为跨构建稳定,仍然可以当**对齐锚点**用。
|
||||
#
|
||||
# 实测(4 份全新在线构建 har/online/20261012-01*,2026-10-12):
|
||||
# entry135/get121/… -> 每份构建恰好 1 个 => fp_salt(随机串每次都变,只能靠结构)
|
||||
# entry46/get9/… -> 每份构建恰好 1 个 => 稳定分组
|
||||
# entry48/get9/call1 -> 每份构建恰好 1 个 => 稳定分组
|
||||
# entry40/get9/… -> 每份构建 **2 个** => 组内无法再分,需运行时取样才能定
|
||||
STRUCT_RULES = [
|
||||
("fp_salt", lambda s: s["entry_instr"] >= 120 and s["get_instr"] >= 100),
|
||||
("struct_46_9", lambda s: s["entry_instr"] == 46 and s["get_instr"] == 9),
|
||||
("struct_48_9_call", lambda s: s["entry_instr"] == 48 and s["get_instr"] == 9
|
||||
and s["call_func"]),
|
||||
("struct_40_9", lambda s: s["entry_instr"] == 40 and s["get_instr"] == 9),
|
||||
]
|
||||
|
||||
|
||||
def struct_signature(p: tp.Program, reg: Dict[int, int], mid: int) -> Dict:
|
||||
"""取一个模块的**结构签名**(案例 §5.3 兜底用)。
|
||||
|
||||
entry_instr = 模块工厂函数体的指令数
|
||||
get_instr = `.get` 闭包(JUMP 目标)函数体的指令数
|
||||
"""
|
||||
entry = reg.get(mid)
|
||||
if entry is None:
|
||||
return {"entry_instr": -1, "get_instr": -1, "call_func": False,
|
||||
"closure_kind": ""}
|
||||
code = p.function_code(entry)
|
||||
ops = Counter(inst["name"] for inst in code)
|
||||
get_n = -1
|
||||
for inst in code:
|
||||
if inst["name"] == "JUMP" and inst["operands"]:
|
||||
try:
|
||||
get_n = len(p.function_code(inst["operands"][0]))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return {
|
||||
"entry_instr": len(code),
|
||||
"get_instr": get_n,
|
||||
"call_func": ops.get("CALL_FUNC", 0) > 0,
|
||||
"closure_kind": _closure_kind(p, entry),
|
||||
}
|
||||
|
||||
|
||||
def _closure_kind(p: tp.Program, entry: int) -> str:
|
||||
"""模块唯一闭包**最后写入什么** —— 用于拆开结构完全相同的模块。
|
||||
|
||||
背景(2026-10-12,4 份全新在线构建):
|
||||
有 2 个模块 entry 都是 40 条、.get 都是 9 条、指令逐条相同,
|
||||
只差一处 JUMP 绝对地址(每构建不同,不能当特征)。
|
||||
真正的区别在**闭包体**(CREATE_CLOSURE 的 target,不是 JUMP 目标):
|
||||
|
||||
A: new Array()[0] = "" (PUSH_EMPTY_STR; SET_PROP)
|
||||
B: new Array()[0] = 1558325408 (PUSH_IMM <随机int32>; SET_PROP)
|
||||
|
||||
实测每份构建恰好 empty×1 + rand×1 —— 稳定、可静态消歧。
|
||||
|
||||
返回: "empty" | "rand" | "const" | ""(无闭包)
|
||||
"""
|
||||
cls = [c for c in p.closures if p.owner(c.pc) == entry]
|
||||
if not cls:
|
||||
return ""
|
||||
try:
|
||||
body = p.function_code(cls[0].target)
|
||||
except Exception:
|
||||
return ""
|
||||
imms = [inst["operands"][0] for inst in body
|
||||
if inst["name"] == "PUSH_IMM" and inst["operands"]]
|
||||
if any(v > 1_000_000 for v in imms):
|
||||
return "rand"
|
||||
if any(inst["name"] == "PUSH_EMPTY_STR" for inst in body):
|
||||
return "empty"
|
||||
return "const" if imms else ""
|
||||
|
||||
|
||||
def _identify_struct(p: tp.Program, reg: Dict[int, int], mid: int) -> Optional[str]:
|
||||
"""字符串规则落空时的兜底识别。"""
|
||||
sig = struct_signature(p, reg, mid)
|
||||
for name, pred in STRUCT_RULES:
|
||||
try:
|
||||
if pred(sig):
|
||||
return name
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return s.strip()
|
||||
|
||||
|
||||
def module_strings(p: tp.Program, reg: Dict[int, int], mid: int,
|
||||
depth: int = 3) -> Set[str]:
|
||||
"""取一个模块的**可达**字符串集合(模块自身 + 内部闭包,递归 depth 层)。"""
|
||||
entry = reg.get(mid)
|
||||
if entry is None:
|
||||
return set()
|
||||
by_owner: Dict[int, Set[str]] = defaultdict(set)
|
||||
for pc, s in p.strings:
|
||||
by_owner[p.owner(pc)].add(s)
|
||||
|
||||
seen: Set[int] = set()
|
||||
frontier = {entry}
|
||||
out: Set[str] = set()
|
||||
for _ in range(depth):
|
||||
nxt: Set[int] = set()
|
||||
for e in frontier:
|
||||
if e in seen:
|
||||
continue
|
||||
seen.add(e)
|
||||
out |= by_owner.get(e, set())
|
||||
for c in p.closures:
|
||||
if p.owner(c.pc) == e and c.target not in seen:
|
||||
nxt.add(c.target)
|
||||
frontier = nxt
|
||||
if not frontier:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def resolve_delegate(p: tp.Program, reg: Dict[int, int], mid: int,
|
||||
max_hops: int = 6) -> List[int]:
|
||||
"""解析模块 m 的委派链: `exports.X = require(N)` 里的 N。
|
||||
|
||||
返回 [m, N1, N2, ...](含自身)。取不到委派时返回 [m]。
|
||||
|
||||
形状(实测): 模块体里出现 `LOAD_REG_IMM; PUSH_IMM N; CALL_FUNC` 且 N 在
|
||||
registry 里 -> 该模块委派给 N。有些模块 require 的是自己,要防环。
|
||||
"""
|
||||
chain: List[int] = [mid]
|
||||
seen = {mid}
|
||||
cur = mid
|
||||
for _ in range(max_hops):
|
||||
entry = reg.get(cur)
|
||||
if entry is None:
|
||||
break
|
||||
code = p.function_code(entry)
|
||||
nxt: Optional[int] = None
|
||||
for k, inst in enumerate(code):
|
||||
if inst["name"] != "PUSH_IMM" or not inst["operands"]:
|
||||
continue
|
||||
if k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM" \
|
||||
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC":
|
||||
cand = int(inst["operands"][0])
|
||||
if cand in reg and cand not in seen:
|
||||
nxt = cand
|
||||
break
|
||||
if nxt is None:
|
||||
break
|
||||
chain.append(nxt)
|
||||
seen.add(nxt)
|
||||
cur = nxt
|
||||
return chain
|
||||
|
||||
|
||||
def _has(strings: Set[str], token: str) -> bool:
|
||||
"""判断 token 是否出现在字符串集合里。
|
||||
|
||||
两种模式:
|
||||
* 普通 token -> **精确**相等
|
||||
* `前缀*` 结尾 -> **子串/前缀**匹配
|
||||
|
||||
为什么需要后者(踩过的坑): `?rand=` 在真机里是 `?rand=1519713624347`
|
||||
(带会话随机数),精确匹配永远命中不了,导致 template_url 被误判成
|
||||
location_href。凡是"带运行时随机后缀"的串都必须用前缀匹配。
|
||||
"""
|
||||
if token in strings:
|
||||
return True
|
||||
if token.endswith("*"):
|
||||
stem = token[:-1]
|
||||
return any(stem in s for s in strings)
|
||||
return False
|
||||
|
||||
|
||||
def _matches(strings: Set[str]) -> List[Tuple[str, int, Set[str]]]:
|
||||
"""列出所有命中的规则,按 specificity(必需串个数)降序。"""
|
||||
hits: List[Tuple[str, int, Set[str]]] = []
|
||||
for name, required, forbidden in FINGERPRINTS:
|
||||
if not all(_has(strings, t) for t in required):
|
||||
continue
|
||||
if any(_has(strings, t) for t in forbidden):
|
||||
continue
|
||||
hits.append((name, len(required), required))
|
||||
hits.sort(key=lambda x: -x[1])
|
||||
return hits
|
||||
|
||||
|
||||
def _identify(strings: Set[str]) -> Tuple[str, Set[str]]:
|
||||
"""用指纹规则判定语义名。返回 (名字, 命中的必需串)。
|
||||
|
||||
取**必需串最多**的那条规则(specificity 优先),而不是列表里第一条 ——
|
||||
这样 `webdriver_detect`(2 个必需串)不会被 `user_agent`(1 个)抢走。
|
||||
"""
|
||||
hits = _matches(strings)
|
||||
if not hits:
|
||||
return "unknown", set()
|
||||
return hits[0][0], hits[0][2]
|
||||
|
||||
|
||||
def disambiguate(rows: List[Dict]) -> None:
|
||||
"""给**重名**的项加稳定后缀,使 37 个锚点尽可能唯一(原地修改 name)。
|
||||
|
||||
为什么需要这一步(2026-10-12 实测,只看 har/online/ 全新样本):
|
||||
字符串规则会把不同采集器判成同一个名字。跨 4 份新构建统计,重名是**稳定的**:
|
||||
|
||||
user_agent x2 -> entry48(委派链) / entry68(直判) 每份各 1 个 ✓ 可消歧
|
||||
window_time x3 -> entry279 / entry40 / entry48(委派链) 每份各 1 个 ✓ 可消歧
|
||||
struct_40_9 x2 -> 结构完全相同 每份各 2 个 ✗ 静态不可分
|
||||
|
||||
消歧后缀用**结构签名**(entry/get 指令数 + 是否含 CALL_FUNC),因为它是
|
||||
跨构建稳定的实测分组依据,而不是猜的语义。
|
||||
|
||||
⚠️ struct_40_9 那组每份构建固定 2 个、结构完全一致 —— 这是**静态识别的能力上限**。
|
||||
要靠运行时取样(TDC.getData 对照)才能分开。本函数会如实保留重名,
|
||||
并在 row["ambiguous_static"] 上标记,调用方据此决定是否需要运行时标定。
|
||||
"""
|
||||
groups: Dict[str, List[Dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[r["name"]].append(r)
|
||||
|
||||
for name, members in groups.items():
|
||||
if len(members) <= 1:
|
||||
continue
|
||||
for r in members:
|
||||
s = r.get("struct") or {}
|
||||
tag = "e%s_g%s%s%s" % (s.get("entry_instr", "-"),
|
||||
s.get("get_instr", "-"),
|
||||
"_call" if s.get("call_func") else "",
|
||||
# 结构完全相同时,用"闭包最后写入什么"再拆一层
|
||||
("_" + s["closure_kind"]) if s.get("closure_kind") else "")
|
||||
# 同组内的 tag 是否唯一?唯一才能消歧
|
||||
r["_tag"] = tag
|
||||
tags = [r["_tag"] for r in members]
|
||||
can_split = len(set(tags)) == len(tags)
|
||||
for r in members:
|
||||
r["ambiguous_static"] = not can_split
|
||||
r["name_unique"] = "%s#%s" % (name, r["_tag"]) if can_split else name
|
||||
if can_split:
|
||||
for r in members:
|
||||
r["name"] = r["name_unique"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 主入口 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def analyze_prog(p: tp.Program, depth: int = 3) -> List[Dict]:
|
||||
"""分析一个**已加载**的 Program(供调用方复用,避免重复解析 tdc.js)。"""
|
||||
reg = dict(p.module_registry(0))
|
||||
_mid, _entry, seq = tdc_layout.find_manifest_module(p)
|
||||
|
||||
rows: List[Dict] = []
|
||||
for i, m in enumerate(seq):
|
||||
own = module_strings(p, reg, m, depth=depth)
|
||||
chain = resolve_delegate(p, reg, m)
|
||||
# 顺着委派链往下找,直到能识别出语义(或链走完)
|
||||
name, hit = _identify(own - NOISE)
|
||||
used_chain = False
|
||||
if name == "unknown" and len(chain) > 1:
|
||||
for deeper in chain[1:]:
|
||||
s = module_strings(p, reg, deeper, depth=depth)
|
||||
n2, h2 = _identify(s - NOISE)
|
||||
if n2 != "unknown":
|
||||
name, hit = n2, h2
|
||||
used_chain = True
|
||||
break
|
||||
# 字符串规则全落空 -> 启用案例 §5.3 的结构兜底(按指令数识别)
|
||||
via_struct = False
|
||||
if name == "unknown":
|
||||
n3 = _identify_struct(p, reg, m)
|
||||
if n3:
|
||||
name, hit = n3, set()
|
||||
via_struct = True
|
||||
sig = sorted((own - NOISE))
|
||||
rows.append({
|
||||
"item": i,
|
||||
"module": m,
|
||||
"name": name,
|
||||
"hit": sorted(hit),
|
||||
"delegate": chain if len(chain) > 1 else [],
|
||||
"via_delegate": used_chain,
|
||||
"via_struct": via_struct,
|
||||
"struct": struct_signature(p, reg, m),
|
||||
"ambiguous_static": False,
|
||||
"strings": sig,
|
||||
})
|
||||
disambiguate(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def analyze(path: str, depth: int = 3) -> List[Dict]:
|
||||
"""分析一份 tdc.js,返回清单每一项的识别结果(按清单顺序)。"""
|
||||
return analyze_prog(tp.load(path), depth=depth)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
import json
|
||||
|
||||
ap = argparse.ArgumentParser(description="按字符串指纹识别 37 个采集器模块")
|
||||
ap.add_argument("path")
|
||||
ap.add_argument("--json", default=None, help="把结果写成 JSON")
|
||||
ap.add_argument("--only-unknown", action="store_true", help="只列出未识别的")
|
||||
args = ap.parse_args()
|
||||
|
||||
rows = analyze(args.path)
|
||||
unknown = [r for r in rows if r["name"] == "unknown"]
|
||||
print("文件 : %s" % args.path)
|
||||
print("清单项 : %d 已识别 %d 未识别 %d"
|
||||
% (len(rows), len(rows) - len(unknown), len(unknown)))
|
||||
print()
|
||||
print("项 模块 语义名 委派链 区分串")
|
||||
print("-" * 108)
|
||||
for r in rows:
|
||||
if args.only_unknown and r["name"] != "unknown":
|
||||
continue
|
||||
dele = "->".join(str(x) for x in r["delegate"]) if r["delegate"] else ""
|
||||
mark = " *" if r["via_delegate"] else " "
|
||||
print("%2d %-5d %s%-21s %-17s %s"
|
||||
% (r["item"], r["module"], mark, r["name"], dele,
|
||||
r["strings"][:5]))
|
||||
if args.json:
|
||||
Path(args.json).write_text(
|
||||
json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
print("\n结果 -> %s" % args.json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+19
-14
@@ -193,6 +193,11 @@ class Program:
|
||||
def top() -> Tuple[str, str]:
|
||||
return st[-1] if st else ("sym", "?")
|
||||
|
||||
# 栈上元素形如 ("reg", int) | ("sym", str):`[1]` 可能是 int,
|
||||
# 插值进 f-string 前必须统一转 str(早期版本在 CALL_* 处直接 join 会 TypeError)。
|
||||
def txt(x: Tuple[str, object]) -> str:
|
||||
return str(x[1]) if isinstance(x, tuple) else str(x)
|
||||
|
||||
def popn(n: int) -> List[Tuple[str, str]]:
|
||||
r = st[-n:] if n else []
|
||||
del st[len(st) - n:]
|
||||
@@ -224,44 +229,44 @@ class Program:
|
||||
st.append(("sym", regs.get(int(v), f"r{v}") if str(v).isdigit() else v))
|
||||
elif n == "BIND_THIS":
|
||||
k, v = st.pop() if st else ("sym", "?")
|
||||
st.append(("sym", f"c.{v}"))
|
||||
st.append(("sym", f"c.{txt(('sym', v))}"))
|
||||
elif n == "MAKE_PROP_REF":
|
||||
key = top()[1]
|
||||
key = txt(top())
|
||||
popn(1)
|
||||
base = top()[1]
|
||||
base = txt(top())
|
||||
popn(1)
|
||||
st.append(("sym", f"{base}.{key}"))
|
||||
elif n == "MAKE_REG_PROP_REF":
|
||||
key = top()[1]
|
||||
key = txt(top())
|
||||
popn(1)
|
||||
k, v = st.pop() if st else ("sym", "?")
|
||||
st.append(("sym", f"{regs.get(v, f'r{v}')}.{key}"))
|
||||
elif n == "GET_GLOBAL_PROP":
|
||||
st[-1] = ("sym", f"c.{top()[1]}")
|
||||
st[-1] = ("sym", f"c.{txt(top())}")
|
||||
elif n in ("GET_PROP", "GET_ELEM"):
|
||||
pass
|
||||
elif n in ("ADD", "SUB", "MUL", "DIV", "MOD", "BIT_AND", "BIT_OR", "BIT_XOR",
|
||||
"SHL", "SHR", "SHR_U", "EQ", "EQ_STRICT", "GREATER"):
|
||||
if len(st) >= 2:
|
||||
b = top()[1]
|
||||
b = txt(top())
|
||||
popn(1)
|
||||
a = top()[1]
|
||||
a = txt(top())
|
||||
popn(1)
|
||||
st.append(("sym", f"({a} {n} {b})"))
|
||||
elif n in ("NEW_FUNC", "NEW_METHOD"):
|
||||
args = popn(int(ops[0]) if ops else 0)
|
||||
fn = st.pop()[1] if st else "?"
|
||||
st.append(("sym", f"new {fn}({','.join(x[1] for x in args)})"))
|
||||
fn = txt(st.pop()) if st else "?"
|
||||
st.append(("sym", f"new {fn}({','.join(txt(x) for x in args)})"))
|
||||
elif n == "CALL_FUNC":
|
||||
args = popn(int(ops[0]) if ops else 0)
|
||||
fn = st.pop()[1] if st else "?"
|
||||
st.append(("sym", f"{fn}({','.join(x[1] for x in args)})"))
|
||||
fn = txt(st.pop()) if st else "?"
|
||||
st.append(("sym", f"{fn}({','.join(txt(x) for x in args)})"))
|
||||
elif n == "CALL_METHOD":
|
||||
args = popn(int(ops[0]) if ops else 0)
|
||||
tgt = st.pop() if st else ("sym", "?")
|
||||
st.append(("sym", f"{tgt[1]}({','.join(x[1] for x in args)})"))
|
||||
tgt = txt(st.pop()) if st else "?"
|
||||
st.append(("sym", f"{tgt}({','.join(txt(x) for x in args)})"))
|
||||
elif n == "STORE_REG":
|
||||
v = st.pop()[1] if st else "?"
|
||||
v = txt(st.pop()) if st else "?"
|
||||
k, ref = st.pop() if st else ("sym", "?")
|
||||
if k == "reg":
|
||||
regs[ref] = v
|
||||
|
||||
Reference in New Issue
Block a user