依赖混淆攻击利用的是您的手误,而"Slopsquatting"攻击则瞄准您的AI助手。当模型虚构出一个不存在的软件包时,攻击者便会注册该名称,静候安装命令的执行。本文将揭示这一攻击链的全过程、常规防御手段为何对它束手无策,以及在npm、Composer和pip生态中真正有效的防御策略。
设想一下,您的编程助手刚刚建议您执行以下命令:
pip install requests-oauth2-helper
看起来没问题。你想要一种简洁的方式将 OAuth2 令牌附加到你的 requests 调用上,而这个名字读起来完全像是能实现这一功能的包。大小写符合惯例,连字符也与生态体系一致,听起来就像五十个真实的 PyPI 辅助工具之一。于是你运行它。测试通过。你继续前进。
问题在于,当模型学会推荐 requests-oauth2-helper 时,这个包根本不存在。模型是凭空编造的。而如果有攻击者注意到这一点,这个名字就不再是空的了。
这就是 slopsquatting:一种供应链攻击,将 typosquatting 核心中的人工拼写错误替换为机器幻觉。该术语由 Python 软件基金会驻场开发者 Seth Larson 提出,并在 2025 年由 Ecosyste.ms 的 Andrew Nesbitt 推广。这是一个小想法,却带来恶劣后果:你不再需要手滑拼错 reqeusts。你的 AI 会面不改色地自信递给你一个假包,而你信任它超过信任自己的笔误。
Typosquatting 由来已久。你在 express 旁边注册 expres,在 python-dateutil 旁边注册 python-dateutl,然后等待某个人的手指打滑。它确实有效,但这是一场赔率很低的数字游戏。大多数人能正确拼写 express。攻击者捕猎的是那不到百分之一的拼错者。
Slopsquatting 完全消除了对人类失误的依赖。开发者逐字符正确输入这个名字,因为他们正在从一个此刻比自己更受信任的来源复制它:刚刚写出周边代码的那个助手。这个错误早就由模型在上游犯下了,而人类只是在忠实地复现它。
关键在于这个转变。在 typosquatting 中,攻击者猜测你的手指可能会滑向什么。而在 slopsquatting 中,攻击者根本不用猜。他们会大规模地阅读模型实际输出的内容,并注册出现的名字。模型是在替他们做目标选择。
如果模型幻觉罕见且随机,slopsquatting 就只会是一个注脚。注册一个假名字,永远等待,一无所获。而它之所以构成真实威胁,是因为这两个条件都不成立。
首先是规模。USENIX Security 2025 的一项研究,《我们为你准备了一个软件包!》,在 Python 和 JavaScript 中跨 16 个大语言模型生成了 576,000 个代码样本,并检查了模型推荐的每个软件包。19.7% 的推荐软件包不存在。这不是基准测试边缘的舍入误差。这是五分之一。该研究记录了 205,474 个不同的幻觉软件包名称。商业模型表现较好(平均至少 5.2%),开源模型表现较差(至少 21.7%),但没有一个模型是干净的。
如果每次幻觉都是独一无二的雪花,规模仍然可控,因为攻击者无法注册二十万个名称,也无法知道哪些名称会再次出现在用户面前。最关键的发现是幻觉是可复现的。研究人员选取了500个曾经生成过虚假软件包的提示词,并将每个提示词再运行十次。43%的幻觉软件包每一次都会出现。58%在不止一次运行中出现。只有39%从未再次出现。
细想一下。这些捏造的名称中近一半是稳定的模型行为,而非噪声。这彻底翻转了成本效益逻辑。攻击者不需要注册所有名称。他们挖掘模型输出,保留反复出现的名称,并注册那些粘性名称。可复现性本身就是侦察手段。模型一遍又一遍地告诉他们,未来开发者最可能被交付的正是哪个虚假名称。
警告
这些名称甚至不必看起来像真正的软件包。使用莱文斯坦距离,同一项研究发现只有13%的幻觉名称是真实名称的简单拼写错误。约38%具有中等相似度,而近一半则高度不相似:完全捏造,但在周围代码的上下文中仍然可信。最后这一类名称能直接绕过拼写错误仿冒检测,我们稍后会回到原因上。
这一切并非理论空谈。这是一个清晰、可重复的顺序,而且每一个环节都是你在正常工作日里亲眼见过的事情。
1. 模型建议导入。你请求一个功能。助手编写代码,并寻找一个符合问题形状的依赖。它给出一个名称。这个名称是虚构的,但合乎语法:正确的生态系统惯例,正确的大小写,正确的“听起来像是存在的东西”的感觉。
2. 因为名称合适,你信任了它。这是承重步骤,而且是心理上的,不是技术上的。自信的AI输出能像自信的高级工程师的PR一样滑过你的防线。这个名称很地道。它没有任何元素与“危险”模式匹配。你审查的是逻辑,而不是核查一个由四个单词组成的包名是否真实,因为谁会那样做呢?
3. 安装运行代码。在 npm 和 pip 中,安装一个软件包可以自动执行脚本:一个 postinstall 钩子,一个 setup.py 构建步骤。攻击者的有效载荷不需要你导入任何东西或调用函数。install 已完成,所以代码已经运行了。
4. 凭证泄露。有效载荷读取构建代理随手可及的内容:环境变量、~/.aws/credentials、~/.npmrc 令牌、一个 GITHUB_TOKEN、.env 文件。它通过 POST 请求将它们发送到攻击者控制的端点。从外部看,这就像软件包在安装期间获取元数据。从内部看,你的 CI 刚刚把钥匙交到了一个陌生人手里。
整个过程安静得令人不安。没有漏洞利用,没有CVE编号,也没有巧妙的内存破坏。开发者只是信任了一个名字,然后运行了他们每天执行五十次的标准安装命令。这就是整个攻击的全部。
人们会在信任这一步上栽跟头,这并非假设。Lasso Security的安全研究员Bar Lanyado注意到,模型反复幻觉出一个名为huggingface-cli的Python包。他作为实验,在PyPI上以huggingface-cli这个确切名称注册了一个空包。在接下来的三个月里,该包获得了超过15,000次真实下载量,而且阿里巴巴的GraphTranslator项目最终在其README中推荐了pip install huggingface-cli,而实际工具的安装命令是pip install -U "huggingface_hub[cli]"。Lanyado的包是无害的。但slopsquatter的包就不会这么无害了。
简而言之,就是“JavaScript、PHP和Go都受此影响。”准确地说是每个生态系统给攻击者提供的操作空间不同,了解这种差异能让你知道防御预算该花在哪儿。
npm给了攻击者最大的操作空间。生命周期脚本(preinstall、install、postinstall)会在npm install时自动执行。这是经典攻击向量,也正是整个生态系统最终开始改变的原因。pnpm v10在2025年初默认禁用了依赖生命周期脚本,npm也紧随其后:npm v12默认关闭了脚本自动执行,该公告于2026年6月发布。在升级到这些版本之前,postinstall就是一把指向你CI系统的上了膛的枪。
package.json(恶意依赖)
{
"name": "requests-oauth2-helper",
"version": "1.0.3",
"scripts": {
"postinstall": "node ./collect.js"
}
}
pip 也紧随其后。 源代码发行版在安装时会运行 setup.py 来计算元数据并构建,这意味着在 pip install 时会执行任意代码。而 wheel(.whl)不会以同样的方式运行安装时代码,这就是为什么 --only-binary 是一个真正的加固手段,而非锦上添花。
# Refuse source builds; only accept prebuilt wheels
pip install --only-binary :all: requests-oauth2-helper
Composer 在设计上无疑是三者中最安全的一个。 Composer 只会执行 root(根)包的 composer.json 中定义的脚本。依赖自身的 scripts 块会被忽略。因此,一个被仿冒投毒的 Composer 包无法像 npm 包提供 postinstall 那样,自动给你一个 post-install-cmd。唯一的例外是插件:恶意的 Composer 插件 可以挂钩安装事件,这正是 composer install --no-plugins --no-scripts 存在的原因,用于处理不受信任的依赖树。攻击载荷必须等到你的代码实际调用它才会触发,这比“安装时运行”的门槛要高得多。
Go 完全没有安装脚本,因此杀伤链发生了转移。 当你添加一个包时,go get 和 go build 不会立刻运行该包中任意的安装代码。被仿冒投毒的 Go 模块中的恶意代码会在你的程序首次执行它时运行——通过导入时执行的包 init() 函数,或在 go test 期间。虽然更晚,但并非永远不会发生。
Go 还通过模块代理带来了它特有的变数。Socket 发现了一个带后门的 BoltDB 拼写错误仿冒包 github.com/boltdb-go/bolt,该包于 2021 年 11 月被上传,被 Go 模块镜像缓存,随后其 Git 标签被重写为干净代码。但代理仍然继续提供缓存的恶意版本。这个包在三年多的时间里一直未被发现。本意为使构建可复现的缓存机制,同时也会让被投毒的版本持久留存。
因此,思维模式并不是“一次攻击,三种语言”,而是:npm 和 pip 在安装时触发,Composer 等待插件或调用,Go 则等待执行,然后永远记住那个坏版本。仿冒投毒者会选择能给他们提供最早、最隐蔽触发点的生态系统。
接下来是令人不安的部分。你已经在运行的多数控制手段都是为另一种威胁模型设计的,而仿冒投毒恰恰会从它们之间的缝隙中溜过去。
锁文件只在首次安装之后才有用。 package-lock.json 或 composer.lock 会锁定精确的版本和哈希,使依赖项无法在你眼皮底下悄悄改变。这对于你已经审查并锁定的包来说很好。但仿冒投毒攻击的正是某个全新名字的首次安装。此时锁文件里还没有任何内容,因为你从未安装过这个东西。AI 三十秒前才建议了它。锁文件忠实地记录了你首次拉取的任何恶意版本,然后它就被固定了。锁文件保护的是连续性,而不是首次接触。
扫描器寻找已知的恶意内容,而这是从未见过的。 漏洞扫描器或恶意软件源会匹配那些已经被标记的包。一个昨天注册的垃圾抢注包,针对的是一个本季度才开始反复出现的幻觉包,它没有CVE、没有公告、没有声誉、没有历史。它因为不存在而显得干净。扫描器并没有坏;它回答的是另一个问题。“这是已知威胁吗?”不同于“这是一个人类选择的真实包吗?”
拼写错误检测的关键在于编辑距离,而这些名字中有一半与任何东西都不相似。 针对拼写错误抢注的标准防御是测量字符串相似度:需要多少次编辑才能把 expres 变成 express?标记那些接近的。但请记住莱文斯坦的发现,近一半的幻觉包名与任何真实包都高度不相似。requests-oauth2-helper 并不是与真实事物相差一次编辑。它根本不是对现有名称的篡改。它是一个恰好听起来合理的全新发明。没有真实的邻居可以测量距离,所以检测器无从触发。
把这三者放在一起,你就会看到这个缺口的形状。这些防御措施中的每一项都假设恶意包要么被更改过(锁文件)、要么曾被标记过(扫描器)、要么模仿真实的东西(拼写错误检测)。垃圾抢注不是其中任何一种。它是一个真正的新名字,从未见过,与任何东西都不相似,由一个听起来自信的机器选择。这些防御措施并不薄弱。它们瞄准的是别处。
好消息是,解决方案并不奇特。这应该是管理任何依赖时的同一种直觉,只不过适用于一个你已经开始过度信任的来源。你不需要一个新的产品类别。你需要停止把AI的包建议当作引文来看待。
在包接触到你的机器之前,先验证它是否存在并得到维护。 在安装模型给你的任何东西之前,先查一下。它在注册表中存在吗?下载量多少、版本有多少、最近一次发布是什么时候、由谁发布、背后是否有一个人类明显维护的仓库?一个垃圾抢注的包通常只有几天历史,下载数可疑地呈整数,且没有历史记录。三十秒的查看就能杀死大部分此类包。
# npm: does it exist, who owns it, how old is it?
npm view requests-oauth2-helper
# PyPI: check the project page, release history, and maintainers
pip index versions requests-oauth2-helper
如果第一个命令返回404,说明模型没有为你找到隐藏的珍宝。它是编造出来的。
默认禁用安装脚本。这一项控制就能使杀伤链最常见的版本失去威力。关闭自动执行,让真正的构建步骤成为显式例外,而不是默认行为:
:::tabs
npm
# Kill lifecycle scripts globally; allow-list the rare package that needs one
npm config set ignore-scripts true
pnpm
# pnpm v10+ blocks dependency scripts by default.
# Approve only the ones you actually trust:
pnpm approve-builds
pip
# Prefer prebuilt wheels so setup.py never runs at install time
pip install --only-binary :all: <package>
composer
# Untrusted tree? No plugins, no scripts.
composer install --no-plugins --no-scripts
:::
只有大约2%的npm包合法地使用安装脚本,这正是生态系统决定将默认值设为关闭的原因,这是一个明智的默认设置。你几乎不会损失什么,同时关上了攻击载荷所依赖的大门。
通过带有允许列表的私有注册表代理所有内容。构建代理不应能够直接访问公共注册表并拉取模型点名的任何包。在前面放置一个代理(Artifactory、Verdaccio、私有PyPI、Composer的Satis),只允许已经过审查的包进入。这样一来,幻觉产生的名称不会在安装之后失败,而是在安装之前就失败,因为它从未被批准。这种控制方式具有良好的可扩展性,因为它将决策从“开发人员是否注意到”转变为“这是否在列表上”。
为新版本添加成熟度延迟。pnpm v11 推出了minimumReleaseAge,默认值为1440分钟(24小时),该功能会拒绝安装尚未公开足够长时间的包版本,以便社区有时间发现明显的恶意软件。一个抢在最新幻觉出现时注册上线的slopsquat包,正是冷静期窗口擅长阻止的对象。
而所有其他规则之下还有一条基本规则:永远不要仅仅因为AI的建议就让依赖项进入仓库。AI的包建议是一个假设,而不是来源。要以对待来自从未见过的账号的Stack Overflow回答的方式来对待它:可能是对的,值得检查,但绝不只看一眼就信任。整个攻击的关键在于第二步中那一次错误的信任。验证名称,攻击就无处立足。
令人不安的事实是,模型不会很快停止这种行为。幻觉是这些系统生成文本方式的一个固有属性,从内部来看,一个伪造的包名读起来与真实包名一模一样。因此,责任落在你身上:运行install的人。你的助手刚刚给你的名字可能是一个真正的库,也可能是一个攻击者的包,披着真正库的名字。区分它们的唯一方法是在安装之前查看,而slopsquatting的全部意义就在于你不会去查看。
附言:感谢您抽出时间阅读这篇文章!这里表达的观点和意见都是我自己的。英语不是我的母语,所以我使用AI来帮助纠正语法,让我的写作更清晰、更易读。如果仍有任何地方听起来有点别扭,感谢您的理解!
最初发布于nazarboyko.com。
喜欢这篇文章吗?让我们保持联系 - 我在领英上,总是乐于聊天、交流想法,或者只是打个招呼。👋
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。