[审计]SoSite 深度安全与代码审查报告

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:07:24

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:7月5日复制的资料/深度安全审查报告_20260703.md。

SoSite 深度安全与代码审查报告

审查日期:2026-07-03

审查范围:全部 128+ 个 PHP 文件 + 4 个 JS 文件 + 19 个 CSS 文件 + 插件代码

审查方法:逐文件阅读 + 全局交叉验证 + 不依赖历史MD文档的独立审查

目标:发现未被 MD 文档记载的遗留问题


一、审查结论

严重级别数量分类
🔴 严重安全风险2权限绕过、SQL注入风险
🟠 高危风险4逻辑漏洞、设计缺陷
🟡 重要缺陷8功能异常、代码质量问题
🟢 优化建议5冗余代码、性能优化


二、🔴 严重安全风险(2项)

2.1 收藏插件展示无权访问的帖子(权限绕过)

文件:plugins/post_favorite/FavoriteController.php L32-43

风险等级:🔴 严重

问题:收藏列表直接从 threads 表读取帖子标题/内容,完全不做权限过滤:

$favorites = $db->fetchAll(
    "SELECT f.id as fav_id, f.thread_id, f.created_at as fav_time,
            t.title, t.created_at, t.reply_count, t.view_count,
            t.user_id as thread_user_id, u.username
     FROM post_favorites f
     LEFT JOIN threads t ON f.thread_id = t.id
     LEFT JOIN users u ON t.user_id = u.id
     WHERE f.user_id = :uid
     ORDER BY f.id DESC LIMIT :lim OFFSET :off",
    [':uid' => $userId, ':lim' => $perPage, ':off' => $offset]
);

攻击场景:

  1. 版块 A 设为仅 VIP 可见,用户先在有权时收藏了里边的帖子
  2. 管理员将用户降级为普通会员,用户失去版块 A 的访问权限
  3. 用户打开收藏列表 → 仍然能看到版块 A 帖子的标题和链接
  4. 用户点击链接 → 被 ThreadController::show() 拦截 → 但标题已泄露

修复建议:在收藏列表查询后,用 Permission::getAuthorizedCategoryIds() 过滤结果:

$allowedIds = \app\Helpers\Permission::getAuthorizedCategoryIds((int)$user['id']);
if (!empty($allowedIds)) {
    $favorites = array_filter($favorites, function($f) use ($allowedIds) {
        // 但这里没有 category_id!需要先JOIN category_id
    });
}

更彻底的修复:查询时 JOIN categories 并 WHERE t.category_id IN ($allowedIds)。


2.2 init.php 数据库初始化函数存在注入风险

文件:protected/database/init.php L82-83

风险等级:🔴 严重(但仅限安装场景)

问题:tableExists() 函数使用字符串插值拼接表名:

function tableExists($db, $tableName) {
    $conn = $db->getConnection();
    $result = $conn->query("SHOW TABLES LIKE '{$tableName}'");
    return $result->rowCount() > 0;
}

getTableColumns() 同样使用 "SHOW COLUMNS FROM {$tableName}"。

当前安全:所有调用处传的是硬编码字符串('thread_votes'、'tags' 等),不是用户输入。

风险:如果未来有代码在安装过程后还调用此函数,且 $tableName 来源于用户输入,即可实现 SQL 注入。

修复建议:加反引号转义或白名单校验:

function tableExists($db, $tableName) {
    if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $tableName)) return false;
    // ...
}


三、🟠 高危风险(4项)

3.1 RateLimiter 完全基于 Session — 无 Cookie 即可绕过

文件:app/Helpers/RateLimiter.php

风险等级:🟠 高危

问题:所有频率限制数据存储在 $_SESSION 中,攻击者可以:

  1. 不携带 Cookie 发起请求 → 每次请求都是新 Session → 频率限制被完全绕过
  2. 甚至不需要换 IP,同一个 IP 不发送 Cookie 即可无限次尝试注册/投票/上传

