[审计]FlintHub 深度代码审计报告

👑Lv.11 元老 🌞 骨干会员
2026-10-23 23:41:08

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/FlintHub代码审计报告.md。

FlintHub 深度代码审计报告

  • 审计日期:2026-09-04
  • 审计方式:只读静态分析 + 运行环境探针验证(PHP 8.2.9 + SQLite,PDO::ATTR_EMULATE_PREPARES=false),未改动任何业务代码
  • 审计范围:8 个指定文件(见文末「已审阅/未审阅」清单)

一、目录结构理解

FlintHub 以根目录 index.php 为唯一前端控制器(无 public/),app/Core/Autoloader.php 用 spl_autoload_register 把 app\/Plugin\ 映射到 app/、plugins/,并对类名做 .. 过滤 + realpath 落盘校验防穿越。app/SplitDB/Schema.php 表明数据全部落在 data/:meta/ 下是若干独立 SQLite 库(business/main_index/sessions/global_id/task_queue/search 季度分文件),帖子/回复真相源按「季度 → 哈希桶」分片到 data/bucket/active/{YYYYQn}/{id%32}.sqlite,正文外置到 data/extern/{Y}/{Q}/{桶}/{id}.txt,main_index.topic_index.bucket_path 是读取路由的唯一依据。

关键点:topic 与 reply 使用两条独立的自增 ID 序列(id_generator 两行,均从 1 起),二者 ID 区间完全重叠——这是后文多个高危问题的根因。


二、问题清单

【高危】

H1. ExternStorage APCu 缓存键主题/回复串读

  • 位置:app/SplitDB/ExternStorage.php:48(配合 :156、:166、:182、:270)
  • 问题:APCu 缓存键 extern_{id} 只用裸数字 ID,而主题与回复各自独立取号、ID 区间重叠(实测 topic 1–24 与 reply 1–32 全部同号,24/24 冲突)。一旦启用 APCu,读取主题 5 会命中回复 5 的正文缓存(反之亦然),造成跨内容串读;write()/delete() 也会互相污染/误删缓存键。当前主机未装 APCu(探针确认 APCU=no),属休眠态高危。
  • 修复建议:缓存键加入类型命名空间,如 extern_t{id} / extern_r{id},或 extern_{type}_{id};delete/write 同步带 type。

H2. ExternStorage .txt 落盘路径主题/回复互相覆盖

  • 位置:app/SplitDB/ExternStorage.php:30-60(路径规则见 app/SplitDB/ShardRouter.php:123-128,调用见 app/Models/Post.php:66、app/Models/Thread.php:57)
  • 问题:.txt 路径 extern/{Y}/{Q}/{桶}/{id}.txt 同样只用裸 ID:主题 R 的桶 = R%32,回复 R 的桶 = 父帖桶。当 父帖桶 == R%32 且同季度时,回复 R 与主题 R 写入同一文件路径,atomicRename 直接静默覆盖对方正文,delete() 也会删掉对方文件。实测当前 0/32 命中纯属小数据侥幸(每条回复约 1/32 概率,扩容改桶后概率更高),规模化后必然发生正文互相覆盖的数据损坏。
  • 修复建议:extern 目录/文件名加类型前缀或独立子目录(如 extern/.../{桶}/t{id}.txt、r{id}.txt),使主题与回复命名空间彻底隔离。

H3. deleteWithCascade 跨库非原子

  • 位置:app/Models/User.php:388-489
  • 问题:$db->begin()/commit() 只包裹 business.sqlite;而 topic_index/reply_index(main_index 库)、桶内 topic/reply 行、ExternStorage::delete() 的文件删除、Thread::hardDelete() 全部在事务外执行(代码注释自认「事务外执行」)。若中途异常或业务库 commit 失败,$db->rollback() 只能回滚 business,main_index/桶/文件已不可逆变更 → 出现「用户已删但索引/正文残留」或「正文/索引已删但用户行回滚」的跨库不一致,且无补偿路径。
  • 修复建议:改为可重入的分阶段清理(先标记 tombstone,再逐库幂等收敛),或提供一致性校验/修复 CLI;至少在异常时记录待清理清单供后台重跑,不要给出「事务保护」的错觉。

H4. searchLIKE 降级路径多余 :since 参数直接抛错

  • 位置:app/Helpers/Search.php:313、:315、:321、:334
  • 问题:searchLIKE() 无条件把 ':since' => $since 塞进绑定数组,但 SQL 仅在 $since !== '' 时才拼 AND created_at >= :since。默认 timeRange='all' → $since='' → SQL 无 :since 占位符,而 ATTR_EMULATE_PREPARES=false 下多余命名参数会抛 SQLSTATE[HY000]: General error: 25 column index out of range(探针在同配置下复现)。结果:一旦倒排索引异常触发降级,LIKE 兜底自身直接 500,安全网完全失效。
  • 修复建议:仅当 $since !== '' 时才把 :since 加入 params;或始终在 SQL 中保留 :since(用 AND (:since = '' OR created_at >= :since))。

【中危】

