Files
dstalk/agents/audits/W18.3-plugin-loader-audit.md
XiuChengWu c545d16120
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
W18: context cleanup + CLI fixes + loader audit + CI matrix (W18.1-W18.4)
- W18.1 (王测+林深): Remove g_max_tokens dead API, UTF-8 bounds protection, deduplicate token counting, 0xC0/0xC1 handling, add 13 test blocks (36 checks)
- W18.2 (赵码+朱晴): Fix /context no-session error message, /status 3-state connection display
- W18.3 (曹武+徐磊): plugin_loader security audit — 9 dimensions, rating C, 1 HIGH + 2 MEDIUM findings
- W18.4 (马奔+胡桐): CI dual-platform matrix (Ubuntu clang-18 + Windows clang-cl), ccache, build timing baseline

Build 0 error, ctest 5/5 pass, metadata check clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:09:21 +08:00

226 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# W18.3 Plugin Loader Security Audit
**Auditors**: 曹武 (security-cao), 徐磊 (qa-xu)
**Date**: 2026-05-27
**File**: dstalk-core/src/plugin_loader.cpp + plugin_loader.hpp (385 lines total)
**Wave Coverage**: 零 (从未被 Wave 流程审计)
**Reference**: plugin-abi.md §3 §5 §6 §8
---
## 1. ABI 安全与异常安全 (§5.3, §8)
**评级: F (多个 C ABI 边界无保护)**
`PluginLoader` 调用插件的 C 函数指针 (`on_init`, `on_shutdown`, `init_fn`) 全路径零 try/catch 保护:
| 调用点 | 位置 | 函数指针签名 | 保护 |
|--------|------|-------------|------|
| `init_fn()` | load_plugin L59 | `dstalk_plugin_init_fn``dstalk_plugin_info_t*(*)(void)` | **无** |
| `on_init(host_api)` | initialize_all L237 | `int (*)(const dstalk_host_api_t*)` | **无** |
| `on_init(host_api)` | initialize_pending L272 | 同上 | **无** |
| `on_shutdown()` | unload_plugin L108-109 | `void (*)(void)` | **无** |
| `on_shutdown()` | shutdown_all L306-307 | 同上 | **无** |
L250-255 的 catch 块仅保护 `topological_sort()`——`on_init` 调用在 try 块**外部**。L237 和 L272 两处 `on_init` 调用均在 try 块覆盖范围之外。若某个插件的 C++ 实现抛出 `std::bad_alloc` 或任何其他异常,异常沿 C 函数指针返回 → `std::terminate()` → 进程崩溃。
这是 F-11.1-1 的 loader 侧对偶问题F-11.1-1 要求插件侧包裹 try/catch但 loader 侧也需要防御性保护——某个插件未严格遵守 §8 规范时host 不应因此而崩溃。
**影响**: 任意一个未做异常防护的插件在 OOM 或 STL 异常时即可拖垮整个 host 进程。防御深度缺失。
---
## 2. 堆纪律 (§3)
**评级: A (完全合规)**
逐调用点检查:
| 调用类型 | 搜索结果 | 判定 |
|----------|----------|------|
| `malloc` / `free` | 0 处 | -- |
| `strdup` (裸) | 0 处 | -- |
| `new` / `delete` (显式) | 0 处 | -- |
| `std::string` / `std::vector` | L83-85, L93, L123-142 | Host 内部使用, 不跨边界 |
| `boost::json` | L125-142 | Host 堆, 不跨边界 |
PluginLoader 是 host 侧组件——所有 `std::string`/`std::vector`/`json::object` 分配均在 host CRT 堆内, 不存在跨 DLL 堆风险。插件返回的 name/version/description 在 L83-85 通过 `std::string` 构造器复制到 host 侧, 符合 §2.2 契约。
---
## 3. 并发安全 (§6.5)
**评级: C (文档声明单线程但无强制)**
| 变量 | 写入点 | 读取点 | 同步 |
|------|--------|--------|------|
| `next_id_` (L54) | L80 `next_id_++` | L80 (读-改-写) | **无** |
| `plugins_` map | L96 (insert), L119 (erase), L323 (clear) | L102, L126, L149, L213, L263, L302, L328 | **无** |
| `PluginInfo::initialized` | L89, L217-246, L271-278, L306-309 | L108, L217, L271, L306 | **无** |
§6.5 明确声明 "PluginLoader 无内部互斥...load/unload 不应在多线程中并发调用"。但 host.cpp 中:
- `dstalk_init` / `dstalk_shutdown` 持有 `g_init_mutex`
- `dstalk_plugin_load` / `dstalk_plugin_unload` **不持有** `g_init_mutex`
这意味着在 `dstalk_init` 持有锁期间调用 `dstalk_plugin_load` 会死锁(不可重入 mutex), 但两个 `dstalk_plugin_load` 并发调用则无保护。虽然实际使用中可能不会并发, 但 zero enforcement 是防御深度缺失。
---
## 4. 输入验证
**评级: C (仅 null 检查, 无路径内容校验)**
`load_plugin(const char* path)` (L26):
- L28: `if (!path) return -1` — null 检查 OK
- L32-35: path 直接传给 `LoadLibraryA` / `dlopen`**无路径内容验证**
`dstalk_plugin_load` 是公开 C API, 任何调用方可传入任意路径。无以下验证:
- 路径是否为绝对路径 (相对路径触发 DLL 搜索顺序劫持风险)
- 路径是否在预期插件目录内
- 文件扩展名是否合法
- 文件是否存在 (由 OS 层报错, 但不记录)
---
## 5. 路径安全与 DLL 完整性
**评级: D (公开 API 无防护, 无来源验证)**
`dstalk_plugin_load` 调用链: 用户输入 → `host.cpp:240``load_plugin(path)``LoadLibraryA(path)` / `dlopen(path)`**零中间验证**
两个调用来源分析:
| 来源 | 路径构造 | 安全评估 |
|------|---------|----------|
| `load_plugins_from_directory()` (host.cpp:150) | `fs::directory_iterator` → 绝对路径 + 扩展名白名单 | OK |
| `dstalk_plugin_load()` 公开 API (host.cpp:240) | 调用方直接传入 | **无防护** |
缺失的防护层:
1. **路径规范化和目录约束**: 无 `fs::canonical` 解析, 无 allowed-dir 前缀检查
2. **扩展名校验**: 公开 API 路径不做 `.dll`/`.so`/`.dylib` 检查
3. **DLL 来源验证**: 无数字签名校验 (WinVerifyTrust), 无哈希白名单, 无证书链验证
4. **符号链接/硬链接**: 无检测, 攻击者可创建指向任意 .so 的符号链接
5. **Windows DLL 搜索顺序**: 相对路径触发搜索顺序劫持 (已知攻击向量)
注意: 相对路径在 `load_plugins_from_directory` 中不会出现 (fs::path 迭代产生绝对路径), 但 `dstalk_plugin_load` 公开 API 无此保证。
---
## 6. 符号解析
**评级: C (解析失败静默, 无诊断信息)**
L42-47 `GetProcAddress` / `dlsym`:
- 返回 nullptr 时正确卸载 DLL 并返回 -1 ✅
- **未调用 `GetLastError()` / `dlerror()`**, 失败原因不可知
- `(dstalk_plugin_init_fn)` 强制转型: 无签名验证机制。若插件导出同名但签名不同的函数 → 调用时 UB (栈损坏/寄存器错乱)
- `dependencies` 数组 (L92-94) 仅按名称字符串匹配 (`topological_sort` L164), 无版本号约束。同名但不同版本的插件会产生隐蔽的初始化顺序错误
---
## 7. 错误处理
**评级: D (零错误日志, 无错误码区分)**
`load_plugin` 有 5 个独立失败点, 全部返回 -1 **且不记录任何日志**:
| 失败点 | 行号 | 是否有日志 |
|--------|------|-----------|
| path 为 null | L28-29 | N/A (入口守卫) |
| LoadLibrary/dlopen 失败 | L37-39 | **无** |
| GetProcAddress/dlsym 失败 | L49-56 | **无** |
| init_fn() 返回 null | L60-67 | **无** |
| API 版本不匹配 | L70-77 | **无** |
调用方 (host.cpp:240) 也仅检查 `id >= 0`, 不记录失败原因。生产环境中排查 "为什么插件加载失败" 需要附加调试器。
`initialize_all` 中的 `fprintf(stderr, ...)` (L229, L239-240) 绕过了 host 日志基础设施——`host_api` 参数在手却不用 `host->log()`。在 GUI/服务进程中 stderr 可能被丢弃。
---
## 8. 日志安全
**评级: B (格式化安全, 终端注入风险低)**
- `fprintf(stderr, "[WARN] Plugin '%s' skipped...", plugin.name.c_str())` — 使用 `%s` 格式说明符, 无格式化字符串注入风险 ✅
- `plugin.name.c_str()` 来自 `info->name` — 插件作者控制。理论上可注入 ANSI 转义序列 (VT100 控制字符) 到 stderr, 扰乱终端显示。CVSS 低 (仅影响日志可读性)。
- `[ERROR]` 消息包含 `result` 错误码 (L239-240), 但 result 来自插件的 `on_init` 返回值——恶意插件可伪造错误码混淆日志。
- 成功加载无日志 (对比: host.cpp L153-154 记录了成功加载, 但 `load_plugin` 内部无)
---
## 9. 资源清理
**评级: B (正常路径正确, 异常路径有遗漏)**
正常路径:
- `load_plugin` 失败时正确调用 `FreeLibrary`/`dlclose` 释放已加载的 DLL ✅ (L51-55, L62-65, L71-75)
- `shutdown_all` L313-322 逐个 `FreeLibrary`/`dlclose` 所有 handle ✅
- `~PluginLoader` 调用 `shutdown_all()`
缺陷:
- `PluginInfo` 无拷贝/移动控制: 含原始指针 `void* handle``dstalk_plugin_info_t* info`。若被拷贝 (当前 `std::move` 仅发生在 L96), 源对象析构后 handle/info 双悬垂。缺少 `=delete` 拷贝构造/赋值。
- `shutdown_all` L306: 若 `on_shutdown()` 抛异常 (即使违反规范), 当前无保护——异常穿透 `shutdown_all` → 跳过后续插件的 shutdown + skip 所有 FreeLibrary → 句柄泄漏。`~PluginLoader` 也会因异常析构导致 terminate。虽然有 L294 `catch(...)` 降级路径, 但仅覆盖排序失败, 不覆盖 shutdown 回调。
---
## TOP 3 严重发现
### 发现 1 — [HIGH] 5 处 C ABI 调用点 zero try/catch 保护 (违反 §5.3, §8)
**位置**: load_plugin L59, initialize_all L237, initialize_pending L272, unload_plugin L108-109, shutdown_all L306-307
**问题**: PluginLoader 调用插件的 `on_init`/`on_shutdown`/`dstalk_plugin_init` 五个 C ABI 入口均无 try/catch 保护。若任意插件的 C++ 实现抛出异常 (std::bad_alloc 或其他 STL 异常), 异常穿越 C 函数指针边界 → `std::terminate()` → 进程崩溃。L250-255 的 catch 块仅覆盖 `topological_sort()`, `on_init` 调用在 try 块外部。
**修复方向**: 在每个 C 函数指针调用点加 `try { ... } catch (const std::exception& e) { log; return -1; } catch (...) { log; return -1; }``on_shutdown` 的 void 返回类型需加 `catch(...) { /* log only */ }` 防止析构期二次异常。
### 发现 2 — [MEDIUM] load_plugin 5 个失败点全静默返回 -1, 无日志无错误码区分
**位置**: load_plugin L28-77 (全部 6 个 return -1 路径)
**问题**: LoadLibrary/dlopen 失败、符号找不到、init_fn 返回 null、API 版本不匹配——全部返回 -1 且一条日志不写。`GetProcAddress`/`dlsym` 失败时不调用 `GetLastError()`/`dlerror()` 诊断。生产环境中问题完全不可排查。
**修复方向**: 每个失败路径加 `host->log(DSTALK_LOG_ERROR, "load_plugin: %s: <reason>", path)`, 可区分错误码 (-2 file not found, -3 not a valid DLL, -4 symbol missing, -5 init failed, -6 version mismatch)。
### 发现 3 — [MEDIUM] 公开 API 路径零验证, DLL 加载无来源完整性检查
**位置**: load_plugin L32-35 (path → LoadLibraryA/dlopen 直传), host.cpp L240 (dstalk_plugin_load 公开入口)
**问题**: `dstalk_plugin_load` 公开 C API 接受任意路径, 不作规范化、目录约束、扩展名校验、签名验证。相对路径触发 Windows DLL 搜索顺序劫持。`load_plugins_from_directory` 的自动加载路径虽安全 (绝对路径+扩展名白名单), 但公开 API 独立于此防护。
**修复方向**: `load_plugin` 入口调用 `fs::canonical` 规范化路径, 校验扩展名 (.dll/.so/.dylib), 校验前缀在 allowed-dir 内。可选项: WinVerifyTrust (Windows) 或 ELF 签名验证。
---
## 整体评级
| 维度 | 评级 |
|------|------|
| ABI 安全与异常安全 | **F** |
| 堆纪律 | A |
| 并发安全 | C |
| 输入验证 | C |
| 路径安全与 DLL 完整性 | D |
| 符号解析 | C |
| 错误处理 | D |
| 日志安全 | B |
| 资源清理 | B |
| **综合** | **C** |
**总评**: PluginLoader 在堆纪律上干净 (host 侧无跨堆风险), 但在 ABI 异常安全和错误处理方面存在系统性缺陷。最严重的问题是 5 处 C ABI 调用点全无 try/catch——这是所有已审计插件的共性问题 (F-11.1-1, F-13.1-1, F-13.2-1) 在 loader 侧的对应缺陷。loader 不保护自己, 意味着即使所有插件都严守 §8 规范, 一个疏忽即可拖垮整个进程。load_plugin 全静默失败 + 路径无验证 + 符号解析无诊断共同构成生产可观测性黑洞。建议在下一修复 Wave 中系统性加固这 5 个调用点并添加错误日志管线。
---
## Findings Summary
| ID | Severity | Title |
|----|----------|-------|
| F-18.3-1 | HIGH | 5 处 C ABI 调用点 zero try/catch: on_init/on_shutdown/init_fn 穿越 ABI → std::terminate() (load_plugin L59, initialize_all L237, initialize_pending L272, unload_plugin L108-109, shutdown_all L306-307) |
| F-18.3-2 | MEDIUM | load_plugin 全静默失败: 5 个独立失败点均返回 -1 无日志, GetProcAddress/dlsym 不调 GetLastError/dlerror (L28-77) |
| F-18.3-3 | MEDIUM | 公开 API dstalk_plugin_load 路径零验证: 无规范化/目录约束/扩展名校验/签名验证, 相对路径触发 DLL 搜索劫持 (host.cpp:240 + load_plugin L32-35) |
| F-18.3-4 | MEDIUM | initialize_all 用 fprintf(stderr) 替代 host->log(): 绕过诊断回调系统, host_api 在手却未用 (L229, L239-240) |
| F-18.3-5 | MEDIUM | PluginLoader 零内部同步: next_id_++ 非原子, plugins_ 无 mutex; dstalk_plugin_load 不持 g_init_mutex (§6.5 文档声明单线程但代码无强制) |
| F-18.3-6 | LOW | init_fn 强转无签名验证: GetProcAddress/dlsym 结果盲转为 dstalk_plugin_init_fn, 签名不匹配→UB (L43-47) |
| F-18.3-7 | LOW | Plugin name 终端转义注入: fprintf(stderr) 打印插件名未过滤 ANSI 控制字符, 恶意插件可扰乱终端 (L229, L240) |
| F-18.3-8 | LOW | PluginInfo 缺拷贝控制: 含 raw 指针 handle/info, 无 =delete 拷贝构造/赋值, 潜在的 double-free/UAF (plugin_loader.hpp L10-21) |