[审计]site_stats 插件审计报告

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

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

site_stats 插件审计报告

  • 审计对象:plugins/site_stats/(站点统计 v1.0.0)
  • 审计日期:2026-09-24
  • 审计依据:docs/插件开发规范.md(1292 行,2026-09-22 版)+ 核心实际代码交叉验证
  • 审计方式:静态代码审计 + 运行时实测(php -S + 伪造会话)+ A/B 反证
  • 结论:2 项 P0(含 1 项越权、1 项功能必然失败),必须修复后才可上线

⚠️ 插件自带的 说明.md 末尾有一张「落地检查清单」,自称 13 项全部 ✅。

本次审计证明其中至少 2 项与实现不符(鉴权、CSRF),该自述文档不可作为审计依据。


一、结论摘要

编号级别问题位置状态
P0-1🔴 越权普通会员被判定为管理员,可访问后台统计页AdminController.php:45-62已实测确证
P0-2🔴 功能坏清理按钮调用 Csrf::verifyOrDie() 缺参 → 必然 500AdminController.php:34已实测确证
P1-1🟠 鉴权层绕过后台二次验证(admin_verified_at)AdminController.php:10,26静态确证
P1-2🟠 卫生plugin.json / 说明.md 为 CRLF(§11.19 要求 LF)2 个文件已实测
P2-1🟡 健壮性建表会话标记无「数据层版本号」hook/init_after.php:8静态确证
P2-2🟡 规范db() 自愈每请求跑 DDL(§11.15 不建议)Plugin.php:10-18已实测(影响可忽略)
P2-3🟡 准确性ipHash() 信任可伪造的 X-Forwarded-ForPlugin.php:89静态确证
P2-4🟡 准确性趋势日期依赖 SQLite localtimePlugin.php:143静态确证
P2-5🟡 一致性传死变量 currentNav,未用 __nav_activeAdminController.php:20已实测

合规通过项:class_alias、生命周期三方法语义、SQL 参数绑定、SQLite DDL 语法、i18n 三语零差异、主题变量、图标名、零内联、视图转义、CSRF 表单字段、permissions 声明、无 BOM、无残留文件、钩子触发源收敛与节流。


二、P0-1 越权:普通会员可看全站统计

2.1 缺陷代码

plugins/site_stats/AdminController.php:44-62:

/** 管理员判定(group_id=1 / role=admin / is_admin) */
private function checkAdmin(): bool
{
    $u = \app\Helpers\Auth::getCurrentUser();
    ...
    if (!empty($u['is_admin'])) { return true; }        // ① 死代码
    if (($u['role'] ?? '') === 'admin') { return true; } // ② 只有 id=1 满足
    return (int)($u['group_id'] ?? 0) === 1;             // ③ ← 越权根源
}

2.2 为什么错

核心的正确口径(app/Helpers/Auth.php:20-27):

public static function isAdmin(): bool
{
    $user = self::getCurrentUser();
    return $user !== null
        && ($user['role'] ?? '') === 'admin'
        && (int)($user['group_id'] ?? 1) === 4;   // ← 要求 group_id === 4
}

数据库实际取值(data/meta/business.sqlite):

user_groups 表:
  id=1  普通会员   is_default=1     ← 默认用户组
  id=2  VIP会员
  id=3  版主
  id=4  管理员                       ← 真正的管理员组
  id=5  封禁账户

users 表分布:
  group_id=1, role=user    → 284 人
  group_id=4, role=admin   →   1 人(id=1 flinthub)

三点致命错误:

  1. ③ 的 group_id === 1 指向的是「普通会员」默认组,不是管理员组(管理员是 4)。

在 return 短路下,284 个普通会员全部通过判定。

  1. ① 的 is_admin 字段 users 表根本不存在(已查 PRAGMA table_info(users)),

该分支是死代码,empty() 恒为 true → 取反恒 false。

  1. 插件把三个条件用 OR 串起来,而核心是 AND(role=admin 且 group_id=4)——

这是「放宽」而非「等价实现」。

2.3 实测证据

探针会话(user_id=2,即「虚拟化大师」role=user, group_id=1):

GET /admin/site-stats
  未登录            -> HTTP 302(跳 /login)           ✅ 正常
  user_id=2 普通会员 -> HTTP 200, 15827 字节           ❌ 越权
                        [统计面板已渲染][含PV数据]
  user_id=1 管理员   -> HTTP 200, 15820 字节           ✅ 正常

普通会员返回 200 且完整渲染出统计面板(含 PV/UV 数值),与管理员页面无差别。

