引言
分析对象是某地图详情接口触发的某滑块验证,包括 FYToken、_rand、bx-pp 和
bx-et 四条参数链的完整生成过程,以及人类鼠标轨迹的构造方法。分析从最终的 slide
请求出发,逐层向上反推至 NC、punishpage、Fireye 和 ET,记录每一步的断点位置、Hook 代码
和中间数据,最终实现脱离浏览器的纯 HTTP 复现。
分析时的资源版本为 nc.js 1.97.0、fireyejs.js 1.234.20 和 et_f.js 1.83.8。
资源更新后函数名和偏移可能变化,但分析方法不变。
组件与术语
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_rand/ppSign |
|
|
|
|
|
crypto.randemUUID
|
请求流程
某地图的详情接口触发风控时,页面会进入滑块验证。整个业务流程中,详情接口会请求两次:第一次返回验证页和 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 的初始配置:

图 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
图 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 里有 slidedata、x5secdata、ppt、_rand、
landscape、ts 和 v;请求头里有 bx-pp 和 bx-et;Cookie 里则能看到
cna、tfstk、arms_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 再解一次,
可以得到滑块几何信息 ncbtn、umidToken、ncSessionID 和 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、_rand、bx-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


图 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-et、bx-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;
};
})();
rawOpen、rawSetRequestHeader 和 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 再解析后只有
ncbtn、umidToken、ncSessionID 和 et。这些值在进入 replaceCallback
之前就已生成。
再切到调用栈里的 punishpage.min.js,可以同时看到改写前的 t 和改写后的 r。r 的 URL 变为
/_____tmd_____/slide,数据区新增 x5secdata、_rand、ts、ppt 和
landscape,同时带上 ppSign 与 sign: true。


图 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) |
|
t.data
|
x5secdata |
e.SECDATA |
|
e.data 中复核 |
ppt |
_config_.ppt
|
|
|
_rand |
genRandomValues 返回值 |
|
|
ts |
Date.now()
|
replaceCallback |
|
v |
("v=" + Math.random()).replace(".", "") |
|
|
landscape |
o.isLandscape && (a.landscape = 1) |
|
|
bx-pp |
e.ppSign |
|
setRequestHeader("bx-pp", e.ppSign) |
bx-et |
window.etSign(e.url + "?" + query) |
|
|
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)

图 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 |
|
|
MTInterval |
|
|
MinMTDwnLog |
|
|
MaxKSLog |
|
|
MaxFocusLog |
|
|
MaxNGPLog
NGPInterval |
|
NGP 的完整含义 |
timeout |
|
|
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

图 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。一次拖动会触发上百次
移动事件,连续输出会拖慢页面,同时改变事件之间的时间间隔。
条件断点停下后,mousedown、mousemove 和 mouseup
都会进入 et_f.js 的函数 M。在 Scope 中展开事件对象,可以看到它是浏览器生成的
MouseEvent,当前的 currentTarget 是 document。
随后分别在 mousedown、按住左键的 mousemove 和 mouseup 处暂停,记录 Scope
中与鼠标状态有关的字段:

