本文由 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原样传入 baseupdate()(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_langCookie 经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 兜底分支可加一次读侧短重试。
三、范围外但重要的发现
- 附件误删联动:
app/Helpers/Settings.php:613-617(见 M2),退役表残留导致「清理孤儿附件」误删在用文件,属跨模块数据丢失,值得单列。 - 前置游客缓存登录态门控:
index.php:22-50直读sessions.sqlite用正则(?:^|[;}])user_id\|i:判登录,异常/畸形一律回落完整管线(安全兜底正确);未发现用户态泄露,此项为「已核查、无问题」记录。 - 数据目录 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。
五、最终报告
问题列表
| 级别 | 编号 | 文件:行号 | 摘要 |
|---|---|---|---|
| 高危 | H1 | ExternStorage.php:48 | APCu 键 extern_{id} 主题/回复串读(启用 APCu 即触发) |
| 高危 | H2 | ExternStorage.php:30-60 | .txt 路径 extern/.../{桶}/{id}.txt 主题/回复互相覆盖(规模化必现正文损坏) |
| 高危 | H3 | User.php:388-489 | 删用户跨库非事务,business 事务外改 main_index/桶/文件 |
| 高危 | H4 | Search.php:313-334 | 搜索降级默认 timeRange='all' 绑定多余 :since 直接抛错,安全网失效 |
| 中危 | M1 | Search.php:313 | 降级搜索读退役 threads 表 → 帖子结果恒空 |
| 中危 | M2 | Settings.php:613-617 | 孤儿附件清理读退役表 → 误删在用附件 |
| 中危 | M3 | User.php:427-429 | 附件文件删除缺路径白名单(与 hardDelete 不一致) |
| 中危 | M4 | Schema.php:793-802 | 归档桶缺失静默建空表,掩盖归档丢失 |
| 中危 | M5 | Model.php:104-119 | update() 无列白名单的批量赋值面 |
| 低危 | L1 | Model.php:106-117 | 空 $sets 拼出非法 SQL |
| 低危 | L2 | Model.php:198 | buildWhere 全非法键 → WHERE 1 |
| 低危 | L3 | Model.php:135 | count() 数组条件死路径 → WHERE Array |
| 低危 | L4 | IDGenerator.php:47-96 | 高争用最坏 ~15s 阻塞 |
| 低危 | L5 | PageCache.php:42-49 | 游客 lang 键无界 → 缓存文件磁盘耗尽 |
| 低危 | L6 | ExternStorage.php:69-81 | Windows 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 或数据上量前优先修复。