2026年3月15日,JavaScript 运行时 Deno 的维护者做了一个令人意外的决定,直接关闭了编号为 Issue #9666 的安全漏洞报告。这个早在2021年3月3日就被提交的漏洞指出,恶意代码可以利用 iTerm 自定义转义码修改终端颜色,把 Deno 弹出的权限审批提示隐藏起来。官方拒绝修复的理由很实在,一方面提示框里还有一个 emoji 图标部分可见,另一方面现在的 AI Agent 经常需要修改终端颜色,不能为了堵漏洞而破坏正常功能。

这一动作恰好印证了开发者兼安全研究者 Goldstein 在2024年8月31日发表、并于2026年更新进展的深度批评。作为 Node.js 创始团队重新打造的新一代运行时,Deno 一直把默认安全(Secure by default)挂在官网首页当核心卖点。然而 Goldstein 的拆解表明,这套基于 V8 与标准库拦截的权限模型在终端协议和操作系统子进程面前近乎千疮百孔。它非但防不住真正的恶意代码,反而给架构师和开发者制造了危险的安全错觉。

终端里的权限弹窗,为何成了攻击者的障眼法

过去几年,Node.js 因为缺乏内置权限控制,长期暴露在供应链攻击的枪口之下,任何一个 npm 包默认都拥有完整的系统读写与执行权限。Deno 试图补上这块短板,允许脚本在运行期通过 API 动态申请权限,并在终端里打印出一个 [y/n/A] 的交互提示框。

恶意代码借带内信号通道直接涂抹隐藏终端里的权限审批提示(剖面示意)
恶意代码借带内信号通道直接涂抹隐藏终端里的权限审批提示(剖面示意)

问题恰恰出在这个看似优雅的终端提示框上。终端模拟器与应用程序之间使用的是带内信号(in-band signaling),也就是直接通过标准输出(stdout)发送控制指令,再从标准输入(stdin)读取回复。在 Deno 里,一段代码向 stdout 打印任何字符、或者从 stdin 读取数据,都不需要申请任何权限。

这就构成了一个荒谬的安全悖论。允许未受信任的代码随意布置终端状态,随后又在同一个终端里弹出安全审批,相当于在已经被注入恶意脚本的网页上直接弹窗索要银行密码。除了前文提到用颜色隐藏文字的 Issue #9666,早年间的 Issue #9750(创建于2021年3月10日)还展示了提示词填充攻击,恶意代码可以诱导用户提前按下回车,或者诱骗终端自动把确认字符塞进输入流。尽管 Deno 在2021年9月9日通过 PR #11931 修补了那次填充漏洞,但终端非标准转义序列层出不穷,应用层根本无法穷尽拦截。

相比之下,Linux 系统的 sudo 之所以能在终端安全询问密码,是因为它在任何业务代码执行之前就率先接管了输入。如果开发者为了躲避终端欺诈,在启动时加上 --no-prompt 标志或设置 DENO_NO_PROMPT 环境变量,也只能禁用交互提示并在越权时直接报错,根本无法约束命令行预先授予的子进程权限。