影响面:

  • 注册频率限制(AuthController.php L130):每小时3次 → 实际可无限注册
  • 投票频率限制(ApiController.php L31):每分钟30次 → 实际可无限投票
  • 上传频率限制(ApiController.php L98):每分钟5次 → 实际可无限上传
  • 远程拉图频率限制(ApiController.php L130):每分钟20次 → 实际可无限拉图

MD 文档已知但一直未修复:安全审查备忘.md 7.7节记录了此问题,标为"建议"级别。

修复建议:将注册/投票等敏感操作的限流改为基于 IP 存储在 DB/Redis 中:

// 临时方案(DB存储,基于IP)
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$key = '_rate_limit_' . $action . '_' . $ip;
// 使用 settings 表或独立 rate_limits 表


3.2 QA 问答插件完全独立于权限系统之外

文件:plugins/qa/QaController.php 全部

风险等级:🟠 高危

问题:QA 问答系统使用了独立的表(qa_questions、qa_answers 等),完全不引用 app\Helpers\Permission。管理员无法限制特定用户组的问答访问权限。

具体风险:

  1. QA 问题对所有已登录用户可见,无版块级别的权限控制
  2. 即使设置了某个版块为 VIP 可见,QA 系统不受影响
  3. QA 中创建/回答问题的频率无限制(缺 RateLimiter)

影响面:权限系统存在"后门"——管理者以为限制了某个用户组的访问,但 QA 问答系统完全绕过。

修复建议(二选一):

方案 A:为 QA 增加独立的权限表

方案 B:在 QA 控制器入口处引用 Permission::canView() 做全局权限检查


3.3 签到插件每次请求都建表 + SQLite 分支

文件:plugins/daily_checkin/CheckinController.php L142-154

风险等级:🟠 高危(性能 + 安全隐患)

问题:每次签到请求都执行一次建表语句:

$db->query("CREATE TABLE IF NOT EXISTS daily_checkin (
    id {$ai},
    user_id INTEGER NOT NULL,
    checkin_date {$dateType} NOT NULL,
    ...
)");

并且 L144-145 仍在使用 SQLite 分支:

$ai = $db->getDbType() === 'mysql' ? 'INT AUTO_INCREMENT PRIMARY KEY' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
$dateType = $db->getDbType() === 'mysql' ? 'VARCHAR(10)' : 'TEXT';

问题:

  1. 每次签到都多一次 CREATE TABLE IF NOT EXISTS 查询(MySQL 需要检查表是否存在)
  2. SQLite 分支代码仅用于兼容但项目已"仅 MySQL"
  3. 严重影响性能:签到页面每次加载都调用 CREATE TABLE

修复建议:

  1. 将建表逻辑剥离到 activate() 方法中(插件启用时执行)
  2. 删除 SQLite 分支代码,仅保留 MySQL 语法

3.4 友情链接插件每次请求建表 + SQLite 分支

文件:plugins/friend_links/AdminController.php L15-28

风险等级:🟠 高危

问题:与 3.3 相同,每次进入友情链接管理页面都执行建表语句和 SQLite 分支判断:

$dbType = $db->getDbType();
$ai = $dbType === 'mysql' ? 'INT AUTO_INCREMENT PRIMARY KEY' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
$db->query("CREATE TABLE IF NOT EXISTS friend_links (id {$ai}, ...)");

修复建议:将建表逻辑移到插件初始化钩子中。


四、🟡 重要缺陷(8项)

4.1 删版块后帖子变为 category_id=0(孤儿)

文件:app/Models/Category.php L45-49

风险等级:🟡 中

代码:

public function deleteWithReset(int $id): void
{
    $this->db->query('UPDATE threads SET category_id = 0 WHERE category_id = :cid', [':cid' => $id]);
    $this->delete($id);
}

问题:

  1. category_id = 0 在 categories 表中不存在,帖子变成"孤儿"
  2. 首页 HomeController 用 categories JOIN 查询,这些帖子的 category_name 为 NULL
  3. 版块页 ForumController::category() 不再能查到这些帖子(因为 category_id 匹配不上)