ET 收到的 mousemove 事件
图 7:mousemove 条件断点命中 et_f.js 的 M,控制台展开了当次 MouseEvent 的按键状态、目标和坐标字段。
|
|
isTrusted |
buttons |
|
|
|---|---|---|---|---|
mousedown |
true |
|
nc_1_n1z
btn_slide |
button=0 |
mousemove |
true |
|
nc_1_n1z
btn_slide |
movementX=1
movementY=0 |
mouseup |
true |
|
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.log、console.trace 或断点。滑动结束
后调用 __stopMouseHook(),它会移除三个监听器并返回完整数组;需要导出时再执行:
//以下为代码正文…
copy(JSON.stringify(window.__mouseRecords))
同时保存 event.timeStamp、performance.now() 和 Date.now(),是为了在离线分析时区分
事件时间、页面运行时间和系统时间。这个 Hook 解决的是”完整记录一次手势”,前面的条件
断点解决的是”确认 ET 的接收函数和事件对象”,两者不能互相替代。
从事件字段到完整手势
确认 ET 收到的 MouseEvent 字段后,下一步是组织一次完整拖动。页面决定的是滑块坐标、
按钮尺寸和可移动距离;接近路径、悬停点数、拖动采样密度和各阶段时间则需要自行构造。
事件字段
一条事件包含:
//以下为代码正文…
type、x、y、ts、wallTs、dt、wallDt
targetId、targetTag、offsetX、offsetY
movementX、movementY、button、buttons、which、detail
offsetX 按按钮当前位置计算,而不是一直减去初始 left。
手势阶段
完整手势按顺序包含:移向滑块、在按钮上悬停、按下左键、按住不动、向右拖动和松开。
|
|
|
|
|---|---|---|
|
|
N_approach
mousemove |
|
|
|
N_hover
mousemove |
|
|
|
mousedown |
|
|
|
mousemove |
buttons=1,用来表示按下后的停留 |
|
|
N_drag
mousemove |
|
|
|
mouseup |
buttons 置为 0 |

图 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 个横向步长拆成 n1、n2、n3 三段,且 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=15、i=16 传给核心函数 r(u,i)。核心函数需要原值时,会根据编号回到值表中读取。因此 15 和 16 只是动态运行时内部传递数据的编号,不代表浏览器向 genRandomValues 传了两个实参。 
图 9:包装函数准备调用 r(u,i);右侧 Scope 同时显示完整 FYToken、未传入的第二个形参,以及转换后的内部编号 u=15、i=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 一类低级代码的输出, 而不是人工编写的签名算法。它负责准备内存、保存调用状态和运行内部程序,可以把它理解成 外层的执行引擎。

图 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 开始的
字节可以连续解码成 LDX、LDA、CMP、JSR、BNE 和 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 字节刚好形成一段连贯的控制流:
|
|
|
|
|---|---|---|
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 |
JMP |
4c 15 10 |
JMP $1015 |
0x1015 |
仅有一两条指令可能是字节碰巧重合,但这里后续代码仍能连续解码,子程序、分支、栈和状态
标志的变化也都符合 6502 规则。用独立的 6502 模拟器执行后,还能从约定输出区得到 120
字节结果。这些证据共同确定了指令集。
浏览器与本地的关键地址对齐如下:
|
|
|
|
|---|---|---|
0x0100:0x0104 |
|
0x00000000、0x00000002、0x00000001、0x00000302;LDA $0100 读低字节,最后一轮还使用 0x0101=0x03 |
0x0140 |
|
|
0x029A |
|
|
0x1000 |
|
|
0x02B0:0x0328 |
|
|
本地还原流程为:
-
1. 把 NCTOKENSTR、UA hash、URL hash、cnahash 和时间写入模板; -
2. 对 challenge 做 31 倍多项式 hash,与 BaXia170@key组成 16 字节 RC4 key;RC4 输出的十六进制文本再进入 PBKDF2-HMAC-SHA256(空 salt、1048577 次、32 字节); -
3. 执行四轮 6502 程序; -
4. slide 阶段在第四轮写入 FYToken 的多项式 hash 和长度; -
5. 读取 0x02B0:0x0328的 120 字节; -
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 映射回页面阶段,例如:
|
|
|
|
|---|---|---|
_$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 |





图 11:从 _$PI 到 _$PS,每个 marker 调用前后 ppSign 的值都在变化。基础值由 program.wasm 的 PoW 生成,后续 marker 持续改写。
randemUUID 不是只在提交前调用一次,而是随着页面初始化、加载、PoW、滑动结束和验证
开始逐步推进内部状态。
本地还原时可以从当次 punish HTML 中提取原始脚本,放进
Node/JSDOM 执行。后续所说的”nonce VM 运行环境”,就是这段脚本在浏览器中能够读取到的
页面数据。想在本地环境跑起来还需要以下配置:
|
|
|
|
|---|---|---|
|
|
window._config_、challenge、基础 ppSign |
|
|
|
cna
arms_uid、tfstk、临时的 bx-cookie-test |
|
|
|
|
|
|
|
|
|
|
|
_$PI
_$PS 及各阶段对应时间 |
|
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

