某滑块完整逆向:从参数链反推到纯 HTTP 复现

Python技术杂谈 2026-08-25 10:47:35 阅读(5) 评论(0)

引言

分析对象是某地图详情接口触发的某滑块验证,包括 FYToken、_randbx-pp 和
bx-et 四条参数链的完整生成过程,以及人类鼠标轨迹的构造方法。分析从最终的 slide
请求出发,逐层向上反推至 NC、punishpage、Fireye 和 ET,记录每一步的断点位置、Hook 代码
和中间数据,最终实现脱离浏览器的纯 HTTP 复现。

分析时的资源版本为 nc.js 1.97.0fireyejs.js 1.234.20 和 et_f.js 1.83.8
资源更新后函数名和偏移可能变化,但分析方法不变。

组件与术语

组件
角色
NC (NoCaptcha)
组装 analyze 请求,调用 AWSC 获取 FYToken
AWSC
某 Web 安全 SDK,加载 Fireye 并转发调用
Fireye (fireyejs)
行为采集与 FYToken 生成
ET (et_f.js)
事件采集、XHR/fetch 包装、tfstk 维护、etSign 签名
punishpage
改写 NC 的 analyze 为 slide,组装 _rand/ppSign
program.wasm
bx-pp 基础 PoW
nonce VM (内联)
crypto.randemUUID

,按 marker 推进 ppSign

请求流程

某地图的详情接口触发风控时,页面会进入滑块验证。整个业务流程中,详情接口会请求两次:第一次返回验证页和 challenge;滑块通过后,浏览器带上新签发的
x5sec 再请求一次,才能拿到原本的详情数据。接口地址是:

https://ditu.amap.com/detail/get/detail

这里的”两次”只指详情接口本身,不是说整个页面只发出两条 HTTP 请求。两次详情请求
之间,浏览器还会加载 AWSC、punishpage、Fireye、ET 和 NC 等 JS 资源,并发出
多条 report 和 initialize 请求。每完成一次滑动验证,才会对应提交一条
/_____tmd_____/slide;如果失败后重新滑动,Network 中就会出现多条 slide。

打开 DevTools 重新请求,Network 中首先出现的是:

GET /detail/get/detail HTTP/2
Host: ditu.amap.com

这个地址本身不带参数,会话信息都在 Cookie 里。第一次请求返回 200,
响应头中有 bxpunish: 1,响应内容也不是详情 JSON,而是 punish HTML。在这段 HTML
中搜 window._config_,可以看到当前 challenge 的初始配置:

某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 1:punish HTML 中的 window._config_,其中包含 challenge、请求路径与后续验证所需的初始数据。

页面继续加载 NoCaptcha(后面简称 NC)、Fireye、
ET 以及几个JS运行时资源。按 Network 中的发起时间排序,请求顺序如下:

//
第一次请求详情接口 -> punish HTML
program.wasm / punishTextFetch
report(loadPageSuccess)
nonce.js
report(loadSuccessAWSC)
report(loadSuccessEt)
report(loadSuccessRand)
report(loadSuccessNC)
initialize.jsonp
report(initSuccess)
slide
获得 x5sec
第二次请求详情接口 -> 详情 JSON

某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 2:同一会话内,从第一次请求详情接口到验证通过后返回详情 JSON。

中间的 report 请求先放在一边。手动把滑块拖到最右侧,不管成功还是失败都会多出一条请求 /_____tmd_____/slide,它才是最后的验证请求:

GET /detail/get/detail/_____tmd_____/slide?slidedata=…&x5secdata=…&ppt=…&_rand=…&landscape=1&ts=…&v=… HTTP/2
Host: ditu.amap.com
bx-pp: …
bx-et: …
Cookie: cna=…; tfstk=…; arms_uid=…

query 里有 slidedatax5secdatappt_rand
landscapets 和 v;请求头里有 bx-pp 和 bx-et;Cookie 里则能看到
cnatfstkarms_uid 等字段。

slidedata 是 URL 编码后的 JSON。把它单独解码,里面还有一层字符串化的 p

//以下为代码正文…
{
"a": "NCAPPKEY",
"t": "NCTOKENSTR",
"n": "234!...",
"p": "{\"ncbtn\":\"...\",\"umidToken\":\"...\",\"ncSessionID\":\"...\",\"et\":\"1\"}",
"scene": "register",
"asyn": 0,
"lang": "cn",
"v": 1
}

t 就是当前的 challenge,n 是以 234! 开头的 FYToken。p 再解一次,
可以得到滑块几何信息 ncbtnumidTokenncSessionID 和 et

slide 返回的是 JSON。通过时外层 code 为 0:

//以下为代码正文…
 

{
“code”: 0,
“dt”: “success”,
“ec”: 200,
“result”: {
“code”: 0,
“sig”: “from bx”
},
“success”: true
}

失败时 HTTP 状态仍然是 200,success 也仍然为 true,只是外层 code
变成了 300或8778:

//以下为代码正文…
{
  "code": 300,
  "dt": "success",
  "ec": 200,
  "result": {
    "code": 300,
    "sig": "from bx"
  },
  "success": true
}

所以这条请求不能只看 HTTP 状态,最终以 code 和 x5sec 是否签发为准。
拿到 x5sec 后,浏览器会再次请求 /detail/get/detail。两次业务请求的 URL
完全相同,差别在于第二次请求的 Cookie 中已经有了这次验证签发的 x5sec。这时
返回的才是原本要获取的详情 JSON。

一个常见的误区是把 FYToken、_randbx-pp 和 bx-et 当成四个独立的签名去处理。
把几个参数单独跑通以后,组出来的请求仍然会返回 code=300。这几个值虽然在
不同阶段生成,读取的却是同一个 challenge、同一组 Cookie 和同一份页面运行状态。
单独对上字符串的长度和格式,还不等于对上了整条验证链。

要把这个问题搞清楚,只能回到最后的 slide 请求,从它的发送位置一层层往上找。

从 slide 请求向上反推

/_____tmd_____/slide 是最稳定的起点。XHR 断点命中时,调用栈顶部是
et_f.js 的 Xe,继续向下可以回溯到 punishpage 和 NC:

//以下为代码正文…
Xe                         et_f.js
l                          punishpage.min.js
l                          punishpage.min.js
S.replaceCallback          punishpage.min.js
(匿名)                     nc.js
setTimeout -> t -> m -> i -> s    nc.js
某滑块完整逆向:从参数链反推到纯 HTTP 复现某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 3:XHR 断点命中 slide 后,调用栈从 et_f.js 回溯到 punishpage 和 nc.js

调用栈从顶部到底部依次是 et_f.js → punishpage.min.js → nc.js。展开每一层时,关键不是
看函数名,而是看局部变量:在 punishpage 层看到改写前后的请求对象 t 和 r,才确认 NC
的 analyze 被改写为 slide;在 nc.js 层看到 t.data 已包含 234! 开头的 FYToken,才确认
token 在进入 punishpage 之前就已生成。这个逐层展开的过程,比直接看最终调用栈更能定位
每个字段的生成层。

这个顺序改变了后续的分析方向:NC 并不直接写出 slide,punishpage 会改写 NC 原本
要发送的 analyze 请求,ET 再包装最后的 XHR。

XHR 断点适合查看调用栈,但是一下断,原本的滑动动作也结束了,所以还需要在控制台
hook slide,让页面正常完成滑动,同时保存发送前的 URL、
bx-etbx-pp、Cookie 和调用栈:

