技能注册表威胁模型Skills Registry Threat Models
随着 AI Agent 生态中「技能注册表」(Skills Registry)的兴起,以 Markdown 等轻量格式分发的技能定义文件正在成为新的攻击面。作者围绕技能注册表构建了系统的威胁模型,涵盖恶意技能注入、供应链篡改、描述文件伪装等风险场景,并尖锐地提出「距离第一个针对 Markdown 文件的 CVE 还有多远」的警示。文章呼吁社区在技能分发机制成熟之前,尽早将安全建模纳入标准流程,而非事后补救。
Andrew Nesbitt
Agent 技能将提示词、脚本、依赖项和工具权限打包在一起,供 AI agent 按需加载。技能注册中心是它们的分发渠道:可以是托管市场、索引中心,或者很多时候仅仅是一个精选的 GitHub 仓库列表。ClawHub、Tessl 和 skills.sh 都在过去一年里相继上线,它们大多以现有的包注册中心为蓝本。
由于一个技能可以声明对来自 npm、pip、cargo、brew、go、apt 或其他任何工具的包的依赖,并且通常是同时依赖多个,因此技能注册中心严格来说是包管理器客户端的超集。安装单个技能会在用户机器上跨越多个包管理器运行安装命令,代表的是一份用户从未读过的清单文件。因此,包管理器领域在过去十年里记录的所有威胁,在技能的安装路径中依然适用。
激活时,技能主体会被加入到 agent 的系统提示词中,这使得它不仅像普通包那样是代码执行媒介,还成为了提示词注入媒介。工具权限继承自运行时,因此技能会以当前会话已批准的 bash、文件编辑和网络授权来运行。此外,大多数加载器的解析路径接受任意的 git URL 作为来源,这将注册中心层面的威胁模型直接降维成了 GitHub 的身份模型。
slug 会在九十天后过期,因为代码里的常量写的就是九十。安装命令运行时没有附带 no-build 标志,因为没人加上这个字符串参数。锁文件记录的是名称而非字节,因为字节根本没写入客户端。这些全都是按预期工作的设计决策,而不是静态扫描器能够挑出的错误代码行。它们能干净地通过所有旨在寻找错误代码行的检查,并且值得我们去刻意设计,而不是放任默认。
本文涵盖了加载器、注册中心、作为注册中心客户端的 agent 运行时,以及加载器自身的依赖项,并参考了已发表的研究、扫描器报告和包管理器的先例。
加载器
加载时的代码执行
技能以几种形态之一激活,而大多数加载器同时支持多种形态:仅注入到提示词中而不运行其他内容的指令;加载器通过 agent 的 bash 工具调用的 scripts/ 目录;或者是技能文件中的 shell 代码片段,加载器会在模型介入循环之前就对其进行求值执行。
每条路径是否默认开启、是否存在将其关闭的设置,以及该设置在每条代码路径上是否保持一致——这些问题与包管理器过去十年间为 postinstall 和 setup.py 等安装脚本所解答的问题如出一辙。用户“agent 运行某个工具,我来批准”的心智模型,根本无法涵盖在 agent 抵达提示词阶段之前就已经运行的加载器命令。
一旦技能清单被允许声明自己的安装步骤,那么一份列出了 {kind: node, package: x}、{kind: uv, package: y} 和 {kind: brew, formula: z} 的清单,就相当于一个将任务委托给另外三个包管理器的单一构件,而且每个包管理器对于“安装过程是否执行代码”都有各自的回答。切断其中一方的生命周期脚本攻击媒介(例如在 node 调用时加上 --ignore-scripts)非常简单,通常也是最先被修复的。而其他包管理器上的等效设置(如 Python 的 build-isolation 标志、Homebrew 的 tap 允许列表,以及 go 和 cargo 的等效设置)往往会滞后数月之久。
调用前的代码执行
大多数加载器会在每个轮次将所有已安装技能的描述注入到系统提示词中,以便模型在进行工具选择时可以使用它们。技能的主体和脚本在激活前不会加载,但描述会在每次请求时加载,这意味着无论用户是否调用该技能,由技能作者控制的文本都会被放入代理的指令中。
用户曾经安装过但后来遗忘的技能仍然是提示词的一部分,如果描述中包含加载器未清除的对抗性标记、隐藏的 HTML 或 Unicode 控制字符,这就是一种无需提示就会触发的提示词注入攻击。需要弄清楚加载器如何处理这些描述:是否有长度限制、是否进行内容清理、它们是作为数据还是作为指令呈现给模型,以及用户是否可以列出当前对提示词有贡献的技能。
版本固定保证
大多数技能格式使用 git 作为分发渠道,而“版本”通常意味着“获取时的默认分支”。少数加载器接受 commit SHA;记录实际使用的 SHA 的加载器就更少了。锁文件的等效机制(如果存在的话)通常只记录名称和版本,而不记录具体的字节内容,因此固定版本的 `[email protected]` 会解析为当前拥有该名称的任意内容,而不是用户最初收到的文件。
每个文件的哈希值通常已经存在于服务器上,并在发布时计算得出,只是从未被写入客户端格式中。包管理器领域花了十年时间来弥补这一差距,最终在 `go.sum` 以及 `package-lock.json` 和 `Cargo.lock` 的每个条目中都引入了 content-hash(内容哈希)字段,从而确保锁文件中记录你曾经获取的字节就是你下次获取的字节。
下次启动时自动更新很常见,而且更新路径几乎总是遍历锁文件并重新安装每个条目,而不会针对版本间的任何能力变更重新提示用户。如果一个技能在补丁版本更新中添加了新的 `requires.env` 值(即声明了一个新的密钥作为依赖项),更新过程会在无需用户交互的情况下直接应用它,因为清单文件被视为数据,而之前的用户授权是与名称绑定的。
技能名称标识
名称大多继承自来源:路径、owner/repo(所有者/仓库)或 owner/repo/skill(所有者/仓库/技能)三元组。规范化规则通常是不成文的。如果两个已安装的技能解析为相同的磁盘名称并相互覆盖,或者两个技能的描述让模型无法区分,这两种方式都可以用来遮蔽(shadow)用户信任的技能。
一些注册表还支持标识转换:将重命名、合并和所有权转移的某种组合作为独立的流程,每个流程都有自己的数据模型和确认步骤。更新会遵循发布者使用的这三种方式中的任何一种,最终安装在磁盘上的技能可能会拥有与用户最初安装的技能不同的所有者、不同的源仓库以及不同的一组声明能力。
跨多源解析
用户通常配置了多个技能源:供应商策划的市场、社区市场、包含项目本地技能的个人仓库,以及刚刚打开的工作区。当一个名称可以从多个源中解析出结果时,所面临的问题就与 Alex Birsan 在 2021 年针对包管理器记录的依赖混淆(dependency-confusion)模式相同:是最高版本优先、第一来源优先、拒绝安装,还是按来源进行固定。
加载器的安装命令通常会先尝试特定于技能的索引,如果没有匹配项,就会静默回退到通用包注册表(npm、PyPI),因为安装请求的扇出(fan-out)总得有个归宿。当“找不到版本”和“找不到包”都会触发回退时,这种回退的范围就扩大了,因为此时技能注册表中已列出的名称也会被暴露,只需在下游注册表上发布更高的版本,就足以截获后续的安装请求。
工具权限继承
技能在运行时会使用代理(agent)被授予的所有工具权限。如果用户在当前会话中批准了 bash、文件编辑和网络访问权限,那么他们安装的每个技能都会继承这三种权限。某些格式允许技能声明自己的 allowed-tools(允许的工具)列表,而加载器在技能处于活动状态时会将其视为已预先批准,这样技能就可以将绕过审批的机制与使用该工具的代码一并发布。
对于签入代码仓库的技能,其唯一的关卡通常是用户在打开项目时点击确认的“工作区信任”对话框,这意味着仅仅克隆并打开仓库,就足以赋予技能广泛的工具访问权限。这个对话框也是一道薄弱的关卡,因为一旦它成为每次打开项目流程的一部分,用户就会下意识地直接点击通过。需要弄清楚技能是否被沙盒化,它们是否能够扩展白名单,以及在“信任工作区”和“运行一切”之间是否存在任何针对单个技能的审查步骤。
requires.env(或等效)字段列出了技能在运行时所需的机密信息(secrets),并且大多数加载器允许在跨版本更新时对其进行静默添加。如果一个技能的首个版本未声明任何机密,而其补丁版本声明了 AWS_SECRET_ACCESS_KEY,那么从代理内部来看,这与一开始就声明了该密钥的技能是无法区分的。
跨加载器可移植性
技能清单(manifest)格式在不同加载器间的可移植性日益提高,但与安全相关的字段在不同加载器中的解释方式并不总是一致。allowed-tools 声明在某些加载器中会在运行时强制执行,在另一些中仅被视为参考建议,而在第三类加载器中则因完全无法识别该字段而被忽略。在某个加载器上会在添加变量前提示用户确认的 requires.env 列表,在另一个加载器上可能会被静默扩展到环境中。需要确认加载器是否应用了其声称支持的格式中的每一个与安全相关的字段,以及未知字段是否会触发拒绝或警告,而不是被静默丢弃。
指令作为有效载荷
与普通软件包的结构性区别在于,技能的有效载荷不仅是代码,还包含了会加入代理系统提示词(system prompt)的指令。同一个产物可以改变代理后续的决策方式,与用户随后在对话记录中看到的内容相矛盾,干扰同一会话中稍后加载的其他技能,或者安排利用已授权的工具,读取该技能本身未被赋予的上下文信息。这种影响范围(爆炸半径)并不会在技能停止运行时结束,因为提示词内容仍会保留在上下文中。加载器是否隔离技能贡献的文本,在执行前是否将其展示给用户,还是将其视为权威指令,这是一个值得深究的问题。
在执行时获取远程 Markdown 并将其引入上下文的技能(例如文档查找、RAG 风格的检索、webhook 响应),会使得控制该远程端点的人成为代理提示词的参与者。获取的内容在发布时扫描中是不可见的,也不属于注册表所传递的清单的一部分。
将恶意行为分散在清单文件(manifest)及其附带的描述文本中,使其对大多数扫描器不可见。清单文件可以声明一个看似无害的依赖项,而描述文本则说明一旦安装该依赖项后该做什么;静态扫描器只处理清单文件,文本分类器只处理描述文本,而承载核心逻辑的指令恰好位于两者之间的边界上。
Snyk 对两个注册表中的 3,984 个技能(skills)进行了 ToxicSkills 审计,报告指出,100% 确认恶意的样本使用了恶意代码模式,其中 91% 同时使用了提示注入(prompt injection)。这两层机制结合起来,诱导 Agent 接受那些人类审查员本会拒绝的代码。注册表的扫描器是将这些模式关联起来检查,还是孤立地进行检查,决定了它是否真的起到了防护作用。
传递性的包管理器攻击面
一个在安装脚本中调用 pip install、声明了 package.json,或者通过 shell 调用 cargo 的技能,会在其自身的安装路径中引入包管理器的威胁模型:包括抢注拼写错误(typosquatting)、安装脚本执行、依赖混淆(dependency confusion)等。如果一个清单文件的安装扇出(install fan-out)列出了三个包管理器,那么这种威胁就会被引入三次。
加载器的威胁模型是有边界的;而技能的实际依赖图则没有边界,它会从加载器无法感知的包管理器中拉取内容。注册表端的扫描器通常只评估清单文件,而不评估清单文件指向的上游包。因此,对于类似 kind: uv, package: helpful-tool 这样的条目,扫描器只会检查其与所述目的的相称性,而不会检查 helpful-tool 在下一个用户的机器上会执行什么操作。
安装扇出还暴露了一种由 LLM 引发的依赖混淆变体。由模型编写并产生包名“幻觉”的技能脚本,在安装时会解析到公共注册表上抢先注册该名称的人。攻击者会监控模型的输出,寻找那些看似合理的错误包名并提前注册,这种模式被称为“垃圾抢注”(slopsquatting)。
命名空间分配
大多数技能注册表并不拥有自己的命名空间;它们继承了 GitHub 的命名空间,以及名称转让和重新注册的规则。技能的名称就是仓库路径,而该名称的安全性完全取决于拥有该仓库的 GitHub 账户恰好配置了什么安全策略。诸如复活劫持(revival-hijack,即重新注册一个被释放的包名,并向所有仍然固定引用该包的用户推送新版本)和依赖混淆等攻击模式在这里同样适用,只是发生在不同的层级。
那些确实拥有自身命名空间的注册表,大多在扁平命名上实行“先到先得”原则,发布时既没有保留前缀,也不检查近似名称冲突。在迄今为止记录的每一次攻击活动中,针对注册表自身品牌的抢注拼写(typosquatting)一直是攻击者的开场动作。而这里的设计问题在于,发布时的检查到底有没有起到任何作用:是对现有名称进行相似度评分、为第一方内容保留前缀、阻止容易混淆的 Unicode 字符,还是根本什么都不做。
如果一个注册表将已删除的名称保留一段固定的时间窗口然后将其释放,这就为攻击者提供了一个确定性的时间表,可以用来攻击任何按名称和版本进行固定(pin)的锁文件(lockfile)。包管理器领域通过一系列已经被命名的事件,得出了“永久封存名称”的教训;而技能注册表面临的问题是,在架构被照搬的同时,这个教训是否也被一并吸取了。
维护者生命周期
对于大多数 skill 注册表,维护者的生命周期完全取决于源代码仓库:添加维护者在 GitHub 上进行,账号恢复则是 GitHub 的密码重置,注册表根本不参与其中。当 skill 的维护者集合发生变更、skill 被派生(fork)给新所有者,或者长期休眠的账号发布新版本时,基本不会通知下游用户。角色分离非常罕见:维护者只需单一权限即可执行发布、更改设置以及添加其他维护者等操作。
已发布版本的不可变性
默认方式是跟踪 git 分支,这意味着版本就是在拉取(fetch)时该分支所解析到的提交。skill 作者随时可以通过一次推送(push),改变 `skill@v1` 对所有未来安装者的含义;即使存在基于标签(tag)的版本锁定,除非加载器也记录了该提交,否则这种锁定也只是建议性的。Go 的校验和数据库为模块实现了仅追加日志(append-only log)模型,使得无论是源仓库还是代理,都无法在发布后重写版本内容而不被客户端察觉,但对于 skill 而言,这种机制几乎甚至完全不存在。
覆盖已发布版本的操作通常会被拒绝,但此检查是基于内部标识符而非 slug 进行的,因此一旦 slug 过期并被他人重新注册,这种保护就会失效。覆盖检查发生在注册表端,而版本解析发生在客户端,因此不记录单文件哈希值的客户端根本无法区分原始字节与来自新发布者的字节。
从源码到产物的溯源
在传统的包注册表中,出处(provenance)是指注册表的 tarball 与其声称的上游仓库之间的差异。Skill 通常消除了这种差异,因为其产物与上游的 git ref 是同一回事,唯一的问题只剩下是否有人对其进行了签名。
可信发布(Trusted-publishing)和出处证明(provenance attestations)是包注册表给出的解决方案;但对于 skill,类似的机制大多缺失,或者仅存在于注册表自身的插件家族中,而没有涵盖与之并存的 skill 家族。这种不对称性在出现时值得注意,因为它意味着注册表为明显会执行代码的产物类型实现了最新的防御措施,却忽略了那些会注入到系统提示词(system prompt)中的产物类型。
发布凭证
对于托管型注册表,其考量维度与任何包注册表一样为人熟知:作用域(单个 skill 或所有者可发布的所有内容)、权限(仅限发布或也可添加维护者)、有效期(强制、可选、无),以及令牌(token)是否具有可识别的前缀,以便在泄露到公开提交(commit)中时能被密钥扫描器自动撤销。
值得对照检查的常见形态是:使用单一且长期有效的 bearer token,其作用域覆盖整个用户账户而非单个 skill,以明文形式存储在用户主目录下,既没有有效期,在发布时也不提示进行双重认证(2FA)。如果唯一的凭证只是一个等同于会话(session)的 API 密钥,那么以同一用户身份运行的任何其他进程都能代表该用户进行发布,而社会工程学攻击也就简化成了“让该用户的某个其他工具读取一个文件”。
对于注册表仅作为仓库列表这种更常见的情况,发布凭证就是用于登录拥有该仓库的 GitHub 账号的任何凭证。这意味着用户的 2FA 设置、其个人访问令牌的有效期,以及该令牌被复制到了多少个 CI 变量中,全都成为了注册表威胁模型的一部分。
审查与精选
“精选市场”通常只是指 repo 中的一个 JSON 文件列出了其他 repo。管理者只审查名称、描述,或许还有 READMEs,从不检查实际代码(字节),更不用说检查某个 skill 传递引入的依赖项的字节了。
即使 registry 在发布时确实运行了自动扫描器,同样的扫描盲区问题依然存在:扫描器是否检查了多种模式?它是否真正获取了 manifest 指向的上游包?判定结果在二十四小时后是否仍然有效。如果扫描器仅以文本形式检查 manifest,而不去解析其安装时的依赖展开(install fan-out),就会漏掉安装过程中的所有实际操作。
在大多数 skills registry 中,新版本一经发布 agent 便能解析,因此恶意版本的第一个安装者就成了“金丝雀”(canary)。包管理器已经开始采用的冷却窗口机制(即新版本发布后的 24 到 72 小时内,客户端不会主动拉取该版本)在 skills 领域尚未普及。
爆炸半径与检测
一旦某个恶意 skill 被识别出来,有几个问题决定了响应措施是否有效:registry 能否将特定版本标记为恶意并让加载器拒绝加载?能否通知受影响用户他们已安装了该恶意版本?是否有记录谁拉取了什么内容的审计日志?对于那些仅仅是一堆 git URL 列表的 registry 来说,“撤回”(yank)通常意味着从列表中删除该条目,这对已经克隆(cloned)过代码的用户毫无帮助。
值得指出的 skills 特有的问题是,撤回版本(yank-version)、移除软件包(remove-package)和封禁账号(ban-account)之间缺乏隔离。有几个 registry 将这三者视为同一个操作:一旦封禁了维护者(无论是手动封禁,还是因恶意发布或评论诈骗的判定结果而自动封禁),registry 就会批量隐藏该维护者名下的所有 skill。因此,一旦出现扫描器误报,或者查获了因 token 泄露而发起的发布行为,正常的合法工作也会受到连累而被下架。十年前,包管理器领域正是出于这个原因,才将这三种操作彻底分离。
当单用户举报上限仅计算进行中的有效举报时,基于用户举报的社区审核机制就会出现特有的失效模式。如果被隐藏的 skill 不再占用名额,极少数账户就可以通过在每次隐藏生效后循环释放名额,来隐藏无限数量的 skill。只要内容审核系统存在,这种相同的信任图谱 DoS 攻击就始终如影随形;在代码层面修复它只需一行代码,但在应急响应中却需要漫长的工作来善后。
“任意 repo 皆为 registry”的模式
大多数加载器接受任意的 git URL 作为 skill 来源,并将其标榜为最佳实践(happy path)。这使得上述所有关于 registry 端的疑问,都直接退化为“GitHub 给你什么就是什么”,再加上加载器自身的信任确认机制。
加载器在此并没有声称自己是 registry;它拒绝承担 registry 的职责,却依然在行使 registry 的职能。执行 `install https://github.com/user/skill` 时的信任级别,等同于执行 `curl | bash` 时的信任级别,只不过这里的 bash 还能修改 agent 在后续会话中的指令。
作为 registry 客户端的 agent 运行时
存在第二类问题,是因为在注册表中搜索、从候选中挑选并读取 manifest 的消费者是 agent runtime,而不是人类。《Under the Hood of SKILL.md》将这一攻击面划分为三个阶段,并为以下各个子部分提供了大部分实证数据:发现(discovery,注册表将技能排入候选集)、选择(selection,运行时从候选中挑选一个)以及治理(governance,注册表的扫描器在一开始就接受了该构件)。这些都不适用于唯一客户端是人类的包注册表。
发现
一些技能注册表通过 embedding index 进行排名,该索引混合了 manifest、markdown 正文以及技能目录中每个文件的内容。agent runtime 是该索引的消费者之一:它可以将搜索和安装作为普通工具来调用,无需经过用户。
技能作者如果在辅助文件中填满相关文本,就能在 manifest 从未描述过的查询中获得排名,这就是针对密集检索器的 GASLITE 语料库插入攻击,在注册表搜索中表现为排名攻击。在大多数研究案例中,只需在 manifest 末尾附加简短的对抗性触发器,就足以将不相关的技能推入目标查询的前十名。按原始安装量排名也有同样的漏洞,因为发布者可以通过控制某个 header 来刷高匿名下载量,进而伪造安装量。应当明确说明哪些字段会贡献给搜索向量,辅助文件内容是否被包含在内,以及去重和流行度信号起到了什么作用(如果有的话)。
选择
一旦候选列表提交给运行时,模型就会根据 description 字段从中进行选择,而它选择现有技能的对抗性变体的概率远高于随机选择的基线。包含指令性语言、时效信号、夸大的能力描述,或诸如“officially verified”等信任短语的描述,往往比功能相同但描述更朴素的技能更容易被选中。同一篇论文报告称,在四个模型中,对抗性变体优于同等原版的平均概率高达 77.6%,其中测试中最大的模型更是超过了 80%。
需要明确加载器在将 description 加入 prompt 之前对其做了哪些处理:用户是否能看到该描述;像“verified”或“official”这种明显的诱导性词汇除了是发布者自己敲上去的字眼外,是否还有其他实际意义;以及在排名出现并列时,打破平局的机制是否会受到发布者的影响。
治理
技能 manifest 有一部分是自然语言指令,任何处理它们的扫描器都必须理解这些指令的含义。有几种模式可以稳定地击败基于 LLM 的审查器:
这四种情况都存在相同的扫描器盲区模式:仅针对纯文本、纯代码或这两者前 N 个字符执行的检查,都可以通过将核心载荷内容移动到检查所依赖的边界之外来绕过。随之而来的问题是,注册表将如何处理介于“可疑”而又算不上完全“恶意”的判定结果:是阻止发布、显示警告,还是带着一个设置在用户不可见字段中的标志继续发布该技能。
加载器自身的供应链
加载技能的代理框架本身也是一个带有依赖项的包,这些依赖项通常来自某个包管理器,而该框架稍后会代表某个技能在该包管理器中执行安装操作:例如 npm CLI 其本身就是 npm 包,或者 Python 工具自带其 pip 依赖项。框架中一旦出现被攻陷的依赖项,就意味着在负责协调每次技能加载并保存所有已批准工具授权的组件内部实现了代码执行。本节提出的问题同样适用于框架自身的清单,并且是在适用于该框架自身依赖树的所有包管理器设计问题之上的。
Markdown CVE
针对特定技能的 CVE 很快就会出现并被提交,就像 2018 年针对恶意 [email protected] 版本提交的 CVE 一样。现有的漏洞管理技术栈并未为此做好准备。目前没有任何技能注册表注册了 PURL 类型,因此 SBOMs、OSV 订阅源和 Dependabot 风格的扫描器没有规范标识符可用于匹配已安装的技能。制品本身是自然语言文本而非代码,因此作为该技术栈基础的版本和哈希跟踪只能在文件级别起作用,而无法在语义级别起作用:经过改写的载荷会产生不同的 sha256 值,但带来的漏洞却是相同的。
针对注册表设计属性(slug 预留策略、lockfile 格式、冷却时间默认值)的 CVE 甚至更加不在收录目录的范围内,这和 npm 或 PyPI 的情况如出一辙。在这种记录体系下,零 CVE 漏洞就是“注册表设计正按照文档描述正常运行”的具体体现。
包注册表最终产出了 npm 的威胁与缓解措施页面以及 OpenSSF 的《包仓库安全原则》,这两份文档均由运营注册表的人员亲自编写。技能领域也有大量来自其他方面的相关资料,但没有一份是来自加载器或注册表内部的:例如 Snyk 的 ToxicSkills 等扫描器厂商的攻击目录,《Agent Skills in the Wild》和《Towards Secure Agent Skills》等学术实证与分类研究,以及 OWASP Agentic Skills Top 10 等社区管控列表。
需要完整排版与评论请前往来源站点阅读。