- 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>
12 KiB
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_mutexdstalk_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) |
调用方直接传入 | 无防护 |
缺失的防护层:
- 路径规范化和目录约束: 无
fs::canonical解析, 无 allowed-dir 前缀检查 - 扩展名校验: 公开 API 路径不做
.dll/.so/.dylib检查 - DLL 来源验证: 无数字签名校验 (WinVerifyTrust), 无哈希白名单, 无证书链验证
- 符号链接/硬链接: 无检测, 攻击者可创建指向任意 .so 的符号链接
- 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_sortL164), 无版本号约束。同名但不同版本的插件会产生隐蔽的初始化顺序错误
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_allL313-322 逐个FreeLibrary/dlclose所有 handle ✅~PluginLoader调用shutdown_all()✅
缺陷:
PluginInfo无拷贝/移动控制: 含原始指针void* handle和dstalk_plugin_info_t* info。若被拷贝 (当前std::move仅发生在 L96), 源对象析构后 handle/info 双悬垂。缺少=delete拷贝构造/赋值。shutdown_allL306: 若on_shutdown()抛异常 (即使违反规范), 当前无保护——异常穿透shutdown_all→ 跳过后续插件的 shutdown + skip 所有 FreeLibrary → 句柄泄漏。~PluginLoader也会因异常析构导致 terminate。虽然有 L294catch(...)降级路径, 但仅覆盖排序失败, 不覆盖 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) |