//以下为代码正文…
(() => {
  const proto = XMLHttpRequest.prototype;
  const rawOpen = proto.open;
  const rawSetRequestHeader = proto.setRequestHeader;
  const rawSend = proto.send;
  const requests = new WeakMap();

proto.open = function (method, url) {
requests.set(this, {
method: String(method),
url: String(url),
headers: {}
});
return Reflect.apply(rawOpen, this, arguments);
};

proto.setRequestHeader = function (name, value) {
const request = requests.get(this);
if (request && /^bx-(?:et|pp)$/i.test(name)) {
request.headers[name] = String(value);
}
return Reflect.apply(rawSetRequestHeader, this, arguments);
};

proto.send = function (body) {
const request = requests.get(this);
if (request && request.url.includes(“/_____tmd_____/slide”)) {
window.__slideCapture = {
…request,
body,
cookie: document.cookie,
stack: new Error().stack
};
}
return Reflect.apply(rawSend, this, arguments);
};

window.__restoreSlideHook = () => {
proto.open = rawOpen;
proto.setRequestHeader = rawSetRequestHeader;
proto.send = rawSend;
};
})();

rawOpenrawSetRequestHeader 和 rawSend
保存的是浏览器原函数,Reflect.apply(..., this, arguments) 负责按原来的 this 和实参
继续执行,避免 Hook 破坏 XHR 行为。WeakMap 用于把 URL 和请求头绑定到各自的 XHR
对象,防止并发请求的数据串到一起。它只在 URL 命中 slide 时保存一次数据,不在所有
请求上打印日志。读取完 window.__slideCapture 后执行 __restoreSlideHook(),把三个
原函数恢复。

回到调用栈里的 nc.js,局部变量 t 是改写前的请求对象:

//以下为代码正文…
url      = https://cf.aliyun.com/nocaptcha/analyze.jsonp
callback = callback
data     = {a, asyn, lang, n, p, scene, t, v}

data.n 已经是 234! 开头的 FYToken,data.t 是当前 challenge。data.p 再解析后只有
ncbtnumidTokenncSessionID 和 et。这些值在进入 replaceCallback
之前就已生成。

再切到调用栈里的 punishpage.min.js,可以同时看到改写前的 t 和改写后的 rr 的 URL 变为
/_____tmd_____/slide,数据区新增 x5secdata_randtsppt 和
landscape,同时带上 ppSign 与 sign: true

某滑块完整逆向:从参数链反推到纯 HTTP 复现

某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 4:上图是 NC 组装 analyze 并调用 replaceCallback;下图是 punishpage 保留原请求 t、生成 slide 配置 r 后交给请求发送函数。

最终发出的 slide 请求由三部分组成:

//以下为代码正文…
query: slidedata、x5secdata、ppt、_rand、landscape、ts、v
header: bx-et、bx-pp
cookie: cna、tfstk、arms_uid 等当前值

字段级对应关系也可以在这两个 punishpage 断点中拆开:

最终位置
首次可见表达式/值
所在层
核对方式
slidedata JSON.stringify(t.data)
punishpage 改写层
t.data

 由 NC 已组装完成
x5secdata e.SECDATA
punishpage 改写层
最终值在发送前的 e.data 中复核
ppt _config_.ppt

 存在时写入
punishpage 改写层
发送前快照为 1,RAND 调试轮次为 2,该值随会话配置变化
_rand
slide 阶段 genRandomValues 返回值
secaptcha/punishpage
浏览器显式输入为 FYToken
ts Date.now()

 形成的局部时间戳
replaceCallback
传入 slide 配置
query v
("v=" + Math.random()).replace(".", "")
请求发送函数
序列化后追加
landscape o.isLandscape && (a.landscape = 1)
punishpage 改写层
仅在 landscape 分支写入
bx-pp e.ppSign
请求发送函数
setRequestHeader("bx-pp", e.ppSign)
bx-et window.etSign(e.url + "?" + query)
请求发送函数
签名完整 URL

NC 组装 analyze 请求时 FYToken 已经存在;punishpage 把 analyze 改成 slide 时加入
x5secdata_rand 和 ppSign;等 slide 的完整 URL 拼好以后,才生成 bx-et

从 slidedata.n 找到 FYToken

前面下断在 nc.js 时,局部变量 t.data 就是 NC 准备提交的 analyze 参数,其中
t.data.n 已经是一串以 234! 开头的长字符串。最终 slide 只是把整个 t.data
序列化为 slidedata,所以请求里的 slidedata.n 与这里的 t.data.n 是同一个值。

token 每次都会变化,直接搜索它的内容没有意义。在 nc.js 中搜索 getFYToken
可以找到 n 的赋值位置:

//以下为代码正文…
a.n = o.__fy.getFYToken(o.__fy_options)
某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 5:在 nc.js 中搜索 getFYToken 后命中的赋值语句;断点停下时,悬浮结果已经显示当次生成的 234! token。

a 是正在组装的 analyze 参数,o.__fy 是 NC 从 AWSC 取得的 Fireye 接口,
o.__fy_options 则是传给该接口的配置。把断点下在这一行,然后 F11 单步进入
awsc.js

//以下为代码正文…
i.getFYToken = function () {
    return this.fyObj.getFYToken(r)
}
AWSC 在这里没有生成 token,只是把配置 r 转交给已经加载好的 Fireye 对象。再次单步 进入,才会到达 fireyejs.js 中真正的生成函数。完整的调用关系是:<
//以下为代码正文…
/pre>nc.js 组装 a.n
  -> awsc.js 的 getFYToken
  -> this.fyObj.getFYToken(r)
  -> fireyejs.js 内部生成函数
  -> AWSC 将返回值原样交还给 NC

在 Fireye 入口查看 arguments[0],拿到的不是鼠标轨迹数组,也不是 challenge 字符串,
而是一组行为采集配置:

配置项
当次值
从名称和调用位置能够确定的作用
MaxMTLog
300
限制鼠标轨迹记录量
MTInterval
4
鼠标移动采样间隔
MinMTDwnLog
30
与鼠标按下记录有关的门槛
MaxKSLog
14
限制键盘类记录量
MaxFocusLog
6
限制焦点变化记录量
MaxNGPLog

 / NGPInterval
200 / 4
另一类行为采集器的记录上限与间隔;仅凭该断点无法确认 NGP 的完整含义
timeout
2000
本次调用使用的超时配置

getFYToken 读取的是 Fireye 已经积累的内部状态,显式参数主要控制各类
事件怎样采集,并不是把”轨迹 + challenge”作为一段明文传进去。challenge 没有出现在
arguments[0],不代表生成过程与 challenge 无关;Fireye 仍可以从初始化配置、闭包或
页面状态中读取其他数据。

Fireye 怎样消费鼠标事件

getFYToken(options) 没有轨迹参数,鼠标数据必须在这次调用之前进入 Fireye。
继续跟进 Fireye 的行为模块,可以把这条路径拆成四步:

//以下为代码正文…
startRecord()
  -> mousedown(event)
  -> mousemove(event) 按 MTInterval 采样并写入 FL 缓冲区
  -> getFYToken(options) 把当前 FL 缓冲区编入 FYToken

mouseup 会继续走页面和 NC 的事件处理,并触发后续验证;Fireye 的 FL 行为块
主要由按下和移动记录组成。“同一组事件交给 Fireye 和 ET”指的是两者共享
同一条坐标与时间序列,不是说它们最终保存的字段和事件数完全相同。

验证这条路径时,先用后面的鼠标 Hook 保存一次完整滑动,再取出同次 slide 中的
FYToken。去掉 234! 前缀并解开四个数据块后,第一块是 FL 行为数据,后面统一记为
B1_FL。它会随按下坐标、移动点和事件时间变化。固定时间、随机源和环境数据后,
只修改一个移动点并重新生成 token,变化集中在 B1_FL
而调用栈所在的第三块不会因这个坐标本身改变。这个对照把原生鼠标事件、Fireye 缓冲区
和 FYToken 中的行为块连了起来。