影响:删版块后,该版块的帖子在其他地方不再显示,除非管理员手动指定新分类。

修复建议:

// 方案一:强制要求先手动转移帖子再删除版块(UI 提示)
// 方案二:弹窗提示"此版下有 N 个帖子,将移至未分类"


4.2 ForumController 计数查询使用脆弱的字符串替换

文件:app/Controllers/ForumController.php L29-43

风险等级:🟡 低

$allowedWhere = '';
if (!empty($allowedIds)) {
    $inClause = implode(',', $allowedIds);
    $allowedWhere = " AND t.category_id IN ({$inClause})";
}
// ...
$total = $threadModel->count('deleted_at IS NULL' . ($allowedWhere ? str_replace('t.', '', $allowedWhere) : ''));

问题:

  1. str_replace('t.', '', $allowedWhere) — 用字符串替换来去掉表别名,写法脆弱
  2. 如果 $allowedIds 中有非 int 值(理论上不会,但无防御),会导致 SQL 注入

修复建议:将 count() 方法改为支持别名参数:

$total = $threadModel->count('t.deleted_at IS NULL' . $allowedWhere, []);


4.3 博客修改封面图片存在 TOCTOU 竞态

文件:app/Controllers/BlogController.php L193-206

风险等级:🟡 低

代码:

$coverImage = (string)($blog['cover_image'] ?? '');  // L193: 从DB读取旧封面
// ...
if (!empty($_FILES['cover_image']['name'])) {
    $result = Upload::file($_FILES['cover_image']);
    if ($result['success']) {
        if ($coverImage && is_file(\UPLOAD_PATH . $coverImage)) { @unlink(\UPLOAD_PATH . $coverImage); }  // L199: 删除旧封面
        $coverImage = $result['filename'];  // L200: 设置新封面
    }
}

问题:并发请求删除旧封面时,可能两个请求都读到同一个旧封面文件名,然后都尝试 unlink — 第二个请求文件已不存在,@unlink 静默失败。虽不产生数据不一致,但行为不符合预期。

影响:极低,只在罕见并发场景下产生无意义的 unlink 调用。


4.4 博客评论无频率限制和权限控制

文件:app/Controllers/BlogController.php L86-105

风险等级:🟡 中

问题:博客评论没有任何 RateLimiter 调用和权限检查:

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $this->requireLogin();
    Csrf::verifyOrDie($_POST['csrf'] ?? '');
    $content = trim($_POST['content'] ?? '');
    if ($content !== '') {
        // 直接插入评论,无频率限制,无权限校验
        $blogModel->execute('INSERT INTO blog_comments ...');
    }
}

攻击场景:攻击者可脚本无限刷博客评论,无任何频率限制。

修复建议:在评论插入前加 RateLimiter::hit('blog_comment', 10, 60);


4.5 QA 控制器缺少 RateLimiter

文件:plugins/qa/QaController.php

风险等级:🟡 中

问题:QA 的提问(save())、回答(answer())、投票(vote())接口都没有调用 RateLimiter::hit()。

攻击场景:攻击者可脚本无限创建问题/回答/投票。

修复建议:在 save()、answer()、vote() 方法入口加:

RateLimiter::hit('qa_save', 5, 60);   // 每小时最多5个问题
RateLimiter::hit('qa_answer', 20, 60); // 每小时最多20个回答
RateLimiter::hit('qa_vote', 30, 60);   // 每分钟最多30次投票


4.6 论坛版块总数统计不按权限过滤

文件:app/Controllers/ForumController.php L97-98

风险等级:🟡 低(体验问题)

$totalThreads = Settings::getTotalThreads();
$totalPosts = Settings::getTotalPosts();

这两个数字是全局的,用户会看到"总帖子 100"但只能看到 20 条(因为他无权访问某些版块)。体验不一致。

修复建议:计算用户可见版块的帖子数(已有 $allowedIds)。


