很多人在逆向 DataDome 时都经历过这样的时刻:JavaScript 跑完了,请求返回 200,甚至拿到了新 Cookie,然后带着这个 Cookie 去请求正文,还是 401。

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

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

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

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

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

在一个旧版 CAPTCHA 样本中,请求里有两个值得追踪的名字:ddCaptchaEncodedPayload 和 plv3。前者涉及行为数据等内容,后者在这个样本里来自环境检测相关的计算。

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

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

顺着请求组装的位置向前找,我们在旧样本中确认了这样的关系:这就是“从使用点反查定义”,也可以理解成最初级的逆向数据流分析。请求组装是输出端,赋值语句是连接线,而我们要沿着连接线找生产者。

分析时是从第一行往下追来源;实际执行时,通常先算出 o,再赋值,最后组装请求。逆向追查方向不等于运行方向。

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

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

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

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

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

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

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

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

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

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

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

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

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

因此,在 Node 中执行原脚本时,如果那个脚本内部有这套自定义 VM,仍然是 Node 执行外层 JavaScript,外层解释器再执行内层字节码。不是把 DataDome 的字节码直接交给 Node 当 JavaScript 运行。

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

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

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

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

```js

const bytes = new Uint8Array([0x01, 0x03, 0x01, 0x05, 0x02, 0xff]);

const handlers = {

0x01: (ip) => { const value = bytes[ip + 1]; stack.push(value); return ip + 2; },

0x02: (ip) => { const b = stack.pop(); const a = stack.pop(); stack.push(a + b); return ip + 1; },

0xff: (ip) => { running = false; return ip + 1; }

};

let ip = 0, running = true, stack = [];

while (running) { const opcode = bytes[ip]; ip = handlers[opcode](ip); }

console.log(stack); // [8]

```

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

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

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

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

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

在教学 VM 的基础上,假设增加一个 JUMP_IF_ZERO。它的 handler 可以这样写:它只说明跳转规则。结合一段教学字节码,才知道具体的两条路。把这些位置与跳转边连起来,就是控制流图(CFG)。它回答“有哪些可能路径”。某一次运行只走其中一条,动态日志不能自动覆盖另一条。

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

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

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

识别出解释器后,一个自然想法是:把原脚本放进 Node,缺哪个浏览器对象,就补哪个。这类临时替代实现叫 stub,也就是占位实现。

这里有两个不同的工作:

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

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

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

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

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

& 是按位与,>>> 是无符号右移。在本例中,它们会先按各自规则把操作数转成 32 位整数;NaN 在这种整数转换中会成为 0。这不是说 Number(stub) 本身返回 0,也不是所有运算都把 NaN 当 0。它发生在这里特定的位运算转换中。

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

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

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 之前检查数组,而不是只盯最终字符串长度。逆向要逐层问:这个 chars 是哪个对象?谁向它写过?oneValue 又是哪次调用的返回值?

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

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

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

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

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

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

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

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

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

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

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

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

最终那条 Node 路线,是在隔离环境中执行当轮原始 JavaScript;如果该版本内部有 VM,也由原脚本自己的解释器执行。

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

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

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

9 月一些固定样本中的 cyrb53 是一个把输入压缩成数值的哈希子过程,不是整个字段。它使用的 charCodeAt 按 UTF-16 码元读字符串。码元是编码单位,不总是人眼看见的一个字符。Python 直接遍历这个字符串,会看到一个 Unicode 码点。若要模拟上述 JavaScript 子过程,需要显式取出两个 UTF-16 码元。

Math.imul(a, b) 做 32 位整数乘法并给出有符号结果;>>> 0 再把对应的低 32 位按无符号整数解释。还有末端的 btoa:它把“每个字符表示一个字节”的字符串转成 Base64,并不自动把所有 Unicode 文本按 UTF-8 编码。

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

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

trace 是程序运行时记录下来的事件序列;插桩是为获得这些事件而添加钩子、包装调用或改写代码。它们都不是完全透明的旁观者。

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

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

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

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

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

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

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