fireyejs.js 的函数内部已经是控制流平坦化的状态机,没有必要在这里沿每个分支单步。
执行单步跳出,回到 awsc.js 后可以直接看到 Fireye 返回的完整 234! 字符串;AWSC
不再加工它,而是把结果交还给 NC,NC 随后将其写入 a.n

某滑块完整逆向:从参数链反推到纯 HTTP 复现

FYToken 的 Fireye 入口与 AWSC 返回边界

图 6:awsc.js 把采集配置转交给 fyObj.getFYToken;函数执行完后,Scope 中可以看到 Fireye 返回的 234! token。

getFYToken 不只处理 options,还会把生成时取得的 Error 调用栈写进 FYToken。浏览器
记录的是 nc.js -> awsc.js -> fireyejs.js 及对应的网页资源地址;在 Node 中直接调用虽然
也能得到格式和校验都正确的 234! 字符串,调用栈数据块里却会变成本地文件和
node:internal。将 FYToken 生成过程放到 Node 后,必须还原 token 中记录的浏览器
调用栈,并重新计算相关校验,不能把 Node 直接调用的返回值原样提交。

这也是前期使用 DevTools 断点、没有包装 getFYToken 的原因。包装函数既会在调用栈中
增加一层,还可能改变 Function.prototype.toString 的结果;断点则能在不改写原函数的
情况下查看输入和返回值。

事件是怎样进入 ET 的

直接使用 mousemove 断点,光是把鼠标从页面其他位置移到滑块上,断点就会反复触发,
无法调试真正的拖动过程。因此在 et_f.js 的事件处理函数 M 上加了条件断点,只在左键已按下
且鼠标正在移动时暂停:

//以下为代码正文…
event.type === "mousemove" && event.buttons === 1

另外不要在 mousemove 的处理函数里每次都执行 console.log。一次拖动会触发上百次
移动事件,连续输出会拖慢页面,同时改变事件之间的时间间隔。

条件断点停下后,mousedownmousemove 和 mouseup
都会进入 et_f.js 的函数 M。在 Scope 中展开事件对象,可以看到它是浏览器生成的
MouseEvent,当前的 currentTarget 是 document

随后分别在 mousedown、按住左键的 mousemove 和 mouseup 处暂停,记录 Scope
中与鼠标状态有关的字段:

某滑块完整逆向:从参数链反推到纯 HTTP 复现

ET 收到的 mousemove 事件

图 7:mousemove 条件断点命中 et_f.js 的 M,控制台展开了当次 MouseEvent 的按键状态、目标和坐标字段。

事件
isTrusted buttons
目标
关键观察
mousedown true
1
nc_1_n1z

 / btn_slide
按下位置、button=0
mousemove true
1
nc_1_n1z

 / btn_slide
movementX=1

movementY=0
mouseup true
0
nc_1_n1z
释放位置、button=0

这一层能确认 ET 直接观察真实 DOM 事件。它不能单独证明服务端对 isTrusted 或某个坐标字段
给了多少权重。

条件断点只能看某一个事件。要拿到一整次滑动,又不能让上百个 mousemove 反复暂停,
可以使用下面这个内存记录 Hook:

//以下为代码正文…
(() => {
  const types = ["mousedown", "mousemove", "mouseup"];
  const records = [];

const record = event => {
const target = event.target;
records.push({
type: event.type,
timeStamp: event.timeStamp,
performanceNow: performance.now(),
dateNow: Date.now(),
pageX: event.pageX,
pageY: event.pageY,
clientX: event.clientX,
clientY: event.clientY,
movementX: event.movementX,
movementY: event.movementY,
offsetX: event.offsetX,
offsetY: event.offsetY,
button: event.button,
buttons: event.buttons,
targetId: target && target.id || “”,
targetClass: target && String(target.className || “”)
});
};

for (const type of types) {
document.addEventListener(type, record, true);
}

window.__stopMouseHook = () => {
for (const type of types) {
document.removeEventListener(type, record, true);
}
window.__mouseRecords = records;
return records;
};
})();

它没有替换 ET 的函数 M,而是在 document 的捕获阶段旁路观察同一批原生鼠标事件。
事件到达时只向数组追加普通对象,不执行 console.logconsole.trace 或断点。滑动结束
后调用 __stopMouseHook(),它会移除三个监听器并返回完整数组;需要导出时再执行:

//以下为代码正文…
copy(JSON.stringify(window.__mouseRecords))

同时保存 event.timeStampperformance.now() 和 Date.now(),是为了在离线分析时区分
事件时间、页面运行时间和系统时间。这个 Hook 解决的是”完整记录一次手势”,前面的条件
断点解决的是”确认 ET 的接收函数和事件对象”,两者不能互相替代。

从事件字段到完整手势

确认 ET 收到的 MouseEvent 字段后,下一步是组织一次完整拖动。页面决定的是滑块坐标、
按钮尺寸和可移动距离;接近路径、悬停点数、拖动采样密度和各阶段时间则需要自行构造。

事件字段

一条事件包含:

//以下为代码正文…
type、x、y、ts、wallTs、dt、wallDt
targetId、targetTag、offsetX、offsetY
movementX、movementY、button、buttons、which、detail
生成完成后统一回填 target、offset、movement、按键状态和时间差。按钮被拖动后,
offsetX 按按钮当前位置计算,而不是一直减去初始 left。

手势阶段

完整手势按顺序包含:移向滑块、在按钮上悬停、按下左键、按住不动、向右拖动和松开。

事件阶段
事件数
构造方式
接近
N_approach

 个 mousemove
从页面内的起始点移向按钮
悬停
N_hover

 个 mousemove
在按钮内做小范围移动
悬停后按下
1 个 mousedown
按下位置在按钮内随机选取
按住
1 个原地 mousemove
坐标不变,buttons=1,用来表示按下后的停留
拖动
N_drag

 个 mousemove
横向步长分段生成,再调整总和使终点到达轨道右侧
释放
1 个 mouseup
沿用最后一个拖动点的坐标,将 buttons 置为 0
某滑块完整逆向:从参数链反推到纯 HTTP 复现
轨迹生成器内部结构

图 8:生成器根据滑块几何、时间和随机 seed 产生完整事件序列,同一组事件分别交给 Fireye 和 ET。图中将按下和按住合并为一个处理单元,实际事件序列仍按六个阶段生成。

源事件总数由三组移动点和三个状态事件组成:

//以下为代码正文…
N_approach + N_hover 个接近/悬停 mousemove
+ 1 个 mousedown
+ 1 个按下后的原地 mousemove
+ N_drag 个有效拖动 mousemove
+ 1 个 mouseup
= N_approach + N_hover + N_drag + 3 个源事件

事件顺序有六个阶段,计时则只分为接近、悬停、按住、拖动和释放五段。
mousedown 是按下时刻的单个事件,不再单独定义一段时长。

三段拖动与相关噪声

将 N_drag 个横向步长拆成 n1n2n3 三段,且 n1+n2+n3=N_drag
起步段放入更多 0/1 像素步长,中段加入少量较大步长,
收尾段再增加小步。三段生成完成后,统计当前步长总和与目标距离的差,然后只调整中段和收尾段中的
2—5 像素步长,直到终点与页面计算的拖动距离一致。

垂直方向使用 smoothstep 漂移叠加两组正弦相关噪声,使纵向位移在起点和终点回到 0:

//以下为代码正文…
drift(p) = DeltaY * p^2 * (3 - 2p)

noise(p) = sqrt(sin(pi*p)) * (
A1*sin(2*pi*f1*p + phase1)
+ A2*sin(2*pi*f2*p + phase2)
)
候选轨迹生成后,程序计算接近路径长度、重复点数、横向步长、速度分位数和加速度变号次数。
任意一项超出选定的范围,就丢弃该候选并使用新的随机数重新生成。

完整事件序列会同时交给 Fireye 和 ET:Fireye 将其写入 FYToken,ET 则在同一次验证中累积事件状态。

