[插件] 用AI做了个极验V3验证插件,flinthub大佬看看有没有什么问题

🌱Lv.2 新手 🌛 见习会员
2026-10-26 10:29:03

本帖内容需要回复后才能查看

回复本帖后可以立即查看内容

| 浏览 10 次 | 回复 3 次

全部回复 (2)

👑Lv.11 元老 🌞 骨干会员
2026-10-26 11:25:02
看一下,感谢分享。
是不是感觉做插件不难?chigua
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
#1 楼
👑Lv.11 元老 🌞 骨干会员
2026-10-26 12:02:49

三方审计结论,供你和AI参考,官方修复版在本文下部(稍等提供):

第三方插件审计报告 —— 极验滑块验证 V3(geetest_v3)1.0.6

项内容
审计对象flinthub-plugin-geetest_v3-1.0.6.zip(20 文件 / 145,307 B)
作者folcc
声明适配min_core: 1.2.0(站点实测核心 1.2.9)
审计依据docs/插件开发规范.md + 项目级红线清单(§3 权限 / §4.1 class_alias / §5.3 后台身份 / §7.3 零内联 / §9.4 双重触发 / §11.11~§11.35)
审计方式静态逐行 + 真实安装(站点 plugins/)+ HTTP 端到端 + jsdom 真实 DOM + 卸载零残留复核
结论整体质量高,但不建议直接上生产:1 项高危可用性缺陷 + 3 项中危安全问题
审计后状态已彻底卸载,plugins_cache.json 逐字节还原(sha256 4b3648c1008bc576…),站点零残留

一、结论摘要

这个插件的工程质量在第三方插件里属于上乘:路径归一化逐字节复刻核心 Router::dispatch()(堵住了验证码插件最常见的 ?r= 绕过)、后台身份/CSRF/零内联/CSP 同源 iframe 全部合规、三语 i18n 零缺失、DDL 幂等 + 建表戳 + 卸载清理齐全、全程零 PHP 告警。

但有一类问题它没处理:「前端拦截」与「服务端放行」的判定条件不一致。服务端用 ready()(开关 + 凭证齐备)做短路,前端只用 matchScene()(路径命中)。两者一错位,就出现「服务端已放行、客户端不让提交」的死锁。

级别编号问题影响面
🔴 高危H1关闭总开关 / 未填凭证后,前端仍无条件拦截登录提交 → 登录死锁全站所有用户无法登录
🟠 中危M1「不可达时=拒绝」不覆盖挑战申请失败;register.php 失败会自动降级为本地解码放行配错 ID 或极验故障时静默失去防护
🟠 中危M2宕机模式挑战可自解 + 通过后不消费 + 重放只记 Session → 跨会话无限复用宕机期间验证形同虚设
🟠 中危M3承载页限流按 session_id() 计数,而该页 session_abort() 不落盘 → 不带 Cookie 即可绕过可被当作打极验 API 的跳板
🟡 建议L1日志表 scene/result 拼 i18n 键未转义潜在存储型 XSS 通道(当前不可控)
🟡 建议L2--c-info-text 语义用错,琥珀兜底值被深蓝覆盖后台统计「不可达」显示成深蓝色
🟡 建议L3版本号只同步 2 处(规范要求 12 处)静默卫生债,不影响门禁
🟡 建议L4captcha_id 留空=清空,private_key 留空=不改,行为不一致易误清配置
🟡 建议L5未配置/限流时仍加载极验 CDN 的 gt.js一次无谓外链

二、高危:H1 —— 关闭开关后登录死锁

触发条件

满足任意一条即可:① 插件已启用但 captcha_id / private_key 没填全;② 后台把「启用极验验证」关掉;③ 极验故障期间按文档建议「临时关闭总开关」。

根因(两处判定条件不一致)

服务端(Plugin.php:544-552):

public static function gate(): void
{
    if (REQUEST_METHOD !== 'POST') { return; }
    $cfg = self::config();
    if (!self::ready($cfg)) { return; }   // ← 开关关/凭证缺 → 直接放行
    ...
}

前端注入(hook/layout_body_end.php:28-31):

