FlintHub / SplitDB PHP 全面审计报告
splitdb.zip 的 14 个 PHP 文件,以及 app/ 中与认证、权限、搜索、上传、附件、后台和 SplitDB 集成相关的核心链路。审计日期:2026-09-29。结论
未发现明显的远程代码执行或 SQL 注入点,但组件存在正文丢失、删除后仍可读取、并发重复执行、权限过滤失效、封禁会话继续写入和资源耗尽等风险。上线前应至少完成高危项 F-01 至 F-08 及 APP-01 至 APP-02 的修复或补充验证。
这是一次静态审计。应用压缩包仍缺少根目录入口、完整路由、生产配置和 Web Server 规则,因此涉及“是否能被公网触发”的结论均已明确标注。
高危问题
高危F-01 归档后的正文删除不生效
ExternStorage.php:273-307, 341-354delete()只删除同目录的 .txt 和 APCu 缓存,没有从 .idx 删除 ID。正文已经合并到 .bin 后,readFromBin() 仍会根据旧索引返回内容;函数还可能在归档正文仍可读时返回成功。
.idx.lock 下移除索引条目;硬删除时安排归档块重写/压缩;分别定义软删除和硬删除语义,并只在两者都成功后报告成功。高危F-02 归档合并与编辑存在竞态,可能删除新正文
ExternMerger.php:279-288, 351-355;ExternStorage.php:74-107合并器在写完 .bin/.idx 后先释放索引锁,之后才删除 .txt。编辑流程写入新 .txt 并另行使旧索引失效,二者之间没有共同的组级锁。
高危F-03 命名空间迁移会误判同 ID 的主题和回复
ExternNamespaceMigrator.php:174-191当同一个数字 ID 同时出现在 topic_index 和 reply_index,且两个桶号都匹配文件目录时,代码先命中主题分支,直接重命名为 t{id}.txt。注释声明此情况应作为歧义跳过,但实现没有执行该规则。
tMatch 和 rMatch;两者同时为真时记录歧义并跳过,只有 XOR 为真时才自动迁移。高危F-04 归档写入大小限制与读取限制不一致
ExternMerger.php:316-328;ExternStorage.php:317-320合并器可以把超过 16 MiB 的正文写入 .bin,但读取器遇到长度大于 16 MiB 会返回 null。合并器仍可能随后删除原 .txt。
高危F-05 忽略短写,磁盘异常时可能先损坏再删除原文
ExternMerger.php:310-333, 351-355;ExternStorage.php:80-92合并器忽略两次 fwrite() 的返回值,只检查 fflush()/fclose();多个原子写函数也只判断 file_put_contents() === false,没有判断写入字节数是否等于预期。
高危,条件性F-06 队列没有租约所有者,任务可能重复执行
QueueConsumer.php:164-192, 198-213, 223-242任务处理超过默认 300 秒后会被恢复为 pending,另一个 worker 可重新抢占;原 worker 结束后仍按 id 更新为 done,没有 lease token 或 worker fencing。
lease_token、lease_until 和所有者;完成/失败更新必须带 token;长任务续租,恢复时更换 token。高危,条件性F-07 外置正文和季度路径缺少根目录约束
ExternStorage.php:151-160, 225-257, 341-354;SearchIndexStore.php:47-69, 105-130;ShardRouter.php:107-156relPath 通过字符串拼接使用,只有 ltrim(),没有拒绝 ..、绝对路径或符号链接逃逸。SearchIndexStore::pathFor()、db() 以及部分路由函数也没有校验季度格式。
\d{4}Q[1-4] 格式;拒绝 NUL、绝对路径和 ..;用 realpath() 验证最终路径仍在真实 DATA_PATH 内;非法 type 直接抛错。高危,条件性F-08 敏感数据文件依赖 fail-open 的 Web 防护
ShardRouter.php:45-51;Schema.php:65-149, 270-293, 439-448, 857-863默认数据目录位于项目根下的 data。SQLite、会话、正文和 token 都是明文文件;Apache/IIS 防护文件写失败时只记日志并继续运行,已有防护文件也不会验证内容。Nginx 规则不在压缩包内。
应用层(app/)确认问题
高危APP-01 权限白名单为空时被多个入口当作“不限权限”
Permission.php:114-170;HomeController.php:53-65;ForumController.php:58-100;SearchController.php:76-79;Thread.php:347-352, 548-569权限体系已启用但用户组没有任何 can_view 记录时,getAuthorizedCategoryIds() 正确返回空数组表示“无可见版块”。但首页、论坛列表、游标分页和搜索控制器用 empty() 把空数组解释为“不限制”,或将其转换成 null;首页快照循环因此会遍历全部版块,论坛和搜索也可能查询全部帖子。
null 表示兼容旧版的“不限制”,用 [] 表示拒绝,并让所有控制器和模型保留该语义,禁止用 empty() 代替判断。高危,条件性APP-02 封禁用户的既有会话仍可调用上传接口
Auth.php:29-80;ApiController.php:145-167, 191-207Auth::getCurrentUser() 会检查 status 并清理封禁用户会话,但 /api/upload 和 fetchImage 只判断 isset($_SESSION['user_id']),随后直接使用该 ID 做上传权限判断,没有调用统一的当前用户校验。
Auth::getCurrentUser(),以返回的用户 ID 和状态作为唯一身份来源;封禁、删除和改密时统一清理现有 Session 与记住我 token。中危,资源耗尽APP-03 远程抓图在大小检查前完整读入响应
ApiController.php:248-256, 383-458fetchHopByHop() 通过 stream_get_contents() 或 cURL 返回完整响应,调用方之后才检查是否超过 5 MiB。远端服务器可发送远大于限制的响应,限制并未保护读取过程。
Content-Length;使用带累计字节上限的流式读取或 cURL write callback,超过上限立即中止;同时限制重定向总时长和响应头大小。中危APP-04 倒排搜索计数没有套用版块权限过滤
SearchController.php:76-79, 113-123;Search.php:85-95, 121-159倒排索引路径的 countTargets() 统计跨季度帖子总数时没有接收或应用 allowedCategoryIds;权限只在候选主题取回后过滤。即使用户有部分可见版块,结果数量和分页总数也会包含不可见帖子。
category_id 和状态过滤下沉到索引查询,或先取得完整候选集合再按权限计数,不能让候选上限决定权限结果。中危与可靠性问题
中危M-01 APCu 缓存不区分 dataPath,且命中缓存绕过 limit
ExternStorage.php:25-28, 225-231, 341-352缓存键只有 extern_t{id}/extern_r{id},没有数据根、相对路径或版本;缓存命中时直接返回完整正文,不检查调用方传入的 $limit。
中危M-02 归档读取和迁移提取没有检查短读
ExternStorage.php:322-327;ExternNamespaceMigrator.php:369-378读取长度头后,fread() 只判断是否为 false,不判断返回长度是否等于声明的正文长度。
$content !== false && strlen($content) === $len,否则整条/整块失败并保留原始文件。中危M-03 rename 失败时无条件删除有效目标
ExternMerger.php:227-242;ExternNamespaceMigrator.php:341-355;ExternIdxRepairer.php:244-259第一次 rename() 失败后直接 unlink($target),第二次替换仍失败时,原目标已经被删除。
中危M-04 桶扩容配置不是原子写入,且读取旧值早于加锁
BucketAutoScaler.php:60-109oldSize 在获得锁前读取,两个扩容请求可能基于同一旧值写回不同结果。配置文件用直接 file_put_contents() 截断写回,不是临时文件加原子替换;备份复制失败也未阻断更新。
中危M-05 归档维护锁是尽力而为,迁移没有全局维护锁
ExternStorage.php:173-177;ExternMerger.php:284-288;ExternNamespaceMigrator.php:63-216索引锁打开或加锁失败时,代码仍继续执行读改写;命名空间迁移也没有和正文写入、编辑、删除、合并共享的维护锁。
中危M-06 WAL 不生效时仍继续运行
DBFactory.php:73-82检测到实际 journal_mode 不是 WAL 时只写日志,不阻止继续使用。
synchronous=FULL。中危M-07 APCu 计数失败会静默丢浏览量
ViewCounter.php:66-86apcuUsable() 的结果在进程内缓存;之后 apcu_inc() 的返回值和成功状态未检查,失败时直接返回。
apcu_inc() 成功状态,失败立即回退 SQLite;长驻进程定期重新探测可用性。中危M-08 扫描、修复和搜索存在无界资源消耗
ExternMerger.php:166-212, 310-328;ExternIdxRepairer.php:220-241;SearchIndexStore.php:219-249, 315-381归档扫描将大量文件和 ID 放入内存,IDX 修复整块 file_get_contents(),搜索 token 数量和正文/队列负载没有在本组件设置硬上限。
中危M-09 连接池无法可靠识别原始 SQL 事务
DBFactory.php:128-148;IDGenerator.php:84-86BEGIN IMMEDIATE 不一定让 PDO::inTransaction() 返回真。连接池满时可能把仍有原始事务的连接当成空闲连接淘汰。
需要部署与集成确认的安全控制
- 管理员权限和 CSRF:
app/Controllers/Admin/BaseController.php已执行管理员与二次验证,后台写操作普遍使用 CSRF;仍需在真实路由上确认这些控制器不能被绕过,并确认 CLI/cron 调用不暴露 HTTP 管理入口。 - 密码与 token:已看到
Auth、RememberMe对当前用户状态和 remember token 的校验,但密码登录、邮箱验证和密码重置的完整生成/过期/撤销链路仍需结合根目录入口确认。必须确认密码使用password_hash/password_verify,单次 token 使用后失效。 - Stored XSS:外置正文明确按 HTML 存储;本包不做 HTML 清洗。若上层把普通用户正文直接输出到页面,必须使用可信 HTML 白名单清洗或严格转义。
- 文件上传:
Upload和 API 已有 MIME、路径、CSRF、版块权限与 ZIP Slip 防护,但仍需在真实服务器测试符号链接、超大 multipart、压缩包资源上限以及上传目录不可执行/不可直接访问。 - 数据完整性:
DBFactory.php:33-35强制关闭 SQLite 外键。若上层级联删除不完整,会留下孤儿记录,因此应有集成测试和定期一致性检查。
检查结果与限制
- 多数 SQL 值使用 PDO 参数绑定;批量删除 ID 会先转整数。基于当前 SplitDB 与 app 核心链路,未发现明显 SQL 注入。
- 未发现
eval、命令执行、反序列化等明显 RCE 原语。 - 工作区没有根目录
config.php、index.php、完整路由、Composer manifest/lock、测试和部署配置,因此无法完成依赖漏洞、真实 HTTP 路由、上传和端到端测试审计。 - 环境没有 PHP CLI,未能运行
php -l或 PHPUnit;本报告结果来自代码静态检查和独立复核。
建议修复顺序
- 立即修复 F-01 至 F-05 与 APP-01 至 APP-02:归档删除索引、合并/编辑锁、迁移歧义、短写校验、权限空集语义和封禁会话 API 校验。
- 为远程抓图增加流式大小上限;让搜索计数与列表严格复用同一权限过滤。
- 在所有公共入口统一校验路径、季度、类型和数据根;数据目录移出 Web 根目录。
- 为队列增加 lease token、续租和带 token 的完成条件。
- 修复扩容配置原子写入,并让 WAL/数据目录保护失败时 fail-closed。
- 补齐控制器、认证、上传、部署配置后做一次端到端复审,并加入并发、磁盘满、迁移中断和恢复测试。
outputs/splitdb_audit_report.html