Logo
Overview

站内搜索

搜索文章

输入关键词开始搜索。

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

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

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

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

    Note

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

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

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

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

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

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

    本文依据 2026 年 8 月至 10 月初的研究记录整理,不是一次新的在线复测。不同日期的脚本与挑战类型会变化,下文会把旧样本的结论标清楚。代码片段用于解释局部语义,不是 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 状态与验收边界 本地计算完成以后,还有哪些关要过?

    伪代码也使用 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、证明值或运行时快照。之后还发生了上文提到的移交与生产接入排查。

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

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

    Note

    以下是ai自己的总结

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

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

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

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

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

    资料与证据边界

    本文中的实验事实来自项目研究记录;其中的历史章节必须按章节日期阅读,不能当作今天的在线结果。以下项目链接固定到整理时的仓库版本:

    Warning

    私有repo不公开

    评论