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

12 KiB
Raw Blame History

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_fndstalk_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:240load_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* handledstalk_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)