从 slide 的 _rand 找到生成入口
Network 中有两条请求带着名为 _rand 的字段:一条是页面加载期间的
report(type=loadSuccessRand),另一条是最终 slide。先在 Network 中选中 slide 请求,
从它的 initiator 和调用栈向上追踪;loadSuccessRand 保留为另一条独立路径,避免因字段同名而混在一起。

回到 punishpage 的 slide 组装函数 p(e,t),可以看到它的赋值语句:

//以下为代码正文…
a._rand = window.crypto.genRandomValues(t.data.n)
浏览器只显式传入一个参数,即当次 FYToken。当次快照中 FYToken 长度为 2116,返回的
_rand 为 160 字符。单步进入后,调用进入动态脚本 VM162188。外层包装函数先把 FYToken 和 undefined 放进运行时自己的值表,再把它们所在位置的编号 u=15i=16 传给核心函数 r(u,i)。核心函数需要原值时,会根据编号回到值表中读取。因此 15 和 16 只是动态运行时内部传递数据的编号,不代表浏览器向 genRandomValues 传了两个实参。 某滑块完整逆向:从参数链反推到纯 HTTP 复现
从包装函数进入 rand 核心

图 9:包装函数准备调用 r(u,i);右侧 Scope 同时显示完整 FYToken、未传入的第二个形参,以及转换后的内部编号 u=15i=16

若只想确认一次 slide 调用的输入和输出,也可以在滑动前临时包装
crypto.genRandomValues

//以下为代码正文…
(() => {
  const rawGenRandomValues = crypto.genRandomValues;
  window.__randCalls = [];

crypto.genRandomValues = function () {
const args = Array.from(arguments);
const result = Reflect.apply(rawGenRandomValues, this, arguments);
if (typeof args[0] === “string” && args[0].startsWith(“234!”)) {
window.__randCalls.push({
fyToken: args[0],
rand: String(result),
stack: new Error().stack
});
}
return result;
};

window.__restoreRandHook = () => {
crypto.genRandomValues = rawGenRandomValues;
};
})();

过滤 234! 是为了只留下 slide 阶段,不把其他同名调用混进结果。拿到
window.__randCalls 后调用 __restoreRandHook()。Hook 只能确定公开函数的输入输出,
内部程序的内存布局仍要靠单步进入动态脚本后继续分析。

动态脚本与 6502 执行引擎

进入 r(u,i) 后,我强烈建议用AI辅助你分析,没有必要在这种代码上浪费你的时间精力。大量的 |0 运算、数组 set 操作和
几十万的下标范围,指向 asm.js/wasm2js 一类的低级输出。源码中的典型写法包括:

//以下为代码正文…
n |= 0;
r |= 0;
gf = gf - 64 | 0;
cf.set([111, 113, 83, 56, 15, 232, 219 /* ... */]);
cf[1024 + Af] ^= 31 * Af;
| 0 强制使用 32 位整数,gf = gf - 64 像是在为一次调用预留栈空间,cf.set(...) 负责装入一段初始数据。这些特征更接近 asm.js/wasm2js 一类低级代码的输出, 而不是人工编写的签名算法。它负责准备内存、保存调用状态和运行内部程序,可以把它理解成 外层的执行引擎某滑块完整逆向:从参数链反推到纯 HTTP 复现 某滑块完整逆向:从参数链反推到纯 HTTP 复现 某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 10:上图为 punishpage 的单 FYToken 参数调用;中图为进入 r(u,i) 后的动态 JS 执行引擎;下图为同次调用的 2116 字符 FYToken 与 160 字符 _rand 返回值。

继续跟踪数据复制,发现在外层大内存中有一块 65536 字节区域,下标范围恰好是
0x0000..0xFFFF——这正好是 16 位地址空间的上限。从这块区域的 0x1000 地址开始,
字节可以连续解码成合法的 6502 指令。这个发现过程本身就是分析经验的一部分:从混淆代码中
识别出虚拟 CPU 的内存布局,靠的不是单步每条指令,而是先看内存大小和入口字节的可解码性。

6502 原本是一款 8 位微处理器,这里借用的是它的指令集和运行规则。它有累加器 A
索引寄存器 X/Y、程序计数器 PC、栈指针 SP 和状态寄存器 P;地址使用 16 位,
最多能直接访问 2^16 = 65536 个字节。页面里没有真实的 6502 芯片,而是用 JS 模拟这些
寄存器、内存和每条指令的效果。

所以这里有”机器”和”程序”两层。JS 大函数负责模拟一台能够执行 6502 指令的
机器,64 KiB 内存中的 6502 机器码才是这台机器实际运行的程序。_rand 的执行过程可以
概括成:

//以下为代码正文…
punishpage 调用 genRandomValues(FYToken)
  -> 进入动态脚本 VM162188
  -> JS 执行引擎模拟 6502 CPU
  -> 6502 在自己的 64 KiB 内存中运行程序
  -> 从输出区取出 120 字节并编码为 _rand

判断指令集的依据来自导出的 64 KiB 内存。从地址 0x1000 开始的
字节可以连续解码成 LDXLDACMPJSRBNE 和 JMP 等合法指令。按 6502
的寄存器、栈和标志位规则执行,程序会读取约定的输入区,并把 120 字节结果写到固定输出区。
64 KiB 的大小只与 6502 的 16 位地址空间相互印证,并不是识别指令集的唯一理由。

这份 64 KiB 数据不能简单叫作”固定字节表”。它是一份6502 内存镜像,其中混合了
四类内容:

//以下为代码正文…
6502 程序代码
算法使用的常量和查表数据
每轮调用的输入、控制字和临时工作区
最终 120 字节输出区

页面资源中的 program.wasm 由后面的 bx-pp PoW 调用,不在 _rand 的调用栈中。

确认内层运行的是 6502 程序后,分析目标也随之改变。外层数万行 JS 主要实现执行引擎,
真正决定 _rand 计算过程的是 64 KiB 内存中的指令和数据。等初始化完成后导出这块内存,
从 0x1000 识别指令流,再用一个最小 6502 模拟器执行,输入区、工作区和输出区便可以
逐步标出来。

浏览器动态运行时初始化完成后,将这份 65536 字节内存镜像导出为
6502_template.bin,作为本地模拟器的初始内存。模板 SHA-256 为:

//以下为代码正文…
eff303464a6f86a7eb544d45c0330dc25ebf32f5d5a94c90c831de4c6293cd53

这个 hash 只锁定当前资源版本。复现时仍需要在对应动态运行时完成内存初始化和字节解码后
再导出,不能把这个模板当成跨版本固定资源。

入口 0x1000 的前 16 字节为:

//以下为代码正文…
a2 00 ad 00 01 c9 02 20 f0 1a d0 03 4c 15 10 20

逐条按 6502 opcode 解码,前 15 字节刚好形成一段连贯的控制流:

字节
6502 指令
作用
a2 00 LDX #$00
将寄存器 X 设为 0
ad 00 01 LDA $0100
从地址 0x0100 读取模式值到累加器 A
c9 02 CMP #$02
将 A 与 2 比较并更新状态标志
20 f0 1a JSR $1AF0
调用地址 0x1AF0 的子程序
d0 03 BNE +3
比较结果不相等时向高地址偏移 3 字节,跳过下一条 JMP
4c 15 10 JMP $1015
无条件跳到地址 0x1015

仅有一两条指令可能是字节碰巧重合,但这里后续代码仍能连续解码,子程序、分支、栈和状态
标志的变化也都符合 6502 规则。用独立的 6502 模拟器执行后,还能从约定输出区得到 120
字节结果。这些证据共同确定了指令集。

浏览器与本地的关键地址对齐如下:

