
PoC — Note Toolbar for Obsidian 中由 frontmatter 驱动的任意 JavaScript 执行(GHSA-q8cw-3m8c-5pf2,CVE-2026-87002,CVSS 7.0)。
{{prop_NAME}} → {{js:}} 变量链在 Note Toolbar 中实现由 Frontmatter 驱动的任意代码执行CVE 状态: 已申请,待分配。此发现已发布为 GHSA-q8cw-3m8c-5pf2。在 CVE 分配后,此仓库将重命名为
CVE-YYYY-NNNNN-obsidian-note-toolbar-PoC,并且此横幅将替换为 CVE 链接。
| 研究员 | Dostxodjayev Abdullox (@squeeze440) |
| 公告 | GHSA-q8cw-3m8c-5pf2 |
| CVSS 3.1 | 7.0 (High) |
| 弱点 | CWE-94, CWE-1336 |
{{prop_NAME}} → {{js:}} 变量链在 Note Toolbar 中实现由 Frontmatter 驱动的任意代码执行Note Toolbar(Obsidian 插件)的变量替换引擎中存在输出中和不当问题,允许控制笔记 YAML frontmatter 的攻击者(例如同步/共享仓库中的协作者)在受害者仅打开笔记时,通过在 frontmatter 属性中注入 {{js: ...}} 载荷,从而在受害者机器上实现任意代码执行,而该属性被一个预先存在的、非脚本的工具栏项标签/工具提示/链接通过 {{prop_NAME}} 引用。
Note Toolbar (note-toolbar) — Obsidian 社区插件
仓库:https://github.com/chrisgurney/obsidian-note-toolbar
v1.34.12(提交 520271c3027eb6da38bab10a686b21e14c664c13,2026-07-29),针对 Linux 上的 Obsidian 桌面版 1.13.4。
7.0 (High) — CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
AV:L — 易受攻击的组件(插件的 JS 求值器,运行在 Obsidian 的 Electron 渲染器内)不面向网络;载荷作为本地仓库/文件内容到达,通过受害者使用的任何方式(Git、Syncthing、Obsidian Sync、共享文件夹)同步进来,与此系列中的先前发现一致(例如 obsidian-syncthing-integration、obsidian-codescript-toolkit)。AC:H — 利用取决于攻击者无法控制且必须已存在于受害者安装中的条件:(1) 插件的“Scripting”设置必须已启用(默认关闭),以及 (2) 受害者必须已配置一个工具栏项,其标签、工具提示或链接是对某个 frontmatter 键的裸 {{prop_NAME}} 引用。两者都是现实且文档化的使用模式,但都不是默认值。PR:N — 攻击者无需访问受害者系统,只需能够将带有攻击者控制的 frontmatter 的笔记放入受害者仓库。UI:R — 受害者必须打开/查看特定笔记;无需点击工具栏项,工具栏会自动渲染。S:U、C:H/I:H/A:H — 代码以运行 Obsidian 的本地用户账户身份执行,具有完整的 Node.js 访问权限(require('child_process')、文件系统等)。Note Toolbar 支持一个文档化的 {{prop_NAME}} 变量,该变量将笔记的 frontmatter 值替换到工具栏项的标签、工具提示或链接中(skills/note-toolbar-variables/SKILL.md,wiki Variables.md)。它还支持一个 {{js: <expr>}} 变量,当“Scripting”插件设置启用时,该变量将表达式作为实时 JavaScript 求值。
该漏洞在于 src/Toolbar/VariableResolver.ts::replaceVars() 中的操作顺序:
{{prop_KEY}} 占位符被替换为来自当前笔记的 frontmatter[KEY] 的原始值——无条件地,无论 scriptingEnabled 设置如何,也无论该 frontmatter 值的来源如何。if (this.ntb.settings.scriptingEnabled) 内):该函数随后检查已替换的字符串 s 是否满足 s.trim().startsWith('{{js:'),如果为真,则剥离 {{js:/}} 包装,并将剩余部分传递给 JavaScriptAdapter.use() 进行求值。由于步骤 1 在步骤 2 之前运行并就地重写 s,一个本身以 {{js: 开头并以 }} 结尾的 frontmatter 值会从“显示文本”提升为“执行的脚本”——即使该工具栏项从未被编写为脚本项。工具栏项的所有者只输入了 {{prop_status}};可执行载荷完全来自笔记自身的 frontmatter。
JavaScriptAdapter.evaluate()(src/Adapters/JavaScriptAdapter.ts:143-205)使用真实的、无沙箱的 AsyncFunction 运行表达式:
src/Adapters/Adapter.ts:11
protected static readonly AsyncFunction = (Object.getPrototypeOf(async function(){}) as { constructor: typeof Function }).constructor;
src/Adapters/JavaScriptAdapter.ts:164
const func = new JavaScriptAdapter.AsyncFunction("input", expression);
...
result = await Promise.resolve((func as (...args: unknown[]) => unknown)(args));
这是 JS 引擎真正的 AsyncFunction 构造函数,而不是解释器/沙箱——在 Obsidian 的 Electron 渲染器内,它具有完整的 require()/Node.js 访问权限,在下面的 PoC 中通过 require('child_process').execSync(...) 得到确认。
关键的是,JS 适配器是内置的,不需要配套插件(src/Adapters/AdapterManager.ts:59:adapter = this.js; // built-in, doesn't rely on plugin),不同于需要安装那些插件的 Dataview/Templater/JS-Engine 变量前缀。只有插件自身的“Scripting”开关(scriptingEnabled,默认 false,src/Settings/NoteToolbarSettings.ts:300)对其进行门控。
最后,触发替换的渲染路径是自动的,而非点击门控的:ToolbarRenderer.ts::renderLItems() 每次为笔记渲染工具栏时都会调用 this.ntb.vars.resolveText(toolbar, file)(src/Toolbar/ToolbarRenderer.ts:418)——即在笔记打开/查看时——这会通过 replaceVars() 解析每个项的标签和工具提示。无需点击受影响的工具栏项。
工具栏到笔记的分配本身也是由 frontmatter 驱动的(toolbarProp 设置,默认键 notetoolbar,src/Settings/NoteToolbarSettings.ts:317),因此同步的笔记可以选择在其上渲染哪个工具栏——但如果受害者已经使用包含易受攻击项的默认工具栏或文件夹映射,则利用并不需要这一点。
这与维护者自己的 SECURITY.md 所记录的风险不同(“用户脚本……执行用户提供的 JavaScript……是有意为之且按设计如此”)——那描述的是用户明知故犯地编写 {{js:}} 工具栏项,或明知故犯地导入包含该项的共享工具栏配置。在这里,从配置者的角度来看,工具栏项根本不是脚本项(它是一个普通的属性显示标签),而可执行载荷通过普通笔记内容/frontmatter 到达——与其他 Obsidian 同步/配置代码执行发现(obsidian-syncthing-integration、obsidian-codescript-toolkit)相同的不可信仓库内容威胁模型。
针对真实的 Obsidian 1.13.4 桌面实例(Xvfb + fluxbox)动态验证,加载实际构建的插件(从审计源代码构建的 main.js),而非裸函数提取。
仓库 PoCVault/ 包含 Project Alpha.md:
---
status: "{{js: require('child_process').execSync('id > /tmp/ntb_poc_proof.txt; date >> /tmp/ntb_poc_proof.txt; whoami >> /tmp/ntb_poc_proof.txt'); return 'ok';}}"
notetoolbar: Toolbar
---
安装 Note Toolbar 插件,信任社区插件,通过插件自身的设置 UI 创建一个名为“Toolbar”的工具栏,其中包含单个项,其 Label 字段为 {{prop_status}}(一个普通的属性显示标签——从未配置为脚本项)。
插件设置 Scripting 切换为开启(受害者选择加入,默认关闭)。
仅打开/查看 Project Alpha.md——无需点击工具栏项——渲染工具栏显示 ok(JS 表达式的返回值),并且注入的 shell 命令真实执行:
终端证明(~/engagements/obsidian-note-toolbar/evidence/02_terminal_proof_of_execution.png):
$ cat /tmp/ntb_poc_proof.txt
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),105(docker),992(kvm)
Fri Jul 31 09:31:33 PM +05 2026
kali
Obsidian 截图显示原始 frontmatter 载荷和渲染的 ok 工具栏()。
以运行 Obsidian 的本地用户身份实现完整的任意代码执行,仅通过打开笔记即可触发——无需点击,无需显式“运行脚本”操作,无对话框。在团队/共享仓库工作流中(Obsidian Sync、Git 支持的仓库、Syncthing、Google Drive 等),任何能够编辑笔记 frontmatter 的贡献者都可以在启用了 Note Toolbar 的 Scripting 设置且任何工具栏项显示笔记属性的每个其他协作者上实现 RCE。
{{...}} 模板/变量语法未中和攻击者控制的替换值,而这些值本身包含进一步的模板指令。在 VariableResolver.replaceVars() 中,脚本前缀检查({{js:、{{dv:、{{jse:、<%/{{tp:)应针对原始的、替换前的字符串进行求值,而不是在 {{prop_*}}/{{note_title}} 等替换运行后的字符串——即预先确定工具栏项本身是否被编写为脚本指令,并且绝不让替换后的 frontmatter 值被重新解释为脚本指令。具体而言:在 PROP/SELECTION/NOTE_TITLE 等替换块之前执行 hasVars 风格的脚本前缀检查,并跳过对替换后字符串重新检查 startsWith('{{js:'/'{{dv:'/...)。作为纵深防御,来自 frontmatter 的值也可以被转义/中和,使其永远无法重新进入 {{...}} 语法。
Dostxodjayev Abdullox
此仓库的 SECURITY.md 确认 GitHub 私密漏洞报告已启用,并且是首选渠道(“GitHub 私密漏洞报告:使用此仓库 Security 选项卡下的‘Report a vulnerability’按钮”),对于没有 GitHub 账户的报告者,提供 Google Form 作为后备。独立重新验证:gh api repos/chrisgurney/obsidian-note-toolbar/private-vulnerability-reporting --jq .enabled → true,以及 gh api repos/chrisgurney/obsidian-note-toolbar/security-advisories --jq 'length' → 0(无先前公告,无重复风险)。
~/engagements/obsidian-note-toolbar/evidence/01_obsidian_toolbar_rce_trigger.png源笔记保留在 ~/engagements/obsidian-note-toolbar/evidence/poc_note_frontmatter.md。