记一次云账号 AccessKey 泄露导致云上资源被恶意开机挖矿的溯源与处置

实习生把硬编码 AccessKey 的脚本推上公开仓库,11 分钟后被扫描机器人捡走,攻击者在多地域批量开高配实例挖矿,账单一夜暴涨。

问题背景

我们有一套跑在公有云上的业务系统,日常云费用相对稳定,月度波动不超过 10%。财务侧配了一条粗粒度的费用预算告警,阈值设在月预算的 80%。2026 年 8 月 4 日凌晨 02:40,费用预算告警和云厂商的「账号存在高风险操作」安全通知几乎同时到达,值班同事被叫醒时,当天的消费金额已经是平日整月的三分之一。

登录控制台第一眼看到的不是业务异常,而是一堆自己完全没见过的计算实例——分布在从来没开通过业务的境外地域。这不是性能故障,是账号已经被人拿去用了。下面是这次从「账单异常」倒查到「一行硬编码密钥」的完整过程。

故障现象

  1. 费用曲线断崖式上翘:按小时的消费明细显示,01:12 起每小时消费从个位数直接跳到三位数,且仍在持续增长。
  2. 陌生地域出现陌生实例:在新加坡、法兰克福、硅谷三个从未使用过的地域,各出现 6~10 台计算优化型高配实例(部分是 GPU 规格),实例名是随机字符串,创建时间集中在 01:05~01:30 的二十几分钟内。
  3. 实例行为特征明显:抽查其中一台,CPU 使用率长期 100%,安全组只放行了一个高位端口,实例启动时通过 UserData 拉起了一段脚本,向境外矿池地址发起持续外联。
  4. 账号内多了不认识的子账号:RAM 用户列表里多了一个 svc-backup-agent,创建时间 01:08,挂着高权限策略,并且已经生成了一对新的 AccessKey。
  5. 业务本身完全正常:生产环境的服务、数据库、对象存储访问一切如常,没有任何业务侧报警——这也是它能安静跑将近两小时的原因。

排查过程

第一步:先确认是「凭证被盗用」还是「主机被入侵」

这两者的处置路径完全不同。如果是某台业务服务器被拿下、攻击者在机器上翻到了凭据,那要立刻做主机侧应急;如果是凭证本身在外部泄露,主机可能干干净净。

先看恶意实例是怎么被创建出来的。调操作审计(ActionTrail / CloudTrail 同类能力),按事件名 RunInstances 过滤 01:00 之后的记录:

1
2
3
aliyun actiontrail LookupEvents \
  --StartTime 2026-08-04T00:00:00Z --EndTime 2026-08-04T03:00:00Z \
  --LookupAttribute.1.Key EventName --LookupAttribute.1.Value RunInstances

返回的每条事件里都有 userIdentity 字段,结果非常干脆:

  • 调用者类型是 RAMUser,身份是子账号 ci-deploy
  • 使用的凭证是 AccessKeyId LTAI5t****3n7q
  • 来源 IP 是几个境外 IDC 段的地址,而我们所有运维出口 IP 都在国内固定段。

再查这个 ci-deploy 账号的其他调用记录,攻击者的动作序列一目了然:

1
2
3
4
5
6
7
8
01:03  GetCallerIdentity        # 先确认这把钥匙是谁、还能不能用
01:04  ListPolicies / GetUser   # 摸权限边界
01:08  CreateUser + AttachPolicy + CreateAccessKey   # 建后门账号做持久化
01:05  DescribeRegions          # 列出所有可用地域
01:05  DescribeInstanceTypes    # 挑最贵最能算的规格
01:12  RunInstances × N         # 多地域批量开机
01:20  ListBuckets              # 顺手看看有没有对象存储
01:21  GetBucketAcl × 3         # 试探桶权限

到这里第一个问题有了答案:这是纯粹的凭证盗用,攻击者从头到尾没碰过我们任何一台服务器——所有动作都是通过云 API 完成的。

第二步:确认有没有数据被拖走

这一步比清理挖矿实例更重要。挖矿只是烧钱,数据泄露是事故性质的改变。

顺着审计日志把所有对象存储相关事件拉出来看:攻击者执行了 ListBuckets 和 3 次 GetBucketAcl,但没有任何一条 GetObject 成功记录。原因是这几个桶都配了 Bucket Policy,只允许指定 VPC 内网访问,公网直接调用被拒。同时数据库实例的白名单也只放行了内网网段,审计里没有出现任何 ModifySecurityIps 类的改白名单尝试成功记录。

结论:未发生数据泄露,攻击者的目标就是算力。这一条后来写进了事故报告,也决定了我们不需要走对外披露流程。

第三步:这把钥匙是怎么跑出去的

ci-deploy 是一个历史遗留的 CI 部署子账号。先在内部排查:

  1. 生产服务器上 grep 一遍常见位置,确认这对 AK 没有落在任何一台机器上(我们内部机器都是走实例角色,不放静态密钥):

    1
    
    grep -rn "LTAI5t" /etc /opt /home /var/www 2>/dev/null
    

    无结果。

  2. 查 CI 系统的凭据管理,这对 AK 确实作为环境变量存在,但 CI 的构建日志做了掩码,且访问 IP 全在国内。

  3. 转向代码仓库。先用 gitleaks 扫内部 GitLab 全组织:

    1
    
    gitleaks detect --source ./repos --report-format json --report-path leaks.json
    

    内部仓库扫出几条历史告警,但都不是这一对。

  4. 最后一步是去公网搜。用泄露的 AccessKeyId 前缀在公开代码托管平台做代码搜索——命中了。一名实习生个人账号下的一个 public 仓库里,有个 tools/upload_oss.py,第 9 行赫然写着:

    1
    2
    
    ACCESS_KEY_ID = "LTAI5t****3n7q"
    ACCESS_KEY_SECRET = "wq7****"
    

    他为了在自己电脑上测试对象存储上传,把公司 CI 的密钥复制进了脚本,随手推到了自己的公开仓库。