地址/区间
用途
本地操作
0x0100:0x0104
32-bit little-endian mode word
依次写入 0x000000000x000000020x000000010x00000302LDA $0100 读低字节,最后一轮还使用 0x0101=0x03
0x0140
当轮输入 hash
slide 第四轮写入 FYToken 多项式 hash
0x029A
当轮输入长度
slide 第四轮写入 FYToken 长度
0x1000
6502 入口 PC
每轮从该地址启动模拟器
0x02B0:0x0328
120 字节输出
第四轮后读取并做 URL-safe Base64

本地还原流程为:

  1. 1. 把 NCTOKENSTR、UA hash、URL hash、cna hash 和时间写入模板;
  2. 2. 对 challenge 做 31 倍多项式 hash,与 BaXia170@key 组成 16 字节 RC4 key;RC4 输出的十六进制文本再进入 PBKDF2-HMAC-SHA256(空 salt、1048577 次、32 字节);
  3. 3. 执行四轮 6502 程序;
  4. 4. slide 阶段在第四轮写入 FYToken 的多项式 hash 和长度;
  5. 5. 读取 0x02B0:0x0328 的 120 字节;
  6. 6. URL-safe Base64 编码后得到 160 字符 _rand

固定时间、随机源和输入后,保留了一组可复核测试向量:

//以下为代码正文…
NCTOKENSTR = 0123456789abcdef0123456789abcdef
FYToken    = 234!fixed-test-vector
cna        = To7cImVMq0oCAd70bqD/8xBM
URL        = https://ditu.amap.com/detail/get/detail
UA         = Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
time       = 1784013291848
_rand      = AQIDBKGio6QpABbGgAIPgLzs6gDkXBNeoaFaMJeKpJ6qG7B0TRUGv0nYVz8RC9myRd3dudcndXjv5PAkR1pI2RkWxRuUJo13wpYVxv4RkjbnB888Ya4aTuD-N3sbCi4YenXp-cu57hWOEZ_q1dpX73baXuno1iMZ
SHA-256(UTF-8(_rand)) = 49ed6d1f5deb1010c1c216b0a8cae5d1dc4ddfbb0b1d23082717f9f8643c147b
SHA-256(decoded 120 bytes) = d4d45c583690602c7def7c1457585aa5b81977ef7a373f9d02ec94b15f0a9f54

向量中 random.randint(a,b) 固定取区间中点,每轮两个 4 字节熵块固定为
01 02 03 04 和 a1 a2 a3 a4。这些条件与模板 hash 一起,可以区分”实现了同一内存机”与
“只是生成了同长字符串”。

上述断点、内存地址和测试向量都属于 slide 路径。分析 loadSuccessRand 时需要重新从它的
initiator 设置断点,不直接复用这组输入地址和测试向量。

bx-pp:基础 ppSign 如何变成最终请求头

从 setRequestHeader 回溯

bx-pp 在最终请求头中出现。XHR 断点命中时,从 setRequestHeader("bx-pp", value)
沿调用栈回到 punish page,请求发送函数读取的是 e.ppSign。这个属性在上一层被赋值为:

//以下为代码正文…
var i = p(e, t);
o.startVerify();
var r = {
    url: i.url,
    data: i.data,
    sign: true,
    ppSign: window._config_.ppSign,
    uuid: e.NCTOKENSTR
};

顺序很关键:p(e,t) 只组装 slide 数据,随后 startVerify() 推进 nonce VM,最后才把
更新后的 _config_.ppSign 读入请求对象。p 的参数 e.ppSign 实际为
undefined,不能直接确认为最终来源。

一开始以为 ppSign 是一次生成的值,但在 randemUUID 上设置断点后刷新页面,发现参数
依次出现 _$PI_$PA_$PE 等 marker,每次调用前后 ppSign 的十六进制长度都在
变化。这才确认 ppSign 不是一次生成后保持不变,而是被持续改写的。

基础 ppSign

配置可能会给出 ppSign;或者直接按 pp 参数执行 PoW。program.wasm 的关键过程
是:

//以下为代码正文…
Date.now() -> PCG nonce
pp 参数 + nonce + counter -> pipe
MD5(pipe) -> 满足 q/l 指定的摘要前缀
xb:md5:pipe:nonce:counter:enc:elapsed

最后对 xb: 明文做十六进制编码,得到基础 ppSign

crypto.randemUUID 与生命周期 marker

punish HTML 里还有一段内联 VM,会安装 crypto.randemUUID。在这个函数上打上断点后刷新页面,参数会依次出现 _$PI_$PA
_$PE 等 marker。调用栈会把 marker 映射回页面阶段,例如:

marker
调用方
punishpage.min.js 位置
_$PI t.startInit 1:118169
_$PE t.loadNonce 1:117481
_$PB t.reportLoadPageAndCookieDisabled 1:112419
_$PL t.startRenderCaptcha 1:118306
_$PN t.runPow 1:118402
_$PP t.slideEnd 1:118491
_$PS t.startVerify 1:118642

某滑块完整逆向:从参数链反推到纯 HTTP 复现

某滑块完整逆向:从参数链反推到纯 HTTP 复现

某滑块完整逆向:从参数链反推到纯 HTTP 复现

某滑块完整逆向:从参数链反推到纯 HTTP 复现

某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 11:从 _$PI 到 _$PS,每个 marker 调用前后 ppSign 的值都在变化。基础值由 program.wasm 的 PoW 生成,后续 marker 持续改写。

randemUUID 不是只在提交前调用一次,而是随着页面初始化、加载、PoW、滑动结束和验证
开始逐步推进内部状态。

本地还原时可以从当次 punish HTML 中提取原始脚本,放进
Node/JSDOM 执行。后续所说的”nonce VM 运行环境”,就是这段脚本在浏览器中能够读取到的
页面数据。想在本地环境跑起来还需要以下配置:

类别
本地提供的内容
作用
页面配置
当次 window._config_、challenge、基础 ppSign
保证 VM 使用当前验证任务的数据
Cookie
cna

arms_uidtfstk、临时的 bx-cookie-test
还原各 marker 执行时能读到的 Cookie
页面状态
UA、时间、滑块几何和必要 DOM
满足内联脚本读取的浏览器属性
行为结果
当前 FYToken
在验证阶段交给同一套运行时消费
调用顺序
_$PI

 到 _$PS 及各阶段对应时间
让 VM 按浏览器顺序推进,而不是只调用最后一个 marker

Cookie 也不是启动时一次性写好就不再变。例如 bx-cookie-test 会在中途写入后删除,
tfstk 则随着前面的请求继续更新。本地运行时会在相应 marker 前同步当时的 Cookie,再调用
crypto.randemUUID(null, marker)

具体断点设在 punishpage 函数 F 的这一行:

//以下为代码正文…
window.crypto.randemUUID(null, t)

startVerify() 调用 F() 时没有显式传参,函数内部会使用默认值 e=”captcha”、
t=”_$PS”。断点命中后单步进入 randemUUID,源码切换到 punish HTML 第 25 行的内联
VM;此时调用栈可以完整关联出:

//以下为代码正文…
startVerify()
  -> F(e="captcha", t="_$PS")
  -> crypto.randemUUID(null, "_$PS")
  -> detail:25 的内联 nonce VM
  -> 更新 window._config_.ppSign

某滑块完整逆向:从参数链反推到纯 HTTP 复现
startVerify 进入 randemUUID 内联 VM

图 12:上图为 F 中的实际断点,悬浮值显示 t="_$PS";下图为单步进入内联 VM 后的调用栈,可回溯到 FstartVerify 和请求组装函数。

为了观察 marker 调用前后是否真的改写了 ppSign,控制台还可以在
crypto.randemUUID 安装完成后加一层透传 Hook:

