本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:合并篇(见正文内 merged_from 列表)。
全量代码审计与四批次修复实施报告
本篇由 9 篇原始文档合并去重而成:5 篇分层全量审计报告(Core / Controllers / Models / Helpers / SplitDB)+ 1 篇逐条复核报告 + 3 篇批次修复实施报告(P0+P1 / P2 / P3)。
合并口径:以复核定过级的结论为准,原始报告的重复条目只保留一行;修复情况按批次归并,不再逐篇罗列。
一、总览
2026-09-02 一天之内完成「五层全量静态审计 → 逐条人工复核定级 → 四批次修复落地」。
| 阶段 | 内容 | 产出 |
|---|---|---|
| 审计 | app/ 下 Core / Controllers / Models / Helpers / SplitDB 五层逐行静态审查,不运行代码 | Core 26 项、Controllers 15+ 项、Models 29 项、Helpers 11 项、SplitDB 18 项 |
| 复核 | 对每条发现回到当前磁盘代码取证(文件:行号 + 代码摘录),结合跨层调用链与 Schema 判断真实影响 | 三态归类:确认属实 / 属实但评级需降 / 不属实或已被现状解决 |
| 修复 | P0+P1 一批、P2 一批、P3 四小批 | 合计修复 8 + 12 + 20 = 40 项,跳过 3 项(均注明原因) |
回归基线: cli/smoke_frontend_flow.php 修复前后均为 21 通过 / 0 失败;cli/smoke_queue_consumer.php = 11 通过 / 0 失败。
修复过程纪律: 只改代码不动真实业务数据;回归用独立临时数据冒烟脚本;临时探针脚本用完即删。
二、五层审计发现清单
2.1 Core 层(app/Core/ 6 个文件,约 73 KB,26 项)
| 编号 | 问题 | 复核结论 |
|---|---|---|
| H-01 | TemplateCompiler {loop} 捕获组 [^\s]+? 允许括号,{loop $getUsers() $x} 编译为 foreach,与已删的 {php} 同级函数调用向量 | 属实,评级降为中危·潜在 RCE(模板来自 app/Views/ 与 assets/themes/ 本地文件,后台主题上传/删除已禁用,当前部署不可达;仍作纵深防御修复) |
| H-02 | Model::buildWhere 字符集正则放行括号/逗号/OR,注释却称"禁止任意原始 SQL" | 属实,降为中危·设计缺陷(现存调用方均走参数绑定,未发现拼接用户输入路径) |
| M-01 | CSP 允许 unsafe-inline / unsafe-eval | 属实,维持中危 |
| M-02 | update() 空数据生成 UPDATE t SET WHERE 语法错误 | 属实,维持中危 |
| M-03 | 表名/主键未做标识符引用,仅 insert 列名加引号 | 属实,降低危 |
| M-04 | {yield default} 默认值原样拼接进 PHP | 与 H-01 同源,随 H-01 一并收紧 |
| M-05 | pagination() 的 $url 未做协议校验 | 属实,实际风险低(调用方均为写死常量),降低危 |
| M-06 | Router 插件钩子无外层 try-catch | 部分属实——Plugin::hook() 内部已对每个钩子文件 include 做 try-catch(Plugin.php:243-248),缺的只是外层兜底,降低危 |
| L-01 ~ L-08 | 自动加载无路径校验、viewRaw() 缺安全头、json() 无 nosniff、错误页硬编码、count() 冗余、icon() extraClass 未转义、模板缓存目录 0755、模板缺失输出注释泄路径 | 全部属实,维持低危 |
| A-01 ~ A-05 | Database 单例紧耦合、Model 与 Database 紧耦合、TemplateCompiler 953 行职责过重、安全头重复、注释与行为不一致 | 属实,架构建议级 |
| P-01 ~ P-03 | icon 静态映射解析开销、无查询结果缓存、路由线性匹配 | 属实但收益极小 / 可忽略 |
2.2 Controllers 层(根目录 12 个 + Admin 16 个,15+ 项)
| 编号 | 问题 | 复核结论 |
|---|---|---|
| H-1 | AttachmentController::download 通过 LEFT JOIN business.threads 取版块,SplitDB 迁移后该表已停写 → JOIN 恒 NULL → category_id=0 → 权限校验整段跳过 | ✅ 属实,本轮最值得先修的发现。游客可下载任意私密版块附件 |
| M-1 | DashboardController::index 查已退役 threads 表,仪表盘"最新帖子"恒空 | ✅ 属实(功能缺陷) |
| M-2 | TagsController::show 权限过滤在分页之后执行,分页数据不一致 | 属实 |
| M-3 | Admin/VerifyController::index 管理员二次验证无频率限制 | 属实 |
| L-1 ~ L-5 | 自定义 CSS 过滤不完整、ThemeController::removeDir 死代码、批量操作 ID 未 intval、插入与计数无事务、备份下载 GET 无 CSRF | 属实 |
| B-1 ~ B-3 | 用户搜索总数硬上限 50、$latestThreads 未显式初始化、未检查收件人拒收私信 | 属实(业务逻辑) |
| Q-1 ~ Q-3 | 分页逻辑大量重复、SettingsController 方法体过长、ApiController 职责过多 | 质量改进级 |
2.3 Models 层(app/Models/ 10 个模型,29 项)
| 等级 | 条目 |
|---|---|
| Critical 5 | C-1 删除用户附件删除路径遍历;C-2 未清理用户自己的回复(桶行+extern+reply_index 全残留);C-3 defaultAvatarPath 调 mt_srand 污染全局随机种子;C-4 分片写入无事务保护;C-5 User::search LIKE 通配符转义变量未使用 |
| High 8 | H-1 countByThread 未过滤软删;H-2 删最后回复后 last_reply_time 不回退;H-3 非 sync 模式下 total_threads 永不更新;H-4 Category 删除单版块场景指向已删版块;H-5 BlogCategory 删除无事务;H-6 更新正文旧 extern 文件不删;H-7 Thread::update 空字段 SQL 语法错误;H-8 hardDelete 在事务外执行 |
| Medium 8 | M-1 桶容量只统计 topic;M-2 Model 层直接调 Controller(分层倒挂);M-3 batchFetch 表名列名裸拼;M-4 缓存注释与实现不符;M-5 softDelete 无状态检查;M-6 软删未调 total_posts;M-7 Setting::setValue 先查后插竞态;M-8 邮箱 Token 无过期校验 |
| Low 8 | L-1 缓存路径策略不一致;L-2 无意义 public 包装;L-3 注释编号重复;L-4 废弃方法未清理;L-5 decorate 输出结构不一致;L-6 循环内逐个读 extern 文件 N 次 IO;L-7 无缓存;L-8 batchDelete 未清关联数据 |
复核中已明确判定:M-8 不属实(AuthController 已有 24h 过期校验);L-4 不属实(getLatest/restore 有插件与回收站真实调用方)。
2.4 Helpers 层(11 项)
- 安全维度:SQL 注入 / CSRF / 认证会话 / 加密均通过;XSS 通过但有一处需关注;文件上传存在 finfo 失败时仅凭扩展名降级放行的 polyglot 风险;权限模型与插件安全各 1 项中危。
- 性能维度:缓存策略、查询优化、内存资源、文件 IO 均良好;1 处 N+1。
- 质量维度:规范优秀、错误处理良好;耦合度中等;2 处重复代码;若干死代码与全局函数问题。
2.5 SplitDB 层(18 项)
- H-01 跨季度编辑时旧归档
.idx按"新路径目录"作废,旧季度索引永不失效,编辑可能静默不生效。 - H-02 单条巨型多行 INSERT 占位符数量依赖编译期
SQLITE_MAX_VARIABLE_NUMBER(默认 999),宿主长文整体回滚。 - M-01 浏览量计数 APCu 键无 TTL 只减不删 + flush 先扣后落库(DB 失败丢计数)。
- 其余为健壮性/架构类中低危项(M-02 起,多在本轮 P2 批次处理)。
三、复核定级:三个最关键的跨层真实问题
复核报告(对豆包五层报告的逐条回查)最终锁定三项,全部在本轮 P0/P1 修复:
| 问题 | 跨层表现 | 风险 |
|---|---|---|
| 附件下载权限绕过 | Thread::insert 早已不写 business.threads(只写分片桶 + main_index),但 AttachmentController::download 仍 LEFT JOIN threads 取 category_id | 新帖附件 category_id=0,权限校验整体被跳过,私密版块附件可被游客直接下载 |
| 模板注入 → 任意函数调用 | {php} 已删,但 {loop} 数组变量与 {yield default} 默认值未做表达式白名单 | 主题/插件模板一旦可被写或由不可信来源引入,即有 RCE 风险 |
| 分片写入无补偿 + 退役表仍被读 | 正文/桶/索引多步写入无事务回滚;business.threads/posts 已停写却仍被 Dashboard、附件下载等读取 | "写入走新路、读取走旧路"的静默数据漂移 |
四、修复实施
4.1 P0 + P1 批次(共 8 项,全部修复并验证)
P0 —— 安全 / 越权 / 模板注入(3 项)
| 项 | 问题 | 改法 | 涉及文件 |
|---|---|---|---|
| P0-1 | 附件下载权限绕过(见 §三-1) | 附件行查询去掉 LEFT JOIN threads;版块归属改从 main_index.topic_index 按 thread_id 单查 category_id;校验收紧为白名单式——categoryId <= 0(含 thread_id 为空或索引无行)或 can_view/can_attach 任一不通过即返回 403,不再默认放行。basename + realpath 双重路径防护保留 | app/Controllers/AttachmentController.php |
| P0-2 | {loop} 函数调用向量 | 捕获组由 [^\s]+? 收紧为 [a-zA-Z_][a-zA-Z0-9_\[\]\'"\->]*(标识符 + 数组下标 + 引号 + 属性访问),不含括号与空白;函数调用形态不再匹配该标签,留在输出文本中不编译。三形 loop 与 fixArrayKeys 行为不变 | app/Core/TemplateCompiler.php |
| P0-3 | {yield default} 表达式注入 | cbYieldDefault 重写——默认值一律视为文案:引号包裹且闭合的先去外层引号、未加引号的按纯文本处理,统一反斜杠/单引号转义后以单引号字符串字面量输出;system('id') 只作文本不执行 | app/Core/TemplateCompiler.php |
P1 —— 数据一致性 / 防崩溃 / 功能缺陷(5 项)
| 项 | 问题 | 改法 | 涉及文件 |
|---|---|---|---|
| P1-1 | 仪表盘"最新帖子"恒空 | 改读 main_index.topic_index(status=0 AND deleted_at IS NULL ORDER BY create_time DESC, id DESC LIMIT 10),随后两条 IN(...) 批量补 users.username 与 categories.name(一次请求 3 条查询,无 N+1);清理无用 use | app/Controllers/Admin/DashboardController.php |
| P1-2 | 删用户后其在他帖下的回复成孤儿 | Thread::hardDelete 循环后新增兜底:按 reply_index WHERE uid = 被删用户 取残留 → 按 bucket_path 分组 → 逐桶 DELETE reply 行 + ExternStorage::delete 删正文 + DELETE reply_index 行,并按条数 runtimeDecr('total_posts') 保持全站计数一致 | app/Models/User.php |
| P1-3 | Category 删除 fallback 指向自身 / 唯一版块误删 | fallback 排除自身;唯一版块拒绝删除;补三语文案 | app/Models/Category.php、app/Controllers/Admin/CategoryController.php、lang/{zh,en,zh_tw}.php |
| P1-4 | 编辑正文后旧 extern 文件不删 | 更新时删除旧正文文件,写入新文件 | app/Models/Thread.php、app/Models/Post.php |
| P1-5 | User::search LIKE 通配符转义未生效 | 转义后的变量真正参与绑定 | app/Models/User.php |
P0+P1 验证: 11 个改动文件 php -l 全部无语法错误;{loop} 编译探针(临时脚本,已删)确认合法形态正常编译、{loop $fn() $x} 等攻击形态全部拒绝;{yield default} 探针确认 system('id') 只作纯文本;冒烟脚本 21 通过 / 0 失败,无回归。
4.2 P2 批次(健壮性 / 架构 / 性能,15 项中 12 修 3 跳)
| 项 | 问题 | 改法 | 涉及文件 |
|---|---|---|---|
| P2-① | buildWhere 校验形同虚设 | 改白名单解析器:仅允许「[表前缀.]标识符 比较运算符 :占位符」以 AND 连接;其余(OR/IN/子查询/字面量/函数)一律抛 InvalidArgumentException,fail-closed | app/Core/Model.php |
| P2-③ | 搜索两轮逐帖 find() 的 SQL N+1 | 候选扫描与输出均改一次 Thread::getByIds() 批量取基行;正文由新增 Thread::fillExternContent() 按桶分组批量查 extern_path + 读文件回填;输出顺序/_score/高亮/摘要与旧逻辑一致 | app/Helpers/Search.php、app/Models/Thread.php |
| P2-④ | finfo 失败时非图片仅凭扩展名放行 | finfo 不可用 → 所有类型拒绝上传 + error_log 严重日志,删除降级放行分支 | app/Helpers/Upload.php |
| P2-⑥ | 跨季度/归档后编辑,旧 .idx 永不失效 | ExternStorage::write() 新增可选 $oldRelPath,作废按旧 relPath 所在季度目录定位;Thread/Post::update 均传旧 extern_path;抽 atomicRename() 统一两处 tmp+rename 的 Windows 兜底 | app/SplitDB/ExternStorage.php、app/Models/Thread.php、app/Models/Post.php |
| P2-⑦ | 巨型 INSERT 依赖 SQLITE_MAX_VARIABLE_NUMBER | 按 150 token/条分块 INSERT(占位符 ≤750,低于默认 999),同一事务,任一块失败整体回滚 | app/SplitDB/SearchIndexStore.php |
| P2-⑧ | ViewCounter:apcu 键永驻、flush 先扣后落、归零键不删 | inc() 传 TTL 3600s;flush() 改事务内先落库、COMMIT 成功后统一 apcu_dec,归零键 apcu_delete;DB 失败回滚时计数保留下窗口重试 | app/SplitDB/ViewCounter.php |
| P2-⑨ | Post 直接调 Controller(Model→Controller 反向依赖) | 新建 BucketAutoScaler.php 承接 currentBucketSize / updateBucketSize(+upsertDefine) / emergencyExpand;Post 改调服务类;DatabaseController 三方法降为薄委托 | app/SplitDB/BucketAutoScaler.php(新增)等 3 个文件 |
| P2-⑩ | DBFactory:closeAll 无效代码;evictLRU 淘汰前不查活动事务 | 删无效 foreach;淘汰前检查 inTransaction(),最久未用者在事务中则改挑非事务句柄,全在事务中则放弃淘汰 | app/SplitDB/DBFactory.php |
| P2-⑪ | 队列 done/failed 永不清理;recoverTimeout 仅空闲触发 | 新增 purgeCompleted($retentionSeconds)(默认 7 天);consume() 忙碌与空闲路径均按 MAINTENANCE_INTERVAL=60s 节流维护;构造器新增 retention 参数(默认 604800,向后兼容) | app/SplitDB/QueueConsumer.php |
| P2-⑫ | 回复容量守卫只统计 topic 行数 | 改 topic + reply 双 COUNT(reply 与 topic 同桶同库),60s 进程内缓存与 90%/100% 阈值语义不变 | app/Models/Post.php |
| P2-⑬ | 统计缓存路径不一致 + docblock 谎称 60s 缓存 | 新增 categoryStatsPath() 统一到 data/runtime/category_stats.cache;invalidate 与 get 读写同路径(防失效落空);保留旧路径一次性兼容清理;3 处 docblock 纠偏为"永久文件缓存 + 请求内静态缓存(发帖/回帖主动失效)" | app/Models/Category.php |
| P2-⑭ | Setting::setValue 先查后插竞态 | 改单条 UPSERT:INSERT … ON CONFLICT("key") DO UPDATE SET value=excluded.value(SQLite 3.39 ≥ 3.24,已实测) | app/Models/Setting.php |
P2 跳过 3 项(均注明原因)
| 项 | 建议动作 | 跳过原因 |
|---|---|---|
| P2-② | CSP 移除 unsafe-eval | 会破坏现有功能:assets/lib/fhstate.js(Alpine.js v3.x)依赖 new Function + with(scope) 运行时编译指令,全站视图大量使用;移除将令全部 Alpine 交互失效。需先迁移 CSP 编译版或改原生 JS,属独立改造项 |
| P2-⑤ | Permission 默认放行收敛 | 会破坏现有功能:getAuthorizedCategoryIds() 在权限表整体非空后按"该组有 can_view 行"过滤,历史版块无行;自动插默认记录会让表由空转非空,所有无行旧版块从授权列表剔除。安全落地需存量数据迁移 + 后台权限页语义变更,属产品策略项 |
| P2-⑮ | Thread::softDelete 同步删 thread_tags | 会破坏恢复功能:Thread::restore 只重置 status/deleted_at、不重挂标签,软删即删标签会让误删帖恢复后永久丢标签。更安全路径是在 Tag 查询端过滤,留作后续单独项 |
4.3 P3 批次(代码卫生 / 低危,四个小批)
第一批 · 安全头与响应一致(3 项)
- P3-1 新增
Controller::setSecurityHeaders()(nosniff / XFO / Referrer-Policy / CSP / Permissions-Policy / 条件 HSTS),view()与viewRaw()共用;json()补 nosniff。 - P3-2
Router的route_before_dispatch钩子补 try-catch + error_log 隔离。 - P3-3 备份下载由 GET 链接改 POST 表单 + CSRF token(
Csrf::verifyOrDie),视图按钮同步改 POST 表单。 - 关于一次性 token:项目 Csrf 为 30 分钟会话级、
verify()不轮换(防并发 AJAX 403),且数据库管理页多操作共享同一渲染 token;下载后强制轮换会令同页其余表单失效,故采用会话级 token,"轮换为一次性"留作后续项。
第二批 · 防御性收紧(3 做 1 跳过)
- P3-4 Model 基础 CRUD 的表名与主键统一加双引号标识符引用(子类
$table均为裸标识符、无$primaryKey覆盖,已 grep 确认无保留字风险)。 - P3-5 Admin 批量操作 ID 在控制器层
array_map('intval') + 过滤 ≤0(batch_move 走基类batchMoveIds,内部已 intval+500 上限,不重复加)。 - P3-6
User::countSearch()独立 COUNT;用户 Tab 总数改用 countSearch(不再受 50 截断影响),深页分页可达。 - ⏭️ 跳过 Upload finfo 全拒:P2-④ 已实现,本批无需重复。
第三批 · 代码清理 + 注释纠偏(先 grep 后动)
ThemeController::removeDir:app/cli/plugins/update 全 grep 仅自身定义 + 自递归,无外部调用方 → 删除整个私有方法。Blog::decodeRowsPublic:BlogController.php:89有真实调用 → 不删(跳过并注明)。Settings.php空注释节"分页工具" → 删除。CountingPDO.php文件头"页脚总 SQL即为全库真实查询总数"误导注释 → 纠偏为真实口径(页脚展示Database::getQueryCount()业务库查询数,getTotalSqlCount()仅 cli 诊断用),不改任何逻辑。- Category "60 秒" docblock 已由 P2-⑬ 全部纠偏,无残留;Thread/Post insert 步骤编号"7 出现两次"系将 7.5 误读,现码连续,均无需改动。
第四批 · 收尾低危(5 项)
- P3-7 模板缓存目录
@mkdir0755 → 0750(收紧 group/other 可读)。 - P3-8
icon()的$extraClass拼入 class 前htmlspecialchars转义(纵深防御)。 - P3-9 模板缺失由
echo "<!-- Template not found -->"改为静默失败 + error_log(不再向页面输出内部模板结构)。 - 另含 CSP 相关与响应一致性收尾项(随第一批口径统一)。
P3 验证: 每小批各跑一次 cli/smoke_frontend_flow.php,四次均 21 通过 / 0 失败;queue 冒烟 11 通过 / 0 失败。
五、验证结论
| 验证项 | 结果 |
|---|---|
改动文件 php -l | P0+P1 11 个、P2 14 个文件全部无语法错误 |
| buildWhere 白名单解析器 | 合法形态("key" = :key、username = :username、t.id = :id AND t.status = :status)全部放行;注入形态(id = 1 OR 1=1、子查询、字面量、IN、分号拼接)全部抛异常 |
{loop} / {yield default} 编译探针 | 合法形态正常编译,攻击形态被拒或降级为纯文本(探针为临时脚本,已删) |
cli/smoke_frontend_flow.php | 修复前 21/0 → 修复后 21/0,无回归;覆盖发帖/搜索/回帖/投票/标签全链路 |
cli/smoke_queue_consumer.php | 11/0;覆盖 P2-⑪ recoverTimeout/purge/drain |
| extern 回写核对(P2-⑥) | Thread::update / Post::update 均仍在每次正文编辑后回写 extern_path,无"编辑后仍读旧文件"路径 |
六、明确未动(延续约定)
- M-8:邮箱 Token 过期——
AuthController已强制 24h 校验,原报告判"无过期机制"不属实。 - L-4:
Thread::getLatest/Post::restore——存在 cli、插件、回收站真实调用方,原报告判"废弃方法"不属实。 - L-08(SplitDB):页脚已移除总 SQL 展示,原报告依据的是过期注释。
- C-3:
mt_srand影响安全随机——经核实不影响安全随机,未修改。
本轮处置边界: 未触碰真实业务数据;回归基于独立临时数据冒烟脚本;临时探针脚本用完即删。