1. 凌晨三点告警响起时,我以为是误报
那天凌晨三点十七分,手机在床头柜上震个不停。我眯着眼摸过来一看,是监控平台推来的告警:一台对外提供服务的应用服务器CPU占用率飙到98%,持续超过五分钟。第一反应是误报——这台机器上跑的是个内部测试用的接口服务,白天都没什么流量,凌晨三点更不可能有负载。
但我还是爬起来打开了电脑。连上监控面板一看,CPU曲线从凌晨两点五十分开始爬升,到三点整已经顶到天花板。内存占用也在涨,网络出站流量比平时高了将近二十倍。这不是误报,这台机器正在拼命往外发东西。
登录服务器之后,top命令显示一个陌生的进程占着大量CPU,进程名伪装成了系统自带的日志轮转工具,但可执行文件路径指向/tmp目录下一个随机命名的文件。我心里咯噔一下——这台机器被人打穿了,而且对方正在用它做跳板。
后面几个小时的排查过程,让我把这条攻击链路完整地摸了一遍。攻击者利用的是应用里一个不起眼的链接解析功能,通过SSRF漏洞拿到了云环境里的临时凭证,再借助CI/CD流水线的配置缺陷横向移动到其他机器。整个过程没有用任何高级0day,全是常见的配置疏忽和代码逻辑漏洞,但组合起来杀伤力极大。
这篇文章我会把整条攻击链拆开讲清楚:SSRF是怎么被触发的、攻击者如何从SSRF升级到凭证窃取、CI/CD环节哪里出了问题、以及我最后做了哪些加固。如果你也在维护对外服务、跑着CI/CD流水线、用着云服务器,这篇内容值得你花时间看完。
2. 从一条链接解析请求说起:SSRF是怎么被触发的
2.1 那个"贴心"的链接预览功能
出问题的应用是一个内部使用的协作工具,其中一个功能是:用户粘贴一个URL,系统会自动抓取该链接的标题、描述和缩略图,生成一个预览卡片。这个功能在很多协作类产品里都有,本身不复杂——服务端收到URL,发一个HTTP请求过去,解析返回的HTML,提取<title>和<meta>标签内容。
问题出在这个"发一个HTTP请求过去"上。代码里对用户传入的URL几乎没有做任何限制,只检查了字符串是否以http://或https://开头,然后就交给HTTP客户端去请求了。这意味着用户可以传入任何地址,包括内网地址。
我用一段简化的伪代码来说明当时的逻辑:
def fetch_link_preview(url): # 只做了最基础的前缀检查 if not url.startswith(('http://', 'https://')): return {'error': 'invalid url'} # 直接请求,没有任何目标地址限制 resp = requests.get(url, timeout=5) html = resp.text # 解析标题和描述 title = extract_title(html) desc = extract_meta_description(html) return {'title': title, 'desc': desc}这段代码的问题在于:requests.get(url)里的url完全由用户控制。攻击者可以传入http://169.254.169.254/latest/meta-data/这样的地址,让服务器去请求云厂商的元数据服务。
2.2 云元数据服务为什么成了突破口
这里需要解释一下云服务器上的元数据服务。主流云厂商都会在每台实例上提供一个特殊的内部地址(通常是169.254.169.254),用于让实例内的程序查询自己的配置信息——实例ID、区域、网络配置,以及最重要的:绑定到该实例的临时访问凭证。
这个服务的设计初衷是方便实例内的程序获取自身信息,不需要硬编码密钥。但它有一个前提假设:只有实例内部的程序会访问它。如果外部攻击者能通过SSRF让服务器代为请求这个地址,就能拿到本该只属于这台机器的凭证。
我当时用的云环境,元数据服务的访问路径大致是这样的:
http://169.254.169.254/latest/meta-data/iam/security-credentials/请求这个地址会返回绑定到实例的角色名称,再用角色名称请求下一级路径,就能拿到AccessKeyId、SecretAccessKey和Token。这三个东西凑齐,攻击者就能以这台实例的身份调用云API。
攻击者显然对这个路径很熟悉。我在服务器日志里看到,从凌晨两点四十八分开始,有大量请求打到了链接预览接口,传入的URL参数依次是:
http://169.254.169.254/latest/meta-data/ http://169.254.169.254/latest/meta-data/iam/security-credentials/ http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role-name前两个请求返回的是目录列表和角色名,第三个请求直接返回了凭证的JSON。整个过程不到十秒。
2.3 为什么简单的黑名单挡不住
事后复盘时我在想,如果当时加了一个内网地址黑名单,是不是就能挡住?答案是:能挡住这个特定的攻击方式,但挡不住有经验的攻击者。
原因在于,SSRF的绕过手法非常多。比如:
- 十进制IP:
169.254.169.254可以写成2852039166,很多黑名单只匹配点分十进制格式,匹配不到纯数字。 - 八进制/十六进制:
0177.0.0.1或0xA9.0xFE.0xA9.0xFE,字符串匹配很容易漏掉。 - DNS重绑定:攻击者控制一个域名,第一次解析返回外部IP通过检查,第二次解析返回内网IP实际请求。
- URL解析歧义:
http://evil.com@169.254.169.254/这种格式,不同HTTP客户端对@前面部分的处理不一致,有的会忽略evil.com@直接请求后面的地址。 - 重定向:攻击者自己的服务器返回302跳转到内网地址,如果HTTP客户端跟随重定向,黑名单就失效了。
所以正确的做法不是拉黑名单,而是做白名单——只允许请求特定的、可控的外部地址,或者至少在网络层做隔离,让应用服务器根本无法访问元数据服务。
这里有个实操经验:很多云厂商现在支持在实例配置里禁用元数据服务的v1版本,强制使用需要Token的v2版本。v2版本要求请求时带上一个提前获取的Token,SSRF很难拿到这个Token。如果你的云环境支持,务必开启这个选项。这是成本最低、效果最直接的防护。
3. 拿到凭证之后:攻击者如何横向移动
3.1 临时凭证能做什么
攻击者拿到的那组临时凭证,绑定的是一个CI/CD相关的角色。这个角色本身权限不算高,但有一个关键权限:可以读取对象存储里的构建产物和配置文件。
我后来查了云审计日志,攻击者用这组凭证做了以下几件事:
- 列出所有可访问的存储桶
- 下载了几个存储桶里的构建日志和配置文件
- 在其中一个配置文件里找到了CI/CD系统的访问令牌
- 用这个令牌登录CI/CD系统,查看流水线配置
整个过程在云审计日志里留下了清晰的记录,但当时没有配置实时告警,所以直到服务器CPU飙高才被发现。
3.2 CI/CD系统为什么是横向移动的跳板
CI/CD系统在这条攻击链里扮演了关键角色。原因很简单:CI/CD系统需要部署代码到服务器,所以它天然持有大量敏感信息——服务器SSH密钥、部署凭证、环境变量里的各种密钥。
攻击者拿到CI/CD的访问令牌后,可以:
- 查看所有流水线的配置,从中提取硬编码的密钥
- 修改流水线配置,在构建步骤里插入恶意命令
- 触发一次构建,让恶意命令在构建服务器上执行
- 如果构建服务器有部署权限,恶意命令就能扩散到生产环境
我检查了CI/CD系统的审计日志,发现攻击者确实尝试修改了一条流水线的配置,在构建步骤里插入了一段下载并执行远程脚本的命令。幸运的是,这条流水线配置了人工审批环节,修改没有立即生效,给了我反应时间。
3.3 一个容易被忽略的细节:构建缓存
在排查过程中我发现,CI/CD系统的构建缓存里存着上一次构建的完整工作目录,包括.git目录和构建过程中生成的临时文件。如果构建过程中曾经把密钥写入过临时文件,这些文件就会留在缓存里。
攻击者显然知道这一点。日志显示他下载了构建缓存的内容。我后来检查发现,有一条流水线在构建时会把环境变量写入一个临时配置文件,构建结束后没有清理。这个文件留在了缓存里,里面包含了一个数据库的连接字符串。
这个细节提醒我:CI/CD的构建缓存和构建产物,本质上和源代码一样敏感,需要同等级别的访问控制。
4. 排查过程中的几个关键判断点
4.1 如何确认是SSRF而不是其他漏洞
凌晨三点半左右,我已经确认服务器被入侵,但还不确定入口在哪里。当时有几个候选方向:应用本身的远程代码执行漏洞、弱口令被爆破、或者SSRF。
我做了几件事来缩小范围:
首先看进程的启动时间。ps aux显示恶意进程的启动时间是凌晨两点五十一分,而应用服务的日志里,两点四十八分开始出现大量链接预览请求。时间上高度吻合。
其次看网络连接。netstat显示恶意进程正在连接一个外部IP的443端口,而这个IP不在任何已知的业务白名单里。同时,应用服务进程本身也在频繁发起对外HTTP请求,目标地址是169.254.169.254。
最后看应用日志。链接预览接口的访问日志里,有大量请求的URL参数是内网地址。这些请求的返回内容虽然没被完整记录,但从响应时间(都在5秒超时附近)和后续行为来看,攻击者确实拿到了数据。
三个证据交叉验证,基本可以确定入口就是SSRF。
4.2 为什么没有第一时间关机
发现入侵后,我的第一反应是立刻关机断网。但我忍住了,因为关机可能会丢失内存里的证据——比如恶意进程的运行状态、网络连接信息、以及可能的加密密钥。
我采取的做法是:
- 先用
iptables封掉恶意进程的外连IP,切断它的通信 - 用
kill -STOP暂停恶意进程而不是杀死它,保留现场 - 导出内存镜像和关键日志
- 然后再做隔离和清理
这个顺序很重要。如果先关机,很多内存中的证据就没了,后续的溯源和影响范围评估会困难很多。
4.3 影响范围评估:不止一台机器
确认入口后,我需要评估影响范围。攻击者拿到了CI/CD的令牌,这意味着所有通过该CI/CD部署的服务器都可能受影响。
我做了以下排查:
- 检查CI/CD系统的审计日志,确认攻击者访问了哪些流水线
- 检查所有服务器的登录日志,看是否有异常SSH登录
- 检查云审计日志,看攻击者用临时凭证调用了哪些API
- 检查对象存储的访问日志,确认哪些文件被下载
排查结果:攻击者主要活动集中在一台构建服务器和两台应用服务器上,没有扩散到数据库和核心业务服务器。这算是不幸中的万幸。
5. 加固方案:从代码到架构的完整整改
5.1 链接预览功能的重新设计
针对SSRF入口,我做了几层防护:
第一层:URL白名单校验。不再允许任意URL,而是只允许请求预先配置的域名列表。这个列表由运维维护,普通用户无法修改。
第二层:网络层隔离。把链接预览功能拆成一个独立的微服务,部署在独立的网络命名空间里。这个命名空间的路由表里没有到元数据服务和内网网段的路线,即使代码被绕过,网络层也出不去。
第三层:禁用重定向。HTTP客户端配置为不跟随重定向,避免攻击者通过302跳转绕过白名单。
第四层:响应内容限制。只提取HTML的<title>和<meta>标签,不返回原始响应内容。这样即使请求到了敏感地址,攻击者也拿不到完整数据。
整改后的核心代码逻辑大致如下:
ALLOWED_DOMAINS = {'example.com', 'docs.example.com', 'blog.example.com'} def fetch_link_preview(url): parsed = urlparse(url) # 协议白名单 if parsed.scheme not in ('http', 'https'): return {'error': 'invalid scheme'} # 域名白名单 if parsed.hostname not in ALLOWED_DOMAINS: return {'error': 'domain not allowed'} # 禁止跟随重定向 resp = requests.get(url, timeout=5, allow_redirects=False) # 只提取需要的字段,不返回原始内容 html = resp.text return { 'title': extract_title(html), 'desc': extract_meta_description(html) }5.2 云环境的权限收敛
临时凭证能拿到CI/CD令牌,根本原因是角色权限过大。我做了以下调整:
- 把CI/CD相关的角色权限拆分成多个细粒度角色,每个角色只负责一件事
- 对象存储的访问权限从"整个桶"收敛到"特定前缀"
- 禁用元数据服务的v1版本,强制使用v2
- 给所有临时凭证设置更短的过期时间
这里有个经验:云厂商的权限系统通常支持"权限边界"(Permission Boundary)功能。你可以给一个角色设置权限边界,限制它即使被授予了某些权限也不能超出边界。这个功能在防止权限提升时非常有用。
5.3 CI/CD流水线的安全加固
CI/CD环节的加固主要做了几件事:
密钥管理:把所有硬编码在流水线配置里的密钥迁移到密钥管理服务,流水线运行时动态获取。这样即使攻击者能看到流水线配置,也拿不到实际密钥。
构建环境隔离:每次构建都在全新的容器里进行,构建结束后容器销毁,不保留缓存。需要缓存的依赖通过受控的缓存服务获取,缓存内容经过扫描。
流水线修改审批:所有对流水线配置的修改都需要人工审批,审批通过后才生效。这个措施在这次事件中直接阻止了攻击者的进一步行动。
构建日志脱敏:构建日志在输出前经过脱敏处理,自动识别并替换疑似密钥的字符串。
5.4 监控和告警的补强
这次事件暴露出的另一个问题是:攻击者活动了将近半小时才被发现。我补充了以下监控:
- 元数据服务的访问告警:任何对
169.254.169.254的请求都触发告警 - 异常外连告警:服务器主动连接非常用IP时触发告警
- CI/CD敏感操作告警:流水线配置修改、凭证访问等操作实时告警
- 云API异常调用告警:非预期时间、非预期来源的API调用触发告警
这些告警都配置了多渠道通知,确保第一时间能收到。
6. 几个值得记住的教训
6.1 SSRF不是"低危漏洞"
在很多漏洞评级里,SSRF被标为"中危"甚至"低危",因为它通常只能让服务器发起请求,不能直接执行代码。但在云环境下,SSRF的杀伤力被严重低估了。通过元数据服务,SSRF可以直接升级为凭证窃取,进而控制整个云环境。
我的建议是:任何涉及服务端发起请求的功能,都按"高危"来对待。做白名单、做网络隔离、做响应限制,怎么严格都不为过。
6.2 CI/CD是新的攻击面
传统安全防护往往聚焦在生产环境,但CI/CD系统持有的权限和敏感信息,让它成为了攻击者的理想目标。一次CI/CD系统的入侵,可能比十台服务器被入侵的影响更大。
如果你在维护CI/CD系统,建议定期做几件事:审查流水线配置里的硬编码密钥、检查构建缓存和构建产物的访问控制、确认流水线修改有审批环节、监控CI/CD系统的异常访问。
6.3 临时凭证不是"临时安全"
很多人觉得临时凭证过期时间短,风险可控。但攻击者拿到凭证后,可以在有效期内做很多事情——下载数据、创建后门、提升权限。几分钟的有效期,足够完成一次完整的横向移动。
所以临时凭证的权限必须严格收敛,遵循最小权限原则。同时,凭证的使用应该有实时监控,异常调用立即告警。
6.4 排查时保留现场比快速恢复更重要
发现入侵后,快速恢复服务是本能反应。但如果条件允许,先保留现场再恢复。内存里的进程状态、网络连接、临时文件,这些证据一旦丢失就很难找回。而完整的证据链对于评估影响范围、确认攻击者是否留下后门、以及后续的加固方向,都是不可或缺的。
我自己的做法是:先用网络层手段切断攻击者通信,然后导出关键证据,最后再做清理和恢复。这个顺序虽然会让服务多中断一段时间,但换来的是对事件的完整掌握。
6.5 一个实用的小技巧:蜜标
在加固过程中,我在内网几个关键位置部署了"蜜标"——一些看起来像正常配置文件的诱饵,里面包含虚假的凭证。这些凭证被使用时会触发告警。这个手段的好处是:正常业务永远不会用到这些凭证,一旦被使用,说明攻击者已经进入了内网,而且正在尝试横向移动。
蜜标部署成本很低,但能提供高质量的告警。我在几个客户环境里都推荐了这个做法,反馈都不错。
7. 写在最后
这次事件从发现到完全清理,花了将近两天时间。事后我把整条攻击链画了出来,从SSRF入口到凭证窃取,再到CI/CD横向移动,每一步都是常见的配置疏忽或代码逻辑问题。没有0day,没有高级技巧,但组合起来就是一次完整的入侵。
我后来在团队内部做了一次复盘分享,核心结论是:安全不是一个功能,而是一组习惯。链接预览功能在开发时没人觉得有问题,CI/CD的权限配置在搭建时没人觉得有问题,元数据服务的访问在运维时没人觉得有问题。每个环节单独看都是"小问题",但串起来就是大事故。
如果你正在维护类似的服务,建议花半天时间做几件事:检查所有服务端发起请求的功能是否有SSRF防护、检查CI/CD系统的权限配置和密钥管理、检查云环境的元数据服务是否已加固、检查监控告警是否覆盖了关键操作。这几件事做完,能挡住大部分类似的攻击。
凌晨三点的告警,我再也不想接到了。