//以下为代码正文…
(() => {
  const rawRandemUUID = crypto.randemUUID;
  window.__randemCalls = [];

crypto.randemUUID = function () {
const marker = arguments[1];
const before = window._config_.ppSign;
const result = Reflect.apply(rawRandemUUID, this, arguments);
const after = window._config_.ppSign;

window.__randemCalls.push({
marker,
before,
after,
changed: before !== after,
stack: new Error().stack
});
return result;
};

window.__restoreRandemHook = () => {
crypto.randemUUID = rawRandemUUID;
};
})();

这个 Hook 关注的不是 randemUUID 返回值,而是它执行前后的
window._config_.ppSign。查看 window.__randemCalls,可以把 _$PS、调用方和
ppSign 是否变化放到同一条记录中。若页面对函数改写有完整性检查,就恢复原函数并改用入口断点。

最终明文结构

确认 ppSign 会被 marker 持续改写后,下一步是理解它的结构。在控制台对 _$PS 后的最终值
做十六进制解码,发现是一段 xb: 开头的明文。中途暂停时长度为 596,_$PS 返回后为 612。
对最终值反向分割,得到 base(212 字符)、identity(68 字符) 和 cna(24 字符) 三段:

//以下为代码正文…
baseRawLength = 212
identityLength = 68
cnaLength = 24
finalRaw = baseRaw + "|" + identity + "|" + cna

控制台将 base、identity 和 cna 重新拼接后,得到的字符串与原始明文完全相同, 因此 reconstructed 显示为 true。完整明文再编码为十六进制,就是最终写入请求头的 bx-pp某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 13:将最终 ppSign 解码并拆分为 base、identity 和 cnareconstructed: true 说明三段重新拼接后与原始明文完全一致。

bx-et:事件状态如何进入 slide 签名

另一个请求头 bx-et 来自 window.etSign(完整 slide URL)。在 slide 请求的发送函数中,e.data
先被编码为 query 字符串 a,然后调用 etSign(e.url + "?" + a),返回值 r 最终
写入 setRequestHeader("bx-et", r)

控制台 Hook 只记录低频的签名调用:

//以下为代码正文…
(() => {
  const rawEtSign = window.etSign;
  window.__etCalls = [];

window.etSign = function () {
const cookieBefore = document.cookie;
const result = Reflect.apply(rawEtSign, this, arguments);
window.__etCalls.push({
url: String(arguments[0]),
result: String(result),
cookieBefore,
cookieAfter: document.cookie,
stack: new Error().stack
});
return result;
};

window.__restoreEtHook = () => {
window.etSign = rawEtSign;
};
})();

url 用于确认输入已经包含完整 slide query,result 用于和最终 bx-et 请求头对照,
前后两份 Cookie 用于观察签名调用附近的 tfstk。Hook 记录完入口后,执行
__restoreEtHook() 恢复原函数,再用 DevTools 断点单步进入 et_f.js 继续追踪闭包状态。

从 window.etSign 单步进入 et_f.js,会落到一个计算属性函数:

//以下为代码正文…
ar[dc] = function (e) {
    return M(14, e, 1, void 0, 1, void 0)
}

e 已经是带有 slidedatax5secdata_rand 和时间戳的完整 slide URL。函数 返回的长字符串最终被写入 bx-et。更关键的是,这里调用的仍是前面接收 MouseEvent 的调度器 M:事件阶段向它喂入鼠标数据,签名阶段则以操作码 14 消费当前状态。 某滑块完整逆向:从参数链反推到纯 HTTP 复现 某滑块完整逆向:从参数链反推到纯 HTTP 复现 图 14:事件阶段 M 接收 MouseEvent,签名阶段 M(14, URL) 消费状态——两者进入同一个调度器 M某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 15:从 etSign 进入计算函数后,调用 M(14, e, ...),控制台显示的长字符串最终被写入 bx-et

继续观察页面请求会发现,ET 在 slide 之前已经包装了 XHR、fetch 和 script 加载。
每次 report、nonce script 和 initialize 都可能推进闭包状态与 tfstk。因此 ET 不能
在最终签名时才执行。如果只从 et_f.js 导出 window.etSign,启动一个新的
JS 环境并传入 slide URL,前面积累的请求、鼠标事件和 Cookie 状态都不存在,
生成的 bx-et 自然不属于当前验证过程。脱离浏览器后,
et_f.js 也必须从页面初始化一直运行到 slide 签名,不能每次签名前重新加载。

关键值之间的对应关系

结合前面的 Network 请求、断点和参数解码结果,各字段的来源以及重建时
需要保持的对应关系如下:

浏览器中的来源
重建时的对应关系
cna
页面 Cookie
bx-pp 最终明文后缀、Fireye Cookie 和 ET Cookie 都使用 HTTP Session 中的同一个 cna
UMID
slidedata.p.umidToken
Fireye getUidToken()、FYToken B4 和 slidedata.p.umidToken 使用同一个值
NCTOKENSTR
NC data.t、slide uuid
_rand

 和执行 et_f.js 的 Node 环境使用当次 challenge 初始化
SECDATA _config_.SECDATA
原样写入 slide URL 的 x5secdata
FYToken
Fireye 返回值进入 NC data.n
_rand

 第四轮写入它的 hash 和长度,nonce VM 在 _$PS 阶段收到当次 token
events
ET 的 M 直接接收 DOM 事件
同一组生成事件分别交给 Fireye 和 ET
tfstk
请求过程中变化的 Cookie
Node/JSDOM 中的 ET 更新后同步回 HTTP Session
ncbtn
NC 局部变量与 slidedata.p
生成器根据同一组滑块几何计算 target 和 offset

“同一会话”不只是复用一个 HTTP Session 对象,而是 challenge、身份、事件和 Cookie
都沿同一条时间线流动。

先判断哪些代码能脱离浏览器

找到四个入口后,先不预设纯算、AST 还原还是补环境。判断方法很直接:记下浏览器入口的
显式参数和中间状态,再看核心计算能否从 DOM、Cookie、事件监听器和闭包中分离。
可以完整分离的部分才适合纯算;只有运行原脚本才能保留的状态,再考虑给原脚本补齐必要环境。

FYToken:先试直接调用 Fireye

最直接的做法是保存 fireyejs.js,在 Node 中补到 getFYToken 能够执行,然后传入
断点中记录的 options。这样确实能返回 234! 开头的 token,但解开 B3 数据块后,
里面记录的是本地入口文件和 node:internal,而不是浏览器中的
nc.js -> awsc.js -> fireyejs.js。因此 Node 直接调用的输出还需要修正 B3 调用栈,不能原样写入 slide。

这个对比将问题缩小到 token 内部的环境数据。Fireye 仍然负责采集事件和生成原始 token,
生成后再解开 B1、B2、B3、B4:B1 检查本轮事件,B2 检查浏览器环境,B3 对齐
NC 调用栈,B4 检查 UMID 和会话数据,修改后重新计算分块校验和 token 总校验。

_rand:追进 r(u,i) 后改看内存

genRandomValues 的包装层只负责转换参数,继续单步进入 r(u,i) 才看到 6502 执行引擎。
这时再整理外层数万行 JS 并不会更接近 _rand 算法,真正需要跟踪的是虚拟 CPU
从哪些地址读入数据、又把结果写到哪些地址。

确认 0x1000 入口、输入区和 120 字节输出区后,可以保存初始 64 KiB 内存镜像。
之后只需写入当次 challenge、FYToken hash、长度、时间和随机数据,用标准 6502 模拟器
执行四轮程序并编码输出。到这一步,外层动态脚本才可以完全移除。

bx-pp:先把 PoW 和 marker 改写分开

断点里的 ppSign 不是一次生成后保持不变。program.wasm 先产生基础值,后面的
crypto.randemUUID(null, marker) 又随 _$PI ... _$PS 持续改写它。因此不能把整条链
当成一个签名函数处理,而要分别判断两层代码。