M1. searchLIKE 仍读退役表 threads

  • 位置:app/Helpers/Search.php:313、:321
  • 问题:帖子查询仍 FROM threads t——business.threads 是退役表(P4 起不再写入,探针确认 0 行)。即使修好 H4 的 :since 崩溃,降级搜索对分片后的新帖恒返回空。属「读取退役表的残留查询」。
  • 修复建议:降级路径改读 main_index.topic_index(标题)+ 桶/extern(正文),或明确降级仅覆盖博客。

M2. cleanupOrphanAttachments 读退役表导致误删在用附件

  • 位置:app/Helpers/Settings.php:613-617
  • 问题:用 SELECT content FROM threads/posts WHERE content LIKE :p 收集正文内嵌图片以保护在用附件,但 threads/posts 均为退役空表 → 分片后主题/回复正文里的图片 URL 扫不到 → 后台「清理孤儿附件」会误删仍被正文引用的上传文件(数据丢失)。
  • 修复建议:改为遍历桶 topic/reply 的 extern_path 读正文(或读 excerpt_images/main_index 摘要图字段)来收集受保护文件名。

M3. deleteWithCascade 附件文件删除缺路径白名单

  • 位置:app/Models/User.php:427-429(对照 app/Models/Thread.php:655)
  • 问题:删除用户时对附件文件执行 rtrim(UPLOAD_PATH).DIRECTORY_SEPARATOR.$att['filename'] 直接 unlink,未做 Thread::hardDelete 里的 preg_match('/^[a-zA-Z0-9_.\-]+$/') 白名单校验。若 attachments.filename 含 ../ 或绝对路径片段,可越出 uploads 目录删除任意文件。
  • 修复建议:统一走 basename() + 同款字符白名单校验后再拼接删除。

M4. bucketFromPath 归档桶缺失静默建空表

  • 位置:app/SplitDB/Schema.php:793-802
  • 问题:归档桶文件缺失时静默 mkdir 重建空表并返回连接,读取变成「查不到 = 空」而非报错,会掩盖归档文件丢失/放错位置,导致前台静默显示空帖而非暴露故障。
  • 修复建议:区分「active 首次写入可建」与「archive 缺失应报错/告警」;对 archive 路径缺失抛异常或至少 error_log 计数。

M5. Model::update 无列白名单的批量赋值面

  • 位置:app/Core/Model.php:104-119
  • 问题:update($id, $data) 对 $data 中任意合法列名一律写入(仅键名字符校验,无列白名单),属批量赋值面。虽未发现敏感表把 $_POST 原样传入 base update()(User 敏感字段均走显式 execute()),但基类方法本身不设防,任何新调用点直接传请求数组即可改 role/group_id/status/points。
  • 修复建议:基类 update() 增加可填充列白名单(子类声明 $fillable),越界键忽略并记日志。

【低危】

L1. Model::update 空 $sets 拼出非法 SQL

  • 位置:app/Core/Model.php:106-117
  • 问题:$data 为空或全部键被字符校验过滤时,$sets='' 拼出 UPDATE "t" SET WHERE ... 语法错误抛异常。
  • 修复建议:$sets === '' 时直接 return 0/抛明确异常。

L2. buildWhere 数组全非法键时返回 WHERE 1

  • 位置:app/Core/Model.php:198
  • 问题:数组分支在所有键都非法时 return '1',调用方若误传非法键集合会得到 WHERE 1(全表匹配)。
  • 修复建议:非法键集合应抛异常(fail-closed),与字符串分支一致。

L3. count() 数组条件死路径

  • 位置:app/Core/Model.php:135-140
  • 问题:count() 传入数组条件时会拼成 WHERE Array(当前无此类调用方,属死路径但会致命)。
  • 修复建议:非字符串条件显式走 buildWhere 或抛类型错误。

L4. IDGenerator 高争用最坏 ~15s 阻塞

  • 位置:app/SplitDB/IDGenerator.php:47-96
  • 问题:取号逻辑本身在 BEGIN IMMEDIATE 下原子、无重复发号风险(复核通过);仅记可用性提示:busy_timeout=5000ms × 最多 3 次重试,高写争用下单请求最坏可阻塞约 15s。
  • 修复建议:适当下调 busy_timeout 或重试次数,并对取号超时埋点告警。