时间线一对,脊背发凉:

时间 事件
00:52 该 commit 推送到公开仓库
01:03 攻击者首次 GetCallerIdentity 调用
01:12 首批恶意实例创建

从密钥进入公网到被用来开机,只隔了 20 分钟,其中「被发现」这一步只用了 11 分钟。 这不是有人盯着我们,而是全网都有扫描机器人在实时抓取公开提交里的密钥特征,秒级触发。

第四步:为什么一把 CI 密钥能开机器

看这个子账号挂的权限策略,是 AdministratorAccess。当初配 CI 的同事图省事,没有细化到只给部署所需的几个动作。一把只该用来上传构建产物的钥匙,实际上是整个账号的万能钥匙。

解决方案

处置分两条线并行,止血优先于取证之外的一切。

第一线:切断访问(5 分钟内完成)

  1. 禁用而不是删除泄露的 AK,保留取证痕迹:

    1
    2
    
    aliyun ram UpdateAccessKey --UserName ci-deploy \
      --UserAccessKeyId LTAI5t****3n7q --Status Inactive
    
  2. 删除攻击者创建的 svc-backup-agent 子账号及其 AK。

  3. 检查是否还有其他被新建的凭证、角色信任关系被篡改。

第二线:清理资源与费用

  1. 逐地域列出并销毁恶意实例,同时清理攻击者创建的安全组、自定义镜像、快照(有的攻击者会留镜像做二次拉起)。
  2. 关闭本次事件中未使用的境外地域。
  3. 整理审计日志与实例账单明细,向云厂商提交异常消费申诉,最终追回了大部分费用。

第三线:源头清理

联系实习生删除仓库。但必须强调一点:仓库设私有、把那行代码删掉、甚至 git rm 一次新提交,都不等于密钥安全了——历史提交仍可通过缓存和分叉获取。正确做法是:

1
git filter-repo --path tools/upload_oss.py --invert-paths

重写历史后强推,同时删除该公开仓库。但这只是善后,不是修复。真正的修复只有一条:密钥一旦离开受控环境,就必须视为永久泄露、立即轮换作废。我们直接废弃了这对 AK,CI 改用支持短期凭证的方式重新对接。

根因分析

表面看是「实习生把密钥写进了代码」,但一个人的失误能直接放大成账号级事故,说明防线上有五个洞,任意补上一个都能拦住这次事件:

  1. 长期有效的静态 AccessKey——创建于两年前,从未轮换,没有过期时间。
  2. 权限严重超配——部署用途的子账号挂了管理员策略,越权半径等于整个账号。
  3. 密钥可以被随手复制——没有任何机制阻止开发者把生产凭据拿到个人环境。
  4. 提交侧无扫描卡点——本地无 pre-commit,公开推送无检测。
  5. 调用侧无异常告警——境外 IP 调用、陌生地域开机、CreateUser 这类高危动作,都没有实时告警,最后是靠费用告警兜底发现的,已经晚了近两小时。

预防措施

  • 消灭长期静态密钥:服务器侧统一用实例角色,CI/CD 用短期 STS 临时凭证或 OIDC 联合身份换取,把「不存在的密钥」作为最优解。确实无法避免的 AK,强制 90 天轮换,并开启「长期未使用自动禁用」。
  • 最小权限 + 访问条件:按用途拆分子账号,只授予必需的 API 动作与资源范围;给关键子账号加上来源 IP 条件限制,境外 IP 直接拒绝——单这一条本次就能挡住。
  • 提交侧双层拦截:本地 pre-commit 挂 gitleaks,服务端加 pre-receive 钩子拦含密钥特征的推送,CI 流水线再扫一次。三层里任何一层都能在 00:52 那一刻拦下。
  • 高危 API 实时告警:对 CreateUserCreateAccessKeyAttachPolicy、非常用地域 RunInstances、非常用 IP 调用等配置分钟级告警,直接推到值班群,不要只依赖费用告警。
  • 费用告警细化:从「月预算 80%」改为「按小时环比突增」+「按地域白名单」双维度,把发现时间从两小时压缩到十分钟内。
  • 人的环节:新人入职培训里明确「生产凭据不得离开受控环境」,并提供可用的沙箱账号——如果当初有个只能操作测试桶的沙箱 AK,他根本不需要去复制生产密钥。

总结

这次事故里最扎眼的数字是 11 分钟:从密钥出现在公网,到它被自动化机器人捡起来用。这个速度决定了「事后发现再补救」这条路基本走不通,防线必须前移到提交那一刻。

复盘时我们把它拆成一句话:泄露是必然会发生的,问题在于泄露的那把钥匙能开多大的门、多久会被发现。 静态密钥换成临时凭证,管理员权限降到最小集,无告警补上高危动作监控——三件事做完,同样的失误再来一次,损失只会是一条被拦下的提交记录。至于那位实习生,我们没有处分他,而是让他在部门周会上把这条时间线讲了一遍。效果比任何制度宣贯都好。

使用 Hugo 构建
主题 StackJimmy 设计