4.7 帖子和博客批量删除不清理附件

文件:app/Models/Thread.php L170-177, app/Models/Blog.php L96-104

风险等级:🟡 中

// Thread::batchDelete()
public function batchDelete(array $ids): int
{
    $ids = \array_filter(\array_map('intval', $ids));
    if (empty($ids)) return 0;
    $placeholders = \implode(',', $ids);
    $this->execute("DELETE FROM {$this->table} WHERE id IN ({$placeholders})");
    return \count($ids);
}

问题:批量删除帖子/博客时只删数据库记录,不清理上传的附件文件。这些文件变成"孤儿"文件,只能通过后台"清理孤儿附件"功能清理。

同样的问题在 Blog::batchDelete()(Blog.php L96-104)中也存在——只删 blogs 和 blog_comments,不删附件。

修复建议:batchDelete 中增加附件清理逻辑:

// 删除前先记录附件文件名
$atts = $db->fetchAll("SELECT filename FROM attachments WHERE thread_id IN ({$placeholders})");
foreach ($atts as $att) { @unlink(UPLOAD_PATH . $att['filename']); }
$db->query("DELETE FROM attachments WHERE thread_id IN ({$placeholders})");


4.8 $_GET['r'] fallback 未做安全限制

文件:app/Core/Router.php L76-78

风险等级:🟡 低

if (($uri === '/' || $uri === '/index.php' || $uri === '') && !empty($_GET['r'])) {
    $uri = '/' . ltrim($_GET['r'], '/');
}

问题:$_GET['r'] 直接作为路由路径,虽然路由匹配会校验(不存在时返回 404),但可以用于探测后台页面是否存在(如 ?r=admin/users),即使被重定向也会先走一次路由匹配。

修复建议:限制 $_GET['r'] 中不允许出现 admin 前缀,或直接移除这个 fallback 机制(旧版本兼容)。


五、🟢 优化建议(5项)

5.1 插件钩子文件中残留 SQLite 兼容代码

涉及文件:

  • plugins/daily_checkin/CheckinController.php L144-145(每次签到建表检查)
  • plugins/daily_checkin/Plugin.php(activate 和 deactivate 方法)
  • plugins/friend_links/AdminController.php L15-16(每次管理页面建表检查)
  • plugins/post_favorite/Plugin.php L14-15

问题:多处存在 $db->getDbType() === 'mysql' ? 'INT AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'。项目已仅 MySQL,这些分支永远不会走 SQLite 路径。

修复建议:统一删除 SQLite 分支,仅保留 MySQL 语法。


5.2 几个 Model 类仅为空壳

文件(6个空Model):

  • app/Models/BlogCategory.php
  • app/Models/BlogComment.php
  • app/Models/Message.php
  • app/Models/Tag.php
  • app/Models/OnlineUser.php
  • app/Models/PointsLog.php
  • app/Models/ThreadTag.php
  • app/Models/ThreadVote.php
  • app/Models/PostVote.php
  • app/Models/Attachment.php

问题:大部分仅定义了 $table 属性,实际查询通过 query() 或 Database::getInstance() 直接执行。这些空 Model 类增加了文件数但不提供实际价值。

修复建议:按需保留有使用价值的(如 Message 有 deleteOwned 方法),删除完全无方法的空壳类。


5.3 Controller 层仍有大量直接 SQL 调用

涉及文件:

  • ForumController.php — $threadModel->query(...)(L50)
  • BlogController.php — $blogModel->query(...)(L39)
  • ProfileController.php — $userModel->getThreads(...)(已有封装)
  • MessageController.php — 基本通过 Message Model

问题:SQL一致性迁移计划.md 计划将 Controller 中的所有 $db->query() 调用搬到 Helper 中,但目前尚未执行。


5.4 User::getStats() 4 条独立查询可合并

文件:app/Models/User.php L85-97