终端权限提示的带内信号劫持路径 1. 免权限输出 恶意脚本直写 stdout 无需任何运行时授权 2. 污染终端状态 发送自定义转义序列 篡改颜色或预填输入 3. 弹窗同轨渲染 Deno 在受控通道内 打印权限审批文本 4. 盲签越权放行 提示被隐匿或自动确认 沙箱防线瞬间失守 架构症结(Issue #9666 / #9750) 把安全审批建立在攻击者可随意读写的通道上,打补丁只能封堵单一序列,无法根治机制缺陷。

子进程白名单形同虚设,操作系统边界一触即溃

如果说终端交互还可以靠禁用提示来规避,那么 --allow-run 权限的设计则在操作系统底层逻辑面前彻底坍塌。许多开发者习惯写下 --allow-run=uname 或 --allow-run=vim,以为这样就只允许脚本运行某一个特定的安全程序。

应用层白名单锁不住系统底层暗门,环境变量可直接劫持子进程(剖面示意)
应用层白名单锁不住系统底层暗门,环境变量可直接劫持子进程(剖面示意)

事实远非如此。在操作系统中,程序并没有全局唯一的法定名称,uname 只是一个去环境变量 PATH 目录列表里按顺序查找同名文件的快捷方式。而在 Deno 中,给子进程设置环境变量并不需要额外权限。Goldstein 在演示中只用了几行代码,把子进程的 PATH 指向一个临时目录,就能用恶意脚本直接顶替真正的 uname。

哪怕 Deno 锁死 PATH,动态链接器依然是一道敞开的后门。绝大多数系统程序在启动时都需要动态加载器把依赖库读进内存,而加载器直接听命于环境变量。攻击者只要在调用 uname 时传入 LD_PRELOAD,指向一个恶意的动态库,就能在正牌程序启动的瞬间执行任意原生代码。

针对这一子进程提权缺陷(Issue #11964,创建于2021年9月9日),Deno 维护者最初将其定性为预期行为,直到2022年1月10日才改口承认,可执行文件白名单确实会制造虚假的安全感。随后官方通过 PR #25271 打上补丁,规定必须拥有无限制的 --allow-run 权限,才允许向子进程传递以 LD_ 或 DYLD_ 开头的环境变量。

应用层白名单锁不住操作系统的暗门,子进程一启动沙箱便已死。

这种打地鼠式的修补依然治标不治本。只要宿主程序本身支持配置或脚本扩展,白名单就会变成特洛伊木马。比如开发者允许脚本调用编辑器修改提交信息,授出 --allow-run=vim,恶意代码转头就能传入 VIMINIT 环境变量,在编辑器启动时静默删光整个硬盘。當前的维护者在 GitHub 讨论中甚至留下过一句名言,称这套机制主要用来防御不够聪明的恶意代码。


营销话术与免责文档分裂,真正的隔离必须下沉内核

最值得玩味的是 Deno 在产品营销与技术文档之间的自我拉扯。在宣传页面上,细粒度白名单被包装成坚不可摧的护城河;但翻开 Deno 官方文档深处,上面却白纸黑字地写着,子进程独立于父进程的权限运行,无限制的 --allow-run 会使沙箱失效,建议在进行威胁评估时直接把 --allow-run 和 --allow-ffi 等同于完全放开的 --allow-all。

权限模型只是防误操作的安全带,锁不住驾驶座上的恶意代码
权限模型只是防误操作的安全带,锁不住驾驶座上的恶意代码
JavaScript 运行时权限定位与真实边界对比 Deno:割裂的安全沙箱叙事 台前卖点:默认安全与细粒度白名单 提供 --allow-run=cmd 等参数,暗示精准可控 幕后免责:文档承认等同于全权放开 将 --allow-run 与 --allow-ffi 视同 --allow-all 现实代价:诱导架构师裸奔运行不可信代码 为兼容 AI Agent 改色关闭终端伪造漏洞 #9666 Node.js:诚实的工程安全带 官方定位:受信任代码的安全带(Seat belt) 通过 --permission 开启,仅用于防止内部误操作 边界声明:明确放弃反恶意代码承诺 官方文档直言面对蓄意攻击不提供任何安全保证 工程共识:隔离必须交还操作系统内核 依赖 bwrap、容器、系统调用过滤或 MicroVM

在这件事上,老对手 Node.js 的态度反而诚实得多。Node.js 后来也引入了通过 --permission 开启的权限模型,但在官方文档中明确将其定位为受信任代码的安全带(seat belt),并直截了当地声明,面对恶意代码时不提供任何安全保证。安全带的作用是防止好司机因为写错路径而误删文件,而不是用来锁住坐在驾驶座上的劫匪。

对于正在搭建插件系统、Serverless 多租户环境,或是频繁在本地运行 AI Agent 命令行工具的开发者而言,这次争论敲响了一记实质性的警钟。随着本地 AI 编程工具大量接管终端并自动执行子进程,依靠 V8 虚拟机外壳做标准库拦截的路子已经走到了尽头。

  • 风险.切勿在生产环境中仅凭 Deno 的 --allow-run 白名单或交互提示来隔离第三方不可信脚本,符号链接穿越、环境变量污染与内存耗尽攻击随时可能击穿进程边界。

真正可靠的防线,从来不在语言运行时的命令行参数里。要管住来路不明的代码,唯一的办法是放弃应用层的伪沙箱幻想,直接调用 bwrap(Bubblewrap)、Linux 内核级系统调用过滤,或者将其彻底关进容器与微虚拟机(MicroVM)的笼子里。