L5. PageCache 游客 lang 键无界 → 缓存文件磁盘耗尽

  • 位置:app/Helpers/PageCache.php:42-49、:107
  • 问题:游客 flinthub_lang Cookie 经 I18n::sanitize 正则白名单(无穿越、无登录态泄露,serve/render 均 !isLoggedIn() 门控,探针确认),但该白名单不限长度/取值,每个不同语言串都会落一个新缓存文件且仅发帖时全量失效 → 游客可制造无界缓存文件(磁盘耗尽 DoS)。
  • 修复建议:langKey 仅允许已安装语言包集合(glob(lang/*.php) 白名单),非集合内一律回落默认键。

L6. atomicRename Windows 兜底分支瞬时读空

  • 位置:app/SplitDB/ExternStorage.php:69-81
  • 问题:atomicRename 在旧版 Windows 语义下先 unlink($destPath) 再 rename,两条操作之间并发读者会短暂看不到文件 → read() 回退返回空串(正文瞬时空白)。
  • 修复建议:优先依赖 PHP 8+ rename 直接覆盖(当前已是首选路径),Windows 兜底分支可加一次读侧短重试。

三、范围外但重要的发现

  1. 附件误删联动:app/Helpers/Settings.php:613-617(见 M2),退役表残留导致「清理孤儿附件」误删在用文件,属跨模块数据丢失,值得单列。
  2. 前置游客缓存登录态门控:index.php:22-50 直读 sessions.sqlite 用正则 (?:^|[;}])user_id\|i: 判登录,异常/畸形一律回落完整管线(安全兜底正确);未发现用户态泄露,此项为「已核查、无问题」记录。
  3. 数据目录 HTTP 防护(部署红线):本机实际加载的 nginx-server.conf:42 含 location ~ ^/(data|protected|cli)/ deny,探针 HEAD /data/meta/business.sqlite 返回 403,防护有效;但 app/SplitDB/Schema.php:96-144 生成的 data/.htaccess 仅 Apache 生效、web.config 仅 IIS 生效,若换用未引入该 conf 的 Nginx 站点(如面板 vhosts/0localhost_80.conf 无 deny 规则)则会裸奔——建议 README/安装检查项固化。

四、特别声明

  • 所有 8 个指定文件均真实存在并已逐行审阅,无「文件不存在」项。
  • cli/rebuild_search.php:读取 main_index.topic_index + business.blogs,未依赖退役 threads 表,未发现明显问题。
  • app/SplitDB/IDGenerator.php:取号原子性/无重复发号未发现明显问题,仅保留 L4 一条低危可用性提示。
  • app/Models/User.php::search() 的 LIKE 转义(\ % _ + ESCAPE '\' + (int)$limit)正确,未发现明显问题;问题集中在同文件 deleteWithCascade。

五、最终报告

问题列表

级别编号文件:行号摘要
高危H1ExternStorage.php:48APCu 键 extern_{id} 主题/回复串读(启用 APCu 即触发)
高危H2ExternStorage.php:30-60.txt 路径 extern/.../{桶}/{id}.txt 主题/回复互相覆盖(规模化必现正文损坏)
高危H3User.php:388-489删用户跨库非事务,business 事务外改 main_index/桶/文件
高危H4Search.php:313-334搜索降级默认 timeRange='all' 绑定多余 :since 直接抛错,安全网失效
中危M1Search.php:313降级搜索读退役 threads 表 → 帖子结果恒空
中危M2Settings.php:613-617孤儿附件清理读退役表 → 误删在用附件
中危M3User.php:427-429附件文件删除缺路径白名单(与 hardDelete 不一致)
中危M4Schema.php:793-802归档桶缺失静默建空表,掩盖归档丢失
中危M5Model.php:104-119update() 无列白名单的批量赋值面
低危L1Model.php:106-117空 $sets 拼出非法 SQL
低危L2Model.php:198buildWhere 全非法键 → WHERE 1
低危L3Model.php:135count() 数组条件死路径 → WHERE Array
低危L4IDGenerator.php:47-96高争用最坏 ~15s 阻塞
低危L5PageCache.php:42-49游客 lang 键无界 → 缓存文件磁盘耗尽
低危L6ExternStorage.php:69-81Windows unlink-rename 瞬时读空

审阅覆盖

  • 已审阅(指定 8 文件):app/Core/Autoloader.php、app/SplitDB/Schema.php、app/Core/Model.php、app/SplitDB/ExternStorage.php、app/SplitDB/IDGenerator.php、app/Models/User.php、app/Helpers/Search.php、cli/rebuild_search.php
  • 交叉验证另读:app/Core/Database.php、app/SplitDB/{ShardRouter,DBFactory,SearchIndexStore}.php、app/Models/{Thread,Post}.php、app/Helpers/{PageCache,Permission,Content,Csrf,I18n,Theme,Settings,Vote}.php、index.php、app/Controllers/{Home,Forum,Search}Controller.php、app/Views/layouts/main.php、nginx-server.conf 及 vhosts
  • 未审阅:各 app/Controllers/(除上述)、app/Views/(除 main.php)、plugins/、cli/(除 rebuild_search、migrate_extern_to_bin 头部、migrate.php 局部)、app/SplitDB/{Queue,Archive*}.php、Segmenter、TemplateCompiler、安装/升级脚本

总结

FlintHub 的 SQL 注入面防护扎实(参数化 + buildWhere 白名单 + LIKE 转义均到位),但核心数据一致性不可靠——主题/回复共用裸 ID 命名空间导致 extern 正文与 APCu 缓存串读/覆盖(H1、H2)、删用户跨库非事务(H3)、以及搜索降级与附件清理仍读退役表(H4、M1、M2)这几条会在真实流量/维护操作下造成可复现的正文损坏、误删与 500,需在启用 APCu 或数据上量前优先修复。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 5 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入