$threads = (int)$this->queryOne("SELECT COUNT(*) as c FROM threads WHERE user_id = :uid ...")['c'];
$replies = (int)$this->queryOne("SELECT COUNT(*) as c FROM posts WHERE user_id = :uid ...")['c'];
$views = (int)$this->queryOne("SELECT SUM(view_count) as total FROM threads WHERE user_id = :uid ...")['total'];
$blogs = (int)$this->queryOne("SELECT COUNT(*) as c FROM blogs WHERE user_id = :uid ...")['c'];

可合并为一条 SQL:

SELECT
  (SELECT COUNT(*) FROM threads WHERE user_id = :uid AND deleted_at IS NULL) as thread_count,
  (SELECT COUNT(*) FROM posts WHERE user_id = :uid) as reply_count,
  (SELECT SUM(view_count) FROM threads WHERE user_id = :uid AND deleted_at IS NULL) as total_views,
  (SELECT COUNT(*) FROM blogs WHERE user_id = :uid) as blog_count


5.5 Posts 查询结果含过多冗余字段

文件:app/Models/Post.php L9-19

"SELECT p.*, u.username, u.avatar, u.signature, u.role, u.points, u.level, u.post_count ..."

每个回复都查询了 u.signature, u.role, u.points, u.level, u.post_count 等字段。这些在回复列表中很少用到,但增加了传输数据量。默认每页20条回复,80条回复页就传了 80×6=480个冗余字段值。

修复建议:只在需要时才 SELECT 这些字段,或缓存在 Auth::getCurrentUser() 中。


六、已确认遗留(与MD文档一致的未修复项)

#问题优先级首次提及说明
1fillable 白名单P207-02Model 层 insert/update 无批量赋值保护
2SQL 一致性迁移中SQL一致性迁移计划.mdController 中散落 $db->query()
3旧帖子批量补缩略图脚本低07-02未编写 CLI 脚本
4标签云颜色按频率分级低项目档案.md未实现
5role/group_id 双轨制统一架构级07-01role 字段仍在使用


七、修复优先级总表

立即修复 — 🔴 P0

#问题修复方案预估工时
2.1收藏插件权限绕过查询中 JOIN categories + WHERE IN 权限过滤30分钟
2.2init.php SQL注入风险加正则白名单校验10分钟

尽快修复 — 🟠 P1

#问题修复方案预估工时
3.1RateLimiter Session绕过关键接口改用 DB 存储1小时
3.2QA无权限控制加 Permission 检查或独立权限表1-2天
3.3签到插件每次建表移至 activate()30分钟
3.4友情链接每次建表移至 activate()30分钟

建议修复 — 🟡 P2

#问题修复方案预估工时
4.1删版块后帖子变孤儿提示先转移30分钟
4.5QA缺RateLimiter加频率限制30分钟
4.4博客评论缺RateLimiter加频率限制10分钟
4.7批量删除不清理附件加附件清理30分钟

顺手修复 — 🟢 P3

#问题修复方案
5.1插件SQLite残留删除分支代码
5.2空壳Model类删除无用文件
5.3SQL一致性迁移按计划逐步执行
5.4getStats 4条查询合并为单条
5.5Post查询冗余字段精简SELECT


八、总结

本次深度审查共发现 19 项新增问题(2项严重 + 4项高危 + 8项中危 + 5项建议)。

最值得关注的 3 个问题:

  1. 🥇 收藏列表权限绕过(2.1)— 用户收藏的隐藏版块帖子在收藏列表中标题直接泄露,影响数据保密性
  2. 🥈 QA 完全绕过权限系统(3.2)— 整个问答插件不引用任何 Permission 类,后台设置的版块权限对 QA 完全无效
  3. 🥉 签到插件每次请求建表(3.3)— 每个签到页面请求都多一次 CREATE TABLE IF NOT EXISTS,影响性能且包含死代码

整体评价:相比第一次审查时的"黑名单绕过可上传 WebShell"级别,当前项目安全性已有质的提升。目前的问题主要集中在"插件层权限控制缺失"和"遗留的兼容代码"上,而非核心框架的架构缺陷。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 1 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录