[审计]FlintHub / SplitDB PHP 全面审计报告

👑Lv.11 元老 🌞 骨干会员
2026-10-26 10:56:44

FlintHub / SplitDB PHP 全面审计报告

审计对象:splitdb.zip 的 14 个 PHP 文件,以及 app/ 中与认证、权限、搜索、上传、附件、后台和 SplitDB 集成相关的核心链路。审计日期:2026-09-29。

结论

未发现明显的远程代码执行或 SQL 注入点,但组件存在正文丢失、删除后仍可读取、并发重复执行、权限过滤失效、封禁会话继续写入和资源耗尽等风险。上线前应至少完成高危项 F-01 至 F-08 及 APP-01 至 APP-02 的修复或补充验证。

这是一次静态审计。应用压缩包仍缺少根目录入口、完整路由、生产配置和 Web Server 规则,因此涉及“是否能被公网触发”的结论均已明确标注。

10高危或条件性高危
11中危及可靠性问题
0已确认 SQL 注入/RCE
SplitDB + app已审查范围

高危问题

高危F-01 归档后的正文删除不生效

ExternStorage.php:273-307, 341-354

delete()只删除同目录的 .txt 和 APCu 缓存,没有从 .idx 删除 ID。正文已经合并到 .bin 后,readFromBin() 仍会根据旧索引返回内容;函数还可能在归档正文仍可读时返回成功。

影响:硬删除不彻底,已删除的帖子、回复或私密正文会继续保留并可被读取。实际公网影响取决于上层是否在读取前强制检查删除状态。
修复:删除时在同一把 .idx.lock 下移除索引条目;硬删除时安排归档块重写/压缩;分别定义软删除和硬删除语义,并只在两者都成功后报告成功。

高危F-02 归档合并与编辑存在竞态,可能删除新正文

ExternMerger.php:279-288, 351-355;ExternStorage.php:74-107

合并器在写完 .bin/.idx 后先释放索引锁,之后才删除 .txt。编辑流程写入新 .txt 并另行使旧索引失效,二者之间没有共同的组级锁。

影响:合并器可能在编辑请求写入新正文后删除该新文件,造成正文为空;或者旧归档内容继续被命中。
修复:把“读 TXT、写 BIN/IDX、删除 TXT”放进与编辑/删除相同的锁内;或者删除前比较 inode、mtime、size/hash,文件变化就放弃删除。

高危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。

影响:大正文归档后变成前台空白,且没有 TXT 兜底。
修复:在写入阶段执行相同硬上限,或把读写都改为流式处理;在删除 TXT 前验证归档条目可以完整读回。

高危F-05 忽略短写,磁盘异常时可能先损坏再删除原文

ExternMerger.php:310-333, 351-355;ExternStorage.php:80-92

合并器忽略两次 fwrite() 的返回值,只检查 fflush()/fclose();多个原子写函数也只判断 file_put_contents() === false,没有判断写入字节数是否等于预期。

影响:磁盘满、短写或文件系统异常时,可能生成截断的 BIN/IDX,然后删除仍然完整的 TXT,造成永久数据损坏。
修复:使用循环写满并检查每次返回值;校验文件长度和条目可读性;校验失败时保留 TXT,禁止删除。

高危,条件性F-06 队列没有租约所有者,任务可能重复执行

QueueConsumer.php:164-192, 198-213, 223-242

任务处理超过默认 300 秒后会被恢复为 pending,另一个 worker 可重新抢占;原 worker 结束后仍按 id 更新为 done,没有 lease token 或 worker fencing。

影响:索引、统计、通知或其他非幂等任务可能重复执行,旧 worker 还可能覆盖新 worker 的状态。是否可触发取决于 handler 的最长执行时间和幂等性。
修复:增加随机 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-156

relPath 通过字符串拼接使用,只有 ltrim(),没有拒绝 ..、绝对路径或符号链接逃逸。SearchIndexStore::pathFor()、db() 以及部分路由函数也没有校验季度格式。

影响:若这些公共参数被 HTTP 参数、被污染的数据库字段或不可信后台输入控制,可能越界读取、删除或创建应用可访问的文件/SQLite 库。代码层缺陷可确认,但当前压缩包没有上层调用,无法确认公网可利用性。
修复:路径只由可信 ID、类型、季度、桶号重新生成;季度只允许 \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 规则不在压缩包内。

影响:部署在 Web 根目录且服务器未正确拒绝访问时,可能泄露会话、密码哈希、密码重置/登录 token、私信和正文。密码是否使用安全哈希、token 是否失效无法从本包确认。
修复:把 DATA_PATH 放在 Web 根目录之外;目录/文件使用最小权限;启动时验证服务器拒绝规则,失败则拒绝启用;短期 bearer token 至少存哈希并支持撤销。

