做代码审计这些年,被问得最多的一类问题就是“这个接口只是拿用户传进来的一个链接去抓张图,能有什么风险”。问这话的人,通常三个月后会在应急群里看到自己系统往外发了一堆莫名其妙的请求。SSRF(服务器端请求伪造,Server-Side Request Forgery)就是这么一个东西:它在漏洞榜单里的存在感不算最高,但在真实攻防里的性价比极高——攻击者不需要拿到任何账号,只要你的服务端愿意替他“跑一趟腿”,他就等于借用了你服务器的网络位置。这篇内容我打算从代码层面把 SSRF 拆开讲透:它到底在攻击什么、哪几类写法天生带病、白盒黑盒两条路怎么查、修复方案怎么分层落地、以及上线之后怎么验证没修漏。适合写过接口的后端、做过代码审计的同学,也适合刚开始接触应用安全、想把原理吃透的读者,我会尽量把每个“为什么这么设计”都讲清楚。
1. 先把 SSRF 的攻击模型讲明白
1.1 请求发起方被掉包了,问题就出在这
理解 SSRF 最快的方式,是拿“代收快递”做类比。正常情况下,你让同事帮你下楼取个快递,你把取件码给他,他去驿站拿回来——这个流程没问题,因为你信任那个驿站。但如果同事不问驿站地址,你说去哪他就去哪,那你完全可以让他去隔壁财务室“取一个包裹”,他也会照做,因为他是以“有权限的人”的身份走过去的。SSRF 的本质就是这个:服务端程序接收了来自外部的地址参数,并且以服务端自身的网络身份去访问它。服务端在内网里往往处于一个相当“受信任”的位置——它能访问数据库、能访问缓存、能访问配置中心、能访问只有内网才开放的管理接口,这些资源对公网用户是关闭的,但对“从服务器上发起的请求”是敞开的。
所以 SSRF 危害的根基不在于“程序会发 HTTP 请求”这件事本身,而在于网络位置的信任落差。你在公网机器上发一个请求到 10.0.0.5 是打不通的,但你的应用服务器发同一个请求,可能一路畅通。攻击者要做的就是把这个落差变成自己的通道。从这个角度看,SSRF 更像一种“位置劫持”而不是“数据泄露”,它的第一层危害是让攻击者获得了内网可达性,第二层危害才是具体能读到什么、打到什么。
1.2 为什么它的危害等级经常被低估
我见过太多团队在漏洞评级会上把 SSRF 定成“中危”,理由是“只是请求了个 URL,又没执行代码”。这个判断在五年前可能还勉强站得住,现在基本站不住。原因有三层。
第一层,内网暴露面的膨胀。微服务化之后,一个中等规模系统内网里跑着几十上百个服务,其中相当一部分是“默认无鉴权”的——因为设计时认为内网就是可信的。服务注册中心、配置中心、监控采集端、健康检查接口、内部任务调度接口,这些一旦被从内部访问,很多是不设防的。
第二层,云上环境的特殊性。云主机通常提供一个链路本地地址上的元数据服务(169.254.0.0/16 网段),实例可以从这里取到自身的配置信息。如果应用服务器能访问这个地址,那么一个 SSRF 就可能把实例级别的敏感信息带出去。这类问题在真实事件里出现的频率不低,而且一旦发生,影响面直接跨到主机层。
第三层,SSRF 常常是攻击链的第一环。它单独看可能只是“能读到内网某个页面的标题”,但配合内网里的某个未授权接口、某个 Redis 未设密码、某个 Actuator 端点暴露,就能滚成完整的内网漫游。评级的时候不能只看单点,要看它作为跳板能撬动什么。
我在内部做风险定级时有个习惯:只要是面向公网、参数可控、且服务端真的会把请求发出去的 SSRF,起步按高危处理,如果目标服务的网络位置能触达数据层,直接按严重处理。宁可评级偏高被业务方怼两句,也别事后补锅。
1.3 SSRF、普通越权、CSRF 到底差在哪
这三个词经常被混着用,实际差别很大。普通越权是“人不对却做了对的事”,比如用 A 用户的 token 去改 B 用户的资料,主体是用户身份。CSRF是“借用户的浏览器发请求”,攻击者利用的是受害者在浏览器里的登录态,请求的发起方是受害者本人的浏览器,网络位置也在用户那边。而SSRF借的是服务器的网络位置,请求发起方是服务端进程,跟用户浏览器毫无关系。
这个区分在实际排查中很重要。有一次某团队报上来一个“疑似 CSRF”,我一看日志,请求的源 IP 全是应用服务器集群的地址,浏览器侧根本没发起过——那就是 SSRF,不是 CSRF。判断依据很简单:看请求从哪个网络位置发出。源 IP 是服务器段、UA 是服务端 HTTP 客户端默认的、没有 Cookie 却带着内网地址,这三条凑齐基本就能定性。
还有一类容易被误判成 SSRF 的,是“用户可控制跳转地址”的开放重定向。开放重定向只影响浏览器跳去哪,服务端并不去访问那个地址,危害等级完全不同。区分点在于:服务端有没有真的发起这次连接。
2. 漏洞成因拆解:哪些写法天生带风险
2.1 三类高发的触发场景
从审计经验看,SSRF 集中在三类业务上,覆盖了绝大多数案例。
第一类是资源抓取型接口。典型如“粘贴图片链接自动下载”“文章外链抓取摘要”“RSS 订阅源解析”“URL 转二维码”“网页截图/PDF 导出”。这类接口的业务语义就是“你给地址,我去取”,天然满足 SSRF 的全部条件。尤其实施截图和 PDF 导出的服务,往往跑在无头浏览器里,它不仅要发请求,还会渲染页面、执行脚本,风险层级还要再高一档。
第二类是回调与 webhook 配置。用户在后台填一个“事件通知地址”,系统在业务发生时往这个地址 POST 数据。这个功能设计上必须允许外部地址,所以没法简单用白名单堵死。这里的风险点往往被忽略:很多人只校验了保存时的格式,没管实际发送时地址是否被二次解析。
第三类是文件与数据导入。比如“从远程 URL 导入 CSV”“从网盘链接同步文件”“从远程仓库拉取依赖清单”。这类接口的输入参数常常藏在表单深处,反而更容易漏掉安全校验,因为开发和测试的关注点都在“文件格式对不对”上。
2.2 从“能发请求”到“造成危害”,中间缺的那几块拼图
不是所有能发请求的地方都等于可利用的 SSRF。要形成实际危害,通常需要几个条件同时成立,理解这个链条对判断风险等级很有帮助。
第一块拼图是地址完全可控。如果程序只允许用户填域名后缀、或者只允许从固定列表里选,那风险就低很多。完全可控指的是协议、主机、端口、路径、甚至查询串都能由外部决定。
第二块是协议不受限。只允许 http/https 和允许任意协议,差别巨大。有些语言的 HTTP 客户端支持 file、ftp、gopher 等协议,一旦放开,读取本地文件或者构造任意字节流就变成可能。这是修复时最该优先掐掉的一环。
第三块是能拿到响应内容。有些接口发完请求只判断“成功/失败”,不回显任何内容,那攻击者拿不到数据,只能做端口探测(靠响应时间或状态码差异推断)。而如果接口把响应体原样返回、或者把报错信息完整抛出,危害立刻升级。
第四块是内网存在可利用目标。这一块不由应用决定,但排查时要评估:这个服务所在的网段能摸到什么。同机房、同 VPC、跨 VPC 打通了的,都要算进去。
我在评审时会把这四块画成一条线,缺一块降一级。反过来说,只要四块齐了,哪怕业务方反复强调“这只是个小功能”,也必须按高危推修复。
2.3 不同语言和框架的坑位不太一样
同样的逻辑,在不同技术栈里踩的坑并不相同,这块值得单独说。
Java 系最典型的是用URL或HttpURLConnection直接打开外部地址,这类 API 默认跟随重定向,而重定向的目标地址不受你最初的校验约束——你校验了 A 域名合法,它 302 跳到内网地址,客户端照样跟过去。另外URL.openConnection()在部分实现下对其他协议也有支持,需要显式限制。用 OkHttp 的话记得把followRedirects和followSslRedirects都关掉,Spring 的RestTemplate要换掉默认的请求工厂才能禁用重定向,这块配置经常被人忘。
Python 系最常见的组合是requests加用户传入的 URL。requests默认allow_redirects=True,这是个高频坑。另外urllib.request.urlopen对file://是支持的,如果代码路径没做协议限制,读本地文件是顺手的事。
PHP 系里file_get_contents、curl_exec、fsockopen都是常见入口,其中file_get_contents默认支持多种协议封装,风险面最宽。如果curl用了CURLOPT_FOLLOWLOCATION,重定向问题同样存在。
Node.js的axios、node-fetch、request各有各的默认行为,axios默认跟随重定向,node-fetch也是。另外 Node 里http.get对 URL 的解析依赖new URL(),某些畸形写法可能导致校验逻辑和实际请求的目标不一致,这类解析差异是老练攻击者最爱钻的空子。
提示:不管什么语言,只要你的校验逻辑和最终发起请求的库对 URL 的解析规则不完全一致,就存在被绕过的空间。修复的铁律是——校验用的解析器和请求用的解析器必须是同一套,或者干脆校验完之后自己拼请求、只把 IP 和端口传给底层。
3. 检测与验证:白盒黑盒两条路怎么走
3.1 白盒:从入口反查,用污点思路缩小范围
白盒审计效率最高的做法不是逐个文件读,而是先定位“所有可能发起外部请求的 API”,再反查参数来源。我通常按这个顺序走:
先搜危险函数。各语言的清单大致如下,可以做成审计脚本的关键字表:
| 语言 | 典型发起请求的 API |
|---|---|
| Java | URL.openConnection、HttpClient.execute、RestTemplate、WebClient、OkHttpClient |
| Python | requests.、urllib.request.urlopen、httpx.、aiohttp.ClientSession |
| PHP | file_get_contents、curl_exec、fsockopen、readfile |
| Node.js | axios、node-fetch、http.request、got |
| Go | http.Get、http.Client.Do |
搜出来之后,逐个看参数从哪来。如果参数直接来自@RequestParam、req.query、$_GET、request.body这类外部输入点,中间又没有经过任何校验函数,那基本可以标红。这里用简单的污点分析思路就够:源是外部输入,汇是请求 API,中间有没有过滤是判断依据。
接着看校验函数的实现质量。这是最容易被糊弄过去的环节——很多代码里确实调了一个isSafeUrl(),但打开一看,里面只有一句!url.contains("127.0.0.1")。这种校验形同虚设,因为127.0.0.1可以用十进制、八进制、IPv6 映射等多种形式表达,也可以用localhost之类的别名。审计时看到黑名单式的校验,一律当作没有校验处理。
还有一个容易被忽略的点:配置项也会引入 SSRF。比如某个服务的健康检查地址、某个代理转发规则、某个 CMS 的远程资源加载地址,如果这些配置能被低权限用户修改,同样是入口。
3.2 黑盒:在授权范围内做无害化验证
这里必须先说清楚前提:所有验证动作只能在你拥有明确书面授权的资产上进行,测试范围、时间窗、允许的测试强度都要提前确认。未经授权的探测,性质上就是攻击行为,这个界限没有任何模糊空间。
在授权范围内,我常用的判断方法是“可控回连”。具体做法:把自己控制的一个测试域名(用保留的示例域名即可)作为参数值提交,看服务端是否真的发起了请求。这一步能确认三件事——参数是否被使用、请求是否从服务端发出、请求的完整形态(UA、Header、body)。确认之后,再逐步测试过滤规则:换不同的地址书写形式看是否被拦、看是否跟随重定向、看响应内容是否回显。
判断响应差异也有技巧。如果接口不回显内容,可以看耗时:访问一个不存在的主机和访问一个存在但端口关闭的主机,超时行为往往不同;访问内网地址和访问不可达地址,响应时间会有可观测的差异。另外看错误信息:把连接超时、拒绝连接、DNS 解析失败这三种错误区分开,就能推断目标端口的状态。这类侧信道判断不需要读取任何内容,但足以证明漏洞存在。
还有一种低成本验证方式是在应用日志侧观察。如果你的团队能拿到服务端的出网日志,那就直接提交一个测试地址,然后去日志里找出网记录,比在响应上猜要准得多。我在内部做验证时基本都走这条路,因为对业务零影响。
3.3 日志与流量侧:事后发现靠什么
很多 SSRF 是在事后排查时才发现的,这时候日志就是唯一线索。有几个特征值得关注:
一是出网目标集中在内网地址段。正常业务访问外网 API,目标 IP 是公网地址;如果日志里出现大量指向 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 的请求,且这些请求的 UA 是服务端 HTTP 客户端的默认值,那就要警觉。
二是同一来源短时间内扫描大量端口或地址。这是典型的探测行为,表现为同一接口在几十秒内被调用上百次,参数里的主机部分不断变化。
三是异常协议。如果你的服务只该发 HTTP 请求,日志里却出现了非 HTTP 的连接记录,那基本可以确定有问题。
我建议的做法是给出网流量建一条基线:正常业务会访问哪些外部域名、哪些内网服务,列成清单。清单之外的出网连接一律告警。这条基线建起来之后,SSRF 的发现率会明显上升,因为它本质上是个“异常出网”问题。
4. 防护方案怎么分层落地
4.1 第一层:地址校验的正确写法
单靠一层校验挡不住所有变种,但这一层必须做,而且要做对。核心思路是白名单优先,黑名单兜底。
如果业务允许,最稳的是域名白名单:只允许访问配置表里明确列出的域名,其他一律拒绝。这对 webhook 之类的场景可能不够灵活,但对内部服务调用完全可行。白名单的粒度建议做到“域名 + 端口 + 协议”三元组,不要只校验域名。
如果业务必须允许任意地址(比如面向用户的外链抓取),那就退到“解析后校验 IP”的方案:先把域名解析成 IP,判断 IP 是否落在禁止网段内,不在才放行。禁止网段至少要包含这些:
| 网段 | 说明 |
|---|---|
| 0.0.0.0/8 | 本网络 |
| 10.0.0.0/8 | 私网 |
| 100.64.0.0/10 | 运营商级 NAT |
| 127.0.0.0/8 | 环回 |
| 169.254.0.0/16 | 链路本地,含元数据服务地址段 |
| 172.16.0.0/12 | 私网 |
| 192.168.0.0/16 | 私网 |
| 224.0.0.0/4 | 组播 |
| 240.0.0.0/4 | 保留 |
| ::1/128、fc00::/7、fe80::/10 | IPv6 环回、唯一本地、链路本地 |
这里有几个非常容易踩的坑,我逐条说。
坑一:只校验字符串不校验解析结果。http://127.0.0.1、http://2130706433、http://0x7f000001、http://[::ffff:127.0.0.1]指向的是同一个地方。字符串匹配永远追不上地址书写形式的变体数量,必须解析成 IP 对象之后再比较。
坑二:DNS 重绑定。你在校验阶段解析了一次域名,得到公网 IP,通过;然后实际发请求时再解析一次,攻击者的 DNS 服务器这次返回内网 IP。两次解析之间的时间差就是攻击窗口。解决办法只有一个:把校验时得到的 IP 固定下来,直接连这个 IP,请求的 Host 头单独设置。绝不要“校验一次、请求时重新解析”。
坑三:重定向。你校验的目标是合法的,但它返回 302 指向内网地址,客户端如果自动跟随,前面所有工作白做。必须显式关闭自动重定向跟随,或者自己实现重定向处理,每一跳都重新做完整校验。
坑四:协议。只允许 http 和 https,其他协议一律拒绝,包括 file、ftp、gopher、dict、ldap 等。这一条要在最前面判断,先看协议再看地址。
4.2 第二层:网络层与架构层的兜底
应用层校验一定会有人写漏,所以必须有兜底。我一般推这两件事:
一是把出网收口。需要访问外部资源的服务,出网统一走一个专门的出口网关,网关侧配置目标白名单,只放行必要的域名。这样即便应用层校验被绕过,请求也出不去。这个方案的落地成本不高,收益很大,尤其是那种服务数量多、修复排期长的团队。
二是网络分段。把处理外部输入的服务(网关层、抓取服务、渲染服务)和核心数据服务放在不同网段,中间加访问控制策略。抓取类服务单独一个安全域,它只能访问公网和极少数必需的内网服务。这样即使被抓穿,横向移动的空间也被压到最小。
这两条属于“修复 SSRF 的长期答案”。短期补丁可以只做应用层校验,但长期一定要往这个方向走,因为它对同类问题(比如命令注入后的回连、反序列化后的外连)都有抑制作用。
4.3 第三层:协议、响应与超时控制
这一层经常被忽略,但很实用。
限制响应大小和类型。抓取服务只需要图片的就别接受text/html,只需要文本的就设一个响应体上限(比如 2MB),超出直接截断。这能防住“把大文件拖进来占满内存”这类附带问题。
设置连接和读取超时。没有超时的 HTTP 客户端是个定时炸弹。SSRF 探测常常依赖响应时间差异,你把超时设死(比如连接 3 秒、读取 5 秒),探测精度会下降,同时避免被拖死连接池。
禁止返回原始错误信息。连接被拒绝、DNS 解析失败、超时,这几种错误对外统一返回“资源获取失败”,具体原因只写进内部日志。这直接掐掉了攻击者通过错误信息推断内网拓扑的通道。
禁用不必要的请求头透传。有些实现会把用户请求的 Header 原样带过去,包括内网才认的认证头。这个必须切断,只允许透传白名单内的 Header。
4.4 一份可以直接抄的校验代码
下面这段 Python 示例,把上面说的几条规则都落进去了。注意几个关键设计:协议先判、域名白名单前置、解析结果做网段判断、返回固定 IP 供后续连接使用。
import ipaddress import socket from urllib.parse import urlparse ALLOW_SCHEMES = {"http", "https"} ALLOW_HOSTS = {"api.partner-a.test", "cdn.partner-b.test"} # 示例域名 DENY_NETS = tuple(ipaddress.ip_network(n) for n in ( "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4", "240.0.0.0/4", "::1/128", "fc00::/7", "fe80::/10", )) def resolve_safe(raw_url: str, allow_any_host: bool = False): """校验通过则返回 (ip, port, host, scheme, path),否则抛异常""" p = urlparse(raw_url) if p.scheme not in ALLOW_SCHEMES: raise ValueError("scheme not allowed") host = p.hostname if not host: raise ValueError("host missing") if not allow_any_host and host not in ALLOW_HOSTS: raise ValueError("host not in whitelist") port = p.port or (443 if p.scheme == "https" else 80) infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) addrs = {info[4][0] for info in infos} if not addrs: raise ValueError("resolve failed") for a in addrs: ip = ipaddress.ip_address(a) if any(ip in net for net in DENY_NETS): raise ValueError("forbidden address") # 关键:返回一个确定的 IP,后续连接直接用这个 IP,避免二次解析 picked = sorted(addrs)[0] return picked, port, host, p.scheme, p.path or "/"使用时,用返回的 IP 去建连,把 Host 头设成原域名:
import http.client ip, port, host, scheme, path = resolve_safe(user_input) conn_cls = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection conn = conn_cls(ip, port, timeout=5) conn.request("GET", path, headers={"Host": host}) resp = conn.getresponse() body = resp.read(2 * 1024 * 1024) # 限制 2MBJava 侧的思路完全一致,只是 API 换成InetAddress.getAllByName()做解析、HttpURLConnection或HttpClient做连接,同时把setInstanceFollowRedirects(false)显式设上。要点不是代码写得多漂亮,而是校验与连接使用同一个解析结果这一点不能破。
注意:如果你的服务前面还有一层反向代理或网关,记得把
Host头的处理规则一起梳理,避免网关按 Host 做路由时被利用。
5. 常见问题与排查实录
5.1 明明做了黑名单,为什么还是被打穿
这是我最常接到的求助。典型对话是:“我们加了 127.0.0.1 和 localhost 的过滤,测试还是说有问题。”我一般会问三个问题。
第一问:地址写成十进制、八进制、IPv6 映射形式,你的过滤能拦住吗?绝大多数黑名单拦不住,因为规则只匹配了字面量。这类改写最终解析出来的 IP 是一样的,程序该连哪还连哪。所以判断标准永远应该落在解析后的 IP 上,而不是原始字符串。
第二问:重定向跟随关了吗?没关的话,前面校验再严也没意义。我见过一个系统,白名单做得非常规范,但客户端默认跟随重定向,测试同学用一个自己控制的域名返回 302 就绕过去了。
第三问:域名是你自己解析的吗?如果用的是系统解析器,需要确认是否有hosts文件被污染、是否有内部 DNS 别名指向内网。有些内网环境里,某个看似正常的域名其实 CNAME 到了内部服务,这种靠黑名单是拦不住的。
排查这类问题,我的做法是先确认漏洞的触发路径,再逐层看哪层校验失效。分成三层看:应用层有没有校验、校验的实现质量如何、网络层有没有兜底。通常问题都出在第二层——校验函数存在,但实现不合格。判断“不合格”的标准很具体:是不是黑名单、是不是只看字符串、有没有处理重定向、有没有二次解析。四条里只要中一条,就按没做校验处理。
5.2 排查速查表
把常遇到的问题整理成一张表,实际排查时对着走会快很多:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 过滤了 127.0.0.1 仍能访问内网 | 黑名单式校验,地址书写形式变体绕过 | 改为解析后判断 IP 网段 |
| 白名单域名仍能打到内网 | 跟随重定向 | 关闭自动重定向,逐跳校验 |
| 校验通过但请求目标变了 | DNS 重绑定,两次解析结果不同 | 固定解析结果,直连 IP |
| 能读取本地文件 | 协议未限制 | 最前置判断,仅允许 http/https |
| 接口不返回内容但耗时异常 | 端口探测侧信道 | 统一超时、统一错误提示 |
| 报错信息里出现内网 IP | 原始异常直接抛出 | 对外统一错误文案,细节入内部日志 |
| 日志里大量内网地址请求 | 已被利用或正在探测 | 立即封禁接口、排查出网基线 |
| 修复后复测仍通过 | 其他入口未覆盖,如配置项、定时任务 | 全量梳理请求发起点 |
5.3 修复上线之后怎么回归验证
修完之后一定要验,而且验的方式要覆盖到上面说的每个坑。我通常按这组用例走,全部在授权环境内执行:
第一组,地址形式变体。用十进制、八进制、IPv6 映射、带用户信息的形式各提交一次,全部应被拒绝。第二组,重定向。用一个能返回 302 到内网地址的测试端点,确认程序不跟随、或跟随后被拦截。第三组,协议。提交非 http/https 的地址,确认在最前置就被拒。第四组,响应控制。让测试端点返回超大响应体,确认被截断且不影响服务稳定性。第五组,错误信息。触发连接失败,确认对外文案统一、不含内部细节。
这五组跑完,才算把这层修到位。我在实际项目里见过不少“修了但没修全”的情况,基本都是只做了第一组和第二组,后面三组没人验,结果上线后从响应大小和错误信息上还能拿到信息。
提示:回归验证不要只测一个接口。凡是共用同一个 HTTP 客户端封装的地方都要过一遍,因为一处封装有问题,所有调用方都受影响。反过来,如果团队有统一的请求封装层,把校验做在这个封装里,覆盖面是最广的,这也是我最推荐的落地位置。
6. 云环境与微服务架构下的新变化
6.1 元数据服务为什么永远是重点
前面提到过链路本地地址段的风险,这里展开说。云主机上普遍存在一个只能从实例内部访问的元数据服务,地址在 169.254.0.0/16 段。它的设计初衷是让实例拿到自己的基础配置信息,问题在于它通常不需要任何认证——因为设计假设就是“能访问到它的必然是实例本身”。
这个假设在 SSRF 面前直接失效。应用服务器能访问它,那么只要应用存在 SSRF,攻击者就能借应用的手去访问。所以 169.254.0.0/16 必须在禁止网段清单的第一位,且不能被任何“例外放行”规则覆盖。
更稳的做法是把这个能力从应用层拿掉。现在不少环境提供了强制跳转的元数据访问模式(请求必须带特定 Header 才能拿到内容),这类机制能有效缓解问题。如果你的环境支持,务必开启,并且把它当作基础设施层的必选项,而不是安全团队的“可选建议”。
6.2 微服务互调带来的额外暴露
微服务架构把内网信任推到了很高的位置。服务之间互调很多不做鉴权,靠“注册在同一个注册中心”作为身份凭证。这带来一个直接后果:SSRF 能摸到的目标变多了,而且这些目标大多不设防。
具体来说,风险集中在几类接口上:健康检查端点(通常返回详细的环境信息)、服务发现接口(能列出全量服务地址)、配置拉取接口(可能返回含敏感字段的配置)、内部管理端点(比如任务触发、缓存清理)。这些接口在公网上是访问不到的,所以开发时往往默认安全,防护等级低于对外接口。
应对的思路是“内网也要做鉴权和最小权限”。服务间调用加统一的内部认证,管理类端点限制来源网段,敏感接口单独加一道鉴权。另外在服务网格层面做目标策略,声明式地规定“A 服务只能访问 B 和 C”,比在应用代码里各自校验要可靠得多。
6.3 抓取类服务该怎么单独设计
如果你的业务里必须有一个“用户给地址、我去抓”的服务(这类需求很常见),我建议直接把它拆成独立服务,按这套标准设计:
网络位置上,它放在单独的安全域,出网只能走统一出口,内网只允许访问极少数必需的下游。身份上,它不持有任何业务系统的凭证,即使被完全控制,损失上限也是可控的。能力上,它只做“取内容并返回”,不落库、不解析业务逻辑、不携带任何内部认证信息。资源上,限制并发数、单次响应大小、总执行时长,避免被打成资源耗尽。
这套设计的核心思想是降低单点被利用后的价值,而不是追求“绝对防住”。安全工程里这个思路很实用——既然无法保证零漏洞,那就让漏洞被利用后的收益尽可能低。
7. 我在实际项目里踩过的几个坑
第一个坑是“校验函数写得很好,但没人调用”。做代码审计时经常遇到这种情况:工具类里躺着一个非常完备的UrlValidator,注释写得清清楚楚,但业务代码里一次都没引用过。后来问开发才知道,这个类是为某个项目写的,后来项目重构,调用被删了,类留了下来。这类问题的排查办法是反过来查:搜校验函数的所有引用,看有几个调用点;再搜所有请求发起点,看有几个经过了校验。两边的差集就是风险点。
第二个坑是“只修了主流程,漏了异步和定时任务”。有个系统的抓取接口做了完整校验,但同一份数据在夜间有个定时任务会重新抓一遍,那条路径直接用了原始 URL,完全没走校验。主流程看着密不透风,旁路敞开。查这类问题要把“同一个数据被处理的所有路径”都列出来,包括消息队列消费、定时任务、补偿任务、离线批处理。
第三个坑是“测试通过了,因为测试环境的 DNS 和线上不一样”。测试环境里内网域名解析不出来,验证时看起来被拦住了;线上环境解析正常,绕过成立。这类差异只有在上线前的预发环境才能发现,所以回归验证最好在接近线上的环境跑。
第四个坑是把Host头和SNI的处理混在一起。有些实现为了“让请求看起来正常”,会把原始域名透传到 TLS 握手,如果域名指向内网,SNI 也会跟着暴露内网域名。这块的处理原则是:只允许白名单内的域名进入 SNI,其余一律用固定值。
8. 最后说说防护思路上的取舍
做了这么多轮修复,我越来越觉得 SSRF 的难点不在技术,而在取舍。业务需要灵活性,安全需要收敛,两边天然有张力。我的经验是,把这个张力显式地摆到台面上讨论,比在代码里偷偷加一行校验要有效得多。
具体到落地,我一般这么推:先把所有发起外部请求的地方梳理出来,形成一份清单,标出哪些是外部可控参数的——这一步往往就能发现几个之前没人注意的入口。然后按风险排优先级,外部可控又回显内容的排最前。接着定策略:能上白名单的上白名单,不能的上“解析后 IP 校验 + 禁重定向 + 限协议”。最后补网络层的兜底,把出网收口。
这个过程里最难的不是写代码,是推动。因为修复 SSRF 意味着要给某些“本来能用”的功能加限制,业务方一定会有意见。我通常的沟通方式是拿一个实际演示说话:在测试环境里,让这个接口去访问一个不该访问的地址,然后把结果摆在评审会上。比起讲原理,一次具体的演示往往更有效。
还有一个习惯我觉得值得分享:把 SSRF 的检查项加进代码评审清单。具体就四条——外部参数有没有进到请求 API、进去之前有没有校验、校验是不是基于解析后的 IP、重定向有没有关。四条都是是非题,评审时照着问一遍,一分钟能过完。清单化之后,这类问题的遗漏率会明显下降,比依赖个人的安全意识靠谱得多。
安全这件事,我一直觉得最有效的不是某个高明的方案,而是把简单正确的做法重复足够多次。SSRF 的防护说白了也就那么几条:别让外部决定去哪、决定了也要验证地址是不是真的安全、验证完还要确保连的就是验证过的那个地址、最后在网络层留一道网。每一条都不复杂,难的是每一条都做到位,且在每次迭代里都不被删掉。