17 KiB
db2 模块安全审计报告
初次审计: 2026-08-06
最近复审: 2026-08-07(P2 修复后全量重审,新增 P3-5/P3-6)
审计范围: db2/src/db2.cpp(全部实现 926 行)、Makefile、docker-entrypoint.sh
对照基准: Python 版 db/dbv2.py(接口兼容性对比)
代码版本: main 分支 ec626dc + P3 全量修复
修复进度
| 优先级 | 总数 | 已修复 | 未修复 |
|---|---|---|---|
| P0 | 2 | 2 | 0 |
| P1 | 1 | 1 | 0 |
| P2 | 2 | 2 | 0 |
| P3 | 6 | 6 | 0 |
| 合计 | 11 | 11 | 0 |
P0 — 严重问题
P0-1: 列名 SQL 注入(列名未校验直接拼接 SQL)
-
位置:
db2.cppL399-455(where keys)、L518-523(columns)、L536-544(order_by)、L609-619(INSERT col_list)、L654-663(UPDATE set_clause) -
当前状态: ✅ 已修复(2026-08-06)
-
修复: 新增
_validate_col静态方法,对所有列名(where keys、columns、order_by、INSERT/UPDATE values keys)校验其存在于表 schema 的col_map中,不存在则抛"unknown column"错误。5 个注入点全部修复。sqlite3_prepare_v2只编译单条语句(; DROP TABLE不会执行),但单语句内注入仍然可行:// 攻击 1:DELETE 全表 {"action": "delete", "table": "Users", "where": {"1=1 OR 1": 1}} // → DELETE FROM Users WHERE 1=1 OR 1 = '1' ← 删除全部行 // 攻击 2:SELECT 数据泄露(UNION 注入) {"action": "select", "table": "Users", "columns": ["password_hash FROM Users UNION SELECT secret FROM Secrets--"]} // → SELECT password_hash FROM Users UNION SELECT secret FROM Secrets-- FROM Users // 攻击 3:ORDER BY 子查询泄露 {"action": "select", "table": "Users", "order_by": [{"column": "(SELECT password_hash FROM Users LIMIT 1)"}]} // → SELECT * FROM Users ORDER BY (SELECT password_hash FROM Users LIMIT 1)各注入点汇总:
注入点 代码位置 拼接方式 where的 keyL399-452 parts.push_back(col + " = ?")columns数组元素L518-523 col_expr += std::string(c.as_string())order_by[].columnL536-544 order += std::string(o.at("column").as_string())values的 key (INSERT)L609-619 col_list += std::string(k)values的 key (UPDATE)L654-663 set_clause += std::string(k) + " = ?" -
与 Python 版差异: Python 版通过 SQLAlchemy 的
table.columns[col]解析列名,列不存在时抛KeyError,不可能注入。C++ 版缺失此校验,是安全回归。 -
修复建议: 对所有列名做白名单校验——要么与表名同样校验
[A-Za-z0-9_],要么严格检查列名存在于col_map中(不存在则抛错)。最稳妥的做法:两者都做。
P0-2: 空 in 数组 → 无 WHERE 子句 → 全表删除/更新
- 位置:
db2.cppL439 - 当前状态: ✅ 已修复(2026-08-06)
- 修复: 空
in数组现在生成恒假条件parts.push_back("0"),使 WHERE 子句匹配零行,与 Python 版 SQLAlchemycolumn.in_([])行为一致。
P1 — 高危问题
P1-1: 所有值绑定为 TEXT,类型信息丢失
-
位置:
db2.cppL368-392(_coerce_value/_bind_value)、L476-480(_bind_params) -
当前状态: ✅ 已修复(2026-08-06)
-
修复:
_coerce_value现在返回boost::json::value,仅对字符串做$now替换,其他类型保持原样。- 新增
_bind_value,按 JSON 类型分别调用sqlite3_bind_int64/sqlite3_bind_double/sqlite3_bind_text/sqlite3_bind_null。 WhereClause::params改为std::vector<json::value>,删除原来的is_null向量;_bind_params统一调用_bind_value。
修复后 INTEGER/REAL 等数值类型以原生 SQLite 存储类写入,避免 TEXT 列上的字符串比较问题,行为与 Python 版一致。
P2 — 中危问题
P2-1: 清理时间戳格式不一致(T vs 空格)导致保留期偏移约 1 天
-
位置:
db2.cppL54-60(utc_now)、L787-789(_do_cleanup) -
当前状态: ✅ 已修复(2026-08-07)
-
修复: 清理 SQL 改用
strftime('%Y-%m-%dT%H:%M:%S','now','-N days'),与utc_now()的T分隔格式完全对齐,截止时间字符串比较正确,保留期精确匹配配置天数。 -
原始问题:
存储的时间戳用
utc_now(),格式%Y-%m-%dT%H:%M:%S(T 分隔)。清理 SQL 用 SQLite 内置datetime('now', '-N days'),格式%Y-%m-%d %H:%M:%S(空格分隔):"DELETE FROM Logs WHERE ts < datetime('now', '-" + std::to_string(_cfg.retention_days) + " days')"SQLite 字符串比较时
T(0x54) > 空格(0x20),导致与截止日同日期的记录ts < cutoff为 false,不会被删除。实际保留期比配置多约 1 天。 -
与 Python 版差异: Python 版两端都用
datetime.isoformat()(T 分隔),一致。 -
修复建议: 用
strftime('%Y-%m-%dT%H:%M:%S','now',-N days)或在 C++ 端算截止时间后参数绑定。
P2-2: 缺少配置完整性校验
-
位置:
db2.cppL131-185(load_config) -
当前状态: ✅ 已修复(2026-08-07)
-
修复: 在 INI 解析后、环境变量覆盖前,对 7 个必需字段(
[server] host/port/workers、[database] url、[logs] retention_days/max_records/cleanup_interval)做必填校验,缺失任一则fprintf明确错误信息后exit(1),不再用错误默认值静默运行。 -
原始问题:
load_config对缺失配置项直接用内置默认值兜底(get(sec, key, cfg.xxx)模式)。Python 版(dbv2.pyL118-129)校验所有必需配置项,缺失则退出。后果:confs 提供的
db.ini拥缺字段时 db2 不会报错,可能用错误的默认值静默运行(如db_url缺失则用sqlite:///database.db)。 -
与 Python 版差异: Python 版严格校验,C++ 版宽松兜底。
-
修复建议: 对
[server] host/port/workers、[database] url、[logs]三项做必填校验,缺失则exit(1)。
P3 — 低危问题
| # | 问题 | 位置 | 说明 |
|---|---|---|---|
| P3-1 | schema 缓存永不失效 | L341-392 | ✅ 已修复:缓存项携带 PRAGMA schema_version,每次取 schema 前比对版本号;DDL 变更后自动失效并重新反射,无需重启 db2 |
| P3-2 | _coerce_value 对 null 返回 ""(注释误导) |
L387-393 | ✅ 已修复(2026-08-06):_coerce_value 改为返回 json::value,null 保持为 null,不再返回空字符串;is_null 向量已删除 |
| P3-3 | limit/offset 未校验负值 |
L620-632 | ✅ 已修复:提取 limit/offset 后校验非负,负值直接抛出 "limit/offset must be non-negative" |
| P3-4 | WHERE 条件数 / IN 数组大小无上限 | L458-512 | ✅ 已修复:_build_where 入口校验 where.size() <= 64,in 分支校验数组元素 <= 1000,超限立即报错 |
| P3-5 | 空 values 对象导致 INSERT/UPDATE 语法错误 |
L628-671 | ✅ 已修复(2026-08-07):_do_insert 和 _do_update 新增 vals.empty() 前置校验,空 values 直接抛出 "insert/update requires non-empty 'values'" 错误,不再生成无效 SQL |
| P3-6 | 未知 WHERE 运算符被静默跳过 | L446-485 | ✅ 已修复(2026-08-07):运算符分支末尾新增 else throw "unknown operator: X",非法运算符立即报错,不再静默跳过导致条件失效 |
已有安全机制(正确实现)
- 表名校验 —
db2.cppL518-522:仅允许[A-Za-z0-9_],防止表名级 SQL 注入 - 列名白名单校验 —
_validate_colL416-422:所有列名必须在表 schema 中存在,5 个拼接点全部覆盖 - 值参数化绑定 —
_bind_valueL395-413:所有 value 按 JSON 类型分别sqlite3_bind_*,无值级 SQL 注入 - per-thread 连接 — L327-331:
thread_local连接,避免跨线程共享 prepared statement - 异常脱敏 —
RpcServer::_sanitize_error(libmsg):正则替换绝对路径为<redacted> - 仅操作已有表 —
PRAGMA table_info反射,无法建表/删表 - 空
in恒假条件 — L470-473:空in数组生成"0"使 WHERE 匹配零行 - SQLite PRAGMA 优化 — WAL + synchronous=NORMAL + foreign_keys=ON + busy_timeout=5000 + mmap_size=256MB,与 Python 版一致
- schema 缓存加锁 + 版本感知失效 —
_schema_mtxL316 保护_schema_cache;缓存项携带PRAGMA schema_version,每次取 schema 前比对版本号,DDL 变更后自动重新反射 - 配置完整性校验 — L155-171:7 个必需字段缺失则
exit(1),不再用默认值静默运行 - confs 引导重试 —
retry_on_refused=true,不要求启动顺序 - 日志内部直写 —
_log_internalL762-791 绕过 RPC 防递归,异常静默不阻塞业务 - 后台清理线程 — 条件变量优雅停止,时间戳格式 T 对齐,按时间+条数双策略清理
- 空 values 前置校验 —
_do_insert/_do_update检测空values立即报错,不生成无效 SQL - WHERE 运算符白名单 — 未知运算符立即抛出
"unknown operator"错误,不再静默跳过 limit/offset非负校验 — 负值直接报错,避免 SQLite 负 LIMIT 返回全表的意外行为- WHERE / IN 输入规模上限 — 单请求最多 64 个 WHERE 条件、单个
IN最多 1000 个元素,防止恶意大请求耗尽资源
修复路线图
立即(24小时)
- P0-1: 列名白名单校验(校验列名在
col_map中存在 +[A-Za-z0-9_]格式校验) - P0-2: 空
in生成恒假条件
短期(72小时)
- P1-1: 按 JSON 类型分别使用
sqlite3_bind_int64/sqlite3_bind_double/sqlite3_bind_text - P2-2: 配置完整性校验(必需字段缺失则退出)
中期
- P2-1: 清理时间戳格式统一为 T 分隔
- P3-1: schema 缓存失效机制(基于
PRAGMA schema_version自动失效并重新反射) - P3-3: limit/offset 非负校验
- P3-4: WHERE 条件数 / IN 数组大小上限(64 / 1000)
- P3-5: 空
values对象前置校验(values为空时直接返回错误,避免生成无效 SQL) - P3-6: 未知 WHERE 运算符校验(遇到非合法运算符时抛出错误而非静默跳过)
审计方法
初次审计(2026-08-06)
- 通读
db2/src/db2.cpp全部 851 行实现 - 逐函数对照 Python 版
db/dbv2.py验证接口兼容性与行为差异 - 审查
libmsg/inc/us/RpcServer.hpp/RpcClient.hpp确认框架层异常脱敏机制 - 审查
confs/configs/db.ini确认配置链 - 审查
docker-entrypoint.sh确认 schema 初始化 - 逐注入点构造 PoC 请求验证 SQL 注入可行性(DELETE 全表、UNION 数据泄露、ORDER BY 子查询)
- 追踪空
in数组代码路径确认全表删除行为
复审(2026-08-07)
- 通读修复后
db2/src/db2.cpp全部 875 行实现 - 验证 P2-1 修复:确认
strftime('%Y-%m-%dT%H:%M:%S','now',-N days)与utc_now()格式对齐 - 验证 P2-2 修复:确认 7 个必需字段校验逻辑正确,
confs/configs/db.ini包含全部必需字段 - 确认 P0-1/P0-2/P1-1/P3-2 修复在代码中仍然正确,无回退
- 全量逐函数扫描发现 P3-5(空 values)、P3-6(未知运算符)两个新既有问题
- 对照 Python
dbv2.py确认新增问题在两版中行为一致(非回归) - 更新安全机制清单行号引用至当前代码
本次 P3-1 修复验证
- 审查
SchemaCacheEntry结构,确认缓存项同时保存schema_version与cols - 确认
_get_schema_version通过PRAGMA schema_version读取当前数据库 schema 版本 - 确认
_get_schema在加锁前读取当前版本,缓存命中时严格比对版本号;版本不一致则重新执行PRAGMA table_info并更新缓存 - 编译通过,无新增告警
- 更新审计文档:修复进度、P3-1 状态、修复路线图、安全机制清单、并发性分析与复审结论
本次 P3-3 / P3-4 修复验证
- 审查
_do_select中limit/offset分支,确认提取int64值后校验>= 0,负值抛"limit/offset must be non-negative" - 审查
_build_where入口,确认校验where.size() <= kMaxWhereConditions(64),超限抛"too many where conditions" - 审查
in运算符分支,确认校验数组元素<= kMaxInElements(1000),超限抛"too many elements in 'in' operator" - 确认常量
kMaxWhereConditions/kMaxInElements位于文件顶部,便于统一调整 - 编译通过,无新增告警
- 更新审计文档:修复进度、P3-3/P3-4 状态、修复路线图、安全机制清单与复审结论
并发性分析
并发模型
RpcServer (N workers, 默认 4)
├── thread 1 → thread_local DbConn (sqlite3*)
├── thread 2 → thread_local DbConn (sqlite3*)
├── thread 3 → thread_local DbConn (sqlite3*)
└── thread 4 → thread_local DbConn (sqlite3*)
Cleanup thread → thread_local DbConn (sqlite3*)
每个 worker 持有独立的 sqlite3* 连接(thread_local,L327-331),通过 WAL 模式实现并发读写。
优势
| 机制 | 位置 | 效果 |
|---|---|---|
| WAL 模式 | L219 | 读不阻塞写、写不阻塞读,多读并发零争用 |
| per-thread 连接 | L327-331 | 无跨线程共享 prepared statement,无需连接池锁 |
| busy_timeout=5000 | L222 | 写-写冲突时等锁最多 5 秒,短暂突发写入可自动串行化 |
| synchronous=NORMAL | L220 | WAL 下减少 fsync,写吞吐提升约 2-3 倍(对比 FULL) |
| mmap_size=256MB | L223 | 热数据走内存映射,读性能接近 memcpy |
| schema 缓存加锁 | L316 | 仅首次请求做 PRAGMA table_info,后续锁外快路径命中缓存 |
| 无 GIL | — | C++ 线程真并行,不受 Python GIL 限制 |
典型场景表现:
- 读密集(社区首页、用户查询):4 worker 并发读无争用,SQLite 读延迟 ~0.01ms,吞吐可达数万 QPS
- 读写混合(dnfs 增删节点 + 查询):WAL 允许读写并行,偶尔写-写冲突由 busy_timeout 消化
瓶颈与风险
1. 写-写串行化
SQLite 任何时刻只允许一个写事务。4 个 worker 同时写时,后到者排队等锁:
thread 1: BEGIN → INSERT → COMMIT (持有写锁 ~0.1ms)
thread 2: BEGIN → INSERT → 等锁... → COMMIT
thread 3: BEGIN → INSERT → 等锁... → COMMIT
busy_timeout=5000兜底:等锁超 5 秒则抛SQLITE_BUSY,返回 error 响应- 实际影响:单表写入吞吐约 300-800 TPS(SQLite 典型值),对本项目(日志写入 + 偶尔 CRUD)足够
2. 无事务包装的批量操作
当前每条 execute 请求是独立的 auto-commit 事务。若客户端需要"插入 100 条要么全成功要么全失败",db2 不支持——每条独立提交,中途失败无法回滚前序行。Python 版同样如此(既有设计限制)。
3. _log_internal 与业务写竞争
后台清理线程的 DELETE FROM Logs(L806-851)和业务 worker 的 INSERT INTO Logs 可能同时争用写锁。清理操作涉及大量删除,持锁时间可能达数百毫秒,期间业务写入被阻塞。
4. _schema_mtx 锁粒度
全局互斥锁保护整个 _schema_cache。极端高并发(数百 QPS 首次请求不同表)时可能短暂排队,但实际影响极低(缓存命中后锁外快路径,仅首次 miss 持锁)。
与 Python 版对比
| 维度 | Python db (dbv2.py) | C++ db2 |
|---|---|---|
| 并发模型 | RpcServer N worker 线程 + 共享 engine | RpcServer N worker 线程 + per-thread sqlite3* |
| 连接管理 | SQLAlchemy 连接池(check_same_thread=False) |
thread_local 独占连接 |
| 写冲突处理 | SQLAlchemy 自动重试 | busy_timeout=5000 |
| 事务控制 | conn.commit() 显式提交 |
auto-commit(每条隐式事务) |
| GIL 影响 | Python GIL 限制真正并行 | C++ 无 GIL,真并行 |
结论:db2 的并发性优于 Python 版——无 GIL 限制、无连接池锁开销、per-thread 连接零共享。在当前项目规模下(日志写入 + 业务 CRUD,峰值百级 QPS)完全够用。若未来写入量级跃升至千级 TPS,需考虑:① 日志写入改批量事务 ② 清理线程错峰执行 ③ 或迁移至 PostgreSQL。
复审结论
P0/P1/P2/P3 级问题已全部修复(11/11)。当前版本安全基线已超越 Python 版(C++ 版新增了 schema 缓存失效、空 values 校验、未知运算符校验、limit/offset 非负校验、WHERE/IN 输入规模上限,Python 版尚无),可用于生产部署。