应用层(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-207

Auth::getCurrentUser() 会检查 status 并清理封禁用户会话,但 /api/upload 和 fetchImage 只判断 isset($_SESSION['user_id']),随后直接使用该 ID 做上传权限判断,没有调用统一的当前用户校验。

影响:管理员封禁账号后,仍持有未过期 Session 的用户可能继续上传本地附件或触发远程抓图写盘,直到 Session 自然失效。
修复:所有需要登录的 API 统一调用 Auth::getCurrentUser(),以返回的用户 ID 和状态作为唯一身份来源;封禁、删除和改密时统一清理现有 Session 与记住我 token。

中危,资源耗尽APP-03 远程抓图在大小检查前完整读入响应

ApiController.php:248-256, 383-458

fetchHopByHop() 通过 stream_get_contents() 或 cURL 返回完整响应,调用方之后才检查是否超过 5 MiB。远端服务器可发送远大于限制的响应,限制并未保护读取过程。

影响:已登录用户可反复提交大响应 URL,消耗 PHP worker 内存并造成请求级拒绝服务;是否可扩散为全站影响取决于进程管理和并发限制。
修复:读取前检查并限制 Content-Length;使用带累计字节上限的流式读取或 cURL write callback,超过上限立即中止;同时限制重定向总时长和响应头大小。

中危APP-04 倒排搜索计数没有套用版块权限过滤

SearchController.php:76-79, 113-123;Search.php:85-95, 121-159

倒排索引路径的 countTargets() 统计跨季度帖子总数时没有接收或应用 allowedCategoryIds;权限只在候选主题取回后过滤。即使用户有部分可见版块,结果数量和分页总数也会包含不可见帖子。

影响:用户可通过搜索页获知受限版块的匹配数量,页面可能显示空页、错误页数或错误的混合总数;当 APP-01 的空数组语义错误同时发生时,可能进一步直接返回受限主题数据。
修复:计数与取数使用同一版块过滤策略;最好把 category_id 和状态过滤下沉到索引查询,或先取得完整候选集合再按权限计数,不能让候选上限决定权限结果。

中危与可靠性问题

中危M-01 APCu 缓存不区分 dataPath,且命中缓存绕过 limit

ExternStorage.php:25-28, 225-231, 341-352

缓存键只有 extern_t{id}/extern_r{id},没有数据根、相对路径或版本;缓存命中时直接返回完整正文,不检查调用方传入的 $limit。

影响:多租户或同一进程服务多个数据根时可能串读;摘要调用可能得到全文;编辑/迁移后可能读到旧内容最长 600 秒。
修复:缓存键加入规范化 dataPath、type、relPath 和版本;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),第二次替换仍失败时,原目标已经被删除。

影响:异常权限、占用、网络盘或文件系统故障下,可能丢失原有 BIN、IDX 或备份。
修复:采用目标改名为备份、替换成功后再删除备份;只在确认是目标已存在导致的兼容场景时执行替换。

中危M-04 桶扩容配置不是原子写入,且读取旧值早于加锁

BucketAutoScaler.php:60-109

oldSize 在获得锁前读取,两个扩容请求可能基于同一旧值写回不同结果。配置文件用直接 file_put_contents() 截断写回,不是临时文件加原子替换;备份复制失败也未阻断更新。

影响:桶数可能回退,扩容时间和前一桶数不一致,进程读取到半截 PHP 配置时可能站点报错。
修复:先锁后重读并校验;写临时文件、校验、同步后原子 rename;备份失败禁止继续。

中危M-05 归档维护锁是尽力而为,迁移没有全局维护锁

ExternStorage.php:173-177;ExternMerger.php:284-288;ExternNamespaceMigrator.php:63-216

索引锁打开或加锁失败时,代码仍继续执行读改写;命名空间迁移也没有和正文写入、编辑、删除、合并共享的维护锁。

影响:锁失效或维护任务并发时可能出现 lost update、错过新文件、块拆分与正文写入交叉。
修复:锁获取失败必须中止;迁移期间停写或使用统一维护锁和版本校验。

中危M-06 WAL 不生效时仍继续运行

DBFactory.php:73-82

检测到实际 journal_mode 不是 WAL 时只写日志,不阻止继续使用。

影响:在网络盘或不支持共享内存的文件系统上,锁行为和崩溃恢复假设不成立,高并发下更容易出现 SQLITE_BUSY 和一致性问题。
修复:若 WAL 是架构前提,应拒绝该数据根或进入维护模式;队列和 ID 库另评估 synchronous=FULL。

中危M-07 APCu 计数失败会静默丢浏览量

ViewCounter.php:66-86

apcuUsable() 的结果在进程内缓存;之后 apcu_inc() 的返回值和成功状态未检查,失败时直接返回。

影响:APCu 运行中耗尽或异常时,浏览量不落库且无告警。
修复:检查 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 数量和正文/队列负载没有在本组件设置硬上限。

影响:内存耗尽、SQL 语句过长、长时间持锁或队列数据库膨胀。是否可被普通用户触发取决于上层输入限制和后台权限。
修复:限制正文、token、任务负载和查询 take;迁移/修复按字节或时间预算分页;大块改为流式解析。

中危M-09 连接池无法可靠识别原始 SQL 事务

DBFactory.php:128-148;IDGenerator.php:84-86

BEGIN IMMEDIATE 不一定让 PDO::inTransaction() 返回真。连接池满时可能把仍有原始事务的连接当成空闲连接淘汰。

影响:同一进程新建重复连接,产生锁等待、SQLITE_BUSY 或连接数失控。
修复:统一使用 PDO 事务 API,或由连接池维护明确的事务租约,事务中的连接禁止淘汰。

需要部署与集成确认的安全控制

  • 管理员权限和 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;本报告结果来自代码静态检查和独立复核。

建议修复顺序

  1. 立即修复 F-01 至 F-05 与 APP-01 至 APP-02:归档删除索引、合并/编辑锁、迁移歧义、短写校验、权限空集语义和封禁会话 API 校验。
  2. 为远程抓图增加流式大小上限;让搜索计数与列表严格复用同一权限过滤。
  3. 在所有公共入口统一校验路径、季度、类型和数据根;数据目录移出 Web 根目录。
  4. 为队列增加 lease token、续租和带 token 的完成条件。
  5. 修复扩容配置原子写入,并让 WAL/数据目录保护失败时 fail-closed。
  6. 补齐控制器、认证、上传、部署配置后做一次端到端复审,并加入并发、磁盘满、迁移中断和恢复测试。
报告为静态审计结果,不代表已在真实服务器上完成渗透测试。文件位置:outputs/splitdb_audit_report.html
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 4 | Replies 0

All Replies (0)

No replies yet

Please Log in