diff --git a/.gitignore b/.gitignore index 8b0919f..39321fb 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,11 @@ har/** *.har *.zip +# 探索性脚本与临时诊断产物(一次性、不入版本控制;可复现的验证脚本放 scripts/verify/) +scratch/ + +# 在线抓取的原始 tdc.js 样本(每份 ~78KB、每次请求都不同,属可再生数据) +# 需要时用 `scripts/fetch_online_samples.py --n 4` 重新抓取即可。 +har/online/ + diff --git a/docs/tdc-案例.md b/docs/tdc-案例.md new file mode 100644 index 0000000..49f6430 --- /dev/null +++ b/docs/tdc-案例.md @@ -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。* \ No newline at end of file diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md index d6f97fc..6454b93 100644 --- a/har/1ms_dianxuan/PROGRESS.md +++ b/har/1ms_dianxuan/PROGRESS.md @@ -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 的固定标记)。
→ 即「产槽」与「分段」**同时成立**:旧已知 #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 条,互相独立:
① 真机明文有 **3 处连续空格串**(`+5 / +10 / +24`),`\x00` 补齐不会产生空格;
② **不需要密钥**的纯算术:4 段长度 360/72/312/40 → base64 480+96+416+56 = **1048**,与真实 collect 的 1048 字符精确相等(差一个字节都不行);
③ `==` 只出现在 1046/1048,恰好对应 sd 段 33B→XTEA 补 7→40B。
→ 改为 `crypto.build_collect()`(4 段独立加密 + 空格 pad 到 24)。旧函数**仍保留但已不是主路径**。
⚠️ 注意被推翻的是**加密结构**,不是槽数模型——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//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//tdc_01_*.js + +# ③ 四段结构判决(不需要密钥:密文长度算术应精确等于 1048 / 该份的长度) +.venv/bin/python scripts/verify/verify_segment.py <已解密的 collect 明文文件> +.venv/bin/python scripts/verify/verify_seglen.py + +# ④ 指纹跨构建一致性(语义名集合应完全一致、单构建内 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 +``` diff --git a/scripts/fetch_online_samples.py b/scripts/fetch_online_samples.py new file mode 100644 index 0000000..5a8c353 --- /dev/null +++ b/scripts/fetch_online_samples.py @@ -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()) diff --git a/scripts/verify/verify_40_9_disamb.py b/scripts/verify/verify_40_9_disamb.py new file mode 100644 index 0000000..536fba2 --- /dev/null +++ b/scripts/verify/verify_40_9_disamb.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""验证 struct_40_9 的消歧特征是否跨构建稳定。 + +判别: + A 组: 闭包体最后写入 **空串** (PUSH_EMPTY_STR; SET_PROP) + B 组: 闭包体最后写入 **随机 int32** (PUSH_IMM ; 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 "仍不可分") diff --git a/scripts/verify/verify_control_negative.py b/scripts/verify/verify_control_negative.py new file mode 100644 index 0000000..4a9b2b0 --- /dev/null +++ b/scripts/verify/verify_control_negative.py @@ -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;②③④应明显更低 => 归一化未过拟合,配对有效") diff --git a/scripts/verify/verify_fp_consistency.py b/scripts/verify/verify_fp_consistency.py new file mode 100644 index 0000000..0bff1e0 --- /dev/null +++ b/scripts/verify/verify_fp_consistency.py @@ -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())))) diff --git a/scripts/verify/verify_pair_correct.py b/scripts/verify/verify_pair_correct.py new file mode 100644 index 0000000..f4ffeb6 --- /dev/null +++ b/scripts/verify/verify_pair_correct.py @@ -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 "配对有误,需检查") diff --git a/scripts/verify/verify_reorder.py b/scripts/verify/verify_reorder.py new file mode 100644 index 0000000..b87732f --- /dev/null +++ b/scripts/verify/verify_reorder.py @@ -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() diff --git a/scripts/verify/verify_seglen.py b/scripts/verify/verify_seglen.py new file mode 100644 index 0000000..c8c0610 --- /dev/null +++ b/scripts/verify/verify_seglen.py @@ -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 +""" +import base64 +import re +import sys +from pathlib import Path + +if len(sys.argv) < 2: + print("用法: verify_seglen.py ") + 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:]) diff --git a/scripts/verify/verify_segment.py b/scripts/verify/verify_segment.py new file mode 100644 index 0000000..c42f3d1 --- /dev/null +++ b/scripts/verify/verify_segment.py @@ -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:]) diff --git a/tcaptcha/client.py b/tcaptcha/client.py index d1108a9..cd1e672 100644 --- a/tcaptcha/client.py +++ b/tcaptcha/client.py @@ -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" diff --git a/tcaptcha/crypto.py b/tcaptcha/crypto.py index 2c0256d..8a5451b 100644 --- a/tcaptcha/crypto.py +++ b/tcaptcha/crypto.py @@ -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): diff --git a/tcaptcha/tests/test_collectgen.py b/tcaptcha/tests/test_collectgen.py index 4c32c7c..f026bd2 100644 --- a/tcaptcha/tests/test_collectgen.py +++ b/tcaptcha/tests/test_collectgen.py @@ -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}") diff --git a/tools/tdc_collectgen.py b/tools/tdc_collectgen.py index fd3d798..3958de1 100644 --- a/tools/tdc_collectgen.py +++ b/tools/tdc_collectgen.py @@ -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() + + +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)} 槽") diff --git a/tools/tdc_layout.py b/tools/tdc_layout.py index 325c724..5510381 100644 --- a/tools/tdc_layout.py +++ b/tools/tdc_layout.py @@ -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))` 次数最多者。 diff --git a/tools/tdc_modules.py b/tools/tdc_modules.py new file mode 100644 index 0000000..507f899 --- /dev/null +++ b/tools/tdc_modules.py @@ -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 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__[_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()) diff --git a/tools/tdc_program.py b/tools/tdc_program.py index e6d8c25..3ca1740 100644 --- a/tools/tdc_program.py +++ b/tools/tdc_program.py @@ -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