Logo
Overview

站内搜索

搜索文章

输入关键词开始搜索。

    从 DataDome 逆向开始,学会分析一段看不懂的 JavaScript

    以一次 DataDome 客户端逆向为主线,从定位 plv3 的来源,到拆解自定义 VM、追踪数据流、理解浏览器环境,再到解释为什么拿到 Cookie 仍不算成功,在具体问题中学习逆向。

    2026年10月4日
    17 min read
    从 DataDome 逆向开始,学会分析一段看不懂的 JavaScript

    借助 ai 小白也能搞 js 逆向,本次逆向的目的是在仅通过 node 环境在不使用真实浏览器的情况下过 datadome 的盾,减少资源占用。大体思路就是拿到足够的正样本,然后模拟真实浏览器获得挑战并通过挑战,拿到 cookie 来复用,可以在 node 里复用也可以在 curl_cffi 里复用。

    Note

    本文会借助 ai 写大部分的内容,同时我也会校对,写为一篇记录与 ai 边做边学的博客

    **2026-10-17 补记:**文末第 13 节新增了权限上下文调查和两次独立 Node interstitial fresh 3/3 的实测。该轮没有修改权限模型;成功仅覆盖当次受控条件,不是完整浏览器等价或生产上线的证明。

    **2026-10-18 补记:**第 14 节继续分析两处身份矛盾:“解挑战用一套身份、后续请求用另一套身份”,以及 UA-CH arch/bitness 与声明平台不一致;同时保留历史生产窗口中的 76 轮、16 篇入库及挑战分布,解释这些数字与成功率的区别。142 身份修正后仍是 0/12,153 的通过也有条件限制;重点是分清“修正确定性错误”和“证明服务端接受”,不是把一次成功写成永久结论。

    逆向 DataDome 时,有一个很容易让人提前庆祝的时刻:JavaScript 跑完了,请求也返回了 200,甚至拿到了新的 Cookie。

    然后带着这个 Cookie 去请求正文,还是 401。

    如果只把逆向理解成“把一段混淆代码还原出来”,这件事就很难解释。代码不是已经能跑了吗?字段不是已经生成了吗?究竟还有哪里不对?

    这篇文章想借分析 Reuters 页面中 DataDome 客户端的经历,讲清楚这些问题。不是先摆一整章术语,再让你自己寻找它们的用途,而是沿着调查往前走:遇到什么现象,为什么看这个位置,用到什么知识,最后能下什么结论。

    你只需要会读基本的 JavaScript,知道对象、数组、函数和 HTTP 请求是什么。不需要先背汇编指令,也不需要一开始就会写反编译器。

    本文主体依据 2026 年 8 月至 10 月初的研究记录整理;第 13—14 节单独补录 10 月 17—18 日的新实验与身份分析,不把新结果倒写成旧样本已经证明的事实。不同日期的脚本与挑战类型会变化。代码片段用于解释局部语义,不是 DataDome 原始源码,也不包含 Cookie、完整挑战请求或可复用的证明值。实际实验应在自有或获授权的环境中进行。

    阅读地图:我们会沿着哪两条线走?

    先用同一段伪代码区分两件事。下面的名字仅用于讲解,不是 DataDome 原始变量名:

    伪代码:同一段程序的两种读法
    function calculate(environment) {
    const width = environment.width
    let result
    if (width > 0) {
    result = encode(width)
    } else {
    result = 'unavailable'
    }
    return result
    }

    控制流关心执行顺序:先读 width,再判断,条件成立走 encode,不成立走另一条分支,最后返回。数据流关心值的来去:width 来自环境,经由判断或编码,影响最终的 result。一个值可以同时决定走哪条路、算出什么结果。

    章节 主要看什么 你要带着的问题
    第 1 节 数据流:从输出找生产者 plv3 是谁赋值的?
    第 2 节 装载与执行顺序 哪段脚本先运行,计算何时开始?
    第 3—4 节 控制流为主,配合栈上的数据变化 VM 在哪里,字节码怎样决定下一步?
    第 5 节 数据类型怎样改变控制流 占位函数为什么让分支走错?
    第 6—7 节 数据流与局部算法复算 数组里到底是什么,怎样核对变换?
    第 8 节 两条线共用的观测方法 怎样只追有关位置,又不丢掉来源?
    第 9 节 数据依赖与因果验证 中间值变了,为什么结果没变?
    第 10 节 环境语义对两条线的影响 对象、异常和异步时序为什么重要?
    第 11—12 节 HTTP 状态与验收边界 本地计算完成以后,还有哪些关要过?
    第 13 节 新观测与 fresh 基线 发现真实差异,就必须先修它吗?
    第 14 节 身份一致性与服务端接受 确定性错误和概率性通过,怎样分开看?

    伪代码也使用 JavaScript 写法与语法高亮;标题里的“伪代码”仍表示它依赖省略的上下文,并非可直接运行的完整程序。

    下文把代码块分成两类:标为“伪代码”的只说明关系,函数名不代表现成工具;标为“可运行”的短例子可以独立运行。所有教学字节、属性名和示意数组都不冒充真实挑战材料。

    可运行例子里,console.log(value) 会显示值;console.assert(condition) 表示“预期这个条件为真”,正常时不输出,条件为假时显示断言失败。它默认不是抛异常终止程序的测试框架。

    1. 面对几十万字节的代码,第一件事不是从头读

    最初关心的问题很具体:浏览器提交给 DataDome 的动态字段,是怎么生成的?

    在一个旧版 CAPTCHA 样本中,请求里有两个值得追踪的名字:ddCaptchaEncodedPayload 和 plv3。前者涉及行为数据等内容,后者在这个样本里来自环境检测相关的计算。这里只把它们作为该版本的定位线索,不把名字当作所有版本通用的协议定义。

    如果直接打开 bundle,也就是打包后的大型 JavaScript 文件,从第一行读起,很快就会迷失:大量短变量名、嵌套函数、常量表和间接调用,几乎都在争夺注意力。

    于是换一个方向:不问整个文件在干什么,先问请求中的这个字段是谁写进去的。

    顺着请求组装的位置向前找,我们在旧样本中确认了这样的关系:

    请求中的 plv3
    ↑
    window.plv3
    ↑
    某个模块返回的对象 o.r
    ↑
    模块内部的一段计算

    这就是“从使用点反查定义”,也可以理解成最初级的逆向数据流分析。请求组装是输出端,赋值语句是连接线,而我们要沿着连接线找生产者。

    其中一个赋值关系等价于:

    伪代码:从消费端逐级找生产者
    request.fields.plv3 = window.plv3 // 这里消费结果,不一定在这里计算
    window.plv3 = o.r // 继续查 o 的赋值
    o = runEnvironmentModule() // 再进入返回 o 的那个模块

    分析时是从第一行往下追来源;实际执行时,通常先算出 o,再赋值,最后组装请求。逆向追查方向不等于运行方向。 runEnvironmentModule 是便于讲解起的名字,不是说在混淆源码里能直接搜索到它。

    它看起来平平无奇,却把问题从“读懂整个网页”缩小到了“解释这个对象的 r 从哪里来”。

    注意,这时我们没有证明 r 正确,更没有证明服务端会接受它。我们只找到了一段可追踪的来源关系。逆向中的很多进展,就是这样一点点缩小问题,而不是突然读懂所有东西。

    2. HTML 很短,不代表逻辑不存在

    接下来遇到的是一个看似无关、实际上很基础的问题:样本到底保存完整了吗?

    早期容易把 HTML 当作程序本身。页面很短,没有熟悉的内联 VM,就怀疑服务端只给了一个终止页。但后续样本把逻辑拆到了外部 JavaScript:HTML 只是入口,真正的计算要等其他资源加载后才开始。

    因此,分析对象不能只是“一个 HTML 文件”,而应是一个资源关系:

    页面响应
    → loader
    → 挑战页面或 iframe
    → 内联初始化脚本与外部脚本
    → 事件触发后的计算
    → 请求组装

    这里顺带就遇到了浏览器的脚本执行模型。

    对普通的外部 defer 脚本来说,下载可以与 HTML 解析并行,但执行要等待文档解析完成,并保持这类脚本之间的顺序;DOMContentLoaded 又与这些脚本的执行有关。这不是“把所有代码收集好,随便拼起来执行”就能等价替代的。

    例如,前一段脚本可能只注册了事件监听器,后一段才设置输入。你提前派发事件,得到的就是另一条执行路径。把多个文件拼成一个文件,还可能改变源码位置和错误栈,而这些信息本身也可能被程序读取。

    用一个装载过程的伪代码看会更直观:

    伪代码:注册回调不等于立即执行回调
    // HTML 内联脚本:此时只是登记一个以后执行的函数。
    on('DOMContentLoaded', () => calculate(config))
    // 外部 defer 脚本:在 DOMContentLoaded 之前准备数据。
    config = initializeFromPage()
    // 文档解析与相应 defer 脚本完成后,浏览器派发事件。
    dispatch('DOMContentLoaded') // 这时 calculate 才读 config
    // 错误的离线调度:若在第二步之前派发事件,读到的可能还是 undefined。

    callback 就是登记后等待调用的函数;loader 是负责装载后续资源的入口脚本。它们不是另一种加密算法。这里的事件名是浏览器的真实概念,on、dispatch 则只是示意接口。

    这一步学到的是:程序的输入不只有函数参数,还有装载方式、执行顺序和事件发生的时机。

    我们因此把资源来源、脚本顺序、样本时间和文件 SHA-256 一起记录。哈希在这里不是用来“破解”的,而是给样本一个稳定身份证:只有确认字节相同,前后两轮结果才有资格直接比较。

    3. VM 究竟藏在哪里?先画出它和浏览器、Node 的关系

    这一节开始看控制流。 第 1 节顺着 o.r 找到的生成模块,在 8 月旧 CAPTCHA 样本中对应 bundle 内部的 detection-js/dist/vm-obf.js。这里说的模块名来自旧样本分析,不保证网络面板里有一个单独叫 vm-obf.js 的请求:模块可以被打包在更大的文件里面。

    它与其他部分的位置关系是:

    结构图:VM 不是后来额外下载的一个软件
    浏览器的 JavaScript 引擎(研究时也可以由 Node 承载 JavaScript)
    └─ 执行挑战 bundle
    ├─ 行为相关模块 → 另一部分请求材料
    └─ 环境检测模块 vm-obf.js
    ├─ 解码内部数据 → 自定义字节码
    ├─ 构建 handler 表 → 每种操作的实现函数
    └─ 解释循环 → 读取字节码,查表,调用 handler
    └─ 计算结果进入返回对象 o.r → window.plv3

    发现 VM,不是因为“代码太乱所以猜它是 VM”,而是观察到三种结构一起出现:被逐步读取的数据、不断变化的位置指针、按编号分派的处理函数。我们沿着结果来源进入模块,才碰到这层解释器。

    这里至少有三个容易重名的东西:

    • 浏览器/Node 的 JavaScript 引擎负责执行 JavaScript。
    • DataDome 旧样本的自定义 VM,是 JavaScript 写的一段解释器程序。
    • Node 的 node:vm 模块提供执行上下文,不是上述自定义指令集本身,也不是完整浏览器。

    因此,在 Node 中执行原脚本时,如果那个脚本内部有这套自定义 VM,仍然是 Node 执行外层 JavaScript,外层解释器再执行内层字节码。不是把 DataDome 的字节码直接交给 Node 当 JavaScript 运行。后来的其他挑战路线也不能被默认认为一定采用同一套 VM。

    字节码到底是什么,长什么样?

    字节码就是按照某套规则编码的指令数据。每个字节可以写成 0—255 的数字,常用十六进制展示;0x01 表示数值 1,0xff 表示 255。字节本身没有天然的指令含义,含义由解释器规定。

    我们临时定义一套教学规则,只计算 3 + 5:

    编号,也就是 opcode 我们给它起的名字 后面还要读什么 行为
    0x01 PUSH 再读 1 字节,作为数字 把数字压入栈
    0x02 ADD 没有额外参数 弹出两个值,相加后压回
    0xff HALT 没有额外参数 停止解释循环

    栈是一种后放进去的值先取出的容器。JavaScript 数组的 push 向尾部放值,pop 取出尾部的值,正好能演示这种规则。

    教学字节码:不是 DataDome 的真实字节
    字节偏移 00 01 02 03 04 05
    原始字节 01 03 01 05 02 ff
    按规则解码 PUSH 3 PUSH 5 ADD HALT

    偏移 0 的 01 是操作编号,偏移 1 的 03 是它的参数,也叫操作数;偏移 2 的 01 又开始下一条指令。不是每个字节都能当成一个 opcode。

    真实样本的数据可能先被编码或压缩,解码后还混有字符串、常量和 handler 记录。因此,看到长字符串或数字数组只能把它列为候选,还要找到“谁怎样读取它”才能判断格式。

    handler 是“处理某种指令的函数”

    handler 就是处理器函数。在这套教学规则里,handlers[0x01] 是一个函数,专门执行 PUSH;handlers[0x02] 是另一个函数,专门执行 ADD。opcode 是编号,handler 是按这个编号找到的实现,二者不是同一个东西。

    下面把指令数据和解释器放在一起,可直接运行:

    可运行:一套只有三种指令的教学 VM
    function runToyVm(program) {
    let ip = 0 // 下一次读取的字节偏移,不是源码行号
    let halted = false
    const stack = []
    function readByte() {
    if (ip >= program.length) throw new Error('字节码提前结束')
    const value = program[ip]
    ip += 1
    return value
    }
    const handlers = {
    0x01: function PUSH() {
    const operand = readByte()
    stack.push(operand)
    },
    0x02: function ADD() {
    if (stack.length < 2) throw new Error('栈里的值不够')
    const right = stack.pop()
    const left = stack.pop()
    stack.push(left + right)
    },
    0xff: function HALT() {
    halted = true
    },
    }
    while (!halted) {
    const opcode = readByte()
    const handler = handlers[opcode]
    if (!handler) throw new Error('未知指令')
    handler()
    }
    return stack.pop()
    }
    const program = new Uint8Array([0x01, 0x03, 0x01, 0x05, 0x02, 0xff])
    console.assert(runToyVm(program) === 8)

    Uint8Array 是按字节保存无符号整数的数组。上例里,解释循环先读 opcode;如果遇到 PUSH,它的 handler 还会再读一个操作数。因此 ip 不是每条指令固定加 1:

    指令起点 发生的事 执行后栈内容 下一次取指位置
    0 PUSH 读取位置 1 的数字 3 [3] 2
    2 PUSH 读取位置 3 的数字 5 [3, 5] 4
    4 ADD 弹出 5、3,压回 8 [8] 5
    5 HALT 设置停止标志 [8] 6,但不再取指

    顺着表的第一列与最后一列读,是控制流;顺着栈里的 3 → 3,5 → 8 读,是数据流。真正的 VM 复杂得多,但我们观察状态变化的方法没有变。

    4. 找到 103 个 handler,为什么还不算读懂程序?

    分析 vm 的话只能靠 ai 了。

    2026 年 8 月 9 日那个旧样本里,研究记录确认了 103 种有效 opcode handler。这个数字只属于那个样本,不是 DataDome 的固定规格。

    “认识指令”与“读懂程序”相差在哪里? 第 3 节只要知道 PUSH 与 ADD,就能读懂 3 + 5。但如果另一个 handler 表示“从某个对象上读取某个属性”,你还得知道对象和属性名是从哪来的,才能判断它究竟在读屏幕尺寸还是权限状态。

    伪代码:同一种 READ_PROPERTY 指令,业务含义可能完全不同
    const key = stack.pop()
    const object = stack.pop()
    const value = object[key]
    stack.push(value)
    // 如果 object 是 screen、key 是 "width",读取的是宽度。
    // 如果 object 是另一个对象、key 是另一字符串,就在做另一件事。
    // 看懂 handler 只解释了“怎样读取”,还没解释“这次读谁”。

    从 handler 看出分支,再画控制流

    在教学 VM 的基础上,假设增加一个 JUMP_IF_ZERO。它的 handler 可以这样写:

    伪代码:带一个字节目标地址的条件跳转
    function JUMP_IF_ZERO() {
    const target = readByte() // 取出跳转目的偏移
    const value = stack.pop()
    if (value === 0) {
    ip = target // 改变下一条指令的位置
    }
    // 否则不改 ip,继续读取后面的指令
    }

    它只说明跳转规则。结合一段教学字节码,才知道具体的两条路:

    教学控制流:以下偏移也不是实际样本地址
    0: PUSH input // input 是本次选定的单字节输入值
    2: JUMP_IF_ZERO 8
    4: PUSH 1
    6: JUMP 10
    8: PUSH 0
    10: HALT
    input != 0:0 → 2 → 4 → 6 → 10
    input == 0:0 → 2 → 8 → 10

    这里的 JUMP 10 是无条件把 ip 改成 10;JUMP_IF_ZERO 则先检查栈上的条件。把这些位置与跳转边连起来,就是控制流图(CFG)。它回答“有哪些可能路径”。某一次运行只走其中一条,动态日志不能自动覆盖另一条。

    更复杂的 VM 还会处理函数调用。为了让被调用的函数返回后知道从哪里继续,它需要保存返回位置和局部状态;这份调用记录就叫调用帧。这里先记住用途,不必把所有调用帧布局背下来。

    搜到一个标记字节,不等于找到一条 handler 记录

    旧样本里,handler 记录包含标记、opcode、源码长度和 JavaScript 源码。下面的数值只是按该旧格式构造的示意,不是提取出的真实 handler:

    结构示意:长度字段决定这段记录在哪里结束
    7e | 02 | 00 00 03 | 78 3d 31
    ↑ ↑ ↑ ↑
    标记 编号 长度为 3 三个 UTF-8 字节,文本是 x=1
    检查顺序:
    1. 剩余字节够不够容纳记录头?
    2. 按大端序读取的源码长度,是否越过数据末尾?
    3. 内容能否按预期解码,并符合 handler 的结构?

    “大端序”指高位字节在前;这里 00 00 03 表示长度 3。这类记录描述的是 handler 函数怎样实现,与调用它的 opcode 指令数据要区分。

    旧样本中有 169 个字节与标记相同,但结构验证后只有 103 个有效记录。其他同值字节可能只是数据。搜索命中是候选,结构验证才是证据;恢复指令手册之后,还要继续读程序。

    5. Number 是什么?占位函数为什么会改变分支?

    现在把数据类型与控制流接起来。 识别出解释器后,一个自然想法是:把原脚本放进 Node,缺哪个浏览器对象,就补哪个。

    这类临时替代实现叫 stub,也就是占位实现。下面的 readMissingProperty 是本文为了讲解临时起的名字,不是 DataDome 原函数,也不是解完所有 handler 后自动生成的还原脚本。

    这里有两个不同的工作:

    • 还原 handler:解释原程序的一条“读属性”指令怎样操作栈和对象。
    • 编写宿主环境:由我们提供被读取的 document、navigator 等对象,以及缺失访问的处理行为。

    原指令最终可能只是执行 object[key]。若这个 object 是我们提供的 Proxy,占位返回值就来自 Proxy 的 get 拦截器,而不是来自另一条“已经逆出来的函数”:

    伪代码:原程序读属性,宿主模型决定它读到什么
    const syntheticObject = new Proxy(
    {},
    {
    get(target, key) {
    return placeholderFunction // 错误的万能占位策略,不是真实浏览器实现
    },
    },
    )
    function READ_PROPERTY(stack) {
    const key = stack.pop()
    const object = stack.pop()
    stack.push(object[key]) // 原 handler 的等价语义;这里触发上面的 get
    }
    const stack = [syntheticObject, 'width']
    READ_PROPERTY(stack)
    // 栈顶现在是占位函数,而不是预期的数字。

    width 仍是教学属性名,并不表示旧样本中那处尚未完全归因的属性已被确认是宽度。实际研究可以先运行原 JS、观察环境访问,再按需要理解有关 handler;两项工作往往交替推进,不必等全部指令都读懂才开始搭环境。

    前面的名字如果缩写成一个函数,表达的就是下面这种错误占位策略:

    伪代码:万能占位对象掩盖了本来不同的类型
    function readMissingProperty(name) {
    return placeholderFunction
    }
    const width = readMissingProperty('width') // 预期可能是数字,实际却拿到函数
    if ((width & 255) === 0) {
    enterBranchA()
    } else {
    enterBranchB()
    }

    8 月旧样本的固定 stub 路径就发生过类似问题:本来参与数值运算的属性读取拿到了占位函数,类型转换后进入错误分支,异常又被 VM 内部捕获。外层没抛出异常,不代表里面已经算对。

    把一行嵌套断言拆成三步

    Number(value) 是 JavaScript 内置的数值转换函数,尝试把一个值转换成数字。这里没有 new,不是创建包装对象,也不是自动调用作为参数传入的函数。

    可运行:先看最普通的数值转换
    console.log(Number('123')) // 123:数字字符串转数字
    console.log(Number('hello')) // NaN:无法转换成有效数字
    console.log(Number(undefined)) // NaN

    NaN 全称是 Not-a-Number,是数值类型里的一个特殊值,表示无效的数值结果。Number.isNaN(value) 则只检查“这个值是否已经是 NaN”,它自己不做数值转换。

    可运行:函数本身和函数调用结果不是同一个值
    const stub = () => 123
    console.log(stub()) // 123:加括号才调用函数
    console.log(Number(stub())) // 123:把调用结果转数字
    console.log(Number(stub)) // NaN:尝试转换函数对象本身
    console.log(Number.isNaN('hello')) // false:字符串本身不是 NaN
    console.log(Number.isNaN(Number('hello'))) // true:先转换,才得到 NaN
    const converted = Number(stub) // 第一步:尝试把函数对象转成数字
    const conversionFailed = Number.isNaN(converted) // 第二步:检查是不是 NaN
    console.assert(conversionFailed) // 第三步:验证我们预期的现象

    所以原先的 console.assert(Number.isNaN(Number(stub))),只是把最后三行嵌套写在了一起。

    对这个未改写转换方法的普通函数,转换过程大致是:先尝试取得原始值;普通 valueOf() 仍返回对象,再经 toString() 得到函数源码文本;这段文本不是数字字符串,于是结果是 NaN。如果对象自定义了 Symbol.toPrimitive 等转换行为,结果可能不同,不能把本例推广到所有对象。

    没有人显式写 Number,位运算也会转换

    & 是按位与,>>> 是无符号右移。在本例中,它们会先按各自规则把操作数转成 32 位整数;NaN 在这种整数转换中会成为 0:

    可运行:不正确的类型怎样变成不正确的分支输入
    const stub = () => 123
    console.log(stub & 255) // 0:没有调用 stub,而是转换函数对象
    console.log(stub() & 255) // 123:先调用,拿真实返回的数字运算
    console.log(undefined >>> 0) // 0:undefined 经数值与整数转换

    这不是说 Number(stub) 本身返回 0,也不是所有运算都把 NaN 当 0。它发生在这里特定的位运算转换中。

    于是,万能占位对象不只是“值不够真实”,还会改变 if 的条件,带程序走另一条路。后来将宿主根对象分开、保持派生对象身份一致,合成路径才推进到正常结束;这只证明模型修复了某些路径条件,不证明整个浏览器已被模拟。

    另外,Node 的 vm context 只是执行上下文,不是安全隔离边界。Node 官方明确指出 node:vm 不是安全机制。运行外来脚本仍需要独立、受限的执行环境,不能把宿主文件、网络能力和凭据暴露给它。

    6. 306 个字符和 149 个函数,具体是在比较什么?

    这一节转回数据流,先把两次运行的身份说清楚。 这里比较的是同一个 8 月旧样本在真实 Chromium 中的运行,与它在我们提供合成浏览器环境的 Node 中的运行。“合成”指 Node 的宿主环境,不是说真实浏览器那边的数据是我们编造的。

    观察项 真实 Chromium 运行 Node + 合成宿主环境运行
    研究对象如何运行 真实浏览器 Node 中旧 VM/handler 的离线执行
    环境从哪里来 浏览器自身的实现与本次状态 我们提供的模型、替代实现与配置
    末端对应数组 306 个已观察到的元素位置 长度 149,观察到索引 0—148 的写入
    元素是什么 每个是长度 1 的字符串 值的类型被记录为 function
    最终字符串长度 407 1,391

    这些都是那个旧样本的观察,不是新版应满足的固定长度;真实浏览器运行也不能仅凭这一段数组记录就被称为服务端接受的正样本。

    149 不是“逆向出了 149 个函数”,306 也不是“应该还原 306 个 handler”。 103 种 handler 是指令实现种类,149/306 是这里的数组元素/写入数量,三者不是一个计数。149 个位置存着函数类型的值,甚至不必对应 149 个不同的函数对象。

    既然知道末端会做数组拼接,就在 join 之前检查数组,而不是只盯最终字符串长度:

    伪代码:从结果倒查到数组写入点
    result = replaceCharacters(btoa(joined))
    joined = chars.join('')
    chars[index] = oneValue
    oneValue = producer(input)

    这里的 producer 只是“产生一个元素的调用”的代称,不是已经还原出一个同名函数。逆向要逐层问:这个 chars 是哪个对象?谁向它写过?oneValue 又是哪次调用的返回值?

    两条路径的差异,不是“一个数组名不一样”

    旧浏览器 trace 记录了 306 次相关写入,值都被识别为长度为 1 的字符串;相邻调用也返回长度为 1 的字符串,写入与返回有对应关系。合成路径的对应末端数组长度是 149,元素被归类为函数。

    用脱敏伪代码重画,类似这样:

    伪代码:只表达观测到的形状,不猜测字符原值
    // 浏览器路径:browserWrites 代表实际观察到的写入记录。
    const browserChars = new Array(306)
    for (const { index, value } of browserWrites) {
    // 观测:typeof value === 'string',value.length === 1
    // value 对应某次生产调用的返回值,字符原值不在这里公开。
    browserChars[index] = value
    }
    const browserJoined = browserChars.join('')
    // 合成 Node 路径:nodeWrites 代表对应合成路径的写入记录。
    const nodeChars = new Array(149)
    for (const { index, value } of nodeWrites) {
    // 观测:typeof value === 'function'
    nodeChars[index] = value
    }
    const nodeJoined = nodeChars.join('')

    这不证明两个循环原本都应固定运行 306 次,也不证明合成路径的每个函数都来自同一个属性。它只表达当时已确认的容器长度、元素类型与写入关系。因此,“形状不对”在这里有具体含义:Node 数组的长度和元素类型,都没有复现该浏览器对照中的末端数组。它还不是全部服务端拒绝原因的证明。

    为什么不是 306 个函数?当时并没有查明完整原因

    “元素为什么是函数”与“为什么只有 149 个位置”是两个问题。把一个返回值从字符串换成函数,本身不会自动让数组长度变短;反过来,把长度强制改成 306,也不会让函数变成正确字符。

    旧记录已经在 Node 的数组构造处看到长度 149,随后写入 0—148。因此不只是最终 Base64 多了或少了几个字符。下一步应分别追踪:

    1. 数组构造的长度参数由谁算出?
    2. 循环边界、分支和上游输入怎样影响写入数量?
    3. 负责生产元素的调用为什么返回函数形状的值?

    下面只是说明这两个维度可以怎样分别变化的伪代码,不是宣称旧程序已经被还原成这段逻辑:

    伪代码:输入条目数与元素类型,是两条待追踪的依赖
    function assemble(environment) {
    const items = collectInputs(environment) // 条目数可能受采集路径影响
    const chars = new Array(items.length)
    for (let i = 0; i < items.length; i += 1) {
    chars[i] = produceElement(items[i], environment) // 另查返回值的类型与来源
    }
    return chars
    }

    当时尚未完成这两条依赖的全部归因,不能把少掉的 157 个位置解释成“缺了 157 个 API”或“少逆了 157 个函数”。也不能用后来另一版本 interstitial 的成功,反推这个旧 VM 的 149 已经被逐项修成 306。

    函数元素数量与不同函数数量的区别,可以用一个独立例子验证;它不复现旧样本的具体引用关系:

    可运行:149 个函数类型元素,可以只引用同一个函数
    const placeholder = () => undefined
    const values = new Array(149).fill(placeholder)
    console.assert(values.length === 149)
    console.assert(values.every((value) => typeof value === 'function'))
    console.assert(new Set(values).size === 1) // 不同函数对象只有一个

    更小的可运行示意能解释为什么“两边最终都是字符串”仍然不够:

    可运行:join 会转成字符串,不会替你调用数组里的函数
    const browserLike = ['A', 'B', 'C'] // A/B/C 是教学字符,不是真实样本
    const nodeLike = [() => 'A', () => 'B']
    console.log(browserLike.join('')) // ABC
    console.log(nodeLike.join('')) // 拼接函数的源码文本,不是 AB
    console.log(nodeLike.map((fn) => fn()).join('')) // AB:显式调用才拿到返回值
    console.assert(typeof browserLike.join('') === 'string')
    console.assert(typeof nodeLike.join('') === 'string')
    console.assert(nodeLike.join('') !== 'AB')

    上例解释普通函数的行为;真实占位对象还可能有自定义转换,不能拿这个例子的源码长度去复算旧样本的 1,391。但结论已经够用:上游传入了不同形状的数据,同样得到 string 类型不代表内容正确。

    为什么还要把“生产调用”和“目标数组写入”关联起来?

    前面的排查已经从“最终字符串不一样”缩小到“编码前数组不一样”。接下来要决定改哪里:究竟是生产元素的函数返回错了,还是数组写入拿错了值,或者我们看错了数组?

    所以这里不是忽然插入一节日志教程,也不是要证明浏览器与 Node 的日志发生了“同一次写入”。它要在各自的一次运行内部证明:某次调用的返回值,确实写进了最后被 join 消费的那个数组。之后才比较两次运行中对应的来源链;对象 ID 不能直接跨进程对齐。

    为什么不能只看日志相邻?下面的反例中,生产调用成功返回了一个值,但被编码的数组根本没使用它:

    伪代码:相邻发生的调用与写入,可能不属于目标来源链
    const temporary = []
    const chars = []
    const value = producer(input)
    temporary[0] = value // 它被写进了另一个数组
    chars[0] = fallbackValue // 最终目标数组使用的是另一个值
    const result = chars.join('')

    如果只记录“调用返回了字符”以及“随后发生数组写入”,就会错误地把 producer 当成最终元素的来源。即使确实写入目标数组,也还可能被后续写入覆盖。

    旧浏览器记录通过相关调用返回、数字索引写入以及目标数组的消费关系,支持了“这条末端路径消费单字符字符串”的结论;Node 对应位置却记录为函数类型,才有理由继续向生产调用的输入和环境读取追查,而不是先改 btoa。

    下面是追踪工具可以生成的事件格式示意。array#1、call#1、序号都是教学编号;这里只记录形状,不公开原值:

    事件示意:时间相邻只是线索,仍要关联对象和调用
    const trace = [
    { seq: 40, kind: 'call_return', call: 'call#1', type: 'string', length: 1 },
    {
    seq: 41,
    kind: 'array_write',
    array: 'array#1',
    index: 0,
    fromCall: 'call#1',
    type: 'string',
    length: 1,
    },
    { seq: 42, kind: 'join', array: 'array#1' },
    ]

    数组对象身份区分“同一个数组”和“另一个内容相似的数组”;调用编号与帧内值传递帮助区分“同一生产者”与“只是时间靠近”。字符串是原始值,没有数组那样的对象身份,不能仅凭两个单字符相等就断言来源相同。

    这条“生产调用 → 返回值 → 特定数组写入 → 编码消费”的链叫数据来源追踪,也就是 provenance。只保留与最终结果相关的链,叫向后切片。相关元素也可能被后续覆盖,所以必须追到最终被消费的写入,不是找到一次写入就停止。

    这一步把疑点放回了字符生产和上游环境输入,而不是把所有差异都归咎于最后的 Base64。Base64 只是表示方式,不会帮你把错误输入修正成正确材料。

    7. 最终用 Node,为什么中途还要写 Python?

    这是数据流验证工具,不是运行路线迁移。 最终那条 Node 路线,是在隔离环境中执行当轮原始 JavaScript;如果该版本内部有 VM,也由原脚本自己的解释器执行。

    Python 主要用在研究侧:对已经理解的局部哈希、字节变换或编码,另写一份参考实现,核对我们的解释。并没有因为写了 Python,就把整个挑战计算替换成 Python。

    两条路线:一条计算本轮材料,一条离线检查局部规则
    实际执行路线:
    当轮原始 JS + 环境模型
    → 隔离 Node 执行
    → 本轮计算材料
    → HTTP 控制器提交与验证
    离线研究路线:
    固定样本里的某个子过程
    ├─ 原 JS 在受控输入下运行 → 中间结果 A
    └─ 独立 Python 参考实现 → 中间结果 B
    比较 A 和 B;再换输入,重复验证

    图里的 HTTP 控制器指负责获取资源、提交结果与管理会话的程序;它和执行计算的 Node 是不同职责。Python 的局部对照不需要把浏览器答案塞进最终计算路线。

    它的意义是把“黑盒又跑通了”推进到“这一小段规则我们能独立解释”。假如两个结果不同,就查字符串编码、整数位宽、输入来源或模型错误。如果相同,只能为被测子过程和输入集合提供证据,不能直接宣布整个 DataDome 已被重写。

    Python 不是必须的,也可以用另一份独立 JavaScript 实现。关键是独立表达规则,而不是在 Python 里再调用原函数冒充复现。后来仍选 Node 执行原脚本,是另一层工程选择;局部复算用于排查和回归,不要求成为最终执行依赖。

    一个“同样输入,语言理解不同”的例子

    9 月一些固定样本中的 cyrb53 是一个把输入压缩成数值的哈希子过程,不是整个字段。它使用的 charCodeAt 按 UTF-16 码元读字符串。码元是编码单位,不总是人眼看见的一个字符:

    可运行:JavaScript 把这个字符表示成两个 UTF-16 码元
    const text = '😀'
    console.log(text.length) // 2
    console.log(text.charCodeAt(0)) // 55357,即十六进制 0xd83d
    console.log(text.charCodeAt(1)) // 56832,即十六进制 0xde00

    Python 直接遍历这个字符串,会看到一个 Unicode 码点。若要模拟上述 JavaScript 子过程,需要显式取出两个 UTF-16 码元:

    可运行:研究侧对齐 JavaScript 的输入单位
    text = '😀'
    encoded = text.encode('utf-16-le', errors='surrogatepass')
    units = [
    int.from_bytes(encoded[i:i + 2], 'little')
    for i in range(0, len(encoded), 2)
    ]
    assert units == [0xD83D, 0xDE00]

    运算结果,也要对齐位宽

    Math.imul(a, b) 做 32 位整数乘法并给出有符号结果;>>> 0 再把对应的低 32 位按无符号整数解释:

    可运行:同一组比特的有符号与无符号读法
    const signed = Math.imul(0xffffffff, 5)
    const unsigned = signed >>> 0
    console.log(signed) // -5
    console.log(unsigned) // 4294967291

    这个特定输入的 Python 对照,可以这样表达低 32 位截断;它不是适用于任意 JavaScript 值的完整 imul 实现:

    可运行:只复算上面的整数例子
    unsigned = (0xffffffff * 5) & 0xffffffff
    signed = unsigned if unsigned < 0x80000000 else unsigned - 0x100000000
    assert signed == -5
    assert unsigned == 4294967291

    还有末端的 btoa:它把“每个字符表示一个字节”的字符串转成 Base64,并不自动把所有 Unicode 文本按 UTF-8 编码。在支持 btoa 的现代 Node 或浏览器中:

    可运行:编码成功不等于前面的输入正确
    console.log(btoa('ABC')) // QUJD
    try {
    btoa('😀') // 码元超过单字节范围,不是这个接口接受的二进制字符串
    throw new Error('这里本应拒绝这个输入')
    } catch (error) {
    console.assert(error.name === 'InvalidCharacterError')
    }

    所以这一节不是“为了最终运行而换语言”,而是借跨语言复算暴露隐含语义。即使最后一直用 Node,理解这些语义也能帮助发现环境模型给错类型、给错输入的问题。算法名称和常量仍须绑定样本版本。

    8. “缩小观察范围”,究竟缩小什么?

    这一节是控制流与数据流共用的测量方法。 trace 是程序运行时记录下来的事件序列;插桩是为获得这些事件而添加钩子、包装调用或改写代码。它们都不是完全透明的旁观者。

    9 月的一次日志只保留了尾部,没有显示预期的 cyrb53 起始调用。若据此判断“这一分支没执行”,就把工具的容量限制误当成程序行为了。

    “缩小范围”不是让程序少执行一点,更不是把不喜欢的分支删掉,而是:程序仍按原逻辑运行,只让日志保存与当前问题有关的事件。 这样才不会让成千上万条无关记录挤掉关键证据。

    从一个具体问题确定过滤条件

    假设当前只问:“传给 join 的数组第一个元素是谁写的?”可以按下面的顺序缩小:

    1. 锁定消费对象:在 join 处给接收数组标一个对象 ID,而不是追所有数组。
    2. 反查写入位置:找到向该对象写入的 handler 与字节码位置。
    3. 保留附近指令窗口:查看该写入前后的值怎么进栈、怎么调用、怎么返回。
    4. 跨窗口追来源:如果值来自某个全局槽,就只额外跟踪这个槽的相关读写。

    “全局槽”是该 VM 状态存储中的一个位置,例如 vm.globals[7],不是在说真实浏览器一定有 window[7] 这个属性。

    下面所有位置、对象和槽号都是教学数值,不是可套用到真实样本的固定地址:

    伪代码:允许相关来源跨出 IP 窗口,不只截取几行
    const watchArray = 'array#1'
    const watchSlots = new Set([7])
    const startIP = 100
    const endIP = 140
    let remainingFollowingSteps = 0
    let filteredOut = 0
    function onEvent(event) {
    const inWindow = startIP <= event.ip && event.ip <= endIP
    const followsJump = remainingFollowingSteps > 0
    const arrayRelated = event.arrayId === watchArray
    const slotRelated = watchSlots.has(event.slotId)
    if (event.kind === 'instruction' && remainingFollowingSteps > 0) {
    remainingFollowingSteps -= 1
    }
    if (event.kind === 'instruction' && event.ip === endIP) {
    remainingFollowingSteps = 8 // 教学预算:再跟实际执行的 8 条指令
    }
    if (inWindow || followsJump || arrayRelated || slotRelated) {
    boundedRecord(event)
    } else {
    filteredOut += 1 // 主动过滤,和容量溢出不是一回事
    }
    }

    IP 窗口按代码位置筛选,后继步数按实际执行顺序筛选。 假如窗口末端跳到了 IP 900,只记录 100—140 就会丢掉后继;多保留有限的实际后继步骤,可以看到那个跳转后的局部结果。它不是自动追完所有可能分支。

    研究工具中对应的思路包括 --vm-history-range 的指令范围、--vm-watch-global-slot 的指定槽读写,以及关联数组的跟踪。实际工具还会限制后继记录和观察槽数量;这些是项目诊断选项,不是 Node 自带 API,也不能跨脚本版本照抄 IP。

    找到晚了,就回到同一个固定样本重跑

    假设你直到 join 才知道目标数组是谁,早先的写入不能凭空补进日志。正确做法是先做定位运行,再在固定样本上提前安装相关观察点重跑,或用事先受限保存的前置事件核对。

    “同一个数组”也不能跨运行靠原来的对象地址判断;应从它的构造位置、所属调用等重新识别。若目标值在窗口之前初始化,就把初始化点纳入观察,不能只保存消费端便宣称来源完整。

    日志容量要算清,未记录不等于未执行

    一份报告至少要解释以下计数的口径:

    计数 含义
    total 钩子实际观察到的事件数量
    filteredOut 因为不符合筛选条件而主动忽略的数量
    retained 满足筛选条件且实际保存的数量
    dropped 满足条件,却因容量上限丢弃的数量
    thrown 观察到的调用中,抛异常的数量

    这是教学计数模型,不要求项目所有历史报告都恰好采用同一字段或口径。在这个模型里,前四项满足 total = filteredOut + retained + dropped;thrown 是事件的附加分类,不另加到总数里。

    示意统计:这些数字不是一次真实实验结果
    const wide = { total: 10000, filteredOut: 0, retained: 1000, dropped: 9000 }
    const narrow = { total: 10000, filteredOut: 9600, retained: 400, dropped: 0 }

    后者能说明“被选中的事件没有因容量溢出丢失”,不能说明被主动过滤的 9,600 条没有执行,也不能说明钩子安装之前没有事件。

    最后仍须保留未插桩基线。插桩可能改变函数源码、调用栈、计时与调度;在同一固定样本和受控输入下,比较插桩前后的输出、异常和路径,能帮助发现这种干扰。无法控制的差异应标注,不能让一份漂亮日志代替原程序行为。

    9. 改了时区,哈希变了,最终字段没变,说明什么?

    这里研究数据依赖,不是只数走了多少条指令。 9 月 27 日的一组固定样本实验中,改变时区相关条件后,一组中间哈希发生变化,但最终 plv3 没变。

    “这组哈希没用”是一个过早的判断。继续追踪发现它确实有多个读取点;当时只完成了部分消费路径的局部归因,不能把其他路径全部判成噪声。

    下面这个教学例子能说明,为什么“被使用”与“改变最终结果”不是同一件事:

    可运行:不同中间值可以经过掩码后变得相同
    function consume(hash) {
    return hash & 255 // 只保留低 8 位
    }
    console.assert(257 !== 513)
    console.assert(consume(257) === 1)
    console.assert(consume(513) === 1)

    这只是一个可能机制,不是声称当时已经用它解释了所有路径。差异还可能在查表时合并、在后续被覆盖,或影响了我们没观察的输出。

    反事实实验的意思是“在可控条件下改变一个因素,看会怎样”。要避免把随机变化当成时区影响,实验安排至少应类似这样:

    伪代码:先确认基线可重复,再做单因素改动
    const baseline = {
    sample: fixedSample, // 同一份脚本字节
    clock: fixedClock,
    randomSeed: fixedSeed,
    timezone: timezoneA,
    }
    const a1 = runOffline(baseline)
    const a2 = runOffline(baseline)
    if (!sameResults(a1, a2)) {
    throw new Error('先研究基线波动,不急着归因')
    }
    const changed = copy(baseline)
    changed.timezone = timezoneB
    const b = runOffline(changed)
    const a3 = runOffline(baseline) // 恢复基线,再验证
    // 比较中间结果、消费路径与最终结果,而不只比较最终字符串。
    compareRuns([a1, a2, b, a3])

    如果时间、随机源、脚本版本或走到的分支也变了,就不是干净的单因素对照。变化告诉你哪里敏感;完整的来源与消费链,才帮助你解释它为何影响或没有影响输出。

    10. 对象、异常与异步,为什么也要建模?

    我们已经看到类型能改变分支。接下来三类环境语义,会同时影响控制流和数据流,不能靠“这个 API 名字存在”判断模型正确。

    到 10 月初,Node 侧实际具备了哪些环境?

    “补全浏览器环境”容易让人以为我们重写了一个 Chromium。更准确地说,我们实现或接入了已研究路径所需的一部分宿主接口与行为。下面依据仓库实现及 9 月底到 10 月初的修复记录归类,不是今天又对生产做了一轮在线验收,也不是所有接口都完全兼容。

    环境层 已实现或接入的内容 仍需保留的边界
    资源装载与 DOM 外部/内联脚本顺序、文档事件;Document、HTMLDocument、Element 等模型;iframe 的独立上下文 不是完整 HTML 浏览器或通用 DOM 实现
    XML 接口 XMLDocument 身份、DOMParser 对应 MIME 的返回类型、非法类型检查、Window/Worker 暴露范围 XML 树解析仍是部分模型
    身份与能力接口 navigator、screen、窗口尺寸等;NavigatorUAData 的只读属性、查询与 toJSON;标准 PDF 插件/MIME 的对象关系 一些值是显式声明的模型配置,不是宿主硬件实测;未知完整版本等不能补造
    CSS 与几何 已测路径的样式级联、inline/computed 区分、尺寸/位置、部分动画和 SVG 几何、系统字体/颜色/滚动条规则 是限定范围的解析与计算,不是完整 Blink 布局引擎
    Canvas 2D 成功路线接入原生 canvas 后端,支持实际绘制、文字测量与图像输出;外围接口由模型适配 仍保留 stub 模式;使用原生 Canvas 不等于复制 Chromium 的全部字体与栅格化行为
    WebGL 软件 GLES 支撑的 WebGL1 路径,着色器、buffer、uniform、像素结果;接口实例与私有句柄绑定 不是全量 GPU 浏览器;该软件适配路径不提供 WebGL2
    事件、时间与异步 事件分发、焦点、相关输入事件、定时器和动画帧;Date、performance、随机相关输入的模型与调度 离线实验可固定输入,实际运行不能靠冻结旧答案;调度仍不等于完整浏览器事件循环
    可复用标准实现 JavaScript 内建由引擎执行;WritableStream、writer、controller 接入 Node 标准实现 不是所有 Web API 都天然存在,接口内部布局与浏览器也不保证完全相同

    此外,权限、媒体等能力有显式的可用/不可用分支和有限模型。“对象存在”“方法能调用”“行为测试通过”“完整浏览器等价”是四种不同强度的说法,不能混用。

    代码上,主入口可从 createSandbox、runBundleSource 看;PDF 模型看 buildPluginSurface,UA-CH 接口看 createNavigatorUAData,Canvas 看 createCanvasContext,CSS 与软件 WebGL 另有独立模块。以上是真实项目中的入口名,和第 5 节为了教学临时起的 readMissingProperty 不同。

    如果只画职责,Node 这一层类似下面这样;函数名除上述明确列出的入口外,均为说明职责的伪代码:

    伪代码:原脚本负责计算,宿主模型负责回答环境访问
    const environment = {
    document: buildPartialDocumentModel(),
    navigator: buildDeclaredNavigatorModel(),
    screen: buildScreenModel(),
    graphics: attachCanvasAndSoftwareGLES(),
    scheduling: buildEventAndTimerModel(),
    streams: reuseNodeWebStreams(),
    }
    // 执行的仍是当轮原始 JS;不是将所有 handler 翻译成一份新业务脚本。
    const material = await executeOriginalScripts(resources, environment)
    // 网络会话归 HTTP 控制器管理,不属于伪装出来的 DOM 或 GPU。
    await controller.submitAndValidate(material)

    也要注意历史顺序:10 月 2 日 interstitial 成功那一轮没有继续补一批新 API 才突然通过。复盘记录说,已有 DOM、事件、Canvas 与 GLES 模型构成基础;那一轮重点改变了对照条件、挑战路线选择及结果交接。它既不证明所有模型都正确,也不证明每一项模型都是通过的必要条件。请求头、压缩和 Cookie 交接属于下一节 HTTP 客户端这一层,不要列成 Node 已模拟的浏览器接口。

    下面三个短例子,再说明这些实现为什么不能只停在“字段名字补上了”。

    对象身份:内容一样,不等于同一个对象

    可运行:一个保持身份的属性与每次新建的属性
    const stableScreen = { width: 100 }
    const stable = {
    get screen() {
    return stableScreen
    },
    }
    const fresh = {
    get screen() {
    return { width: 100 }
    },
    }
    console.assert(stable.screen === stable.screen) // 两次取得同一个对象
    console.assert(fresh.screen !== fresh.screen) // 内容相似,却是两个对象

    有些 API 需要保持稳定对象身份,上例说明该怎样理解“稳定”,不意味着所有 API 都必须返回同一个对象。

    iframe 还可能处于独立的 realm,即拥有自己的全局对象和一组内建构造器的 JavaScript 环境。同源且可访问的独立 iframe 可以出现这样的关系:

    伪代码:不同 realm 的 Array 构造器不是同一个对象
    const parentArray = parentRealm.Array
    const childArray = childRealm.Array
    console.assert(parentArray !== childArray)
    const childValue = new childArray()
    console.assert(childValue instanceof childArray) // true
    console.assert(!(childValue instanceof parentArray)) // 跨 realm 不成立

    把所有 iframe 都指向同一套构造器,会改变这类判断。instanceof 在这里依赖构造器的原型关系,不是只检查变量“长得像数组”。

    异常:访问失败也是程序的一种输入

    伪代码:错误地总返回成功,会让 catch 分支消失
    try {
    const value = frame.localStorage.getItem('demo')
    report({ storage: 'available' })
    } catch (error) {
    report({ storage: 'unavailable', errorName: error.name })
    }

    某次跨站挑战帧的实测里,访问存储受到限制。若模型总是返回一个可用对象,就走了与当时浏览器不同的分支。但这不等于所有页面都该禁用存储;要按版本、权限和页面上下文记录实际行为。

    同理,读不到的高熵浏览器版本或硬件信息应保持不可用,不能因为模型有字段可以填就凭空编造。

    异步:正确值晚到一步,也可能没有进入输出

    可运行:相同的对象,在不同时间读到的内容不同
    const collected = []
    Promise.resolve().then(() => collected.push('ready'))
    const tooEarly = collected.join('') // 同步读取,微任务还没执行
    console.assert(tooEarly === '')
    setTimeout(() => {
    const later = collected.join('')
    console.assert(later === 'ready')
    }, 0)

    这里 Promise 回调进入微任务,当前同步代码结束后才执行;定时器回调又在之后。join 不会等待未来发生的写入。

    真实浏览器还涉及渲染、I/O 和不同任务来源,本例不是完整调度器。但它准确解释了为什么“环境接口最后能算出值”仍不够:必须在原程序读取它的那个时间点,提供相符的行为。

    现在从客户端内部的数据流,走到 HTTP 交互与状态。 Node 产生的是提交材料;Cookie 是服务端响应返回的,不是 Node 自行签发的一张通行证。

    项目要求候选 Cookie 在 Reuters 首页、栏目页、文章页上分别通过真实文档验证。严格 3/3 的意思不是三个任意 200,而是三类页面都不是挑战页、空壳或错误页。这是项目验收规则,不是 DataDome 官方协议。

    伪代码:传输失败、挑战页与正常文档分开计数
    async function probeDocuments(session) {
    const results = []
    for (const pageClass of ['首页', '栏目页', '文章页']) {
    let response
    try {
    response = await session.request(pageClass)
    } catch (transportError) {
    record(pageClass, 'transport_error')
    results.push('transport_error')
    continue
    }
    const status = isExpectedDocument(response, pageClass)
    ? 'usable'
    : 'not_usable'
    record(pageClass, status)
    results.push(status)
    }
    // 只有三个 pageClass 都为 usable,才报告 3/3。
    return results.every((status) => status === 'usable')
    }

    isExpectedDocument 是正文验证逻辑的代称,不是只判断状态码的库函数。没取得候选、没有运行探针时,应报告“未运行”,不能混写成已测三个页面全部失败。

    用一个结构体展开 Session

    10 月 2 日的一批实验里,生成端已通过 3/3,交给实际抓取文章的 worker 进程后却只有 1/3。这时如果继续只改 VM,就可能找错了层级。

    Cookie 可以理解为会话里的一部分数据,而 Session 还管理“怎样把请求送出去”。用伪代码展开如下;它不对应某个库的真实构造参数:

    伪代码:Session 不等于一串 Cookie 文本
    const session = {
    cookieJar, // 按域名、路径、有效期等选择发送内容的 Cookie 容器
    clientConfig: {
    version: clientVersion,
    identity: declaredIdentity,
    profile: transportProfile,
    proxy: proxyConfig,
    routing: routingConfig,
    },
    requestPolicy, // 管理导航/脚本/接口请求的头、来源、语言与压缩设置
    applicationState: {
    clientHintsByOrigin, // 按来源保存的协商状态
    targetAddressMappings,
    },
    connectionPool, // 活跃连接及相关传输状态
    }
    function request(session, resource, kind) {
    const headers = session.requestPolicy.build(resource, kind)
    const cookies = session.cookieJar.selectFor(resource)
    return transport.send({
    resource,
    headers,
    cookies,
    config: session.clientConfig,
    pool: session.connectionPool,
    })
    }

    这里的 profile 指客户端采用的一组传输行为配置,不是用户账户。origin 指协议、主机和端口组成的来源。headers 是请求元数据;请求上下文在这里表示“这是导航、脚本还是接口请求,来自哪里”,不能全部塞入同一组固定头。Client Hints 是浏览器/客户端可协商提供的身份与能力信息,相关状态还可能按 origin 区分。连接池则是为了复用已经建立的连接,不是一份可以靠复制 Cookie 顺便复制的对象。

    “复制 Cookie”与“完整新 Session”怎么对照?

    伪代码:复制应用层状态,不伪称复制活跃 TCP/TLS 连接
    const original = validatedSession // 生成端已经通过 3/3 的 Session
    const cookieOnly = createSession(defaultConfig)
    cookieOnly.cookieJar = clone(original.cookieJar)
    // 请求策略、配置和协商状态仍可能与 original 不同。
    const matched = createSession(copy(original.clientConfig))
    matched.cookieJar = clone(original.cookieJar)
    matched.requestPolicy = copy(original.requestPolicy)
    matched.applicationState = copy(original.applicationState)
    // matched 使用自己的新连接池,没有复制 original 的活跃连接。
    await probe(original) // 正对照:确认候选此时仍可用
    await probe(matched) // 检查复制完整配置与状态后的复用
    await probe(original) // 后置对照:避免把候选失效误归因于 matched

    在所测记录里,两枚独立候选都曾在完整配置的新 Session 中通过 3/3。因此,那个条件下并不要求保留原 TCP 连接才能复用。它不是说任何 Cookie 都能随便跨环境使用。

    消融实验,移除的究竟是什么?

    消融就是从已能工作的条件里拿掉一部分,再看结果。本项目的 _RequestContextSession 是自己写的包装器,不是 curl_cffi 自带功能;它同时管理请求头上下文和压缩设置等行为。

    伪代码:一次移除组件,不等于只改了一个请求头
    const control = cloneConfigurationIntoNewSession(original)
    if ((await probe(control)) !== '3/3') {
    throw new Error('完整配置的正对照未通过,先停止归因')
    }
    const variant = cloneConfigurationIntoNewSession(original)
    variant.requestPolicy = policyWithoutRequestContextWrapper()
    await probe(variant)
    await probe(original)
    await probe(cloneConfigurationIntoNewSession(original))

    两枚独立候选移除这个包装器后,都出现三个 401;原 Session 复测仍为 3/3。但其中一轮后置完整新 Session 对照还出现了传输错误,不能润色成所有对照全部成功。

    这个结果支持“该请求上下文组合在所测条件下影响复用”,不支持“已经定位某一个头”,也不能解释所有历史失败。包装器内有多项因素,实验还没控制所有目标连接的实际出口。

    最后再区分三个看起来都像“请求成功”的阶段:

    证据边界:前一步不能替后一步作证
    代理 CONNECT 200 → 建隧道这一层的结果
    目标 HTTP 响应 200 → 目标请求有了响应,正文仍需检查
    三类页面为正常文档 → 本次 3/3 真实页面验收

    浏览器的 request 事件也只表示请求流程开始,不能单凭它证明字节已到达服务端。本地计算、网络交互、服务端接受和最终使用端复用,是连续但不同的检查点。

    12. 做到哪里,才可以说“成功”?

    把这段经历连起来,能看到好几个不同的终点:

    • 找到 plv3 的赋值位置,完成的是字段定位;
    • 标注 VM 指令,完成的是解释器局部语义;
    • 到达 halt,说明某条执行路径结束;
    • 独立复算编码,证明对应子过程被理解;
    • 浏览器正样本,说明那组条件下存在可接受路径;
    • 独立新挑战通过真实文档验证,才支持对应路线的接受结论;
    • 实际使用端可以复用,又是额外的工程验证。

    这不是故意把成功标准抬高,而是避免用一个层级的证据回答另一个层级的问题。

    这里的 fresh 指使用当轮新挑战,而不是拿旧材料重放;interstitial 指服务端当次提供的设备检查中间页,与滑块挑战不是同一条路线。

    在 10 月 2 日的历史记录中,已经有两次独立 Node 运行在服务端当次提供的 interstitial 设备检查路线上完成 fresh 3/3;输入没有使用浏览器产出的 Cookie、证明值或运行时快照。之后还发生了上文提到的移交与生产接入排查。10 月 17 日的新一轮结果单独放在下一节,不替换这段历史。

    但这不等于同一时期的滑块路线已经被验证通过,也不等于 8 月旧 VM 的所有未知点都被补齐。早期旧版滑块的成功、后来的设备检查成功,是不同版本和路线的证据,不能合并成“DataDome 已经全部逆向完成”。

    这里有个重要区别:用 Node 执行原始脚本并建模所需环境,和把整个算法独立重写,是两种不同成果。 前者的服务端成功,不能替代后者尚未完成的数据流解释。

    13. 10 月 17 日续记:发现差异以后,先验证它是否阻止通过

    先把时间线钉住,避免把两轮实验读成同一件事:

    • 本节(10 月 17 日白天):调查权限差异,并用原有模型跑两次独立 Node fresh。没有修改权限模型,当时实际服务仍使用浏览器。
    • 第 14 节(当晚至 18 日):后续接入改用纯 Node 和 153 身份,并修正挑战与后续请求的身份不一致。这是另一轮改动,不是权限模型修复;下节只展开其中与 DataDome 分析有关的部分。

    所以“本轮没有修改权限模型”只限定本节这一轮:它不等于整个项目后来没有改生产,也不等于权限差异已经被修复。

    这一轮调查把前面几种证据边界串到了一起:我们找到了真实差异,补到了运行时证据,最后却没有先修改那个差异,而是用现有模型完成了两次独立 Node fresh。

    它提醒我,“这处和浏览器不同”与“必须先修好这处才能通过”,是两个需要分别验证的判断。

    一个 denied,为什么不能直接复制给 Node?

    10 月 16 日对历史脚本的局部分析发现,相关分支先查询麦克风权限,再查询摄像头权限,任一项为 denied 就提前结束;否则继续到设备枚举。Node 模型未显式声明权限时默认返回 prompt,再配合空设备列表,就可以解释为什么末端得到空字符串,而历史浏览器记录得到 denied。

    到这里,我们知道了两类输出怎样产生,却还不知道浏览器为什么被拒绝,更没有证明这个字段导致了服务端拒绝。

    摄像头和麦克风是否能用,不只由用户点过“允许”还是“拒绝”决定,还与页面的权限策略、是否处于安全上下文、父子 frame 关系等条件有关。若实际挑战在跨域 iframe 中执行,模型却把它当成不受限制的顶层页面,那么仅仅改一个默认字符串,可能只是掩盖了上下文缺失。

    因此,我们先整理了七项依据:父子页面的策略响应头、是否嵌入及父页面是否顶层、当时的 allow 与 sandbox、安全上下文。重点不在字段数量,而在于**“没采集”不能偷偷变成“没有限制”或“已经拒绝”**。

    例如下面这个可运行的小函数,只概括策略结果,不生成用户权限状态,也不代表 DataDome 原始算法:

    可运行:策略允许不等于用户授权
    function summarizePolicy(state) {
    if (typeof state?.allowed !== 'boolean') return 'unknown'
    return state.allowed ? 'not-blocked-by-policy' : 'blocked-by-policy'
    }
    console.assert(summarizePolicy(undefined) === 'unknown')
    console.assert(summarizePolicy({ allowed: false }) === 'blocked-by-policy')
    console.assert(summarizePolicy({ allowed: true }) === 'not-blocked-by-policy')

    这里的 not-blocked-by-policy 不等于用户已经 granted,unknown 也不是另一种写法的 denied。如果把三种情况混在一起,后面的实验即使“结果对齐”,也可能对齐了错误的原因。

    REA 的静态分析与浏览器观测,分别回答什么?

    这一轮用 REA 读取本地保存的 JavaScript,辅助定位模块、属性和关系。它不需要往在线浏览器里插入代码,但也不会自动解开所有动态属性或 Promise 的真实返回值;局部分支解释还需要既有来源映射和隔离执行对照。

    静态分析能回答的是:“如果这里收到 denied,代码会走哪条路?”它不能凭空回答:“浏览器当时为什么收到 denied?”后一个问题需要当时的上下文记录。

    在明确允许的有限采样中,我们用自己的采集器通过 CDP(Chrome DevTools Protocol,浏览器调试协议)读取 frame 树、iframe owner 属性、有效权限策略和网络响应头。这次没有改写目标 JS,也没有开启断点或执行覆盖率,没有调用或截获 navigator.permissions.query()。

    实际取得的五份挑战 iframe 快照一致显示:

    • 挑战位于跨域 iframe,父页面是顶层文档,安全上下文为 Secure;
    • 实际 allow 只有 accelerometer; gyroscope; magnetometer;
    • 实际 sandbox 为 allow-scripts allow-same-origin allow-forms;
    • camera 与 microphone 的有效策略均为 allowed: false,阻止来源为 IframeAttribute。

    这把此前“可能缺少 iframe 委派”的解释推进到了当前浏览器确实报告这条策略阻止。但它依然不是目标脚本某次 query 返回值的直接日志,更不是服务端如何给这个字段赋权的证据。

    采集自身也不能被忽略:基线本身也连接 CDP,不是完全没有调试接口的真人对照。两份浏览器对照都通过,只说明这对会话没有出现增加观测后无法通过的现象,不能证明采集不可检测或没有时序影响。

    真正的下一步:不改权限模型,先跑 fresh

    有了这些证据,仍然不应该直接把 Node 的默认权限改成 denied。我们先问了一个更接近目标的问题:当前模型现在究竟能不能完成一轮新的挑战?

    中间并非一次就成功。离线预检出现过问题;一次新的浏览器复核也只有栏目和文章成功,主页仍停在挑战中,只有 2/3。这时 Node 没有执行,不能写成“Node 又失败了”,也不能拿更早的浏览器正样本替它通过门槛。

    后来,原研究出口又出现其他账号的目标流量。我们没有修改生产路由,而是选了一个当前空闲的研究出口,让新的浏览器对照和两次 Node 固定在同一节点、同一出口。浏览器在同一会话中依次验证主页、栏目、文章并重访主页,四次导航最终都通过后,才立即进入 Node fresh。

    Node 的两次路径都是:

    本轮实际验收链,而不是复制浏览器答案
    自己的 HTTP 会话取得新的挑战 HTML 和原始 JS
    → 隔离、无网络 executor 中的 Node 独立执行
    → 原 HTTP 会话提交自己生成的结果
    → 收到 HTTP 200、view=redirect 和新的候选 Cookie
    → 同一 HTTP 会话验证主页、栏目、文章真实正文
    验收项 Node 1 Node 2
    初始主页响应 401 401
    实际执行原始挑战 JS 是 是
    提交结果 200 / redirect 200 / redirect
    三类真实文档 3/3 3/3
    使用浏览器 Cookie 或证明 否 否

    两次挑战 URL、生成请求和候选 Cookie 各不相同,原始 JS 没有重写;两次都不是无挑战页面的直接 Cookie 复用。浏览器参考材料不是 Node 的答案输入,不能因为研究阶段用了浏览器对照,就把结果误写成浏览器替 Node 完成了计算。

    而且,Node 自己取得了完整的新 HTML 和主脚本。之前浏览器没有完整留存的挑战正文,不必成为 fresh 一直推迟的理由;独立 Node 本来就应该自己获取当轮材料。当然,这也不能倒过来证明此前缺失的浏览器正文与 Node 下载的字节完全一样。

    这两次成功,改变了什么结论?

    本轮没有修改权限模型。因此,不能把这两次成功归功于一个没有实施的权限补丁,也不能继续坚持“只有先修权限差异才能进入 fresh”。但它不意味着此前 DOM、Canvas、GLES 等环境模型没有价值——已有实现仍然是原始脚本能执行的基础。

    我们现在可以说:当前模型在这轮受控条件下,完成了两次独立 interstitial 新挑战,并通过严格真实正文 3/3。

    这不代表 slider/captcha 路由也已验证,不代表完整 Chromium 等价,也不代表生产已经切到 Node-only 或长期稳定。出口、时段和脚本版本与历史失败并不完全相同,所以这次也不是证明所有旧失败原因的单因素实验。

    对我来说,这一轮最有用的方法变化是:既要补证据,也要及时回到目标上验证基线。发现差异后先问它是否真的阻止结果,而不是把“所有差异清零”当成永远排在 fresh 前面的任务。

    Note

    以下是 ai 自己的总结

    14. 10 月 17 日晚—18 日:把身份一致性与通过结果分开看

    第 13 节结束时,我们证明了:当前模型在受控条件下完成了两次独立的 interstitial fresh,并通过严格 3/3。但这还不能回答另一个问题:挑战脚本里看到的客户端,与后续 HTTP 请求呈现的客户端,是不是同一个?

    这仍然属于逆向的输入分析。前面追的是“程序读了什么、怎样算出字段”,现在要把视野扩到程序之外:同一轮交互里的声明、网络特征和会话状态,有没有互相矛盾?

    14.1 先区分三层:运行环境、声明身份、传输指纹

    Note

    browserless(本案例中的纯 Node 方案):指不启动 Chromium,由 Node 执行原始挑战 JavaScript。它不是“没有网络请求”,也不是“没有浏览器语义”:HTTP 客户端仍负责获取挑战、提交结果和验证文档,隔离执行器负责计算,所需的 DOM、Canvas、WebGL 等接口由环境模型提供。

    声明身份(declared identity):客户端主动告诉服务端“我是谁”的字段,例如 User-Agent 和 Sec-CH-UA。这些字段可以由不同代码分别填写,所以需要检查它们是否表达同一套身份。

    传输指纹(transport fingerprint):TLS 握手和 HTTP/2 行为等网络层特征,不是 UA 字符串本身。chrome146 这类传输 profile 模拟的是一组已知网络栈特征,并不表示正在运行一个 Chrome 146 浏览器。

    因此,“声明是 Chrome 153”和“传输使用 chrome146 profile”描述的是不同层次。不能只看两个版本号就判断是否被服务端接受,也不能因为两层能分开配置,就认定这种组合一定没有可检测的差异。

    这里还要区分几种结果:

    名称 本文中的含义 不能据此推断什么
    interstitial 设备检查中间页,本轮 Node 在这条路线取得了通过记录 不代表其他挑战路线也能通过
    hard_block 本轮记录中服务端直接拒绝、没有进入当前执行器可处理路径的页面类别 不是本地 JS 异常,也不能单凭它断言某个字段算错
    vm_slider 本轮分类器识别的另一种滑块挑战路线 不能把 interstitial 的结果套用过来
    严格 3/3 主页、栏目、文章三类真实文档都通过正文结构验证 不等于只检查三个 HTTP 200

    这些是分层记录结果的词,不是“解出算法”“拿到 Cookie”“服务端接受”的同义词。

    14.2 第一处不一致:用 A 身份解挑战,却用 B 身份请求正文

    先看一条最容易被忽略的链路:

    同一轮交互里的两个身份来源
    挑战阶段:用身份 A 执行挑战 JS,由 HTTP 客户端提交结果
    ↓ 得到候选 Cookie
    文档阶段:用身份 B 携带这个 Cookie 请求真实文档

    10 月 17 日的排查发现,挑战阶段已经使用 153 描述符和 chrome146 传输 profile,后续文档会话却仍读取固定的 142 描述符、使用 chrome142 传输 profile。它们来自不同的配置源:一处改了,另一处没有跟着改。

    这时,即使挑战已经通过,后续请求也换了一套身份呈现。服务端可能综合 Cookie、UA、Client Hints、网络特征和会话状态判断请求;仅仅复制 Cookie,不能保证其他条件仍然相同。这里解释的是需要排查的机制,不是宣称已经逆向出服务端的具体绑定规则或打分权重。

    修复 9ff1a59 没有引入新的挑战算法,而是让两个阶段读取同一份活动身份描述,并使用同一个传输 profile。代码与回归测试能够证明这处不一致已消除;后续接受记录则是另一层证据,不能仅凭修复前后各几次请求,就算出这项改动单独贡献了多少通过率。

    这也呼应第 11 节:“同一 HTTP 会话”不等于“同一个 Cookie 罐子”。 Cookie 只是会话的一部分,默认请求头、连接和传输行为也要一起检查。脚本执行正确,不会自动修正它外面的 HTTP 客户端。

    14.3 第二处不一致:UA 说 x86_64,Client Hint 却说 arm

    第二处问题更适合用来练习“确定性错误”和“因果结论”的区别。

    **Client Hints(客户端提示)**可以理解成结构化的客户端信息:Sec-CH-UA 描述品牌和主版本,Sec-CH-UA-Platform 描述操作系统,Sec-CH-UA-Arch 描述 CPU 架构,Sec-CH-UA-Bitness 描述 32/64 位。它们与 UA 并不是几份互不相关的答案。

    当时运行模型的宿主是 aarch64,而 142 声明描述符写的是 X11; Linux x86_64。旧代码却从宿主的 os.uname() 推导 arch/bitness,产生了下面的组合:

    声明平台与架构字段来自两套不同依据
    User-Agent: ... X11; Linux x86_64 ... Chrome/142 ...
    sec-ch-ua-arch: "arm"
    sec-ch-ua-bitness: "64"

    这里的错误可以不依赖服务端结果来确认:模型声明自己是 x86_64 客户端,却拿运行模型的 ARM 宿主去回答架构问题。 “承载模型的机器”与“模型要表达的客户端”被混成了一件事。

    修复 694733e 改为从声明描述符里的 Linux 平台 token 推导 arch/bitness,宿主架构只做支持性校验,不再覆盖声明值:

    声明平台 token sec-ch-ua-arch sec-ch-ua-bitness
    x86_64 x86 64
    aarch64 arm 64
    i686 x86 32

    这个修复只覆盖这两个字段,不等于整套身份已经与真实 Chromium 完全一致。例如模型里的 platform version 当时仍来自宿主内核,仍需单独记录,不能把“arch 修好了”扩大成“所有变量都固定了”。

    下面的可运行小例子只演示这条映射规则,不是 DataDome 原始代码:

    可运行:从声明平台推导 arch/bitness
    function chromiumArchFromPlatform(platform) {
    if (platform.includes('x86_64')) return { arch: 'x86', bitness: '64' }
    if (platform.includes('aarch64')) return { arch: 'arm', bitness: '64' }
    if (platform.includes('i686')) return { arch: 'x86', bitness: '32' }
    return null
    }
    console.assert(chromiumArchFromPlatform('X11; Linux x86_64').arch === 'x86')
    console.assert(chromiumArchFromPlatform('X11; Linux aarch64').bitness === '64')
    console.assert(chromiumArchFromPlatform('X11; Linux mips64') === null)

    最后一行同样重要:遇到没有建模的平台 token,返回 null 表示未知,让调用方决定如何处理,而不是猜一个看起来差不多的值。“不知道”不能悄悄变成“默认 x86”。

    修好确定性错误,为什么还是没有通过?

    修复后,142 身份经历了两批各 6 次的复测,记录名称是 C7 和 C8:

    批次 返回形态 严格通过
    C7 3 次 hard_block,3 次 vm_slider;后者不在当轮执行器的可处理范围内 0/6
    C8 6 次全部为 hard_block 0/6
    合计 没有取得该身份的严格接受记录 0/12

    这证明了一个很窄、但很重要的结论:消除已知的 arch 矛盾,并不足以让 142 在这些尝试中通过。

    它不能证明 arch 永远不重要,也不能证明 C7 出现滑块就是 arch 修复造成的。两批实验并非只改变这个字段、其余条件全部固定的成对对照;服务端状态、时段和出口等因素仍可能影响页面类别。页面形态有变化,是下一步调查的线索,不是单因素因果证明。

    更不能把这一修复倒写成 153 通过的原因:修的是 142 分支的已知矛盾,153 的接受记录属于另一组条件。

    14.4 有通过记录,为什么仍不能说“稳定”?

    同期 153 身份的记录中既有严格 3/3,也有 hard_block。因此只能说它在部分已观察条件下可被接受,不能承诺每次都能通过。文中把这种现象简称为“概率性通过”,并不是说已经知道服务端在随机掷骰子;看起来有波动,也可能是我们没有控制或记录全部变量。

    统计之前,先确定分母是什么:独立新挑战次数、HTTP 请求次数和三类文档验证次数不是同一个单位。重试后的一次成功,不能冒充第一次尝试就成功;一次 Cookie 复用成功,也不能增加一次独立 fresh 的计数。

    要比较两个版本,应尽量固定脚本版本、身份描述、出口与验收规则,并同时保留失败记录。否则“这次过了、上次没过”只是一对现象,还不是修复效果的因果结论。

    历史生产窗口:轮次、挑战和入库

    2026-10-17 晚间,生产接入了由 Node 执行挑战、HTTP 客户端取文的纯 Node 方案,使用前面提到的 153 声明身份与 chrome146 传输 profile。这里不展开部署方式,只看实际运行结果:研究里的一次严格通过,后来有没有转化为持续请求和真实保存?

    下面复录当时留存的日志汇总,观察窗口为 2026-10-17 19:33 UTC 至 2026-10-18 02:12 UTC,约 6 小时 39 分钟。这是历史窗口,不是实时生产看板,也不是本次重新发起实验得到的结果。

    这里的轮次是应用完成一轮处理工作的单位,一轮包含多次 HTTP 请求;挑战事件是这些请求中命中挑战的计数,同一轮里可以重复出现。它们都不等于一次独立的新挑战求解。

    指标 观察值 怎么读
    完成轮次 76 当时约每 5–6 分钟一轮,不是 76 次独立 fresh
    新增并入库 16 篇 新增和保存计数均为 16;是实际产出,不是通过率
    出现挑战的轮次 30 / 76(约 39.5%) 至少命中一次挑战的轮次占比,不是最终失败率
    挑战事件总数 143 一轮内可能重复计数,不是 143 次独立 fresh

    再把单轮挑战次数展开,分布如下:

    单轮挑战次数 对应轮数
    0 46
    3 1
    4 24
    8 4
    12 1

    可以自己核对:轮数合计为 46 + 1 + 24 + 4 + 1 = 76;有挑战的轮数为 1 + 24 + 4 + 1 = 30;挑战事件合计为 3×1 + 4×24 + 8×4 + 12×1 = 143。在有挑战的轮次里,单轮 4 次最常见,但这个计数本身不能告诉我们背后的独立挑战数或具体重试路径。

    尤其不能把剩余 46/76 ≈ 60.5% 直接叫作“Node 挑战成功率”。这 46 轮只是没有记录到挑战,可能沿用已被接受的会话;另外 30 轮虽然遇到挑战,也可能重试后继续完成。要算首次 fresh 成功率,还需要逐次独立挑战的总数,以及首次尝试就通过严格验收的数量;上面的轮次汇总没有提供这个分母。

    当时记录到的恢复案例,耗时通常是几十秒到一两分钟,重试后可重新取得严格 3/3。这是出现挑战后到恢复接受的大致耗时,包含网络等待和重试,不是一次 Node 计算的执行耗时,也不是全部 143 次事件的完整延迟统计。能恢复,与每次都一次通过,是两回事。

    入库也值得保留,但要放对位置:入库量不是挑战成功率。对照下面几层,就不容易把“拿到 Cookie”一路偷换成“整个链路已经完成”:

    • 发现:知道一条文章链接存在,还没有拿到正文。
    • 取文:请求并取得可解析的真实正文,不是只收到 HTTP 200。
    • 严格 3/3:验收选定的主页、栏目、文章三类真实文档;不负责证明发现完整,也不自动写入数据库。
    • 入库:把取得并解析的文章实际保存下来;本窗口的 16 篇说明链路产生了真实新增,不只是反复探测已有文章。

    因此,16 篇不能除以 76 轮或 143 次挑战来计算通过率;没有新增的轮次也不能直接算失败,可能没有待保存的新文章。这个窗口能支持“当时已产生实际入库、遇到挑战后存在恢复记录”,但不能证明长期稳定,更不能推广到所有出口、时段、脚本版本或 slider/captcha 路线。153 仍按前文的“概率性通过”理解。

    14.5 这一轮能下什么结论?

    可以把差异分成三类,而不是混成一句“环境没补全”:

    类型 本文例子 能下的结论
    已观察到的环境差异 浏览器权限为 denied,Node 默认是 prompt 没有修改该模型也完成了两次 fresh;不能归功于不存在的补丁
    自己输出里的确定性矛盾 两阶段身份不同;声明 x86_64 却回答 arm 可以修正并验证一致性,但不自动证明服务端接受
    仍待解释的接受差异 142 修复后 0/12,153 有通过也有拒绝 只支持当轮条件下的结果,不能推出普遍原因或长期稳定

    所以,对“到底是什么发挥了作用”应分层回答:已有环境模型让原始 JS 得以执行;身份修复消除了已经确认的矛盾;fresh 验证说明某组条件被接受。这三层证据相关,却不能互相替代,更不能凭结果把每项改动的贡献倒推出来。

    第 13 节说的是“发现差异后,先验证它是否阻止通过”。这一节补上后半句:修正已确认的内部矛盾,然后重新验证;修完仍不通过,就保留未知原因,而不是把失败一概归咎于出口,或再找一个字段写死。

    如果从头再做一次,我会怎样开始?

    我不会先要求自己学完所有 VM、浏览器和密码学知识。第一轮只拿一个固定样本,完成一条尽可能短、能复查的来源链:

    1. 先确认资源完整,区分挑战路线,记下样本时间与哈希。
    2. 从某个输出字段反查写入点,不从整个 bundle 的第一行开始读。
    3. 碰到分派循环,再补学 opcode、栈、调用帧和控制流。
    4. 执行卡住时,检查真实值的类型与使用方式,不靠万能 stub 消灭异常。
    5. 对照输出前的数组、字符和编码步骤,逐段缩小差异。
    6. 需要跨语言复算时,再认真处理 UTF-16、位宽和类型转换。
    7. 每次解释缺失日志之前,先确认自己的观察没有截断或漏装。
    8. 把离线算法结论、浏览器对照、服务端接受和实际复用分开记录。

    DataDome 只是这次的案例。真正可以带走的方法,是把一个巨大的“为什么不行”,拆成一串可以验证的小问题。

    逆向的进步,不是你给多少变量起了名字,而是有多少句“可能是这样”,已经变成“在这些条件下,我能证明它是这样”。

    资料与证据边界

    本文中的实验事实来自项目研究记录;历史章节必须按章节日期阅读,不能当作今天的在线结果。第 13 节依据独立 DataDome Lab 中 2026-10-17 的浏览器观测、门槛失败和 Node fresh 验收记录;第 14 节依据当晚至 18 日的身份一致性排查、142/153 身份差分、C7/C8 复测,以及上述时间窗口留存的生产轮次与入库汇总。两节都只公开脱敏方法、结论与聚合统计,不公开原始样本、Cookie、账号、出口、代理凭据或可复用证明;原始日志与实验材料不随文章公开。下列早期项目链接仍固定到原整理版本,不冒充新增实验的公开复现包:

    Warning

    私有 repo 不公开

    评论