$gt3Scene = \Plugin\geetest_v3\Plugin::matchScene(\Plugin\geetest_v3\Plugin::requestPath());
if ($gt3Scene === '') { return; }         // ← 只看路径,没有 ready() 短路

bootPayload()(Plugin.php:497-532)同样不看 ready(),照样把 7 个场景下发。

前端拦截(assets/script.js:290-310):

var scene = gated(form);            // 只判 POST + 路径命中
if (scene === '') { return; }
ev.preventDefault();                // ← 无条件吃掉提交

点「关闭」走 closeMask()(script.js:133-140),不调用 release() → 表单永远不会被交回原生提交。

实测证据

HTTP(阶段四,enabled=0):

检查结果
enabled=0 → ready()=falsePASS
登录页仍然注入 script.jsPASS(= 缺陷成立)
服务端闸门不拦 POST /loginPASS(status=200,无 geetest=fail)
结论服务端放行=是 + 前端仍拦截=是 ⇒ 登录死锁

jsdom 真实 DOM([1] 组):

断言结果
登录页 submit 被 preventDefaultPASS
浮层已插入(用户看到验证弹窗)PASS
点关闭后浮层移除、滚动锁解除PASS
关闭后再次提交 → 又被拦截PASS
关闭后再次提交 → 浮层重新弹出(死循环)PASS

而承载页此时返回 ok:false, gt:"", challenge:"",widget.js 的 initGeetest 拿不到凭证 → onError → 父页面显示「验证组件加载失败,请检查网络后重试。」+ 一个「重试」按钮,重试同样失败。

文档与行为冲突

文档位置原文实际
说明.md:22「总开关。关闭后插件不拦截任何请求」服务端不拦,前端仍拦
说明.md:113「极验故障期间所有人都登不上 → 把…改为放行,或临时关闭总开关」关掉开关并不能恢复登录,反而更彻底

讽刺点:关掉浏览器 JS 反而能登录(前端不拦、服务端 ready()=false 放行)。这说明缺陷纯在前端判定。

修复(1 行)

hook/layout_body_end.php,在 matchScene 之后补 ready 短路:

$gt3Scene = \Plugin\geetest_v3\Plugin::matchScene(\Plugin\geetest_v3\Plugin::requestPath());
if ($gt3Scene === '') {
    return;
}
+ if (!\Plugin\geetest_v3\Plugin::ready()) {   // 未启用/未配置 → 不注入,避免前端空拦
+     return;
+ }

更稳妥的做法是两处都改:bootPayload() 在 !ready() 时返回 'scenes' => [],让 script.js:29 的 !cfg.scenes.length 自行短路——这样即使将来有人只改一处,前端也不会裸拦。


三、中危:M1 —— on_unreachable=deny 不覆盖挑战申请失败

机制

on_unreachable 只在 validateResult() 返回 unreachable 时被查询(Plugin.php:573),而 unreachable 只由 /validate.php 失败产生(Plugin.php:840-846)。

register.php(第一步申请挑战)失败走的是另一条路:

register() 失败(DNS/超时/非 200/响应不是 32 位 hex)
  → failback()(Plugin.php:778)自造 34 位挑战 + 登记指纹
  → 客户端 offline 渲染静态滑块
  → validateResult() 命中 isFailbackIssued()(Plugin.php:811)
  → failValidate() 本地解码
  → 通过即 result=success(mode=0)
  → gate() 直接 return,**从不查 on_unreachable**

实测

本机 api.geetest.com 不可达,配置 on_unreachable=deny:

检查结果
承载页 mode=0(宕机模式)PASS(challenge 长 34)
deny 策略下仍能通过闸门PASS(= 缺陷成立)

影响

  • 配错 captcha_id 不会报错,只会静默降级为「可自解的本地验证」——管理员以为有防护,实际没有。
  • 后台「测试连通性」会给红字提示(test_unreachable),这是唯一的信号,但很容易被忽略。
  • 真正的极验故障期间同理。

修复建议

在 gate() 的成功分支里区分 mode:

if ($res['result'] === 'success') {
+   // 宕机模式的成功 = 本地解码通过,不等价于「极验云端确认过」
+   if ((int) $res['mode'] === 0 && self::cfg('on_unreachable', $cfg) === 'deny') {
+       self::rejectBack('failback_denied');
+   }
    self::markUsed($challenge);
    return;
}