program.wasm 中的 PoW 只围绕 PCG nonce、MD5 和 counter 搜索,输入和输出都能单独确定,
可以直接重写。identity 的生成则依赖 punish HTML 中的内联 VM、Cookie 和 marker 顺序,
因此保存当次 punish HTML 里的原始 VM,在 JSDOM 中按浏览器顺序执行 marker,再读出
base | identity | cna

bx-et:看 etSign 之前发生了什么

只看 window.etSign(完整 slide URL) 的调用形式,很容易将它误判为无状态签名函数。
但前面的断点已经显示,鼠标事件和签名都进入同一个函数 M;继续观察页面加载,还能看到
ET 在 slide 之前已经包装 XHR、fetch 和 script,并在过程中更新 tfstk

因此需要保存 Network 中当次加载的 et_f.js,用 JSDOM 提供 windowdocument
Cookie、XMLHttpRequestfetch 和事件监听器等它实际读取的对象。et_f.js 只执行一次,
Node 进程从 punish 页面初始化一直保留到 slide 完成,这样 etSign 才能读到前面积累的状态。

为了让 Python 能按页面顺序驱动这个 JS 环境,Node 驱动程序封装四个入口。
这些名称不是 et_f.js 原有的 API:

驱动入口
交给 et_f.js 的内容
init
当次 challenge、UA、cna 和 Cookie
observe
浏览器在同一阶段会发起的 XHR、fetch 或 script URL
interact
同一组 mousedownmousemove 和 mouseup 事件
sign
完整的 slide URL,内部调用 window.etSign

Python Session 负责真正发送 HTTP 请求;observe 只让 et_f.js 看到与页面相同的
请求顺序。每一步完成后,驱动程序读出 document.cookie,再把更新后的 tfstk
写回 Python Session。到了 slide 阶段,sign 消费的就是从初始化至今累积在同一个
JS 上下文中的 ET 状态。

四次判断完成后,生成代码的边界确定如下:

//以下为代码正文…
Python HTTP Session
  -> Fireye Node 进程              生成并修正 FYToken
  -> Python 6502 模拟器          生成 _rand
  -> Python PoW + Node nonce VM  生成 bx-pp
  -> 只加载一次 et_f.js 的 Node 进程  生成 bx-et 并维护 tfstk
  -> Python 组装并发送 slide

AST 主要用在局部字符串表、dispatcher 和固定分支上。NC 和 punishpage 的调用入口可以直接通过
搜索和断点定位;Fireye 与 ET 需要保留调用栈、源码形态和运行顺序;_rand 确认 6502 内存后,
直接提取内存比继续整理外层代码更接近核心计算。因此 AST 处理只覆盖需要阅读的局部代码,
每次转换后都保留原脚本用于回退和对照。

脱离浏览器前建立中间检查点

直接从浏览器 token 对比 Node/Python 重建的 token,差异只会表现成两个长字符串不相等。
在开始编写脱离浏览器的生成代码前,先建立下面这些检查点,定位会快很多。

模块
浏览器检查点
本地检查点
FYToken
原 token、调用栈、UMID、轨迹
四块明文、块长度、校验、seed
_rand
slide 阶段的 FYToken 输入、返回值和调用栈
6502 输入区、四轮状态和最终 120 字节
bx-pp
base、marker、identity、Cookie
VM 每个 marker 返回值、最终明文
bx-et
完整 URL、交互前后 Cookie
observe/interact/sign 各阶段状态
slide
URL、headers、Cookie、response
完全相同的发送前快照

Fireye 代码不适合一开始全文格式化替换。实际运行对 Error 栈和源码形态敏感,DevTools
内置 pretty print 只改变显示,不改变运行源码,更适合前期定位。只有入口和中间块
明确后,才有必要对局部状态机或字符串表做反混淆。

本地还原 FYToken 的四个数据块

去掉 234! 前缀并转换自定义 Base64 后,FYToken 头部后面依次是四个变长块。

数据
还原方式
B1_FL
鼠标与键盘行为
先执行 qu7 的反向字节变换,再用 qu15(16) 解压
B2_E
环境与指纹
执行 qu5 的反向字节变换
B3_RK
调用栈
执行 qu8 的反向字节变换
B4_Hj
UMID、会话、环境字符串和 timing
先执行 qu4 的反向字节变换,再用 qu15(8) 解压

qu4qu5qu7 和 qu8 不是加密算法。它们把数据每两个字节分为一组,按组序号循环
执行四种固定操作,具体包括与短字符串异或、循环移位、字节加减和带反馈的异或。解析 token 时
执行对应的反向操作,就能恢复变换前的字节。

每块结构为:

//以下为代码正文…
varint(payloadLength) + checksum(2 bytes) + transformedBytes

token 头部的第 10、11 字节还保存了两字节 seed,重建 token 时继续使用当次 Fireye 生成的值。

把浏览器 token 和本地环境直接生成的 token 都拆成这四块后,差异位置就明确了。本地环境中的
Fireye 已经记录了当轮事件,因此 B1 可以原样保留;B2 需要将 JSDOM 指纹改为浏览器
形态,同时保留当轮动态值;B3 需要换成 NC 实际经过的调用栈;B4 中的 UMID 和会话字段
仍然来自当次生成,只修正 JSDOM 与 Chrome 之间稳定的环境差异。

FYToken 的重建顺序是:先让原始 Fireye 在本地环境中采集当轮事件并生成一个完整 token,
再按上述规则替换 B2、B3 和 B4 中需要对齐的部分。B1 和两字节 seed 沿用当次 Fireye 的值,
B1 的原始编码段直接拼回,B2 执行 qu5 的正向字节变换,B3 执行 qu8 的正向字节变换,
B4 先用 qu15(8) 压缩,再执行 qu4 的正向字节变换。
最后更新每块校验、body 长度和 token 总校验,再映射回自定义 Base64 字母表。

按浏览器实际请求顺序实现本地请求

完成 FYToken、_randbx-pp 和 bx-et 的生成代码后,发起请求时仍要保持浏览器顺序:

//以下为代码正文…
新 Session
  -> cna / arms_uid
  -> 请求 punish page,解析 _config_
  -> 在 Node/JSDOM 中加载一次 et_f.js
  -> 获取 UMID
  -> 生成加载阶段 _rand
  -> 复现 report 与 initialize
  -> 生成一条轨迹
  -> Fireye 生成 FYToken
  -> 生成最终 _rand
  -> nonce VM 生成 bx-pp
  -> ET 回放同一组 events
  -> etSign(完整 slide URL)
  -> ET 观察 slide XHR,推进 tfstk
  -> 同步 Cookie 并发送请求

组装 slide 请求前,需要再比较三组值:

  1. 1. 从 Python HTTP Session 读取目标域下的 cna,再从 ET 所在的 Node/JSDOM
    环境读取 document.cookie,两处的 cna 必须与页面初始化时取得的值相同。
  2. 2. 检查传给 Fireye 的 Cookie 字符串,其中必须包含同一个 cna=<当前值>
  3. 3. 读取 Fireye getUidToken() 返回的值,与即将写入
    slidedata.p.umidToken 的 UMID 比较,两者必须相同。

任意一项不一致就停止组装和发送 slide,因为此时 FYToken、bx-et 或 Cookie 已经
不属于同一次验证。

最终 slidedata 展开后为:

//以下为代码正文…
{
  "a": "当前 NCAPPKEY",
  "t": "当前 NCTOKENSTR",
  "n": "234!...当前 FYToken...",
  "p": {
    "ncbtn": "left|top|width|height|...",
    "umidToken": "当前 UMID",
    "ncSessionID": "parseInt(width+'a'+height+'a'+offsetLeft+'a'+offsetTop, 11).toString(16)",
    "et": "1"
  },
  "scene": "register",
  "asyn": 0,
  "lang": "cn",
  "v": 1
}