一份报告至少要解释以下计数的口径:total 是钩子实际观察到的事件数量;filteredOut 是因为不符合筛选条件而主动忽略的数量;retained 是满足筛选条件且实际保存的数量;dropped 是满足条件,却因容量上限丢弃的数量;thrown 是观察到的调用中,抛异常的数量。

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

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

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

9 月 27 日的一组固定样本实验中,改变时区相关条件后,一组中间哈希发生变化,但最终 plv3 没变。

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

反事实实验的意思是“在可控条件下改变一个因素,看会怎样”。要避免把随机变化当成时区影响,实验安排至少应类似这样:如果时间、随机源、脚本版本或走到的分支也变了,就不是干净的单因素对照。变化告诉你哪里敏感;完整的来源与消费链,才帮助你解释它为何影响或没有影响输出。

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

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

到 10 月初,Node 侧实际具备了哪些环境?“补全浏览器环境”容易让人以为我们重写了一个 Chromium。更准确地说,我们实现或接入了已研究路径所需的一部分宿主接口与行为。

| 环境层 | 已实现或接入的内容 | 仍需保留的边界 |

|---|---|---|

| 资源装载与 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 另有独立模块。

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

有些 API 需要保持稳定对象身份。iframe 还可能处于独立的 realm,即拥有自己的全局对象和一组内建构造器的 JavaScript 环境。同源且可访问的独立 iframe 可以出现这样的关系:把所有 iframe 都指向同一套构造器,会改变这类判断。instanceof 在这里依赖构造器的原型关系,不是只检查变量“长得像数组”。

某次跨站挑战帧的实测里,访问存储受到限制。若模型总是返回一个可用对象,就走了与当时浏览器不同的分支。但这不等于所有页面都该禁用存储;要按版本、权限和页面上下文记录实际行为。同理,读不到的高熵浏览器版本或硬件信息应保持不可用,不能因为模型有字段可以填就凭空编造。

这里 Promise 回调进入微任务,当前同步代码结束后才执行;定时器回调又在之后。join 不会等待未来发生的写入。真实浏览器还涉及渲染、I/O 和不同任务来源,本例不是完整调度器。但它准确解释了为什么“环境接口最后能算出值”仍不够:必须在原程序读取它的那个时间点,提供相符的行为。

Cookie 之外,Session 到底还保存了什么?

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

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

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

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

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

消融就是从已能工作的条件里拿掉一部分,再看结果。本项目的 _RequestContextSession 是自己写的包装器,不是 curl_cffi 自带功能;它同时管理请求头上下文和压缩设置等行为。两枚独立候选移除这个包装器后,都出现三个 401;原 Session 复测仍为 3/3。但其中一轮后置完整新 Session 对照还出现了传输错误,不能润色成所有对照全部成功。

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

最后再区分三个看起来都像“请求成功”的阶段:浏览器的 request 事件也只表示请求流程开始,不能单凭它证明字节已到达服务端。本地计算、网络交互、服务端接受和最终使用端复用,是连续但不同的检查点。

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

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

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

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

截至本文采用的 10 月 2 日记录,已经有两次独立 Node 运行在服务端当次提供的 interstitial 设备检查路线上完成 fresh 3/3;输入没有使用浏览器产出的 Cookie、证明值或运行时快照。之后还发生了上文提到的移交与生产接入排查。

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

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

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

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

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

这个思路能直接用到你自己的逆向或调试场景:面对一个黑盒系统,先别急着从头读代码或猜答案,而是从输出端反查来源,把“为什么不行”拆成一个个可验证的小问题,每一步都用证据说话。要注意的坑是:别把工具的限制当成程序行为,别用一个层级的证据回答另一个层级的问题,也别因为“代码能跑”就以为万事大吉——服务端接受才是真正的终点。

阅读原文 → 返回 AI 技术文档

内容与图片版权归原作者所有 · 原文: https://xingpingcn.top/reverse-engineering-for-beginners/