(若担心误伤真人用户,可退一步:只记日志 + 后台红点提示,但不阻断。当前默认策略是 deny,按语义就该拒。)


四、中危:M2 —— 宕机模式挑战可自解且可跨会话复用

两个独立问题叠加

(a) 答案可自解(协议固有):宕机模式的 validate 由挑战本身决定,算法公开在极验 SDK 里。攻击者只要从 /geetest-widget 合法拿到一枚 34 位挑战,就能不持有私钥算出配套 validate。

实测:首次尝试即构造成功。

challenge = 5112f38a9da9b2a510f752f0cecf8cf92c
randBase  = 84   xPos = 65
validate  = 33ffff211_3fff11_3fff11      ← 纯本地计算,未用私钥

作者已意识到一半问题(Plugin.php:805 注释:「否则攻击者可自造挑战」),并用 gt3_state 登记指纹挡掉了自造挑战;但本机签发的挑战同样可自解,这一半没堵。

(b) 挑战用后不消费(实现缺陷):markUsed()(Plugin.php:621-638)只写 $_SESSION['gt3_used'],gt3_state 里的 f:sha1(challenge) 行通过后不删除,TTL 30 分钟。

于是:换一个 session(换个浏览器 / 不带 cookie)就能拿同一份三件套再通过一次,30 分钟内无限次。

实测(阶段五)

检查结果
gt3_state 里存在该挑战的 f: 记录PASS
会话 A 首次提交 → 通过闸门PASS
会话 A 重放 → gterr=replayPASS
会话 B(全新 cookie)用同一份三件套 → 通过了FAIL(= 缺陷成立)
会话 C 再复用 → 同样通过FAIL(同上)
通过后 f: 记录仍在FAIL(= 缺陷成立)

影响

宕机期间(含 captcha_id 配错导致的长期「假宕机」)人机验证等价于不存在:一枚挑战 → 解一次 → 换 cookie 反复用。叠加上 M1(deny 不生效),这条链路完全敞开。

修复(两行)

gate() 成功分支:

if ($res['result'] === 'success') {
    self::markUsed($challenge);
+   self::consumeFailback($challenge);   // 一次性消费:删掉 gt3_state 里的 f: 行
    return;
}
+ private static function consumeFailback(string $challenge): void
+ {
+     if (strlen($challenge) !== 34) { return; }
+     try {
+         self::db()->prepare('DELETE FROM gt3_state WHERE key = :k')
+             ->execute([':k' => self::ST_FAIL . sha1($challenge)]);
+     } catch (\Throwable $e) {
+         \error_log('[geetest_v3] consumeFailback error: ' . $e->getMessage());
+     }
+ }

注:isFailbackIssued() 的注释说「比按 Session 存更紧」,方向是对的;但「签发即登记」缺少配对的「使用即销毁」,一次性语义没落地。


五、中危:M3 —— 承载页限流可被「不接受 Cookie」绕过

机制

  • 限流键:Plugin::rateTake((string) session_id(), 20, 60)(FrontController.php:55)
  • 该页收尾 \session_abort()(FrontController.php:122)——会话数据不落盘
  • 于是下一次请求 PHP 找不到这个 session,换发新 PHPSESSID → 新限流桶

实测

场景结果
先访问 /login(会话已持久化)→ 连打承载页第 21 次起 ok:false(限流生效)
直接打承载页、每轮 Set-Cookie 都换新 ID24 次全部 ok:true
完全不携带 Cookie24 次全部 ok:true

直接观测:连续 4 次 curl -b jar -c jar 请求承载页,响应头分别是 PHPSESSID=1a68onh6… / mra42a9b… / dt2aarj9… / c89ciamv… —— 每次都是新会话。 而先访问 /login 之后,连续 3 次承载页请求不再下发 Set-Cookie(会话稳定),限流才生效。

影响

限流本意是「防止承载页被当成打极验接口的跳板」。攻击者不带 cookie 即可无限申请挑战,消耗站点极验配额。作者注释也承认「限流是防滥用而非鉴权」,所以不算越权,但防滥用目标没达成。

修复

