Repository navigation
fix(captcha): guest 定时器归属改为词法绑定,取代调用栈嗅探 - #46
Conversation
TriDefender
left a comment
There was a problem hiding this comment.
已按 #43 → #45 → #44 顺序合入前三个,本 PR 的 diff 已收敛(8c89c9f 的 rAF/sink 与 #44 的墓碑存根改动均已进入 master,剩余即词法绑定单提交)。
验证情况(全部本地实证,非仅转述 CI):
- CI 绿 + 本地 worktree 复跑:tsc 0 错,735 pass / 0 fail,与声明一致。
- 在 master(双分派器现行代码)上复现了假阴性方向:
new Function生成的代码调用dual.setInterval确实落在 HOST 车道(句柄为宿主 Timeout 对象,不朽)——根因成立。 bun -e确认 BunglobalThis无原生requestAnimationFrame/moveBy。- 行为契约测试(5 条 guest 入口心跳随窗口死亡 + 顶层声明逃逸 + 宿主 unref 契约)全部通过。
方案 approve:栈嗅探回答的是"谁在调用",而归属要问的是"归谁所有";词法绑定是正确修法。with vs IIFE、Function 替身、console 纳入三个取舍都有实测依据;残余风险(顶层 "use strict" 回退、core-js 从 globalThis 绑定定时器)已在 PR 描述里诚实标注,且由 #44 存根 + #43 分类器兜底。
必须先修一处(documentation only,代码无需改动):
wrapGuestSource 的文档注释(captcha-happy.ts:615-635)仍在描述已废弃的方案——"Wrapping in an arrow-bodied IIFE that RETURNS the inner expression",与实际实现(直接返回裸 with(${scopeRef}){...},:638)矛盾。这是未来读者最会细看的地方,请把这段注释改为描述现状。完成即合。
非阻塞:
makeDualTimers/makeDualConsole 靠 `/alicdn|aliyuncs/.test(new Error().stack)`
来决定一个定时器该进窗口注册表还是宿主车道。但调用栈描述的是"谁在调用",而我们
要问的是"这归谁所有",两者并不等价——宿主与 guest 的栈天然交织。因此它在两个
方向上都会误判,且都已在当前 master 上实测复现:
假阴性:guest 经 new Function 生成的代码,栈上不带 CDN 帧。它 arm 的定时器
落到宿主车道,happyDOM.close() 收不走。FeiLin 的心跳恰好会自我重新武装
(feilin008.js:170382 `tV=setInterval(tE,2e3)`),于是变成一个不朽定时器,
最终踩到已拆解的全局:
ReferenceError: moveBy is not defined
at tE (…/feilin008….js:1:169658)
假阳性:宿主代码在 guest 栈下被调用时被判成 guest,拿到窗口句柄。delay-0
路径下该句柄没有 unref,而 Bun 的 node:_http_server / undici /
AbortSignal.timeout 都会 unref——这正是 v4.5.2 的崩溃形态:
TypeError: setTimeout(() => {}, 0).unref is not a function
归属是"代码从哪来"的属性,所以改为词法绑定:guest 脚本在 evaluateScript 处被
包进 `with (scope) { … }`,scope 持有该窗口的定时器、console 和一个 Function
替身。标识符解析在语法分析期就定死,运行时不再查栈,两个方向的误判都不再可
表达。globalThis 上的定时器因此始终保持宿主原生实现,unref 契约恒成立。
三处设计取舍,都是被实测推翻后才定下的:
• 用 `with` 而非 IIFE 包装。函数包装会吞掉 guest 的顶层 var/function 声明,
initAliyunCaptcha 永远不会出现,每次求解都卡在等待它超时(实测 ok=0
fail=3)。`with` 不引入变量环境,声明的全局语义得以保留。
• 需要 Function 替身。`new Function(body)` 在全局作用域编译,生成的函数又能
看到宿主定时器,逃逸口重新打开(实测其心跳在窗口关闭后仍触发 7 次)。替身
把生成的函数体重新包进同一个 scope,并向下传递自身以覆盖嵌套生成。`eval`
无需处理,它本就继承调用处的作用域链。
• console 一并纳入 scope。FeiLin 每秒左右用不可见的 `%c%d` 格式串探测
devtools;解析到宿主 console 会把 TUI 的交替屏刷成一片裸 "NaN"。
测试相应从"钉住实现"改为"验证可观察契约":不再断言双分派器的内部结构,而是对
5 条 guest 入口路径(直接调用 / new Function / Function 无 new / 两层嵌套
生成 / eval 返回)分别验证"心跳随窗口死亡",外加顶层声明可见性与宿主 unref
契约。
验证(5 并发 × 6 波真实求解 + 133 万次并发 HTTP + 空闲越过墓碑期):
关窗后仍反复触发的定时器 0 个(改动前存在);致命未捕获异常 0;宿主 unref
契约全程保持;HTTP 1,332,201 成功 / 0 失败;求解成功率 80%,同参数 stock
基线 65%。
审阅指出这段文档写的是一个被推翻的中间方案——"Wrapping in an arrow-bodied
IIFE that RETURNS the inner expression"——而实现返回的是裸
`with(scope){…}` 语句,两者矛盾。
真实机制:`with` 是语句而非表达式,`eval` 返回它的块完成值,而块以最后一个
表达式语句完成,因此编译器交来的 `(function anonymous($happy_dom){…})`
原样流出,无需任何 IIFE 包装。(声明不产生完成值,所以以声明结尾的脚本同样
安全,沿用前一个表达式的值。)
注释同时保留选择 `with` 而非函数包装的另一半理由:`with` 引入的是对象环境
而非变量环境,guest 的顶层 var/function 声明得以继续外泄到全局对象。
顺带删除一处 rebase 到已合并的 TriDefender#44 之上时产生的 INERT_WINDOW_METHODS 重复
声明(tsc 不报此错,但运行时解析失败:"has already been declared")。
785eb0e to
cbaf139
Compare
|
已修正,感谢细看这处 —— 注释确实停留在一个被实测推翻的中间方案上。 已改(
|
TriDefender
left a comment
There was a problem hiding this comment.
复核通过,撤销 REQUEST_CHANGES。
- 注释修复质量高于要求:wrapGuestSource 现在准确描述裸
with实现,且把完成值机制讲对了(eval 取语句完成值、块以最后一个表达式语句完成、声明不产生完成值)——比原来那段错误描述更有价值。 - rebase 到含 #44/#45 的 master 后,你顺带删掉了 rebase 引入的
INERT_WINDOW_METHODS重复声明——这个自检很关键,模块级重复const会直接 SyntaxError。 - 本地复跑:tsc 0 错,755 pass / 0 fail;CI 新 head 绿。diff 已收敛为 captcha 两文件,纯词法绑定改动。
合并。至此 #43–#46 崩溃链四环全部闭环(#43 止血 / #44 存根 / #45 保真度 / #46 根因)。
背景
v4.5.5 TUI 模式下反复崩溃(实际使用中间隔 2m47s / 14m37s / 27m15s / 29m15s):
崩溃链条:
tE是个 2 秒心跳,会自我重新武装(feilin008.js:170382tV=setInterval(tE,2e3))happyDOM.close()收不走 → 不朽定时器moveBy,心跳下次触发即ReferenceError(fix(captcha): 墓碑到期后留惰性存根,不再硬删全局 #44 处理)uncaughtException执行exit(1)(fix(tui): guest 验证码 SDK 报错不再终止 TUI #43 处理)本 PR 切断第 2 环 —— 也就是根因。
问题:调用栈不能回答归属问题
makeDualTimers/makeDualConsole用一个谓词决定定时器该进窗口注册表还是宿主车道:问题在于:调用栈描述的是「谁在调用」,而我们要问的是「这归谁所有」。两者并不等价 —— 宿主与 guest 的栈天然交织(guest 发 XHR → 宿主拦截器 → 宿主 fetch;guest promise → 宿主 continuation)。
因此它在两个方向上都会误判,且都已在当前 master 上实测复现:
new Function生成的代码栈上不带 CDN 帧moveBy崩溃unref的窗口句柄 →TypeError: setTimeout(...).unref is not a function(v4.5.2 崩溃形态)假阳性方向在当前 master 上仍 100% 可复现:
Bun 的
node:_http_serverkeep-alive、undici、AbortSignal.timeout都会对 delay-0 定时器调.unref()。与 #37 / 5fcb778 的关系
#37 曾提出词法方案,以「疑似和 main 上的修复 duplicate 了」关闭。5fcb778 确实修了同一片区域,但两者机制不同:5fcb778 保留了运行时栈嗅探,只是加了 30 秒 tombstone。
栈嗅探这一判据从未被修过。上面两个方向的误判在当前 master 上都成立。
同时也要说明:5fcb778 的 tombstone 补上了 #37 方案的一个真实短板 —— 滞留回调撞上被硬删的
Text/document。本 PR 保留它,并由 #44 把宽限期做彻底(到期留惰性存根而非delete)。改动:归属改为词法绑定
归属是「代码从哪来」的属性,所以在语法层面绑定:guest 脚本在
evaluateScript处被包进with (scope) { … },scope持有该窗口的定时器、console和一个Function替身。标识符解析在语法分析期就定死,运行时不再查栈,两个方向的误判都不再可表达。
globalThis上的定时器因此始终保持宿主原生实现,unref契约恒成立。三处设计取舍,都是被实测推翻后才定下的:
为什么是
with而不是 IIFE朴素的词法方案(把 guest 脚本包进函数)会失败:函数包装吞掉顶层
var/function声明,initAliyunCaptcha永远不会出现,每次求解都卡在等待它超时。with不引入变量环境,声明的全局语义得以保留:为什么还需要
Function替身纯词法不够:
new Function(body)在全局作用域编译,生成的函数重新看到宿主定时器,逃逸口重新打开:替身把生成的函数体重新包进同一 scope,并向下传递自身以覆盖嵌套生成。
eval无需处理,它本就继承调用处的作用域链。为什么
console也要纳入FeiLin 每秒左右用不可见的
%c%d格式串探测 devtools。解析到宿主 console 会把 TUI 的交替屏刷成一片裸NaN。测试改写
原测试钉在双分派器的内部结构上(
makeDualTimers(win, host, () => true)之类),机制删除后它们失去意义。改为验证可观察契约:对 5 条 guest 入口路径分别验证「心跳随窗口死亡」——判据是行为(关窗后是否仍触发),不是栈:new Function生成Function无neweval返回外加顶层声明可见性、宿主
unref契约两条。验证
5 并发 × 6 波真实求解 + 133 万次并发 HTTP + 空闲越过墓碑期:
unref契约bun x tsc --noEmit0 错误bun test735 pass / 0 fail(上游基线 732)