图 12:上图为 F 中的实际断点,悬浮值显示 t="_$PS";下图为单步进入内联 VM 后的调用栈,可回溯到 F、startVerify 和请求组装函数。
为了观察 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。 
图 13:将最终 ppSign 解码并拆分为 base、identity 和 cna;reconstructed: 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 已经是带有 slidedata、x5secdata、_rand 和时间戳的完整 slide URL。函数 返回的长字符串最终被写入 bx-et。更关键的是,这里调用的仍是前面接收 MouseEvent 的调度器 M:事件阶段向它喂入鼠标数据,签名阶段则以操作码 14 消费当前状态。
图 14:事件阶段 M 接收 MouseEvent,签名阶段 M(14, URL) 消费状态——两者进入同一个调度器 M。 
图 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 |
|
cna |
|
|
slidedata.p.umidToken |
getUidToken()、FYToken B4 和 slidedata.p.umidToken 使用同一个值 |
NCTOKENSTR |
data.t、slide uuid |
_rand
et_f.js 的 Node 环境使用当次 challenge 初始化 |
SECDATA |
_config_.SECDATA |
x5secdata |
|
|
data.n |
_rand
_$PS 阶段收到当次 token |
|
|
M 直接接收 DOM 事件 |
|
tfstk |
|
|
ncbtn |
slidedata.p |
|
“同一会话”不只是复用一个 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 提供 window、document、
Cookie、XMLHttpRequest、fetch 和事件监听器等它实际读取的对象。et_f.js 只执行一次,
Node 进程从 punish 页面初始化一直保留到 slide 完成,这样 etSign 才能读到前面积累的状态。
为了让 Python 能按页面顺序驱动这个 JS 环境,Node 驱动程序封装四个入口。
这些名称不是 et_f.js 原有的 API:
|
|
et_f.js 的内容 |
|---|---|
init |
cna 和 Cookie |
observe |
|
interact |
mousedown、mousemove 和 mouseup 事件 |
sign |
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,差异只会表现成两个长字符串不相等。
在开始编写脱离浏览器的生成代码前,先建立下面这些检查点,定位会快很多。
|
|
|
|
|---|---|---|
|
|
|
|
_rand |
|
|
bx-pp |
|
|
bx-et |
|
|
|
|
|
|
Fireye 代码不适合一开始全文格式化替换。实际运行对 Error 栈和源码形态敏感,DevTools
内置 pretty print 只改变显示,不改变运行源码,更适合前期定位。只有入口和中间块
明确后,才有必要对局部状态机或字符串表做反混淆。
本地还原 FYToken 的四个数据块
去掉 234! 前缀并转换自定义 Base64 后,FYToken 头部后面依次是四个变长块。
|
|
|
|
|---|---|---|
|
|
|
qu7 的反向字节变换,再用 qu15(16) 解压 |
|
|
|
qu5 的反向字节变换 |
|
|
|
qu8 的反向字节变换 |
|
|
|
qu4 的反向字节变换,再用 qu15(8) 解压 |
qu4、qu5、qu7 和 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、_rand、bx-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. 从 Python HTTP Session 读取目标域下的 cna,再从 ET 所在的 Node/JSDOM
环境读取document.cookie,两处的cna必须与页面初始化时取得的值相同。 -
2. 检查传给 Fireye 的 Cookie 字符串,其中必须包含同一个 cna=<当前值>。 -
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 加入 x5secdata、ppt、
_rand、landscape、ts 和随机 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-Agent、Referer、Client Hints 和 Fetch Metadata。响应为
HTTP 200 时仍要读取 JSON 里的 code,并检查 Session 中是否新增 x5sec。
轨迹参数的黑盒校准
轨迹结构可以从原生事件和 Fireye、ET 的消费方式中确定,但事件数、阶段时长和
候选过滤范围并不会出现在某个 JS 常量里。这些值是在四条参数链和请求
顺序全部对齐后,经过多天黑盒调整保留下来的。
调整时先固定资源版本、参数生成逻辑和请求顺序,每轮只改一类轨迹参数。每个候选
参数组使用多个全新 Session 和 challenge,每个 challenge 只发送一次 slide,不对
失败请求自动重试。调整顺序是:
-
1. 先保留接近、悬停、按下、按住、拖动和释放的事件顺序,只改变是否启用某个阶段。 -
2. 阶段保留后,分别调整 N_approach、N_hover和N_drag。 -
3. 事件数稳定后,再调整五段时长,不同时改动步长分布。 -
4. 最后调整三段横向步长、纵向偏移、停顿和候选过滤范围。
每次请求都保存资源 URL 与 hash、challenge 指纹、参数组、轨迹 seed、阶段事件数、
阶段时长、步长和速度统计、返回 code 与 x5sec。code=300 只记为当次验证被
拒绝,不直接写成服务端命中了某一个具体特征。同一组修改要在多个独立 challenge 下
重复,才能与基线组比较。
当前页面几何下保留的参数如下:
|
|
|
|
|---|---|---|
N_approach |
|
|
N_hover |
|
|
N_drag |
|
|
n1 / n2 / n3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
mousedown
|
|
|
|
|
|
|
|
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。
|
|
|
|
|---|---|---|
|
|
cna
|
|
|
|
tfstk |
|
|
|
|
|
_rand |
|
|
bx-pp |
|
|
bx-et |
|
|
|
|
|
|
排查从最终请求和会话开始。如果 URL、challenge 或 Cookie 已经不一致,继续微调
轨迹没有意义。
离线检查与线上回归
轨迹生成完成后,先不发送请求,而是连续切换 seed 运行生成器。这一步检查两件事:不同 seed
是否产生不同的事件序列,以及候选过滤是否频繁重试。连续生成 5000 条轨迹时,5000 条事件序列
全部不同,平均生成耗时为 2.257 ms,候选次数中位数为 1、P95 为 4。
离线检查之后再运行完整 slide 链路。每轮都新建 HTTP Session、重新获取 challenge,且不重试
slide。同时保存 challenge 指纹、轨迹 seed、候选次数、事件数、FYToken 分块长度、总耗时和返回值:
|
|
|
|
|
|
|
|
|
|---|---|---|---|---|---|---|---|
|
|
7dd13b388637 |
10751820304975776875 |
|
|
|
|
code=0
|
|
|
cc5cc83ab6a5 |
15252766571728109753 |
|
|
|
|
code=0
|
|
|
20bcab1765f2 |
9534287951126624393 |
|
|
|
|
code=0
|
|
|
023efb9fa563 |
11807376305127706189 |
|
|
|
|
code=0
|
|
|
63262d24b187 |
2935359570862269910 |
|
|
|
|
code=0
|
221 是交给 ET 的源事件数,224 是 ET 实际执行的监听器回调数。鼠标进入新元素时还会触发
mouseover 和 mouseout,同一类型也可以注册多个回调,因此这两个数字不会相等。

图 16:运行结果中连续五次 slide 均返回 code=0。各轮 challenge 与分层数据见上表。
在相同的每轮新建 Session、重新获取 challenge 且不重试 slide 的条件下,
将连续验证数量增加到 10 次。每轮保留 FYToken、_rand、bx-pp、bx-et、
轨迹 seed 和返回 JSON,十轮全部返回 code=0,汇总成功率为 100%。

本文由小橙投稿
长期征稿
稿费 1000-2000 元 / 篇,长期有效 。征集爬虫逆向、数据行业、爬虫副业、合规避坑类原创首发文章。详情见:
2、海外代理IP(不限量、动态/静态住宅IP)
3、youtube 等音视频低成本下载回流
4、大模型预训练数据
5、SERP (google/bing等搜索结果)
6、海外电商/社媒数据
若有朋友需要可以在菜单栏里加我微信,谢谢。


我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。
***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.con 原创,没有猿人学授权,请勿以任何形式转载。***

说点什么吧...