把限流键从 session 换成 IP(必要时 IP + UA 指纹):

- !Plugin::rateTake((string) session_id(), self::RATE_COUNT, self::RATE_WINDOW)
+ !Plugin::rateTake(\app\Helpers\Request::ip() ?? 'anon', self::RATE_COUNT, self::RATE_WINDOW)

(rateTake 内部已做 sha1(),IP 原文不会进库。)


六、低危 / 建议

L1 日志表拼 i18n 键未转义(views/admin.php:196-197)

<td><?php echo $this->t('plugin.geetest_v3.scene_' . (string) ($gt3Row['scene'] ?? '')); ?></td>
<td><?php echo $this->t('plugin.geetest_v3.res_'   . (string) ($gt3Row['result'] ?? '')); ?></td>

$this->t() 不转义,I18n::get() 对未知键原样回显键名。当前 scene/result 只由插件自身写常量值(已核 log() 全部调用点),不可控,所以不构成现实漏洞;但同文件 :31 的 $err 已经做了白名单,这两处漏了,属纵深防御缺口。

建议:echo $this->e($this->t(...)),或按 $err 的做法先白名单。

L2 CSS 变量语义用错(assets/admin.css:105)

.gt3-stat.is-warn b { color: var(--c-info-text, #d97706); }

核心 --c-info-text = #0c5460(深青蓝,info 语义),与兜底的琥珀 #d97706 差着语义。实际站点上「服务不可达」统计会显示成深蓝色,与同组 is-ok(绿)/is-bad(红)不搭。

建议改 var(--c-warning-text, #92400e)(核心 admin.css:35 有定义)。

L3 版本号只同步 2 处

规范要求 12 处(plugin.json + 说明.md + 8 个 PHP 头 @version + 2 个 assets)。 本包:plugin.json、说明.md 有 1.0.6;12 个 PHP 文件全部没有 @version 头,6 个 assets 也无版本注释。 不报错、不影响门禁,属静默卫生债——但下次改版时容易漏。

L4 captcha_id 与 private_key 的「留空」语义不一致

AdminController.php:51-64:captcha_id 无条件写入(留空 = 清空),private_key 留空 = 不修改。 而 views/admin.php:86 里 captcha_id 的 placeholder 只写「32 位十六进制字符」,没提示留空会清空。用户容易误清。

建议:placeholder 补「留空表示清空」,或让两者统一为「留空不改」。

L5 未配置/限流时仍加载 gt.js

FrontController.php:160 无论 ok 真假都输出 <script src="https://static.geetest.com/static/tools/gt.js">。 ok=false 时这次外链毫无用处。建议 if ($d['ok']) 再输出。


七、通过项(逐条打勾)

静态审计 60 项全 PASS

组覆盖内容结果
A 包结构单根 plugins/geetest_v3/、plugin.json 9 个必备键、min_core 已声明且 ≤ 站点版本、activated:false、命名空间首段 == 目录名、无垃圾/运行时库/隐藏文件8/8
B 版本号plugin.json / 说明.md 含版本号、PHP @version 一致性3 PASS + 1 WARN(L3)
C 权限net:fetch + route:admin 均已声明;无裸 curl_init / file_get_contents('http…'),外呼全部走 Plugin::http()3/3
D 后台继承 Admin\BaseController、无自造管理员判定、save/test/clearLog 首行均 Csrf::verifyOrDie、视图三层自包、__nav_active、form action 内联9/9
E 渲染钩子无 exit/die/header/$this/裸 echo $_、htmlspecialchars 均带 ENT_QUOTES3/3
F 前端零内联 <script> 块、零内联 on* 属性、data-json 已转义2/2
G CSS24 个 var(--*) 全部在核心有定义、顶层选择器统一 gt3- 前缀3/3
H i18n三语键集合完全一致、无空文案、代码引用键零缺失、零硬编码中文5/5
I SQL无字符串拼接用户输入、DDL 全 IF NOT EXISTS、有建表戳、无重复命名占位符5/5
J 生命周期activate/deactivate/uninstall 齐全、uninstall DROP 三表 + 清戳、glob 模式不含用户输入6/6
K 路由admin_route_register 不自己拼 /admin、admin_url 与路由对齐、前台路由不在 /admin 下4/4
L 钩子plugin.json hooks 与 hook/ 目录一一对应(5/5)、未用死钩子 nav_pc_user_links2/2
M 安全私钥不回显、hash_equals 而非 == 比 md5、is_scalar 防数组注入、有重放防护、redirect 剥 CR/LF、random_bytes 而非 rand6/6

语法与专项

  • php -l:12 文件 × 2 版本(8.0.2nts / 8.2.9nts)= 24 次,0 fail
  • 全角标点紧跟变量(php_var_cjk_check.php):0 命中
  • 同命名空间漏 use(php_missing_use_check.php):0 命中

安装自检 20 项全 PASS

核心识别插件(第 77 个)、三个类可加载、ensureSchemaOnce 成功、建表戳 .gt3_schema.2 生成、独立库创建、gt3_config/gt3_log/gt3_state 三表 + 两索引齐全、默认配置 10 键齐全并播种入库、ready() 正确返回 false、8 个场景正则全部命中核心真实路由(已逐条比对 index.php:249-290)、7 个不该命中的路径全部不命中、默认启用集 = 7 项(message 默认关)、budget() 允许 5 次拒第 6 次。

闸门与绕过测试(阶段二,18/19 PASS,1 条为脚本期望值写错)

攻击面结果
POST /login 无验证码302 gterr=missing ✅
POST /index.php?r=/login302 gterr=missing ✅ (?r= 绕过已封)
POST /index.php?r=login(无前导斜杠)拦截 ✅
POST /?r=/login拦截 ✅
POST /login/(尾斜杠)拦截 ✅
geetest_challenge[]=x(数组注入)判 missing,不崩 ✅
伪造 32 位 challenge + validatelocal_check 拒绝 ✅
缺 challengemissing ✅
5000 字节超长字段限长后判 bad_validate,不崩 ✅
本地 md5 正确但 seccode 伪造geetest_denied(真实回源被极验拒)✅
未启用场景 /message/compose不拦(开关生效)✅
非受管路由 /thread/{id}/edit不拦(精度正确,非一刀切)✅
GET 请求不触发闸门 ✅
POST /register / /thread/123 / /post/new均拦 ✅
回跳 Location 无 CR/LF、无开放重定向✅(/login?next=//evil.com&geetest=fail&gterr=missing,//evil.com 被 http_build_query 编码,不会变成协议相对 URL)

?r= 归一化是本插件最值得肯定的地方:requestPath()(Plugin.php:453-480)逐行复刻 Router::dispatch()(Router.php:59-88)的「剥 BASE_PATH → ?r= fallback → 格式校验 → admin 前缀限制 → rtrim('/')」,连正则 /^[a-zA-Z0-9_\/-]+$/ 都一模一样。这是验证码插件最高频的绕过点,作者一次做对了。

宕机/重放/日志(阶段三,15/15 PASS)

自造 34 位挑战被拒(bad_validate)、登记后本地解码通过、同 session 重放 replay、答案偏差过大 failback_mismatch、限流第 21 次生效、日志写入正常(18 行)、result 值都在 {success,fail,unreachable} 内、scene 值都在场景表内、reason 已做 ASCII 裁剪。

后台门禁(阶段四,10/10 PASS)

未登录 GET /admin/geetest-v3 → 302 /login(不是 404,路由已注册);正文不含 captcha_id / gt3-test-btn(未泄漏);未登录 POST 保存/测试接口均 302,不返回 ok:true。

前端行为(jsdom,24/24 PASS)

拦截与放行边界正确:非场景页不拦、GET 表单不拦、/thread/{id}/edit 不拦、scenes 为空时不拦;已填 validate 时不弹浮层直接交回原生提交(nativeSubmit=1);回跳提示条文案取 i18n、地址栏清掉 geetest/gterr 但保留 next;浮层 role=dialog + aria-modal、关闭按钮有 aria-label、iframe 有 title、文案全走 textContent 无 innerHTML 拼接。

其它

  • 图标 shield → &#xf132;,核心 TemplateCompiler.php:770 已定义,且 index.php:148 / footer_beian.php:39 已在用 → 字体子集必然含该码点,不会出方块。
  • 视图用到的 9 个模板方法(e/t/url/icon/include/section/endSection/getData/query)全部在 TemplateCompiler 有定义。
  • data/ 目录的 .htaccess / web.config 由核心 Plugin::db() 自动补写;Nginx 侧由 nginx-server.conf 的 location ~ ^/plugins/[^/]+/data/ { deny all; return 403; } 覆盖 → 含私钥明文的 SQLite 不会被直接下载。
  • 全部 HTTP 测试(含 404、数组注入、超长字段、DB 写入、外呼)期间 protected/error.log 零插件相关告警。

八、审计过程与残留

阶段内容结果
静态20 文件逐个读完(Plugin.php 1033 行全文)—
静态脚本gt3_audit_static.php60 PASS / 0 FAIL / 1 WARN
语法php -l ×2 版本、全角变量、漏 use24/24、0、0
安装复制到 plugins/geetest_v3/ + Plugin::activate()20 PASS
HTTP 一未配置态 + 资源注入20 PASS(1 条脚本断言误报,已用 curl 直接核对源码确认 ok:false 存在)
HTTP 二闸门与绕过18 PASS(1 条脚本期望值写错)
HTTP 三宕机/重放/限流/日志15 PASS
HTTP 四开关关闭 + 后台门禁10 PASS
HTTP 五宕机挑战一次性5 PASS / 2 FAIL(真缺陷 M2)
HTTP 六限流绕过2 FAIL(真缺陷 M3)
jsdom前端拦截行为24 PASS
卸载Plugin::uninstall() + 删目录 + 还原缓存9 PASS

零残留复核:

  • plugins/ 目录数 76(= 审计前基线)
  • plugins/plugins_cache.json 74,126 B,sha256 4B3648C1008BC576…,与审计前备份逐字节一致
  • GET /geetest-widget → 404(路由随插件消失)
  • GET / 与 GET /login → 200,页面不含 geetest_v3 字样
  • 站点内(排除 .workbuddy-ai/)无任何 geetest / gt3 命名的文件或目录

九、给作者的修复清单(按优先级)

优先级文件:行改法
P0hook/layout_body_end.php:28 后加 if (!Plugin::ready()) { return; };同时 bootPayload() 在未 ready 时返回 scenes => []
P0Plugin.php:568 成功分支加 consumeFailback($challenge),通过即删 gt3_state 的 f: 行
P1Plugin.php:568 成功分支宕机模式(mode===0)在 on_unreachable=deny 时拒绝
P1FrontController.php:55限流键由 session_id() 改为 IP
P2views/admin.php:196-197$this->t(...) 外面套 $this->e(...)
P2assets/admin.css:105--c-info-text → --c-warning-text
P312 个 PHP + 6 个 assets补 @version / 版本注释
P3views/admin.php:86captcha_id placeholder 补「留空表示清空」
P3FrontController.php:160gt.js 仅在 ok===true 时输出

十、总体评价

代码素养明显高于平均水平。几个细节值得点名:

  1. session_abort() 那段分析(FrontController.php:37-45)——识别出「承载页外呼极验 + 核心 /api/pow 并发读写同一行 session,而核心 SessionHandler 是整行 UPSERT 无会话锁」这个竞态,并把限流桶/宕机标记挪进插件独立库。这是踩过真实坑才能写出来的注释。
  2. ?r= 路径归一化逐行复刻核心 Router,而不是「自己再写一套近似逻辑」。
  3. hash_equals 替代官方 SDK 的 ==(Plugin.php:848 注释点明「两个 0e… 会被 PHP 判等 → 假阳性」)。
  4. validateResult() 刻意不对 challenge 做长度断言(Plugin.php:820-825),并解释为什么——这正好避开了官方 SDK 常见误伤。
  5. 承载页自带 CSP、核心 CSP 一行没改,用同源 iframe 隔离第三方脚本。

但它缺一条工程纪律:前端与后端的「生效条件」必须同源。 H1 就是这条纪律缺失的直接后果,而且它同时让文档里的应急手段失效——这在生产环境里是「按文档操作反而把站搞挂」的类型。

修掉 H1 + M1 + M2 三处(合计约 20 行)之后,这个插件可以放心上生产。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
#2 楼

请 登录