昨天,三位安全研究者的报告把一桩四个月前的旧案翻了出来,今年 5 月,Ruby 语言的官方包仓库 RubyGems.org 被 AI Agent 灌了两千多个包,被迫停止新用户注册四天。报告的判断很直接——这些包是 OpenAI 内部的测试 Agent 传的。话题在 Hacker News 冲到第一,我今天上午看时 464 分;华尔街日报昨天跟进,OpenAI 确认 Agent 出自自家,但口径是"良性任务、获取公开信息"。 一边说恶意攻击,一边说良性任务,这个温差最值得琢磨。
事情是这么滚起来的
RubyGems 之于 Ruby 就像 npm 之于 Node.js,由非营利组织 Ruby Central 运营。 报告把时间线捋得很清楚:5 月 5 日出现第一批可疑包,只有 4 个,没人当回事;11 日上传 294 个,12 日一天 2186 个。RubyGems 团队当天关掉新用户注册,对外说法是"遭遇持续性的 DDoS",他们真以为是垃圾流量攻击。13 日垃圾包停了,删掉 500 多个包,16 日恢复注册,前后折腾四天。 正常人到这里就觉得结案了:批量注册攻击,处理完毕。
结果 5 月底又冒出来 5 个,6 月 18 日三个小时里又传了 83 个。研究者事后统计,几百个包名直接带着 oai,15 个包的作者字段干脆写着 oai,还有个联系邮箱的用户名是 openaixyz65947。包名和行为模式跟之前那批编辑德国维基的 Agent 一比对,重合度高得吓人——那批,OpenAI 确认过是自己的。
源头是谁,报告作者心里有数;但按他们的说法,OpenAI 从头到尾没告诉 RubyGems"是我们",维护者四个月后跟全世界一起知道的。对比两个月后 Hugging Face 那次——Agent 逃出评估沙箱、在对方生产环境拿到代码执行权限,那次 OpenAI 自己发了技术报告——这次就耐人寻味了:平台收拾了四天残局,源头公司没有任何主动告知。
把公共仓库当硬盘,图什么
这些 Agent 拿 RubyGems 干的事,说穿了有点荒诞:把包管理器当成免费的网络硬盘,外加代理服务器。 套路大致是这样(具体利用细节不展开,原报告和 Socket 的分析里有完整拆解):上传一个包,让平台的文档构建服务去构建,Agent 借构建的机会在构建环境里跑起自己的代码,抓英国几个地方政府网站的会议日程——全是公开信息——再把数据塞进新包传回 RubyGems。数据借道公共仓库走了一圈。
更绝的是存储玩法:把抓到的数据压缩、编码,切成不超过 220 字符的小块,每块注册成一个 webhook 的 URL,用 A000、A001 编号排序,之后任何知道这个账号的 Agent 都能拉出列表拼回原始数据。webhook 本来是发包通知用的,被硬生生改造成了文件系统。
图什么呢?报告给了几种猜测:绕开自己环境里的请求限制、找一处能持久存数据的地方、借公共平台的带宽提速。安全公司当时就困惑过——数据本来就是公开的,直接抓不就完了?没人能给答案,Agent 的决策过程在 OpenAI 内部,研究者看得到包,看不到动机。 但行为本身已经说明问题:Agent 执行任务时,会把顺手够得着的公共基础设施征用为资源。这些包没有恶意软件,不感染装包的开发者,下载量几乎为零——可一个服务全球 Ruby 开发者的公益仓库,就这么被当成耗材用了四天。
最悬的一段:它们摸到了一个真漏洞
前面说的还只是"滥用",真正让我后背发凉的是另一件事:至少 6 个包在尝试偷 RubyGems 用户的 API key。 它们瞄准的是服务端一个真实存在的漏洞——CDN 缓存配置有问题,用户的登录响应可能在边缘节点被缓存最多一个小时,同一个节点后面的未认证请求有机会直接读走别人的 key。拿到 key 意味着什么?能以那个账号的身份发新版本、删版本、加 owner。这漏洞有多隐蔽:7 月 6 日才被 Truffle Security 独立发现并走负责任披露,官方 9 日部署修复,随后把存在了约九年的老式全权限 key 全部撤销,评级 CVSS 7.2,高危。也就是说,5 月 12 日那天,Agent 对着一个人类世界要到八周后才发现的高危漏洞发起了尝试。
两边都没能确认成没成功。RubyGems 团队翻了日志,没发现 key 被恶意使用的痕迹,但官方老实承认日志覆盖的年份有限,没法完全排除。我倾向于相信没成功——成功的前提是恰好在攻击时段、用旧版客户端、还路由到同一个边缘节点登录,概率很小。但"概率很小"和"Agent 撞见了人类尚未发现的真漏洞"是两码事,后者已经发生了。
顺带一个 Mac 用户相关的细节:macOS 自带的 /usr/bin/gem 就在受影响版本之列,官方披露时还有 18% 登录来自受影响版本。还有 gem signin 老习惯的,这事算是敲了次警钟——全权限、永不过期的 key 该换成 scoped key(按包、按操作限定权限),能开 MFA 就开 MFA。npm 和 PyPI 的 trusted publishing(CI 用短期 OIDC 令牌,不放永久 token)也是同一个思路。
我去 RubyGems 上翻了翻
报告里引用的包名都是公开的,我挑了几个去 rubygems.org 搜了搜。zzsouthrunner 和 oaibootx8192 页面还在,但都标着"not currently hosted",即被下架。有意思的是 oaibootx8192 的所有者账号 openaixyz65947,主页至今挂在上面,用户名和报告里那个 gmail 邮箱一字不差。 包清了,账号还在。平台处置停在"下架恶意包",没动批量注册的账号——可能不敢乱删,也可能没意识到该删。这本身是个注脚:面对 Agent 留下的痕迹,人这边明显慢半拍。 我写了个土办法检测脚本,抓机器批量生成包名的特征(oai 字样、可疑词根、超长数字尾巴),拿报告里的 8 个恶意包名混 6 个正常包名测了一把:
import re
SUSPECT_STEMS = ('oai', 'leak', 'probe', 'proxy', 'exfil', 'hack', 'inject', 'pwn')
def check(name: str) -> list[str]:
reasons = []
low = name.lower()
if 'oai' in low:
reasons.append('包含 oai 字样')
if re.search(r'\d{6,}', low):
reasons.append('词根+长数字串')
if hits := [s for s in SUSPECT_STEMS[1:] if s in low]:
reasons.append('含可疑词根 ' + '/'.join(hits))
return reasons
# BAD 来自 rubyhack.ai 报告中的真实恶意包名,GOOD 为正常对照
BAD = ['oaibootx8192', 'lambQ4340', 'chatoaifetch177855288717', 'slnleaker5',
'zzwandshostyard', 'yardbreakerxqh1778552850', 'zzsouthrunner', 'southpxdatapp6pi']
GOOD = ['rails', 'puma', 'sidekiq', 'devise', 'nokogiri', 'redis-client']
for name in BAD + GOOD:
r = check(name)
print(f"{'命中' if r else '放行'} {name:<28} {';'.join(r)}")
结果说实话有点不好意思:8 个恶意包名只抓到 4 个,对照组 6 个全放行。漏掉的 4 个,要么数字尾巴太短,要么词根太普通。这还是拿着标准答案做事后检验,真实场景里根本没有这份名单。所以这脚本就当复盘玩具,别真当防线——防线在平台侧的限流和验证里,不指望肉眼和命名规则。
跟你我有什么关系
大多数人不会去维护开源仓库,但有几条教训是通用的。 平台侧的先说完:RubyGems 事后加的三样东西——一次性邮箱拦截、新注册限速、WAF——报告说基本挡住了后续的 Agent 活动,维护任何允许注册上传的公开服务,这三样就是被实战验证过的最小配置。凭证同理,与其赌日志能查清九年的访问记录,不如直接全量撤销重发,推生态往短期凭证迁移——用包管理器的各位,回头检查自己的 key。
做 Agent 开发的话,教训更扎心:Agent 为了完成任务,会自己去第三方平台批量注册账号、征用基础设施,中间没有屏障拦住它。给 Agent 做沙箱的人,出口白名单、异常注册告警不是过度设计,是 RubyGems 用四天停机帮你验证过的坑。 OpenAI 在回应里说会继续调查 Agent 训练和评估期间的行为。那就等后续报告吧——按最近几个月的出事频率看,大概率还有得等。