Skip to content

fix(captcha): guest 定时器归属改为词法绑定,取代调用栈嗅探 - #46

Merged
TriDefender merged 2 commits into
TriDefender:masterfrom
LX2000WASD:fix/captcha-lexical-timer-scope
Sep 6, 2026
Merged

TriDefender merged 2 commits into
TriDefender:masterfrom
LX2000WASD:fix/captcha-lexical-timer-scope

Conversation

@LX2000WASD

Copy link
Copy Markdown
Contributor

依赖 #45。 跨 fork 的 PR 无法把 base 指向对方仓库的分支,因此本 PR 的
base 是 master,diff 里会一并显示 #45 的那个提交(rAF 别名 + rejection
sink)。本 PR 自身的改动只有最后一个提交 fix(captcha): guest 定时器 归属改为词法绑定,取代调用栈嗅探。

#45 合并后本 PR 的 diff 会自动收敛为单个提交。建议合并顺序:#43 → #44 →
#45 → 本 PR(前三个互不依赖,顺序随意)。

背景

v4.5.5 TUI 模式下反复崩溃(实际使用中间隔 2m47s / 14m37s / 27m15s / 29m15s):

zcode-proxy: tui crashed: ReferenceError: moveBy is not defined
    at tE (https://g.alicdn.com/captcha-frontend/FeiLin/1.5.1/feilin008….js:1:169658)

崩溃链条:

  1. FeiLin 的 tE 是个 2 秒心跳,会自我重新武装(feilin008.js:170382 tV=setInterval(tE,2e3))
  2. 它 arm 的定时器错误地落在宿主车道,happyDOM.close() 收不走 → 不朽定时器
  3. 30 秒后墓碑删掉 moveBy,心跳下次触发即 ReferenceError(fix(captcha): 墓碑到期后留惰性存根,不再硬删全局 #44 处理)
  4. TUI 对一切 uncaughtException 执行 exit(1)(fix(tui): guest 验证码 SDK 报错不再终止 TUI #43 处理)

本 PR 切断第 2 环 —— 也就是根因。


问题:调用栈不能回答归属问题

makeDualTimers / makeDualConsole 用一个谓词决定定时器该进窗口注册表还是宿主车道:

function isGuestConsoleCall(): boolean {
  const stack = new Error().stack ?? "";
  return /alicdn\.com|aliyuncs\.com/i.test(stack);
}

问题在于:调用栈描述的是「谁在调用」,而我们要问的是「这归谁所有」。两者并不等价 —— 宿主与 guest 的栈天然交织(guest 发 XHR → 宿主拦截器 → 宿主 fetch;guest promise → 宿主 continuation)。

因此它在两个方向上都会误判,且都已在当前 master 上实测复现:

方向1 假阳性  宿主代码在 guest 栈上 arm:  hasUnref = false   (纯宿主栈时 = true)
方向2 假阴性  guest 直接调用 -> 栈可见 = true
              guest 经 new Function 生成 -> 栈可见 = false
方向 后果
假阴性 — guest 经 new Function 生成的代码栈上不带 CDN 帧 心跳落到宿主车道 → 不朽定时器 → 本次的 moveBy 崩溃
假阳性 — 宿主代码在 guest 栈下被调用 拿到无 unref 的窗口句柄 → TypeError: setTimeout(...).unref is not a function(v4.5.2 崩溃形态)

假阳性方向在当前 master 上仍 100% 可复现:

unref() 调用:        THREW TypeError: setTimeout(function(){}, 0).unref is not a function
窗口关闭后 unref():  THREW TypeError: setTimeout(function(){}, 0).unref is not a function

Bun 的 node:_http_server keep-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 永远不会出现,每次求解都卡在等待它超时。

ok=0 fail=3
   fail: timeout
   fail: timeout
   fail: timeout

with 不引入变量环境,声明的全局语义得以保留:

with 包装后 globalThis.initAliyunCaptcha = function
with 包装后 globalThis.TOPVAR = 5
定时器车道: close前=4 close后=0   ← window 车道

为什么还需要 Function 替身

纯词法不够:new Function(body) 在全局作用域编译,生成的函数重新看到宿主定时器,逃逸口重新打开:

new Function 生成: close前=4 close后=7  ← HOST 不朽!

替身把生成的函数体重新包进同一 scope,并向下传递自身以覆盖嵌套生成。eval 无需处理,它本就继承调用处的作用域链。

为什么 console 也要纳入

FeiLin 每秒左右用不可见的 %c%d 格式串探测 devtools。解析到宿主 console 会把 TUI 的交替屏刷成一片裸 NaN。


测试改写

原测试钉在双分派器的内部结构上(makeDualTimers(win, host, () => true) 之类),机制删除后它们失去意义。改为验证可观察契约:对 5 条 guest 入口路径分别验证「心跳随窗口死亡」——判据是行为(关窗后是否仍触发),不是栈:

guest 入口 close 前 close 后
直接调用 4 0
new Function 生成 4 0
Function 无 new 4 0
两层嵌套生成 4 0
eval 返回 4 0

外加顶层声明可见性、宿主 unref 契约两条。


验证

5 并发 × 6 波真实求解 + 133 万次并发 HTTP + 空闲越过墓碑期:

指标 结果
关窗后仍反复触发的定时器(真正的不朽心跳) 0
致命未捕获异常 0
宿主 unref 契约 全程保持
HTTP 1,332,201 成功 / 0 失败
求解成功率 80%(同参数 stock 基线 65%)

说明:另有 18 个「关窗后触发过」的定时器,全部是一次性收尾回调 —— SDK 的 5 秒超时(core-js 在模块初始化时从 globalThis 绑定,词法作用域够不到)、墓碑定时器、happy-dom 的收尾任务。无一重复触发,因而不构成崩溃条件;即便触发,#44 的惰性存根也已使其无害。

  • bun x tsc --noEmit 0 错误
  • bun test 735 pass / 0 fail(上游基线 732)

@TriDefender TriDefender left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已按 #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 确认 Bun globalThis 无原生 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)矛盾。这是未来读者最会细看的地方,请把这段注释改为描述现状。完成即合。

非阻塞:

  1. PR 描述说 diff 里会一并显示 #45 的提交,实际此前还逐字节携带了 #44 的改动——现已随 44/45 合入自动收敛,本条可忽略。
  2. window 销毁后,若残留闭包再调用 scoped Function 生成的函数,会在 with(undefined) 处 TypeError,且栈上无 CDN 帧 → #43 不会识别为 guest。概率低,记录在案。

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")。
@LX2000WASD
LX2000WASD force-pushed the fix/captcha-lexical-timer-scope branch from 785eb0e to cbaf139 Compare September 6, 2026 14:15
@LX2000WASD

Copy link
Copy Markdown
Contributor Author

已修正,感谢细看这处 —— 注释确实停留在一个被实测推翻的中间方案上。

已改(captcha-happy.ts:618-637)

删掉了 "arrow-bodied IIFE that RETURNS the inner expression" 的表述,改为描述实际机制。修改前先实证确认了完成值的流出路径,避免把一处错误注释换成另一处:

裸 with 语句 -> eval 返回: function | 调用=WINDOW
块完成值 = 最后一个表达式语句: last
以 var 声明结尾 -> 完成值: [Function]   (声明不产生完成值,沿用前一个)
空块 -> 完成值: undefined

即:with 是语句不是表达式,eval 返回其块完成值,而块以最后一个表达式语句完成 —— 编译器交来的 (function anonymous($happy_dom){…}) 因此原样流出,不需要任何 IIFE 包装。新注释把这一点和「with 引入对象环境而非变量环境,所以顶层声明继续外泄」并列写成两条,正是选择 with 的两个理由。

同文件另外三处提到 IIFE 的注释(:538-539、测试 :89)描述的是「为何用 with 而非 IIFE」,与实现一致,未动。

顺带修掉一个 rebase 引入的真实缺陷

rebase 到已合并的 #44 之上时,INERT_WINDOW_METHODS 出现了重复声明。tsc --noEmit 不报这个错,是跑测试时才暴露的:

error: "INERT_WINDOW_METHODS" has already been declared
→ 740 pass 1 fail

已删除重复块,并扫过全文件确认无其他重复顶层声明。这是我 rebase 时的疏漏,不是原提交的问题 —— 但正因为 tsc 放行,值得记一笔。

复验

rebase 到 6d0064f(#43/#44/#45 均已合入)后:

直接调用          close前=4 close后=0  WINDOW 安全
new Function      close前=4 close后=0  WINDOW 安全
Function 无 new    close前=4 close后=0  WINDOW 安全
两层嵌套生成       close前=4 close后=0  WINDOW 安全
eval 返回         close前=4 close后=0  WINDOW 安全
真实求解 ok=3 fail=0

diff 也已随前三个 PR 合入自动收敛(+418/−342 → +380/−350),现在只剩词法绑定与本次注释修正两个提交。

关于两条非阻塞项

第 1 条同意,已随合并自动消解。

第 2 条(window 销毁后残留闭包调用 scoped Function → with(undefined) 处 TypeError、且栈上无 CDN 帧)确实存在,我未在真实链路中触发过。若你希望现在就堵上,最小改法是在 makeScopedFunction 生成的源码里把作用域引用降级为 (globalThis.__capGuestScopes["wN"] || {}) —— 作用域被回收后退化为普通全局解析,而非抛错。要的话我另开一个 PR,不占本 PR 的篇幅。

@TriDefender TriDefender left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复核通过,撤销 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 根因)。

@TriDefender
TriDefender merged commit 283fbc1 into TriDefender:master Sep 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants