FlintHub app/ 静态代码审计报告
app/ 除 Views/ 外的 60 个 PHP 文件(约 23,354 行)。
视图层 app/Views/(63 文件 / 7,384 行)的审计见 docs/app视图层审计报告_2026-09-18.html,
结论为「严重 0 / 高危 1 / 中危 17 / 低危 24」。两轮的交叉项(勘误):本报告 H-4(搜索页反射 XSS)与视图层的 V-H01 是同一漏洞 —— 本报告的 H-4 已完整记录两层成因(输入层「
$type 无白名单」+ 输出层「TemplateCompiler.php:408 对 href 不做 htmlspecialchars」)与修复方案。
视图层的增量是端到端实测确认,并实测出更强的载荷(纯 onmouseover 属性注入即可,无需 <script>)与「/ 开头分支同样可注入」。app/ 全量合计(两轮合并、已去重):95 项发现(严重 1 / 高危 8 / 中危 33 / 低危·提示 53),覆盖 123 个 PHP 文件 / 约 30,738 行。 (初稿曾报 97 项,去重 2 处:H-4↔V-H01 同一漏洞、
siteName 双重转义两轮重复记录。误报核查明细见视图层报告 7.1 节。)buildWhere 是真正的 fail-closed 白名单解析器、权限点有列名白名单、密码学用法规范(GCM 版本化密文、hash_equals 全站覆盖、token 只存 sha256)、缓存统一 tmp+rename 原子写、SSRF 有 DNS 复检与逐跳校验。但存在 1 个可直接利用的严重漏洞:HTML 净化器的白名单可被
<style>/<script> 的 CDATA 节点完全绕过,实测产出存储型 XSS,且当前 CSP 含 'unsafe-inline' 无法兜底 —— 任何注册用户发帖即可在他人(含管理员)浏览器执行任意 JS。另有 8 项高危:数据层跨库写路径零事务(桶行 / 主索引 / extern 正文三步非原子,可产生不可达帖子与孤儿正文);封禁体系整体失效(
users.status 全链路不校验);附件上传省略参数即绕过版块权限;搜索页反射 XSS;计数器已实测系统性漂移;后台审计日志系统性缺失(5 个控制器零记录);插件权限强制模式可绕过;外置正文存储的锁不一致 + 缓存不失效(可致编辑静默不生效、正文空白)。核心矛盾:安全控制与真实代码路径存在脱节——机制大多写对了,但在若干关键出口处没有被正确调用,或存在「当前安全、后续必被打破」的薄弱写法。
一、风险分布
| 类别 | 严重 | 高 | 中 | 低/提示 | 小计 |
|---|---|---|---|---|---|
| 输出净化 / XSS | 1 | 1 | 1 | 2 | 5 |
| 认证 / 授权 / 越权 | — | 2 | 2 | 5 | 9 |
| 数据一致性 / 并发 | — | 3 | 6 | 6 | 15 |
| 审计 / 不可抵赖性 | — | 1 | 2 | 1 | 4 |
| 注入 / 密钥 / 配置 | — | 1 | 2 | 4 | 7 |
| 性能 / 查询计划 | — | — | 3 | 5 | 8 |
| 兼容性 / 健壮性 / 错误处理 | — | — | 1 | 7 | 8 |
二、审计范围与方法
覆盖范围(app/ 下除 Views 外全部)
| 模块 | 文件数 | 内容 |
|---|---|---|
app/Core/ | 7 | Autoloader / Controller / Database / Model / Router / SessionHandler / TemplateCompiler |
app/Controllers/ | 14 | 前台控制器(含 ApiController 687 行、AuthController 546 行) |
app/Controllers/Admin/ | 17 | 后台控制器(含 DatabaseController 1002 行) |
app/Helpers/ | 26 | 认证/权限/净化/缓存/插件/积分/搜索/邮件/上传等 |
app/Models/ | 10 | Thread(1250) / User(633) / Post(446) / Category / Blog 等 |
app/SplitDB/ | 15 | Schema(905) / ExternStorage / ShardRouter / QueueConsumer 等分片引擎 |
app/init.php | 1 | 应用引导(Session / i18n / 维护模式) |
app/Views/(93 个视图文件,按要求排除)。但为验证漏洞链路的终点(例如净化产物是否被裸 echo),对 Views/thread/show.php、Views/search/index.php、Views/layouts/main.php 做了少量只读引用确认,未做视图层审计。
审计维度
安全(注入 / XSS / CSRF / 认证 / 授权 / 越权 / 文件与路径 / SSRF / 密码学 / 会话 / 错误信息泄露)、业务逻辑正确性、并发与事务边界、数据一致性与计数器、分片路由与外置存储、性能与查询计划、PHP 8.4/8.5 兼容性、健壮性与错误处理。
方法
- 六路并行深审:按「Core+init / 前台控制器 / 后台控制器 / Helpers 安全组 / Helpers 平台组 / Models+SplitDB」分片,逐文件通读。
- 关键结论强制回读复核:所有高危以上结论均由本报告作者亲自回读源码或运行探针复现,未采信未验证的转述。
- 只读实证:在本机 PHP 8.2.9 上运行净化/模板/限流探针;对真实 SQLite 库执行只读
SELECT与EXPLAIN QUERY PLAN;读取protected/error.log与缓存目录。未写入任何业务数据、未运行任何破坏性命令。 - 标记说明:已实测复现 = 有真实输入输出证据;源码确认 = 逐行回读确认逻辑;推断 = 有据但未实测,需按建议复核。
三、严重问题(1 项)
严重 S-1 HTML 净化白名单被 <style>/<script> CDATA 节点完全绕过 → 存储型 XSS 已实测复现
类别:输出净化 / XSS 验证:本机 PHP 8.2.9 实测
根因。libxml2 的 HTML 解析器把 style/script 的内容建成 XML_CDATA_SECTION_NODE(nodeType=4),而不是文本节点:
// 实测节点树
style(元素, nodeType=1)
└── #cdata-section (nodeType=4) 值 = <img src=x onerror=alert(1)>而白名单循环用 $xpath->query('//*') 遍历 —— 它只返回元素节点,CDATA 节点永远不会进入清洗流程。随后 style 元素因不在 $allowedTags 被 unwrap,其 CDATA 子节点被「提」到 body 顶层;输出循环用 $dom->saveHTML($child) 序列化,而 HTML 模式下 saveHTML 对 CDATA 节点不做实体转义,于是内容原样进入响应。
实测证据(真实输入输出):
IN : <style><img src=x onerror=alert(1)></style>
OUT: <img src=x onerror=alert(1)> ← 原样输出,未转义
IN : <script><svg onload=alert(1)></script>
OUT: <svg onload=alert(1)>
IN : <div><style><img src=x onerror=alert(1)></style></div>
OUT: <div><img src=x onerror=alert(1)></div>
IN : <style>body{}</style><b>ok</b>
OUT: body{}<b>ok</b> ← CSS 正文也整体外泄
# 完整渲染链路
Content::formatPostContent('<style><img src=x></style>')
=> <img src=x>Views/thread/show.php:136,273、Views/blog/detail.php:51 均为裸输出,Content::excerpt()(列表页摘要)同样泄漏,因此列表页、搜索页、博客详情、摘要全线触发。当前
app/Core/Controller.php:77 的 CSP 含 script-src 'unsafe-inline',内联事件处理器照常执行,CSP 完全无法兜底。后果:窃取会话 / CSRF token,以受害者身份(含管理员)执行任意后台操作 —— 与后文「后台审计缺失」「插件启停即代码执行」组合,可直接形成完整接管链。
① 在白名单循环之前整块删除 raw-text 元素(不是 unwrap,是 removeChild):
// app/Helpers/Content.php —— loadHTML 之后、白名单循环之前
foreach (iterator_to_array($xpath->query(
'//*[self::style or self::script or self::xmp or self::plaintext or self::iframe or self::noscript]'
)) as $n) {
$n->parentNode->removeChild($n);
}② 输出侧加兜底,CDATA 节点显式转义(防未来再出现同类节点):
foreach ($body->childNodes as $child) {
$clean .= ($child->nodeType === XML_CDATA_SECTION_NODE)
? htmlspecialchars($child->nodeValue, ENT_QUOTES, 'UTF-8')
: $dom->saveHTML($child);
}③ 回归验证:把上述 4 组载荷加入 .workbuddy-ai/tmp/test_pre_color.php 同款回归集,断言输出中不含 onerror/onload。
注:仅加 ① 后实测同一载荷输出为空串,止血充分。
四、高危问题(8 项)
高 H-1 数据层跨库写路径零事务:桶行 / 主索引 / extern 正文三步非原子 已实测复现
类别:数据一致性 验证:
grep 'begin()|commit()' 对 Thread.php / Post.php 零命中// Thread::insert —— 顺序即"原子性",任一步失败无补偿
$externPath = ExternStorage::write(...); // :64 正文已落盘,不可回滚
$bdb->prepare('INSERT INTO topic (...)')->execute(); // :73-95 写桶
$mi->prepare('INSERT INTO topic_index ...')->execute();// :99-115 写主索引
// Thread::hardDelete —— 先删桶行,再删索引行
$bdb->prepare('DELETE FROM topic WHERE id = :id')->execute(); // :704-705
Schema::mainIndexDb()->prepare('DELETE FROM topic_index WHERE id = :id')->execute(); // :706影响(4 类分裂态):
- extern 写成功、桶 INSERT 失败 → 孤儿正文永久占盘且无索引指向,清理工具定位不到;
- 桶行成功、主索引失败 → 帖子在桶里存在但
topic_index无行:列表搜不到、find()404,却占桶容量且无法被删除工具定位(「幽灵帖」); hardDelete桶行已删、索引行未删 → 详情页 404,但getDeleted()仍列出该帖,需重跑才收敛;batchDelete逐桶删完再删索引,中途异常 → 部分桶已删、索引全在的分裂态。
begin() 能解决的。而且 ThreadController.php:115 那个 begin() 只覆盖 business.sqlite,并不包含桶与索引的写入 —— 它只带来了锁开销,没带来原子性(见 M-6)。① 新建
data/runtime/wal_journal.sqlite,每步前写 intent(op / target / bucket_path / extern_path / state),成功后标 done;② 在
init.php 或 cron_trigger.php 增加扫描器,对未完成 intent 做幂等前滚/回滚(删除 extern 文件、删除桶行均可重放);③ 最小可行改动(可先上线):统一写入顺序为「extern → 桶行 → 索引行」,并在每步失败时于
try/catch 内立即补偿前序产物(@unlink + DELETE),把「静默分裂」降级为「可观测失败」。
高 H-2 封禁体系整体失效:users.status 全链路不校验 + 记住我自动登录绕过封禁 已实测复现
类别:认证 / 授权 验证:全仓 grep 确认非 Views 代码无任何
status 判定点
// Auth.php:52 —— status 被查出来,然后被彻底遗忘
'SELECT id, username, ..., status, ... FROM users WHERE id = :id'
// 全仓 grep 结果(非 Views):
// app/Models/User.php:126 ← 仅后台"列表筛选"
// app/Views/admin/user_edit.php:60 ← 仅下拉框回显
// ⇒ 认证、授权、发帖、上传、后台准入 全部不看 status
// RememberMe.php:46-51 —— 自动登录只校验"用户存在"
$user = $db->fetchOne('SELECT * FROM users WHERE id = :id', [':id' => $userId]);
if (!$user) { self::clearCookies(); return; }
// :92 直接恢复会话,无 status / 封禁组判定
$_SESSION['user_id'] = (int)$user['id'];影响:管理员在「用户编辑」把 status 设为 banned 后,该用户仍可正常登录、发帖、回帖、上传附件,后台呈现「已封禁」假象。真正生效的只有 mod_system 插件的 mod_bans 表,且其钩子 route_before_dispatch.php:16 只拦非 GET 写操作。
更严重的是:被封禁用户若此前勾选过「记住我」,携带 remember_token 访问任意页面即由 init.php:86 自动恢复会话 —— 封禁完全失效,且该路径不触发登录钩子(mod_system 的拦截也一并绕过)。
// ① Auth::getCurrentUser() 返回前统一判定,并销毁会话
if (($user['status'] ?? 'active') !== 'active') {
$_SESSION = []; session_destroy();
\error_log("Auth: 拒绝非 active 用户 uid={$user['id']} status={$user['status']}");
return null;
}
// ② RememberMe::check() 恢复会话前复用同一判定,并触发封禁钩子
\app\Helpers\Plugin::hook('auth_login_check', ['user' => $user]);
if (($user['status'] ?? 'active') !== 'active') { self::removeToken(...); self::clearCookies(); return; }
// ③ AuthController::login() 成功分支补一次 status 复核(防"登录瞬间被改状态"的窗口)高 H-3 /api/upload 省略 category_id 即绕过 can_attach 版块上传权限 已实测复现
/api/fetch-image)类别:越权 验证:逐行回读源码确认判定结构
} elseif (isset($_POST['category_id']) && \ctype_digit((string)$_POST['category_id'])
&& (int)$_POST['category_id'] > 0
&& !\app\Helpers\Permission::canAttach((int)$_POST['category_id'], (int)($_SESSION['user_id'] ?? 0))) {
// ↑ 权限校验被写成「仅当显式传了合法 category_id 才检查」
$response['error'] = '你没有权限在该版块上传附件';
} else {
RateLimiter::hitConfig('upload', 5, 60, true);
$result = Upload::file($_FILES['file']); // ← 直接放行落盘
}影响:任何已登录用户 POST /api/upload?ajax=1(带 csrf + file,不带 category_id)即可上传任意允许类型文件到 UPLOAD_PATH,无视所有用户组的 can_attach=0 设置(版块级与组级拒绝全部失效),并被编辑器以 UPLOAD_URL 引用 —— 零权限用户获得附件托管能力。/api/fetch-image 更严重:只要求登录,完全没有 can_attach 判定,且落盘不建 attachments 记录(只能靠后台孤儿清理),20 次/分钟可持续写入。
category_id 时不得放行:
$cid = (int)($_POST['category_id'] ?? 0);
if ($cid <= 0 || !\app\Helpers\Permission::canAttach($cid, (int)$_SESSION['user_id'])) {
$response['error'] = '你没有权限在该版块上传附件';
} else { /* 放行 */ }/api/fetch-image 复用同一判定,并复用 Upload 的落盘登记流程;同时把大小限制前置到流式读取(当前是整包读入内存后再判 5MB,见 :239)。
高 H-4 搜索页 type 参数未白名单 → 反射型 XSS(CSP 含 unsafe-inline,可直接执行) 已实测复现
类别:输出 / 输入校验 验证:三处源码逐行确认链路闭合
// ① SearchController.php:43 —— 无任何白名单(对比同文件 :46 的 time_range 有 in_array 校验)
$type = $_GET['type'] ?? 'all';
// ② :161 原样传给视图
'type' => $type,
// ③ Views/search/index.php:131 —— 拼进分页 URL,未 urlencode、未转义
<?php echo $this->pagination($page, $totalPages,
'?keyword=' . urlencode($keyword) . '&type=' . $type . '&page={page}'); ?>
// ④ TemplateCompiler.php:408 —— pagination() 对 href 不做 htmlspecialchars
return '<a href="' . $href($i) . '"' . ($cls !== '' ? '' : '') . '>' . $i . '</a>';影响:登录用户访问
/search?keyword=<常见词>&type=threads"><script>alert(document.cookie)</script>
即跳出 href 属性并执行任意 JS。CSP 含 'unsafe-inline',内联脚本可执行;可读取页面内 csrfToken 并以受害者身份执行全部写操作(改资料 / 改密 / 发帖 / 后台操作)。
前提条件(诚实说明):pagination() 在 $totalPages <= 1 时提前 return ''(TemplateCompiler.php:382),因此需关键词命中至少 2 页结果。在正常论坛上这是极易满足的条件(用高频词即可)。
// ① 控制器白名单化(必需)
$allowedTypes = ['all', 'threads', 'blogs', 'users'];
$type = in_array($_GET['type'] ?? 'all', $allowedTypes, true) ? $_GET['type'] : 'all';
// ② pagination() 内统一转义 href(纵深防御,必需)
return '<a href="' . htmlspecialchars($href($i), ENT_QUOTES, 'UTF-8') . '"' ... >';高 H-5 计数器系统性漂移(已在真实库上量化)+ 多条重复递减路径 已实测复现
类别:数据一致性 验证:对真实
data/meta/*.sqlite 只读查询
实测漂移(本机真实库):
_runtime_total_threads = '70' 实际 = 72 ← 偏低 2
_runtime_total_posts = '1402' 实际 = 1410 ← 偏低 8
reply_count 与实际回复数不一致的主题:
topic 58 idx=23 real=22
topic 70 idx=26 real=16 ← 偏差 10
topic 76 idx=5 real=3
topic 77 idx=7 real=5
漂移条数: 4 / 72 个主题三条独立成因:
- ①
User::deleteWithCascade对已软删数据重复递减::519的SELECT id FROM topic_index WHERE uid = :id无status条件,:542-544对每条hardDelete(内部:716再减一次total_threads);:549的reply_index查询同样无 status 条件,:570全量累加后:572-574再runtimeDecr—— 而软删回复在Post::softDelete时已经减过(Post.php:321)→ 双重扣减。这与实测「计数偏低」方向完全吻合。 - ② 回复计数维护散落在调用方:
Post::softDelete()只做runtimeDecr('total_posts'),完全不碰reply_count。唯一正确配对在ThreadController.php:390-392,但插件直调Post::softDelete时没有配对 → 计数虚高。这解释了 topic 70 的 +10。 - ③
runtimeIncr的「零值自校正」反而制造漂移:Settings.php:336的runtimeGet未命中时用runtimeBuildSingle()的COUNT(*)兜底 —— 而该 COUNT 已包含本请求刚插入的那一行,回到:380又+= $amount,flushCounters再对 DB 做一次原子加 → 落库 N+1,且此后每次增量都在漂移值上累加,永久固化。:368-378的零值自校正分支会主动触发该路径。
// ① User::deleteWithCascade —— 两处 SELECT 补状态过滤,与 Thread.php:742-744 口径一致
'SELECT id FROM topic_index WHERE uid = :id AND status = 0 AND deleted_at IS NULL'
'SELECT id, bucket_path FROM reply_index WHERE uid = :uid AND status = 0'
// ② 计数维护下沉到模型内(消除"谁调谁负责"的隐式契约)
// Post::softDelete() 内直接配对:(new Thread())->decrementReplyCount($pid) (加状态守卫防重复)
// Post::restore() 对称调用 incrementReplyCount();控制器与插件删除各自的配对调用
// ③ Settings::runtimeGet 的兜底改为"只读不写"
$value = self::runtimeBuildSingle($key); // 仅填内存
if ($amount > 0 && $value > 0) { self::$runtimeCache[$key] = $value; } // 不写 DB
// 并删除 :368-378 的零值自校正块;DB 权威值统一交给 runtimeBuild()/后台"重建统计"修复后需跑一次后台「维护 → 重建统计」把现有漂移归零,并加一条回归断言:_runtime_total_* 与 COUNT(*) 必须相等。
高 H-6 后台审计日志系统性缺失:5 个控制器零记录 已实测复现
类别:审计 / 不可抵赖性 验证:
grep -c 'AuditLog::log' 五个文件全部为 0$ for f in UserController PluginController TrashedController GroupController CategoryController; do
printf "%-22s AuditLog: %s\n" "$f" "$(grep -c 'AuditLog::log' app/Controllers/Admin/$f.php)"; done
UserController AuditLog: 0
PluginController AuditLog: 0
TrashedController AuditLog: 0
GroupController AuditLog: 0
CategoryController AuditLog: 0影响(按危险度排序):
- 用户角色 / 密码变更、用户删除无痕(
UserController.php:63-72,81-94):管理员 A 可把任意账号(含自己的傀儡号)提为 admin、重置同级管理员密码后登录,或级联删除用户,audit_logs中零痕迹 —— 这是本类最高风险点,也是唯一无审计的高危写操作。 - 插件启停无痕(
PluginController.php:144-178):启用插件 = 以 Web 进程权限执行磁盘上的 PHP(Plugin.php:526-530的class_exists+::activate()),uninstall 还会执行插件自带 DROP TABLE 逻辑。攻击者取得管理员会话后可静默激活恶意插件建立持久化 RCE,无任何记录。 - 权限矩阵变更无痕(
GroupController.php:42,50,84-91):权限矩阵是授权模型的唯一真相源,可静默放开某组对敏感版块的can_view/can_post,或删组批量降级成员。 - 回收站硬删除无痕(
TrashedController.php:34-37):对比ThreadController.php:72的delete分支有日志,此处破坏力相同却漏记 —— 硬删后帖子、回复、附件、倒排索引被级联清理,无法追溯操作者。 - 备份下载无痕(
DatabaseController.php:887-918):备份 zip 含 users(密码哈希)、sessions、全部帖子与 extern 正文,一次下载即等价全站数据外泄,却不留审计(对比 handleBackup/handleRestore/handleDelete 均有日志)。
\app\Helpers\AuditLog::log('admin_action', 'user', (int)$user['id'],
"改角色 {$user['role']}→{$role} / 组 {$user['group_id']}→{$group_id} / 状态→{$status}"
. ($password !== '' ? ' / 重置密码' : ''));
\app\Helpers\AuditLog::log('admin_action', 'plugin', 0, "启用插件 {$name}");
\app\Helpers\AuditLog::log('delete', 'thread', $tid, "回收站硬删除: {$title}");
\app\Helpers\AuditLog::log('admin_action', 'backup', 0, "下载备份 {$filename}");
建议再补一条结构性防线:在 Admin/BaseController 加统一写日志入口,或对「危险操作」做白名单式覆盖自检(新增后台写方法若未调用 AuditLog,在开发环境直接抛异常)。
高 H-7 插件 system:settings「权限强制模式」可被任意插件绕过(供应链控制失效) 已实测复现
类别:安全 / 供应链 验证:逐行回读 + 现网插件调用点核对
// Settings.php:236 —— 只有调用方主动传 $pluginName 才校验
public static function update(string $key, string $value, string $pluginName = ''): void
{
if ($pluginName !== '' && !\app\Helpers\Plugin::requirePermission($pluginName, 'system:settings')) {
return;
}
// ...
}
// Models/Setting.php:28 —— 直写 DB,无任何权限校验
return $db->query("INSERT INTO {$this->table} (\"key\",\"value\") VALUES (:key,:val) ...");
// 实测:现网插件正是这么写的(全部漏传插件名)
// plugins/mod_system/Plugin.php:69,166 Settings::update($dbKey, $value);
// plugins/seo/Plugin.php:43 Settings::update($dbKey, $value);
// plugins/announcements/hook/init_after.php:24 同上
// plugins/friend_links/hook/init_after.php:35 同上
// plugins/content_review/Plugin.php:317-344 (new Setting())->setValue(...) ← 直写 DB影响:permission_enforce=1(默认)下,任何插件只需改写成 Settings::update($k,$v) 或 Setting::setValue() 即可写任意全局设置:site_closed(关站)、permission_enforce(自我降级为警告)、default_theme、rate_*_max(放宽限流)、mail_pass(覆盖邮件凭据)。声明的 permissions 字段对恶意/被投毒插件零约束。
// Models/Setting.php::setValue() 内
$caller = self::detectPluginCaller(); // debug_backtrace 取 plugins/<name>/ 路径段
if ($caller !== '' && !\app\Helpers\Plugin::requirePermission($caller, 'system:settings')) {
\error_log("Setting::setValue 被插件 {$caller} 越权调用: {$key}");
return false;
}
// Settings::update() 改为:$pluginName === '' 时同样反查调用栈,
// 只有确认"非插件来源"才视为核心调用放行
并补齐 plugins/ 下 5 处漏传插件名的调用。
高 H-8 外置正文存储:锁不一致致编辑静默失效 + 改写 .idx 后不清缓存致正文空白 600s 已实测复现
类别:外置存储 / 并发 验证:锁文件与
apcu_delete 出现次数核对(均为 0)
// 编辑作废:持 $idxPath.lock(单 .idx 粒度)
$lockPath = $idxPath . '.lock';
$lockFh = @fopen($lockPath, 'c'); @flock($lockFh, LOCK_EX); // ExternStorage.php:141-144
$idx = json_decode(@file_get_contents($idxPath), true); unset($idx[$topicId]); ... // :147-159
// 合并:读同一个 .idx,却完全不持该锁(用的是另一把整批锁 extern_merge.lock)
$existingIdx = json_decode(@file_get_contents($idxPath), true); // ExternMerger.php:281-287
$mergedIdx = $existingIdx + $newIdx; self::atomicWrite($idxPath, ...);// :326-327
// 读端:.idx 懒加载并缓存 600s
$cacheKey = 'extern_idx_' . md5($idxPath); // ExternStorage.php:248
@apcu_store($cacheKey, $idx, self::APCU_TTL); // :256-264 TTL=600
// 实测:ExternMerger / ExternIdxRepairer 中 apcu_delete 出现次数 = 0 / 0影响(两条独立故障路径):
- ① lost update → 编辑静默不生效:编辑发生在合并读
.idx(:281)之后、写.idx(:327)之前时,合并用旧快照整体覆盖,刚被unset的条目复活 → 读取重新命中.bin旧归档正文。这正是 2026-09-02 旧审计 H-01 的同类后果,只是触发条件从「跨季度」变成「并发」。 - ② 缓存不失效 → 正文空白:批次部分已迁移时读端会缓存「缺新条目」的旧
.idx;合并补齐条目后删除对应.txt(:333-337),此后 600s 内读端仍用旧缓存 →isset($idx[$id])为 false → 回退.txt→ 文件已删 → 返回空白正文且无任何日志。Repairer 同理。
// ① ExternMerger::mergeGroup 复用同一把锁:读取 $existingIdx 前加 $idxPath.lock,
// 写完 .idx 后释放(同一临界区),删除 .txt 放锁外即可
// ② 写 .idx 成功后统一失效缓存,并把键构造收敛到单一出口
public static function idxCacheKey(string $idxPath): string { return 'extern_idx_' . md5($idxPath); }
if (function_exists('apcu_delete')) @apcu_delete(ExternStorage::idxCacheKey($idxPath));
// ③ 建议 ExternMerger/ExternIdxRepairer 完成后顺带 PageCache::invalidate()五、中危问题(16 项)
中 M-1 ViewCounter:APCu 能力探测不足 → 每请求刷错误日志 + 并发重复落库虚高 已实测复现
实测证据(真实生产日志):
$ ls -la protected/error.log → 26,778 字节
$ grep -c "APCUIterator\|APC must be enabled" protected/error.log → 178
$ tail -5 protected/error.log
[17-Sep-2026 18:43:07 Asia/Shanghai] ViewCounter::flush error: APC must be enabled to use APCUIterator
[17-Sep-2026 18:43:07 Asia/Shanghai] ViewCounter::flush error: APC must be enabled to use APCUIterator
[17-Sep-2026 18:43:08 Asia/Shanghai] ViewCounter::flush error: APC must be enabled to use APCUIterator
[17-Sep-2026 18:43:08 Asia/Shanghai] ViewCounter::flush error: APC must be enabled to use APCUIterator
[17-Sep-2026 18:59:44 Asia/Shanghai] ViewCounter::flush error: APC must be enabled to use APCUIterator根因::34 与 :59 只用 function_exists('apcu_inc') / class_exists('APCUIterator') 探测,未探测 apc.enabled。Web 侧 apcu 扩展已加载但 apc.enabled=0 时:inc() 走 @apcu_inc(失败被 @ 吞掉,计数直接丢失,且不走 UPDATE 降级分支),flush() 每请求抛错刷日志。
次生缺陷::79-108 的「读取 → UPDATE → COMMIT → apcu_dec 扣减」跨进程非原子,两个 FPM worker 在 30s 节流窗口边界同时通过检查会各自 UPDATE 同一值 → 浏览量系统性虚高。
// ① 真实能力探测
$apcuOk = function_exists('apcu_inc') && class_exists('APCUIterator') && (bool)ini_get('apc.enabled');
// ② flush() 的 APCUIterator 循环外包 try/catch,异常时回落"直接 UPDATE"模式
// ③ 用 CAS 抢占替代"读后扣减",消除并发重复落库:
if (@apcu_cas($key, $views, 0)) { /* 抢占成功才 UPDATE */ $deferred[] = $key; }
// COMMIT 后直接 apcu_delete($key)中 M-2 RateLimiter:检查-写入非原子 + 无锁读 → 限流可并发穿透;缓存文件不裁剪 源码确认
// :57 —— 读路径无 flock,且落在写方的 truncate~fwrite 窗口会读到空/半截内容
$data = @\file_get_contents($file);
if ($data === false) { return []; } // json_decode 失败 → 返回空数组 → 计数被"清零"
// :184-192 —— 读 → 判断 → 写 三步分离
$records = self::readIpRecords($ipKey); // 无锁读
$records = \array_filter($records, fn($ts) => ($now - $ts) < $windowSeconds);
if (\count($records) >= $maxRequests) { return false; } // 判断
self::writeIpRecords($ipKey, [$now]); // 之后才写(锁只覆盖合并)影响:N 个并发请求可同时读到 count=19 后各自追加,全部放行,实际请求数远超阈值。波及 login(20/900s)、register、forgot_password、admin_verify(5/900s) 等全部 IP 限流 → 密码喷洒与后台二次验证爆破防线在高并发下失效。
次生:写路径 :104-113 锁内重读磁盘"全量历史"再追加,从不丢弃过期项(:150-152 只在内存过滤),单 IP 文件随请求数线性增长(每时间戳约 11 字节);protected/rate_cache/ 无任何清理任务。当前实测 7 个文件 / 11K,尚未爆发。
LOCK_EX 临界区(新增 checkAndAppend());读路径改用 flock(LOCK_SH);json_decode 失败时按「已计满」保守处理而非返回空数组;写入前按最大窗口裁剪(array_filter($latest, fn($t) => ($now-$t) < 86400)),并加 cron 清理 mtime 超 7 天的 ip_*.cache。中 M-3 默认密钥硬编码并随发行包分发(MAIL_PASS_KEY / CRON_KEY) 已实测复现
// config.php:32
define('MAIL_PASS_KEY', 'SoSite_AES_Key_2026!@#XyZ7890AbCdEf='); // 实测 strlen === 36
// config.php:79
define('CRON_KEY', 'fH_9xK2mQ8zL4pW7sN1vY3bT6cR0eA5dU8gJ');影响:① MAIL_PASS_KEY 随 flinthub1.0.0.zip / update.zip / flinthub1.0.3/ 等发行物一并分发,仓库内多份副本。任何拿到 data/ SQLite 的人(备份泄露、任意文件读、目录穿越)用公开默认密钥即可直接解出 SMTP 密码明文 —— GCM 认证标签只防篡改,不防「持钥解密」;CBC 旧密文同样可解。且所有站点共用同一密钥,一处泄露全站通用。
② CRON_KEY 默认值公开(install.php:238 生成空值,但随仓库分发的 config.php 是固定明文)→ 沿用该 config.php 部署的站点,任何人都可用公开默认钥触发队列消费 / 自动扩容。
config.php(bin2hex(random_bytes(32)));密钥改从环境变量或 protected/mail_key.php 读取,不进发行包;后台对仍为默认值的站点强告警;轮换时对已有密文做一次性重加密迁移。中 M-4 TemplateCompiler::safeExpr 防注入白名单可被绕过(RCE 原语,当前无可达注入点) 已实测复现
// :159-163 —— preg_match 只取【第一个】匹配
if (preg_match('/(?:[a-zA-Z_][a-zA-Z0-9_]*|\$\w+)\s*\(/', $expr, $m)) {
$call = strtolower(\ltrim($m[0]));
if (strpos($call, 'isset') !== 0 && strpos($call, 'empty') !== 0) {
return 'false';
}
}
return $expr; // ← 放行原表达式
# 实测(PHP 8.2.9)
isset($x) && system("id") => isset($x) && system("id") ← 原样放行
empty($a) || shell_exec("id") => empty($a) || shell_exec("id")
system("id") => false ← 单独调用被拦
# 编译产物
{if isset($x) && system("id")}Y{endif}
=> <?php if (isset($x) && system("id")): ?>Y{endif}可利用性(诚实评估):当前 app/Views/ 全部是原生 PHP 模板,无任何 .html 模板;assets/themes/ 目录不存在;后台主题上传在单模板体系下已被显式禁用(Admin/ThemeController.php:138-149)。因此当前无可达注入点,不构成即时 RCE。
但这是同一防线的回归:{php} 标签当初正是为消除同一 RCE 向量而移除(:134-135 留有说明)。一旦启用目录主题或恢复主题上传,任何能写模板内容的人即获得 RCE。
isset/empty 做完整标识符比对(防 issetx( 前缀命中):
if (preg_match_all('/([a-zA-Z_][a-zA-Z0-9_]*|\$\w+)\s*\(/', $expr, $mm)) {
foreach ($mm[1] as $fn) {
$fn = strtolower(ltrim($fn));
if ($fn !== 'isset' && $fn !== 'empty') return 'false'; // 任一非白名单调用即整体拒绝
}
}
更稳的做法:{if} 只允许「变量 + 比较 + 逻辑运算」,函数调用一律不支持。中 M-5 Model 层三处缺陷:ORDER BY 非白名单、update() 返回类型不一致、insert() 无赋值面约束 已实测复现
// ① :217-220 —— 字符剥离而非白名单;注释声称"只允许 ASC/DESC"与实现不符
protected static function sanitizeOrderBy($orderBy) {
return \preg_replace('/[^a-zA-Z0-9_.,\s]/', '', $orderBy);
}
// 剩余字符集仍允许关键字与空格:实测 'id OR 1'、'id LIMIT 1 OFFSET 9'、'id, name' 均能拼成合法 SQLite 语句
// ② :127 —— update() 成功返回 PDOStatement,被白名单全过滤时返回 int 0
return $this->db->query($sql, $filtered);
// 且白名单外键仅 error_log 后丢弃 → 调用方拿到真值对象、判定为成功,实际数据未变(静默故障)
// ③ :67-86 —— insert() 只校验列名格式,完全不看 $fillable
// → $model->insert($_POST) 仍可写入 role / points / status 等列影响:① all()/findAllWhere() 的 $orderBy 若被调用方透传用户输入,可篡改结果集范围(LIMIT/OFFSET 绕过分页/枚举)或用 OR 构造布尔表达式做盲注式推断。已确认当前仓库内调用点全部为字面量(Models/Setting.php:37、Category.php:52、BlogCategory.php:21),属潜在缺陷 —— 但正是这类「当前安全」的写法在后续迭代中最容易被打破。
② User::$fillable = ['last_login'](User.php:17),若控制器调用 update($id, ['username'=>..., 'email'=>...]),更新静默不发生而调用方以为成功。
③ insert 侧批量赋值面敞开。
// ① 结构化白名单,非法即抛异常
protected static function sanitizeOrderBy($orderBy) {
$out = [];
foreach (explode(',', (string)$orderBy) as $item) {
if (preg_match('/^\s*([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(ASC|DESC))?\s*$/i', $item, $m) !== 1)
throw new \InvalidArgumentException("非法 ORDER BY: {$item}");
$out[] = $m[1] . (isset($m[2]) ? ' ' . strtoupper($m[2]) : '');
}
if (!$out) throw new \InvalidArgumentException('ORDER BY 为空');
return implode(', ', $out);
}
// ② update() 统一返回受影响行数;全被过滤时 error_log 后 return 0
$stmt = $this->db->query($sql, $filtered); return $stmt->rowCount();
// ③ insert() 增加与 update() 同源的列白名单(复用 $fillable 或引入 $insertable)中 M-6 回帖请求的「长事务」横跨插件 hook / 通知 / 文件上传,却不覆盖真正的桶写入 源码确认
$db->begin(); // :115 business.sqlite(WAL 下是 DEFERRED)
$postId = (new Post())->insert([...]); // :117 → 写 bucket + main_index(不在本事务内!)
... Upload::file($file) ... // :144 文件 I/O
\app\Helpers\Plugin::hook('post_create_after', [...]); // :154 任意插件代码(可能网络 I/O)
\app\Helpers\Notification::notifyReplyAggregated(...); // :161 更多 DB 写
$db->commit(); // :174影响:① WAL 下首次写即持有 business.sqlite 写锁直到 :174,期间执行插件 hook 与文件上传 → 锁被长时间持有,其他写请求超 busy_timeout=5000ms 后抛 SQLITE_BUSY → 500。
② 该事务并不包含 Post::insert 的桶/索引写入,所以「有事务」没带来原子性,只带来锁开销。
③ 异常时 :176 只回滚 business,桶里的 reply 行与 extern 文件照旧留存 → 用户看到「回复失败」但回复其实已存在。
begin → 业务写 → commit;回复主体保持「先 extern 后桶后索引」并用 H-1 的补偿日志兜底。中 M-7 Permission 无记录时默认放行 → 新建版块隐蔽越权 已实测复现
// 无记录时:检查该版块是否已设置过权限(该版块完全无任何组配置 → 放行)
$result = !isset($table[$categoryId]);
return self::$permCache[$cacheKey] = $result;影响:新建版块若管理员忘记配置权限,则游客/任意组都能浏览、发帖、回复、上传(canView/canPost/canReply/canAttach 全部返回 true)。且与 getAuthorizedCategoryIds() 的「最小权限返回空」(:166-168)口径不一致:列表不显示该版块,但直接访问 URL 却能进 → 隐蔽的越权面。
return false;),仅在「整张 category_permissions 表为空」这一全局未启用场景保留放行兜底,与 getAuthorizedCategoryIds 的 empty($table) 分支对齐。中 M-8 标题 / 正文长度仅前端校验,服务端缺失(存储型 DoS) 源码确认
// PostController.php:73-88 —— 只对 content 做长度校验,title 无任何上限
$title = trim($_POST['title'] ?? '');
if ($visibleLen > 60000 || \strlen($content) > 500000) { ... } // title 不在此列
// ThreadController::edit(289-302)/ show 回帖(102-103)—— 标题与正文均无长度校验
$title = trim($_POST['title'] ?? ''); $content = \app\Helpers\Content::decode($rawContent);
if (!$title || !$content) { ... }影响:Settings 中 limit_thread_title(默认 200)只在视图里作为 HTML maxlength 出现,控制器从不校验。直接 POST 即可写入任意长度 title(SQLite TEXT 无上限),并可反复编辑累计 → DB/索引与列表渲染膨胀(存储型 DoS、页面卡顿);回帖正文同样无 500KB 上限,可绕过 PostController 的正文限制。
$maxTitle = (int)Settings::get('limit_thread_title','200'); if (mb_strlen($title) > $maxTitle) { $error = I18n::get('post.title_too_long'); },正文补 mb_strlen(strip_tags($content)) > 60000 判定。中 M-9 改密后旧会话与「记住我」令牌不失效(改密 ≠ 全端登出) 源码确认
// ProfileController.php:62-67 —— 仅重生成当前会话 ID
$userModel->updatePassword((int)$currentUser['id'], $newPwd);
session_regenerate_id(true);
// 未调用 RememberMe::clearTokens(),也未调用 Auth::clearUserCache()
// AuthController.php:404-405(找回密码路径做对了)
// 吊销该用户全部记住登录 token(安全考虑:重置密码 = 全端登出)
RememberMe::clearTokens((int)$row['user_id']);影响:用户自查「改密」场景下,攻击者已窃取的会话(session 仍在 sessions.sqlite 且未过期)与 remember_token 均继续有效,改密无法把攻击者踢下线;而找回密码路径却会 clearTokens —— 两条改密路径安全语义不一致。与 H-2 组合后危害放大。
resetPassword 对齐:RememberMe::clearTokens((int)$currentUser['id']);;更彻底的做法是引入 users.session_version 并在 Auth::getCurrentUser() 比对,使全部旧会话失效。中 M-10 点赞 → 取消 → 再点赞可无限刷积分(TOCTOU + 无去重约束) 源码确认
// Vote.php:173-174 —— 先读
$prevVote = self::getUserVote($type, $id);
$isNewLike = ($vote === 1 && $prevVote !== 1);
// :184 —— 后写(UPSERT 本身原子,但"是否首次"已被读走)
// vote=0 时执行物理 DELETE → 下次点赞 prevVote 重新变回 0
// ApiController.php:93-98
if ($vote == 1 && !empty($result['award_like'])) { Points::award((int)$owner, points_vote_received, ...); }
// points_log 无唯一约束(仅 idx_points_log_user / idx_points_log_reason),无法兜底去重影响:作者对同一目标可反复获得 points_vote_received。以 30 次/分钟限流计,单 IP 每小时可向任意账号注入约 1800 分,等级/积分体系可被线性刷高(可用于养小号)。同时 getUserVote→toggle 之间存在 TOCTOU,并发双击也可能重复发分。
BEGIN IMMEDIATE 事务,或用条件写判定迁移(UPDATE ... WHERE vote <> 1 + rowCount() 判定首次,未命中再 INSERT);并在 points_log 上补 UNIQUE(user_id, reason, related_id, related_type) 作第二道闸。中 M-11 归档只读限制在「编辑」路径可绕过(惰性归档仅存在于 show) 源码确认
// ThreadController::edit 只读持久化标记,从不做惰性归档判定
if (!empty($thread['is_archived']) && !$this->isAdmin()) {
$this->redirect('/thread/' . $threadId . '?msg=archived_no_reply');
}
// 惰性归档仅出现在 show():
if (!$isArchived && $archiveAfterDays > 0 && !empty($thread['created_at'])) { ... UPDATE topic_index SET is_archived = 1 ... }影响:超过 archive_after_days(默认 365)的旧帖,只要没被 show() 访问过(DB 中 is_archived 仍为 0),作者可直接 POST /thread/{id}/edit 或 POST /post/{id}/edit 绕过归档只读限制。回复路径无此问题(Post::insert 在模型层有 is_archived 兜底,Post.php:52-56),但编辑路径没有任何兜底。
lazyMarkArchived($thread) 复用 show() 的逻辑,在 edit 前调用;或在 Thread::update 内按 archive_after_days 现算一次。中 M-12 分类过滤列表被规划器选中单列索引 → TEMP B-TREE 排序(与代码注释矛盾) 已实测复现
实测 EXPLAIN QUERY PLAN:
--- 首页 getLatestAllowed
SEARCH topic_index USING INDEX idx_topic_index_category (category_id=?)
USE TEMP B-TREE FOR ORDER BY
--- 版块页 getByCategoryPaginated
SEARCH topic_index USING INDEX idx_topic_index_category (category_id=?)
USE TEMP B-TREE FOR ORDER BY
=== topic_index 现有索引 ===
idx_topic_index_uid (uid, status, deleted_at, create_time DESC, id DESC)
idx_topic_index_category (category_id)
idx_topic_index_create_time (create_time DESC)
idx_topic_index_list (is_pinned DESC, last_reply_time DESC, create_time DESC)
idx_topic_index_archive (is_archived, create_time)影响:「分类过滤 + (is_pinned, last_reply_time, create_time) 排序」无法由现有任一索引同时满足,每次列表页都要把该分类全部命中行取出排序。当前 72 行无感;单版块到 10 万帖量级时每页都会做一次大范围排序(且 SELECT * 放大临时表)。Schema.php:750-753 的注释「列表查询继续走 idx_topic_index_list」对无分类的首页成立、对版块页与首页白名单不成立。
CREATE INDEX IF NOT EXISTS idx_topic_index_cat_list ON topic_index
(category_id, is_pinned DESC, last_reply_time DESC, create_time DESC, id DESC);
-- 首列 category_id,与 uid 索引同理不会被"无分类"的列表查询选中,
-- 不违反"topic_index 不能加 status 打头索引"的红线
keyset 的右半排序可在 idx_topic_index_list 末尾补 id DESC 解决。中 M-13 QueueConsumer 无租约 / owner 标识 → 超时回收后可被重复执行;CAS 失败被当作「队列空」 源码确认
// :207-209 成功路径:无条件置 done,不校验 status 仍为 processing / 不校验是自己抢占的
$this->db->prepare("UPDATE task_queue SET status='done' ... WHERE id = :id")->execute(...);
// :228-233 超时回收:processing → pending
UPDATE task_queue SET status='pending', retry_count=retry_count+1 ... WHERE status='processing' AND update_time < :cutoff影响:① 任务执行超过 timeout(默认 300s)时被 recoverTimeout 重置为 pending,另一 worker 抢占并再执行一次;原 worker 完成后无条件写 done → 同一任务产生两次副作用(rebuild_search 幂等尚可,若接入发信/扣费类 handler 则重复)。② 无 owner 校验,done 可覆盖 failed/pending 状态。③ CAS 失败时返回 null,consume() 视作「无任务」并 sleep(1)(:71-73)→ 多 worker 争抢同一 id 时每轮白等 1 秒,高并发下吞吐塌陷。
task_queue 增加 owner TEXT + lease_until INTEGER 列;fetchTask 的 CAS UPDATE 一并写入 owner 与 lease;done/failed 的 UPDATE 加 AND owner = :me;recoverTimeout 只回收 lease_until < now 的行;CAS 失败应 continue 立即重取而非 return null。中 M-14 搜索降级路径 LIKE 通配符转义无效(缺 ESCAPE 子句) 已实测复现
// :291 手工转义 % 与 _,但 4 处 SQL 均未声明 ESCAPE —— SQLite 下反斜杠不是默认转义符
$sq = '%' . \str_replace(['%', '_'], ['\\%', '\\_'], $query) . '%';
// 实测(SQLite 内存库,输入 '%')
// 净化后 LIKE 参数 = '%\%%'
// [无 ESCAPE] 搜索 '%' 命中: ["a\\b"] ← 只匹配"含反斜杠"的行
// [带 ESCAPE] 搜索 '%' 命中: ["a%b"] ← 正确影响:%、_ 以及任何含这两个字符的关键词(「50%」「foo_bar」「C++_v2」)在倒排索引不可用时的降级搜索中一律返回错误结果(漏检 + 误检),且用户无法自查。所幸反斜杠是字面量,未构成全表扫描 DoS,但语义已坏。
LIKE 全部补 ESCAPE '\';或去掉手工转义、改用 instr(lower(title), lower(:q)) > 0 避免转义语义。中 M-15 聚合回复通知从渲染后文案正则抓数字 → 用户名含数字即计数错乱 已实测复现
// :432 —— 从已渲染的标题里抓【第一个】数字当计数
if (preg_match('/(\d+)/', (string)($existing['title'] ?? ''), $m)) { $count = (int)$m[1] + 1; }
// lang/zh.php:46 —— 模板里 {user} 在 {count} 之前
'plugin.notifications.reply_aggregated_named' => '{user} 等 {count} 人回复了你的帖子「{title}」',
// 实测
// user=user88 title=2026年规划 → 标题「user88 等 2 人回复了…」→ 解析出 88 → +1 = 89
// user=张三 title=2026年规划 → 解析出 2 → 3(正确)影响:回复者用户名或帖子标题含数字时(「user88」「2026年规划」),「N 条新回复」计数从该数字起跳并持续累加(89、90…),同一主题的未读回复数彻底失真;英文模板 {user} and {count} others 同病。
notifications 增加 agg_count INTEGER DEFAULT 0 列,UPDATE 时 agg_count = agg_count + 1 再用 I18n::get(..., ['count'=>$newCount]) 重渲染标题。最小改动版:用带定位的正则锚定计数位(zh 匹配 /等\s*(\d+)\s*人/),匹配失败时按 2 起步。中 M-16 每请求重复执行 DDL:SearchIndexStore::db() 与 Schema::bucketFromPath() 无「已建」标记 源码确认
// SearchIndexStore::db() 每次调用都跑 ensureSchema(1 条 CREATE TABLE + 3 条 CREATE INDEX)
public static function db(string $quarter, ?string $dataPath = null): PDO {
$pdo = DBFactory::getConnection(self::pathFor($quarter, $dataPath));
self::ensureSchema($pdo); // ← 无进程内"已建"标记
return $pdo;
}
// Schema::bucketFromPath() 每次打开桶都跑 1 次 PRAGMA table_info + 4 条 DDL(含纯读路径)影响:跨 3 个季度、1 轮候选的一次搜索 = 3+3+3 次 db() = 45 条 DDL;首页 attachExcerpts 按 bucket_path 分组最多 32 个桶 → 单次渲染多出约 160 条无谓语句,且这些 DDL 在并发下会争抢 schema 锁、偶发 SQLITE_BUSY。注意 CountingPDO::recordSql(:157)只统计 SELECT/INSERT/UPDATE/DELETE/EXPLAIN,DDL 不计入,因此该开销在页脚「查询数」里被隐藏。
private static array $schemaOk = [];,以归一化绝对路径为键,命中即跳过;仅在真正新建库文件(首次写入)时执行 DDL。