真实请求里的 p 是再次 JSON 序列化后的字符串。URL 加入 x5secdatappt
_randlandscapets 和随机 v,请求头加入 bx-et 与 bx-pp

bx-et 签名时使用的 URL 必须与最终发送的 URL 字节级相同,包括字段顺序、
URL 编码和时间戳。ET 观察 slide XHR 后,先把它更新的 tfstk 同步到 HTTP
Session,然后从 Session 中读出当前 Cookie 快照。最后一步只负责加入两个请求头并
发送 GET,不再重新生成任何参数:

//以下为代码正文…
import json

slide_url = signed_slide_url
cookie_header = cookie_header_for_url(session, target_url)
headers = {
**browser_resource_headers,
“bx-et”: bx_et,
“bx-pp”: bx_pp,
“Cookie”: cookie_header,
}

response = session.get(slide_url, headers=headers, timeout=30)
result = response.json()
print(json.dumps(result, ensure_ascii=False))

这里的 signed_slide_url 就是刚才传给 etSign 的完整 URL;browser_resource_headers
包含同次页面的 User-AgentReferer、Client Hints 和 Fetch Metadata。响应为
HTTP 200 时仍要读取 JSON 里的 code,并检查 Session 中是否新增 x5sec

轨迹参数的黑盒校准

轨迹结构可以从原生事件和 Fireye、ET 的消费方式中确定,但事件数、阶段时长和
候选过滤范围并不会出现在某个 JS 常量里。这些值是在四条参数链和请求
顺序全部对齐后,经过多天黑盒调整保留下来的。

调整时先固定资源版本、参数生成逻辑和请求顺序,每轮只改一类轨迹参数。每个候选
参数组使用多个全新 Session 和 challenge,每个 challenge 只发送一次 slide,不对
失败请求自动重试。调整顺序是:

  1. 1. 先保留接近、悬停、按下、按住、拖动和释放的事件顺序,只改变是否启用某个阶段。
  2. 2. 阶段保留后,分别调整 N_approachN_hover 和 N_drag
  3. 3. 事件数稳定后,再调整五段时长,不同时改动步长分布。
  4. 4. 最后调整三段横向步长、纵向偏移、停顿和候选过滤范围。

每次请求都保存资源 URL 与 hash、challenge 指纹、参数组、轨迹 seed、阶段事件数、
阶段时长、步长和速度统计、返回 code 与 x5seccode=300 只记为当次验证被
拒绝,不直接写成服务端命中了某一个具体特征。同一组修改要在多个独立 challenge 下
重复,才能与基线组比较。

当前页面几何下保留的参数如下:

参数
取值
用途
N_approach
26
从页面内的起始点接近滑块
N_hover
38
在按钮内悬停并微调位置
N_drag
154
按住后的有效拖动点
n1 / n2 / n3
51 / 51 / 52
起步、中段和收尾的拖动点数
接近
1175–1217 ms
从起始点移向按钮
悬停
613–659 ms
按下之前在按钮内移动
按住
382–412 ms
mousedown

 后到开始位移之前
拖动
1806–1840 ms
完成水平位移
释放
176–184 ms
最后一个拖动点到 mouseup

这组参数生成的源事件数和总时长可以直接算出:

//以下为代码正文…
26 + 38 + 1 mousedown + 1 原地 mousemove + 154 + 1 mouseup = 221 个源事件
1175..1217 + 613..659 + 382..412 + 1806..1840 + 176..184 = 4152..4312 ms

26/38 是把按下前的 64 个移动点分成接近与悬停,51/51/52 则是把 154 个拖动点
分成起步、中段和收尾。这些拆分是为了分别控制各阶段的步长与时间,不是页面脚本中
读出的固定常量。

code=300 应该怎样分层排查

常见失败响应为:

//以下为代码正文…
{
  "code": 300,
  "dt": "success",
  "ec": 200,
  "result": {
    "code": 300,
    "sig": "from bx"
  },
  "success": true
}

HTTP 200 和 success:true 只说明请求被处理。slide 是否通过仍要看外层 code 和是否
签发 x5sec

检查点
常见问题
Session
cna

、UMID、Cookie 快照
这些值来自不同的 challenge
初始化
lifecycle URL、callback、marker tfstk
页面顺序缺失,ET 后启动
FYToken
四块明文、B3 栈、B4 UMID
环境或调用栈不一致
_rand
加载输入和 FYToken 输入
两阶段值误复用
bx-pp
base、identity、cna、marker
VM 执行顺序错误,或使用了错误阶段的 Cookie
bx-et
完整 URL、交互前后状态
URL、事件或 observe 顺序错误
轨迹
seed、事件和统计特征
事件数、时长或步长分布超出生成范围

排查从最终请求和会话开始。如果 URL、challenge 或 Cookie 已经不一致,继续微调
轨迹没有意义。

离线检查与线上回归

轨迹生成完成后,先不发送请求,而是连续切换 seed 运行生成器。这一步检查两件事:不同 seed
是否产生不同的事件序列,以及候选过滤是否频繁重试。连续生成 5000 条轨迹时,5000 条事件序列
全部不同,平均生成耗时为 2.257 ms,候选次数中位数为 1、P95 为 4。

离线检查之后再运行完整 slide 链路。每轮都新建 HTTP Session、重新获取 challenge,且不重试
slide。同时保存 challenge 指纹、轨迹 seed、候选次数、事件数、FYToken 分块长度、总耗时和返回值:

轮次
challenge 指纹
轨迹 seed
候选次数
源事件/ET 回调
B1/B2/B3/B4 长度
耗时 ms
结果
1
7dd13b388637 10751820304975776875
1
221/224
696/239/156/818
6677.942
code=0

 + x5sec
2
cc5cc83ab6a5 15252766571728109753
1
221/224
704/239/156/818
6011.436
code=0

 + x5sec
3
20bcab1765f2 9534287951126624393
3
221/224
701/239/156/817
5986.809
code=0

 + x5sec
4
023efb9fa563 11807376305127706189
2
221/224
713/239/156/819
6094.087
code=0

 + x5sec
5
63262d24b187 2935359570862269910
3
221/224
707/239/156/817
5925.002
code=0

 + x5sec

221 是交给 ET 的源事件数,224 是 ET 实际执行的监听器回调数。鼠标进入新元素时还会触发
mouseover 和 mouseout,同一类型也可以注册多个回调,因此这两个数字不会相等。

某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 16:运行结果中连续五次 slide 均返回 code=0。各轮 challenge 与分层数据见上表。

在相同的每轮新建 Session、重新获取 challenge 且不重试 slide 的条件下,
将连续验证数量增加到 10 次。每轮保留 FYToken、_randbx-ppbx-et
轨迹 seed 和返回 JSON,十轮全部返回 code=0,汇总成功率为 100%。

某滑块完整逆向:从参数链反推到纯 HTTP 复现

图 17:十轮运行使用不同轨迹 seed,末尾汇总为成功 10 次,成功率 100%。

本文由小橙投稿

 

长期征稿

稿费 1000-2000 元 / 篇,长期有效 。征集爬虫逆向、数据行业、爬虫副业、合规避坑类原创首发文章。详情见:

1000 元一篇,把你的逆向笔记变现

猿人学的业务:

1、猿人学爬虫逆向课

2、海外代理IP(不限量、动态/静态住宅IP)

3、youtube 等音视频低成本下载回流

4、大模型预训练数据

5、SERP (google/bing等搜索结果)

6、海外电商/社媒数据

若有朋友需要可以在菜单栏里加我微信,谢谢。

某滑块完整逆向:从参数链反推到纯 HTTP 复现

 

猿人学banner宣传图

我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。

***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.con 原创,没有猿人学授权,请勿以任何形式转载。***

说点什么吧...