[审计]飞刀大乱斗(`plugins/feidao`)插件审计报告

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:38:39

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/feidao插件审计报告_2026-09-29.md。

飞刀大乱斗(plugins/feidao)插件审计报告

  • 审计日期:2026-09-29
  • 审计依据:docs/插件开发规范.md(v1.3,2026-09-20)+ docs/插件审计报告.md(此前未收录 feidao,本报告为首次)
  • 插件版本:plugin.json v1.0.0 / Plugin::CLIENT_VERSION = 1.48.2-flinthub / SCHEMA_VERSION = '1'
  • 报障输入:「游戏结束时点击观战,感觉整个游戏就不动了」
  • 结论:报障已复现并定位到根因(P0);另有 2 处确定性缺陷、5 处规范偏差。安全红线与结构规范全部通过。

一、报障根因(P0):死者观战路径没有数据源

1.1 现象与复现路径

步骤代码位置
1. 在公共大地图被击杀 / 本局结束 → finish() 结算assets/original/module.js:230-284(onDeath)、module/net.js:1582-1603(finish)
2. 死亡卡片浮出(#fd-death)module.js:283
3. 点「观战」(#fd-btn-spec)→ 只做本地镜头切换module.js:223-225 → module/spectate.js:37-44(start())
4. 幕布撤掉,露出一个静止的战场——

spec.start() 只设 G.watchIdx/G.watchAt 并给观战条加 .on,不发任何请求、不改任何网络态。

1.2 根因链(每一环都有 文件:行号)

死亡 → module.js:239 net.snapToServer() / module.js:258 net.finish(...)
     → net.js:1585  ended = true          ← 全局「本局已结算」闸门
     ├─ net.js:1630  localTick(): if (!G.inArena || ended) return;   ← 第 1 道门
     │    └─ 逻辑帧不再前进:G.frameNo / G.frameAt / board() / syncSelfFields() 全停
     └─ net.js:1538  syncRoom(): if (syncing || !api || !G.inArena || ended) return;  ← 第 2 道门
          └─ 而 syncRoom() 的**唯一调用点**是 net.js:1685(就在 localTick 里)

ended 的复位点只有一处:net.js:610(在 resetSelf() 内),而 resetSelf() 只被

resetArena()(net.js:666,进场)与 restart()(net.js:703,复活)调用。

→ 点了观战也永远解不开这两道门。

ended 三个赋值点全量核对:net.js:50(初始化 false)、net.js:610(复位 false)、net.js:1585(置 true)。

1.3 为什么「纯观战」(首页 #fd-iwatch)是好的 —— 对照实验

两条路走的是两套机制,这正是问题所在:

首页「观战」`#fd-iwatch`死亡卡片「观战」`#fd-btn-spec`
入口module.js:689-690 → watchArena() → enterFlow(true)module.js:223 → spec.start()
服务端call('watch') → Plugin::publicArenaWatchPayload()不发请求
G.watchertrue(net.js:662 从 session.watcher 取)仍为 false
数据通道watch_sync 轮询(net.js:1542),不受 ended 影响无
结果世界正常动世界冻结

module.js:203-217 的注释明确写着这两条路「算同一回事」,并且为死者那条路做了

UI 层适配(watchingNow() / applyWatchMode() / 隐藏血条刀阶)——

但网络层从未跟上。module.js:326-331 更直接承认了「本地玩法结算后不再产出逻辑帧」。

1.4 证据:真码 A/B 探针(17/17 通过)

探针:.workbuddy-ai/tmp/fd_spectate_probe.js(长期保留,修复后可直接复用验收)

方法:在 vm 沙箱里加载未改动的 assets/original/module/{art.js,net.js},

复刻 module.js 的 G 结构,逐拍调用真实的 localTick()。

=== A. 活着(基线):resetArena 后跑 30 拍 ===
  PASS  逻辑帧在前进  :: frameNo 0 → 30
  PASS  room_sync 持续轮询  :: 4 次 / 1s
=== B. 死亡后(现状代码):finish() 之后跑 30 拍 ===
  PASS  死亡后逻辑帧停止前进(画面冻结)  :: 5 → 5
  PASS  死亡后不再发出 room_sync(无新快照)  :: 1 → 1
  PASS  G.watcher 仍为 false —— 走观战 UI 也不会有数据源
  PASS  榜单仍是死亡那一刻的旧快照  :: 11 行
=== C1. 反证:只放开 localTick 的门(net.js:1630) ===
  PASS  死亡后逻辑帧恢复前进  :: 5 → 35
  PASS  但 room_sync 依旧不涨 —— syncRoom 还有第二道 ended 门(net.js:1538)  :: 1 → 1
=== C2. 反证:两道门一起放开 ===
  PASS  死亡后逻辑帧继续前进  :: 5 → 35
  PASS  死亡后 room_sync 恢复轮询(世界能更新了)  :: 1 → 5
=== D. 只有「复活」能解冻:restart() ===
  PASS  restart() 后帧恢复  :: 0 → 10
=== E. 纯观战者(iwatch 那条路)对照 ===
  PASS  纯观战者逻辑帧在前进  :: frameNo 0 → 30
  PASS  watch_sync 持续轮询  :: 3 次 / 1s
  PASS  G.watcher === true(与死者那条路的关键差异)
断言 17 条,失败 0 条

C1 是本探针独立发现的:静态阅读容易只看到 localTick 那道门,实测证明

必须同时放开 net.js:1538 与 net.js:1630 两道。

探针踩坑记录(已写进 REF_审计与验证):syncRoom 用 syncing 标志串行化,

同步循环里不同步 flush 微任务会把计数 latch 住 → 误判成「没轮询」。

1.5 服务端是否支持「结算后继续同步」——支持,无需改 PHP

已逐行核对:

  • Plugin::roomSync()(Plugin.php:965)的快路径(:980)只要求

room_status === 'running' + 公共战场码;不校验玩家是否还活着。

  • Plugin::roomFinish() 对公共战场把玩家状态写回 'playing'(Plugin.php:937),

所以结算后玩家行仍在。

  • feidao_runs 行结算后仍在(settled_at 置位),:986 的查询不会失败,

:997 的写入有 settled_at === 0 守卫。

  • :996 $merged['alive'] = incoming.alive && stored.alive → 死者保持 alive=false;

:1051 的命中循环以 $merged['alive'] 为前提 → 死后补发的命中一律被忽略(无刷分面)。

即:客户端只要继续轮询 room_sync,就能拿回全量 roomPayload 快照,世界就会动。

1.6 修复建议(未动手,等你拍板)

两条路,改动面差别很大:

方案 B(推荐)—— 只改 net.js 两处门控 + 一个语义开关

  • 新增 let spectatingAfterEnd = false;
  • net.js:1630 → if (!G.inArena || (ended && !spectatingAfterEnd)) return;
  • net.js:1538 → if (syncing || !api || !G.inArena || (ended && !spectatingAfterEnd)) return;
  • 由 module.js:223 那条(死亡卡片观战)在 spec.start() 成功后置真;

spec.stop() / 退出观战 / 回死亡卡片 / restart() 时置假。

  • 服务端零改动(1.5 已验)。
  • 代价/风险:
  1. G.watcher 保持 false → watchLink()(module.js:322-343)的「死者不算掉线」早退分支

继续生效,不会误弹「连接断开」,这点正好合适。

  1. 死者本地会继续跑 updateBots()(本插件的电脑玩家是各客户端本地模拟,

不进 roomPayload)→ 看到的场面与「活着时」一致;但纯观战者那条路看不到本地电脑玩家

(resetArena() 对 watcher 把 bot 全置死,net.js:684-691)→ 两条观战路径观感仍不一致。

  1. 死者会持续 room_sync → last_seen 一直新鲜 → 在 Plugin::onlineCount() /

pickArenaRoomId() 的容量口径里继续占一个名额(现状冻结 15 秒后会自动让位)。

如果在意这点,可让服务端容量口径改成只数 alive 的玩家——那是另一处改动,需单独确认。

方案 A(不推荐)—— 死亡后直接走 enterFlow(true) 变成纯观战者

  • 语义最干净(复用已验证的 watch_sync 通路),但要把服务端签发的

session.watcher 语义扩展到「已结算的参赛者」,牵动 roomPayload / watch_sync / 结算幂等,

影响面大得多。


二、其它确定性缺陷

P1-1 后台看板「战场」恒为 0

  • 位置:AdminController.php:118

```php

"SELECT COUNT(*) FROM feidao_rooms WHERE status IN ('waiting','playing')"

```

  • 实际 feidao_rooms.status 取值只有 waiting / running / finished

(Plugin.php:691 插入 'running';Plugin.php:947 置 'finished')→

'playing' 是玩家表的状态值,房间表里不存在。

  • 实测:插件库当前 SELECT room_code,status FROM feidao_rooms → [('PUBLIC','running')]

→ 该卡片永远显示 0。

  • 修法:IN ('waiting','running')。

P1-2 卸载残留:不清核心 settings 里的 feidao_* 键

  • 位置:Plugin.php:375-383(uninstall())只 DROP 6 张表 + 删戳文件

protected/.feidao_table_cache;未清 settings 表。

  • 核心 app/Helpers/Plugin.php::uninstall()(:835-870)只负责「调插件自己的 uninstall()

+ 把 activated 置 false + 重建缓存」,不会代清 settings。

  • 实测:data/meta/business.sqlite 的 settings 表现存 feidao_last_cleanup;

站长一旦保存过后台表单,还会多出 feidao_entry_fee / feidao_kill_reward /

feidao_daily_cap / feidao_bot_count / feidao_bot_names 五个键。

  • 规范依据:§11.5 第 34 条(卸载残留自检三处)。
  • 修法:uninstall() 里追加 Settings 键清理 + Settings::buildCache()

(§11.16:绕过 Settings::update() 直删后必须重建文件缓存)。

P2-1 会话过期后无回跳入口(§11.28 ①③)

  • FrontController.php:80-82:未登录返回 {ok:0, code:401, message:'请先登录'},

未附 login_url(§11.28 ③ 要求)。

  • assets/original/module.js:139-141:

```js

location.href = '/login?redirect=' + encodeURIComponent(location.pathname + location.search);

```

裸 /login(不处理 BASE_PATH)+ 自己拼回跳参数(§11.28 ① 明确禁止自拼,应走 Auth::loginUrl())。

  • 现状影响有限:FrontController::index() 已 requireLogin(),页面加载时能正常跳登录;

只有「页面开着 → 会话过期 → 点开始」这一路会卡在 toast。

(另注:needLogin() 实际很难触达 —— module.js:661 是 me = data.user || me,

me 一旦有值就不会被 null 覆盖。)

P2-2 死功能:弹幕整条链不可达

  • Plugin::clientSettings()(Plugin.php:235-236)硬编码 danmaku_on => false、

danmaku_cost => 0,后台没有任何入口能改;

  • module/danmaku.js:147-153 见 danmaku_on 为假即 return,连按钮都不建;
  • 即便建了,FrontController.php:171-172 的 danmaku 动作是 return ['ok' => true]; 空壳。
  • 结论:danmaku.js(162 行)+ spectate.js 里的弹幕按钮插入点 = 死代码。

要么接上后台开关与服务端扣费,要么删掉,别留「点了没反应」的控件(符合你的极简取向)。


三、规范偏差(不影响功能)

#位置问题规范
1views/index.php:36,38、views/admin.php:28$this->icon('xxx', N) 恒返回带内联 style="font-size:Npx" 的 <i>§7.3 P1 零内联;改用核心工具类 mn-fs-14/15/16。注:code_repo/daily_checkin/dice 等既有插件同样写法,非本插件独有
2assets/style.css:62,111,125var(--mn-radius-sm, 8px) / var(--mn-radius-md, 10px) —— 这两个变量全仓 0 处定义 → 永远走 fallback§11.7「拼错=静默失效」。仅影响圆角,无主题风险(不是颜色变量)
3FeidaoException.php行尾为 CRLF§11.19:UTF-8 无 BOM + LF(CRLF 例外清单只含 mod_system 4 个文件)
4AdminController.php:63-66(int)($_POST[$k] ?? 0) + 区间夹取 —— 当前表单字段名全部对得上,属潜伏雷:任一字段改名/漏传即静默写 0(entry_fee/kill_reward/daily_cap 三项无下限保护)§J.2(原 MEMORY §7):正解 array_key_exists($key, $_POST) 缺失即跳过
5module/buffs.js:39row.url.replace(/"/g,'%22') 未转义 ) / \;因 clientSettings() 不产出 buffs[].url,当前不可达§7.3 第 4 条(动态色值/URL 应走预定义 class 或 CSS 变量)


四、通过项(逐条对照规范)

安全(第一章三条硬规则 + 一五 8 条红线)

  • 规则 1 文件操作:插件内无任何 unlink/file_put_contents/move_uploaded_file 吃用户输入;

唯一的文件读是 FrontController::asset(),用 realpath() + 前缀比对 + \0/../ 拦截(:208-219)。

  • 规则 2 SQL:全部 prepare() + 绑定(Plugin.php:90-112 是唯一出口);

rankRows() 的 IN (...) 用 :u0..:uN 占位符绑定(Plugin.php:1332-1341),无 implode 拼 id。

  • 规则 3 XSS:视图全走 $this->e();JS 侧昵称/弹幕/榜单一律 textContent 或 esc()

(board.js:37,59、overlay.js:64,94,112-118、store.js:129、spectate.js:115)→ 零 XSS 面。

  • 红线 1/2:无 unserialize/eval/assert;无 $_SERVER['DOCUMENT_ROOT'](全 __DIR__/BASE_PATH)。
  • 红线 3:渲染钩子 nav_plugin_links.php 只 echo,无 die/exit/header。
  • 红线 5 积分:入场费/头像购买走 Points::deduct()(条件原子扣减,:653/:1250),

失败走 refundRun()/Points::award() 回滚,无 UPDATE users SET points=points-N 直写。

  • 红线 6/7:见上;Plugin::db() 由 PluginHelper::db() 提供,未覆盖 PDO 属性。
  • 红线 8 CSRF:FrontController.php:80-85 先登录后 CSRF(顺序正确,§11.28 ②);

AdminController.php:60,103 均 Csrf::verifyOrDie($_POST['csrf'] ?? '')。

  • IDOR:playerLookup()/roomSync()/roomFinish() 全部带 user_id 条件(:494/:968/:906);

settleRunForUser() 校验 run.user_id === userId(:819);roomFinish 用 hash_equals 校验 run_token(:912)。

门禁与权限

  • AdminController extends app\Controllers\Admin\BaseController → 构造函数门禁生效(含管理员二次验证)。
  • plugin.json 的 permissions: ["route:admin", "system:settings"] 与「注册后台路由 + 写全局设置」两个受控能力完全对应。

生命周期与结构

  • 目录名 feidao 无下划线 → 不需要 class_alias(§4.1 不适用)。
  • 建表双保险:init_after.php 走 SCHEMA_VERSION 内容比对门控(不是「文件存在」门控,§B.7 正确)

+ AdminController::index() 兜底 createTable()。

  • 路由前缀:route_register.php 写完整 /feidao;admin_route_register.php 不写 /admin(§11.22 正确)。
  • 4 个钩子全部已在 plugins/plugins_cache.json 注册(实测:feidao 条目 hooks 完整)。
  • nav_plugin_links.php 未声明任何顶层 function/class → 双触发安全(§9.3);图标 16px 正确(§9.3 实测值)。
  • 无 .bak/.tmp/.revert 残留;data/ 同时有 .htaccess + web.config 双防护。

i18n

  • 三语字典各 46 键,zh↔en、zh↔zh_tw 集合零差异;代码引用键 46 个,

零缺失、零冗余(§8 验收口径 = 键集合零差异,已满足)。

  • 例外:Plugin.php 内部 fail('请先登录') 等为硬编码中文(未走 i18n)——移植遗留,建议后续统一。

前端资源与编码

  • 所有资源 URL 拼 BASE_PATH + 版本号(?v=filemtime 或 ?v=CLIENT_VERSION);view.html 零内联 <script>/<style>/on*。
  • php -l 全 21 个 PHP 文件通过;除 FeidaoException.php 外全部 LF + 无 BOM。
  • 依赖的 --mn-text-link / --mn-bg-nav-hover / --mn-shadow-dropdown / --mn-bg-card 等均已定义(各 11 处)。
  • sw.js Cache-First 风险已规避:所有插件资源 URL 都带版本号(版本一变 URL 就变)。

五、建议处理顺序

  1. P0 观战卡死 —— 方案 B(两行门控 + 一个开关);改完直接跑

.workbuddy-ai/tmp/fd_spectate_probe.js 加一条断言「死亡后 room_sync > 0」。

  1. P1-1 看板 0 —— 一个字符串改动('playing' → 'running')。
  2. P1-2 卸载残留 —— 补 uninstall() 的 settings 清理。
  3. P2-2 死功能 —— 决定接上还是删掉(涉及「点了没反应的控件」,按你的取向应删)。
  4. 其余 P2 / 偏差项可随手一起收。

本报告未改动插件任何代码。等你确认方案后再动手。


六、修复记录 —— P0 方案 B 已落地(2026-09-29 晚)

6.1 改动集(3 个文件)

文件改动
assets/original/module/net.js:54 新增 let spectatingAfterEnd = false;;:615 resetSelf() 里随 ended 一起清零;:1543 syncRoom() 门控收窄;:1635 localTick() 门控收窄;:1721~1727 新增 setPostEndSpectate(on),:1728 导出
assets/original/module.js:758 主循环接线 net.setPostEndSpectate(!G.watcher && wantWatch);(wantWatch = watchingNow())
Plugin.php:59 CLIENT_VERSION 1.48.2-flinthub → 1.48.3-flinthub

setPostEndSpectate() 是边沿触发(状态没变直接 return,主循环每帧调也无开销),

置真时清掉 lastSync —— 让恢复后的第一拍立刻补一次同步,不用干等一个轮询间隔。

⚠️ 版本必须 bump:FrontController::asset() 发的是

Cache-Control: public, max-age=31536000, immutable,?v= 是唯一的破缓存手段

(module.js 再按 VERSION 去拉 module/net.js?v=…)。不 bump 等于没修。

6.2 语义:ended 从「停止一切」收窄为「停止自己参战」

状态`spectatingAfterEnd`行为
活着false正常
死了 · 死亡卡片盖着(未点观战)false仍然冻结(原行为,不空跑、省流量)
死了 · 观战条拉起true逻辑帧恢复 + room_sync 继续轮询 → 世界会动
纯观战者(G.watcher)false不受影响,仍走 watch_sync

6.3 验证

.workbuddy-ai/tmp/fd_spectate_probe.js —— 31 条断言全 PASS(vm 沙箱加载未改动真码):

  • 0 组:修复锚点守卫(防被误回滚);并从 module.js 真实源码抽出 watchingNow() 与接线表达式求值,

五种 UI 状态交叉验证「真实表达式 = 手抄语义 = 期望值」。

  • C 组(核心验收):死亡后点观战 → frameNo 5 → 36、room_sync 1 → 5,

且恢复后的第一拍就补了一次同步(lastSync 清零生效)。

  • E 组(反证):内存里把两处门控改回旧写法 → 开观战仍 frameNo 5 → 5、room_sync 零增量

→ 复现「整个游戏不动了」,证明这两行是承重的。

  • B / D / F 组:未开观战仍冻结;退出观战重新冻结;restart() 清掉观战态(再死一次会重新冻结)。
  • G 组:纯观战者对照,room_sync 恒 0,两条路互不干扰。

探针首轮 C 组假 FAIL,真因是探针自己:只调 finish() 没把自己置死,

而接线表达式判的是 !G.self.alive。补 die() 复刻真实死亡落点(kill() 先置死 → onDeath() 再 finish())后全绿。

HTTP 冒烟(php -S + 临时会话,用完已按精确 id 删除):

  • GET /feidao/asset?file=original/module/net.js&v=1.48.3-flinthub → 200,spectatingAfterEnd ×6
  • GET /feidao/asset?file=original/module.js&v=1.48.3-flinthub → 200,setPostEndSpectate ×1
  • GET /feidao(登录态)→ 200,data-version="1.48.3-flinthub"
  • 目录穿越 ?file=original/../FrontController.php → 404(防护未被这次改动破坏)
  • node --check 三个 JS 通过;php -l Plugin.php 通过;全角变量检查通过

6.4 已知代价(未处理,需你定)

  1. 死者在观战时本地 updateBots() 继续跑(本插件电脑玩家是各客户端本地模拟,不进 roomPayload)

→ 与「纯观战者看不到本地电脑玩家」的观感差异依然存在(只有方案 A 能消掉)。

  1. 死者观战期间 room_sync 持续 → last_seen 一直新鲜 → 在 onlineCount() / pickArenaRoomId()

的容量口径里继续占一个名额(改前是冻结 15 秒后自动让位)。

要收掉须改服务端容量口径(只数 alive)—— 另一处改动,等你确认。

6.5 本次未动

  • plugin.json 的 version 仍是 1.0.0(从未维护过)。
  • P1-1 / P1-2 / P2-1 / P2-2 与 5 条规范偏差 均未动。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 1 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录