2.4 影响面

任何注册用户(本机 284 人,生产同量级)访问 /admin/site-stats 即可获得:

  • 全站今日 / 近 7 天 / 近 30 天 PV、UV
  • 热门页面 Top 10 的完整路径 —— 包含 /admin/*、/admin/verify 等后台路径,

泄露后台功能面

  • 近 7 天流量趋势

虽未直接泄露帖子正文,但属于明确的越权信息泄露;且该页面是 POST 写操作的入口

(清理数据),一旦 P0-2 修好,越权用户即可清空全站统计数据。

2.5 正确范式(同项目已有先例)

plugins/vn_block/AdminController.php:23-39(2026-09-23 刚审计修复):

public function settings()
{
    $this->requireAdmin();
    $this->requireAdminVerified();
    ...
}

其文件头注释明确写了理由:

鉴权仍在控制器内用基类 requireAdmin() + requireAdminVerified() 完成

(与后台 BaseController 同口径:管理员 + 30 分钟二次验证)。

2.6 修复

public function index()
{
    $this->requireAdmin();            // 替代 requireLogin() + checkAdmin()
    $this->requireAdminVerified();
    $stats = Plugin::getStats();
    $this->view('plugins/site_stats/admin', ['stats' => $stats, '__nav_active' => 'plugins']);
}

public function purge()
{
    $this->requireAdmin();
    $this->requireAdminVerified();
    \app\Helpers\Csrf::verifyOrDie($_POST['csrf'] ?? '');   // 见 P0-2
    ...
}

并删除自造的 checkAdmin() 方法(AdminController.php:44-62)。

行为差异提示:requireAdmin() 未通过时跳 /,而原实现返回 404。

跳 / 是项目现有范式(vn_block 同),如希望保留 404 语义需自行处理。


三、P0-2 清理按钮必然 500

3.1 缺陷代码

plugins/site_stats/AdminController.php:34:

// 写操作必须校验 CSRF(红线 8)
\app\Helpers\Csrf::verifyOrDie();      // ← 少传了 token

而核心签名(app/Helpers/Csrf.php:60)是必填参数:

public static function verifyOrDie($token): void
{
    if (!self::verify($token)) { http_response_code(403); echo '

403 - CSRF 验证失败

'; exit; } }

3.2 实测证据

POST /admin/site-stats/purge  (管理员会话 + 正确 csrf)
  -> HTTP 500, 125 字节

protected/error.log:
[24-Sep-2026 12:00:17 Asia/Shanghai] 500 Error: Too few arguments to function
app\Helpers\Csrf::verifyOrDie(), 0 passed in
D:\phpstudy_pro\WWW\plugins\site_stats\AdminController.php on line 34
and exactly 1 expected in D:\phpstudy_pro\WWW\app\Helpers\Csrf.php:60

A/B 反证(同一函数,仅改传参):

[A] 正确传参 Csrf::verifyOrDie("abc") : 通过,无异常
[B] 缺参     Csrf::verifyOrDie()      : ArgumentCountError
                                         Too few arguments ... 0 passed ... exactly 1 expected

3.3 影响面

  • 后台「立即清理」按钮 100% 失败(任何点击都是 500)
  • 插件宣称的「后台手动清理」功能从未可用
  • 概率自动清理(route_after_dispatch 里 1% 概率的 purgeOlderThan(90))不受影响,

因为它不经过 CSRF 校验 —— 所以数据不会无限增长,但用户无法手动控制

3.4 修复

\app\Helpers\Csrf::verifyOrDie($_POST['csrf'] ?? '');

核心自身与 vn_block 都是这个写法(如 Admin\BaseController::batchMoveIds() 第一行

即为 Csrf::verifyOrDie($_POST['csrf'] ?? '');)。

视图侧 views/admin.php:105 的 <input type="hidden" name="csrf" value="..."> 是正确的

—— Controller::view() 的 defaults 里有 'csrfToken' => Csrf::token()(app/Core/Controller.php:41),

会自动注入。问题只在控制器侧的调用。


四、P1 级问题

P1-1 绕过后台二次验证

核心后台的门禁在 app/Controllers/Admin/BaseController.php:17-40 的构造函数里:

public function __construct()
{
    if (!Auth::isAdmin()) { ... 跳 /login ... exit; }

    $verifyTimeout = max(60, min((int)Settings::get('admin_verify_timeout', '1800'), 604800));
    if ($uriPath !== '/admin/verify') {
        $verifiedAt = $_SESSION['admin_verified_at'] ?? 0;
        if ($verifiedAt < time() - $verifyTimeout) { ... 跳 /admin/verify ... exit; }
    }
}

插件继承的是 app\Core\Controller(规范 §5.3 允许),不会触发上述构造函数,

因此必须在控制器内自己补等价门禁。site_stats 只做了 requireLogin() + 口径错误的自造判定,

双重缺失:既无正确管理员判定,也无二次验证。

修复见 §2.6(requireAdmin() + requireAdminVerified())。

P1-2 编码卫生:CRLF

§11.19 要求插件内所有文本文件 UTF-8 无 BOM + LF,例外清单只有 plugins/mod_system/ 下 4 个文件。

实测(按字节数 \r\n):

文件BOMCRLF 处数判定
plugin.json无15❌ 应为 LF
说明.md无751❌ 应为 LF
其余 13 个文件无0✅

修复:两个文件转 LF(转完复核字节数)。


五、P2 / 观察项

P2-1 建表会话标记无「数据层版本号」

hook/init_after.php:8:

if (empty($_SESSION['_site_stats_schema'])) {
    \Plugin\SiteStats\Plugin::buildSchema();
    $_SESSION['_site_stats_schema'] = 1;   // 只判「存在」,不比对内容
}

项目红线:门控标记必须带数据层版本号(比对内容,而非判存在),否则日后新增表 / 索引时,

老会话永不重跑 buildSchema(),新表静默建不出来。

当前实际无影响 —— 因为 db() 的每请求自愈(P2-2)会把表兜住。但若按 P2-2 的建议

把 db() 自愈发掉,此处就会变成真缺陷。两处应一起改。

P2-2 db() 自愈每请求跑 DDL —— 形式不合规,实测影响可忽略

Plugin.php:10-18 的 $schemaReady 是请求级静态变量,每请求重置:

public static function db(): \PDO
{
    $pdo = \app\Helpers\Plugin::db('site_stats');
    if (!self::$schemaReady) { self::buildSchema($pdo); self::$schemaReady = true; }
    return $pdo;
}

而 record() 挂在 route_after_dispatch(每页触发)→ 每个页面请求都执行

1 条 CREATE TABLE IF NOT EXISTS + 2 条 CREATE INDEX IF NOT EXISTS。

§11.15 明确:「别在控制器每请求跑 DDL;ensureSchema() 本身无缓存门控,

要在插件侧加戳文件 / 会话守卫。」

⚠️ 但实测开销极小,不应夸大:本机 200 次基准(php8.2.9nts):

操作单次耗时
buildSchema 3 条 DDL0.040 ms
record() 1 条 INSERT21.245 ms
DDL 占每请求 SQL 总耗时0.2 %

CREATE ... IF NOT EXISTS 在对象已存在时几乎零成本。这是形式合规问题,不是性能事故。

若要修,建议与 P2-1 合并:改成「戳文件 + 数据层版本号」门控,顺带把 §11.4 第 24 条

(每页 hook 查询 ≤ 5 次)的余量从 4/5 降到 1/5。

P2-3 ipHash() 信任可伪造的 X-Forwarded-For

Plugin.php:89:

$ip = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '');

X-Forwarded-For 是客户端可控头,且插件未判断请求是否真的经过可信代理。

任何人构造该头即可伪造出无限多个「不同 IP」→ UV 可被随意刷高。

影响仅限统计准确性(IP 只存哈希,无泄露风险)。建议:无反向代理时只取 REMOTE_ADDR;

有代理时校验代理来源 IP 后再取 XFF 链。

P2-4 趋势日期依赖 SQLite localtime

Plugin.php:143:

date(created_at, 'unixepoch', 'localtime') AS day

localtime 取的是 SQLite 进程的系统时区,与应用时区(Asia/Shanghai)可能不一致。

服务器迁移或时区配置变化时,趋势图的日期会整体偏移。建议在 PHP 侧按应用时区归日。

P2-5 传死变量 currentNav,未用 __nav_active

AdminController.php:20:'currentNav' => 'admin'

实测:

  • currentNav 全站只有一处赋值(app/Core/Controller.php:42),零消费点
  • 布局实际读的是 __nav_active(app/Views/layouts/main.php:7:

$navActive = $this->getData('__nav_active', ''))

  • 20 个插件中,19 个统一传 __nav_active => '<插件标识>',只有 site_stats 传 currentNav

当前效果:导航高亮不生效(无功能损坏,但与其他插件不一致)。

建议改为 '__nav_active' => 'plugins'(与 bitiful_storage / vn_block 一致)。

观察项:历史残留探针会话

sessions.sqlite 中有 189 行 vnpnp* 前缀的会话,是 2026-09-23

props / vn_postname 审计遗留的探针数据(csrf 为 32 个 b、含 admin_verified_at)。

本次审计未删除(不属于 site_stats 范围,需你确认)。建议清理:

按 id LIKE 'vnpnp%' 精确删除,或等过期自动回收。


六、合规通过项(逐条实测)

检查项依据结果
目录名含下划线 → class_alias§4.1✅ Plugin.php:174-176,第三参 false,先判 class_exists 幂等
activate() 建表 / deactivate() 不删 / uninstall() 只删独立库表§4.1✅ 语义正确
所有 SQL 参数绑定规则 2✅ 无字符串拼接
SQLite DDL 语法§10.3✅ INTEGER PRIMARY KEY / TEXT / IF NOT EXISTS
独立库 + data/ 防护文件§10.1✅ .htaccess + web.config 齐备
i18n 键三语一致§八✅ 18 键 × 3 语,键集合零差异、无缺失、无冗余
主题变量§11.7✅ 用到的 7 个 --mn-* 在 9 套主题 + modern.css + xenforo.css 全部存在
图标名有效§6.2✅ chart-line 在 TemplateCompiler.php:786 的 $map 中
零内联§7.3✅ 无 <style>/<script>/on*;唯一 style="--ss-h:N%" 属 §7.3 第 4 条明确合规的「内联 CSS 变量注入」
视图输出转义规则 3✅ 全部经 $this->e();数字经 (int)
资源引用§7.1✅ 拼 BASE_PATH + ?v=filemtime
页面级 CSS§6.1✅ 走 section('css')
permissions 声明§三✅ ["route:admin"] 与 admin_route_register 匹配
钩子 $router 判空§9.5✅ admin_route_register.php:4
钩子顶部先判空/收敛§9.5✅ route_after_dispatch 做了 SAPI/方法/路径/爬虫四重收敛
每页触发钩子做节流§11.20✅ 同路径 60 秒会话级节流
编码无 BOM§11.19✅ 15 个文件全部无 BOM
无 .bak/.tmp 残留§二✅
说明.md 存在§11.19✅ 规范认可

额外正面发现:爬虫过滤实测有效 —— 本次审计用 curl(UA 含 curl/)发起的

全部 HTTP 请求未被记入统计表(表中 15 行均为 03:42–03:59 的真实浏览),

证明 route_after_dispatch.php:36 的 UA 黑名单生效。


七、修复清单(建议顺序)

#文件改动级别
1AdminController.php:10-15, 26-31requireLogin()+checkAdmin() → requireAdmin()+requireAdminVerified()P0
2AdminController.php:44-62删除自造 checkAdmin() 方法P0
3AdminController.php:34verifyOrDie() → verifyOrDie($_POST['csrf'] ?? '')P0
4AdminController.php:20currentNav → __nav_active => 'plugins'P2
5plugin.json / 说明.mdCRLF → LFP1
6Plugin.php:10-18 + hook/init_after.php:8建表门控改「戳文件 + 数据层版本号」P2
7Plugin.php:89XFF 仅在可信代理后取用P2
8Plugin.php:143趋势归日改由 PHP 按时区计算P2
9说明.md更新自述(当前「避免每请求跑 DDL」与实际不符;清单自称全 ✅ 亦不实)—


八、复现步骤

# 1. 注入探针会话(普通会员 user_id=2 / 管理员 user_id=1)
#    写入 data/meta/sessions.sqlite,data = 'user_id|i:2;csrf|s:64:"c×64";csrf_time|i:;'

# 2. 启动本地服务器
php -S 127.0.0.1:8899 -t "D:/phpstudy_pro/WWW"

# 3. 越权验证(预期 200 = 越权成立)
curl -s -o /dev/null -w "%{http_code}\n" --noproxy '*' \
  -H "Cookie: PHPSESSID=<普通会员会话>" http://127.0.0.1:8899/admin/site-stats

# 4. CSRF 缺参验证(预期 500)
curl -s -o /dev/null -w "%{http_code}\n" --noproxy '*' -X POST \
  -H "Cookie: PHPSESSID=<管理员会话>" \
  --data-urlencode "csrf=cccc...(64)" --data-urlencode "days=3650" \
  http://127.0.0.1:8899/admin/site-stats/purge

# 5. 查日志确认
tail -3 protected/error.log

探针清理记录(本次审计已执行):

  • 注入探针会话 2 行(wbssprobe*)→ 已按精确 id 删除,残留 0 行
  • 临时脚本 .workbuddy-ai/tmp/ss_probe_seed.php、ss_bench.php → 已 unlink
  • 基准测试插入的 200 行(path='/probe-bench')→ 已按内容标记删除,统计表恢复 15 行
  • 统计表 15 行真实数据未改动(全部为 03:42–03:59 的历史浏览记录)

九、修复与增强结果(第 1 批交付,2026-09-24 12:30)

本节由实施阶段追加。第 1 批范围见 site_stats插件功能增强建议_2026-09-24.md §十。

9.1 修复项(P0/P1/P2 全量)

#项状态证据
P0-1越权:普通会员可看全站统计✅ 已修AdminController 三个 action 统一 requireAdmin()+requireAdminVerified(),删除自造 checkAdmin()。实测:会员会话 GET → 302 /;管理员 → 200
P0-2清理按钮 500(verifyOrDie() 缺参)✅ 已修verifyOrDie($_POST['csrf'] ?? '')。实测:管理员+正确 token → 302 ?purged=0;管理员+错 token → 403;会员 → 302(DELETE 未执行,表行数不变)
P1-1绕过后台二次验证✅ 已修同上,requireAdminVerified()
P1-2CRLF 编码卫生✅ 已修plugin.json、说明.md 转 LF
P2-1建表门控无「数据层版本号」✅ 已修SCHEMA_VERSION=3 + 戳文件 protected/.site_stats_schema_v3.stamp(内容比对,非判存在);旧戳自动清理
P2-2db() 每请求跑 DDL✅ 已修is_file(stamp) 短路;热路径实测 0.0014 ms
P2-3ipHash() 信任可伪造 XFF✅ 已修仅当 REMOTE_ADDR ∈ 可信代理 CIDR 才取 XFF(复刻 AuditLog::TRUSTED_PROXIES 口径)
P2-4趋势归日依赖 SQLite localtime✅ 已修改用 date(created_at + :off),$off = date('Z');并修正零填充方向(升序,与 ORDER BY day ASC 同向)
P2-5传死变量 currentNav✅ 已修改 __nav_active => 'plugins'(经 controller view() 数据传入,_sidebar 才读得到)

9.2 增强项(第 1 批)

#项说明
环比对比6 张卡片各带「较上期」+ 涨/跌/平箭头 + 上期值Plugin::card();prev=0 显示「新增」而非除零
外链三件套①来源类型分布(direct/internal/search/social/external + 占比条)②外链来源域名 Top N ③外链落地页getSourceStats(7,20)
域名白名单ss_config.site_hosts + 后台可编辑 + 「保存并重新分类」全量重算解决「本站多入口 → 站内跳转被误判外链」
后台 UI 改造复用核心 .admin-container + admin/_sidebar + .admin-tabs 4 tab;新增 assets/style.css + assets/script.js用户要求「参考其他插件 + tab + 左侧导航」

9.3 验证结果(Task #11)

检查结果
三语键集合一致性✅ zh/en/zh_tw 各 53 键,键集合零差异 + 键序一致 + 无重复键 + 无未引用键
引用键覆盖✅ 源码引用 53 键在三语字典全部存在
零内联复扫✅ 仅 2 处 <script src> 引用;无 <style> 块、无 on*、无静态 style=""(仅 --ss-h/--ss-w 变量注入,§7.3 合规)
双主题走查✅ 默认主题 vs ?preview_theme=modern-night(暗夜星辰):.ss-admin 内容块 sha256 一致(27cb0acef86bc9d9);内容段内 --mn-* 命中 0
语法/编码✅ 全文件 php -l 通过;UTF-8 无 BOM;LF
残留文件✅ 无 .bak/.tmp/.revert/.orig
数据保全✅ 探针数据 0 残留;curl 被 bot 正则过滤 → HTTP 探针未污染数据表

9.4 需老搭档决定的两项

  1. 前置页面缓存导致的 PV 偏低:纯游客 GET /、/forum、/forum/category/{id} 命中 data/runtime/pages/ 静态缓存后直接 exit(早于 init.php),插件钩子不执行 → 这几类列表页的游客 PV 会计漏。详情页不受影响。是否接受?(可选缓解:缓存命中前挂一个轻量计数)
  2. 189 行历史残留探针会话(sessions.sqlite 中 id LIKE 'vnpnp%',来自 2026-09-23 的 props/vn_postname 审计)—— 是否删除?(本次未动)
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 1 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录