1. 先说清楚:为什么一个“能发起网络请求”的功能会变成跳板
做安全测试和攻防对抗这么多年,我几乎每次遇到“URL回调”“图片抓取”“Webhook推送”这类功能,都会下意识多问一句:这个请求到底发到哪里去了?因为很多开发者只想着“功能能用就行”,完全没意识到,一个能在服务端发起网络请求的入口,一旦没做限制,就等于给攻击者递了一把能穿防火墙的钥匙。这就是SSRF,服务端请求伪造。
我见过最典型的一个场景,业务系统部署在内网,前端需要从外网某个URL拉取一张图片存到OSS,结果后端接口直接接收参数里的URL,用curl去请求。攻击者把这个URL改成http://127.0.0.1:6379,就能探测内网的Redis;改成http://192.168.1.100:8080,就能扫内网其他业务的管理后台。更麻烦的是,很多内网服务默认信任“同一网络环境”里的机器,对外网的攻击层层设防,但对内网来的请求几乎不设防——所以一旦这个能发内网请求的功能被利用,攻击者相当于拿到了一个“内部可信身份”,后面能做的事就完全不是一个量级了。
这篇文章我想把SSRF这套东西掰开讲清楚:它为什么会存在、http/https/dict/ftp/gopher这些协议在这个场景里分别能干什么、实际测试中怎么判断一个入口有没有风险,以及最重要的——作为开发者或运维,怎么把它彻底堵死。不管你是写业务代码的,还是做安全建设的,看完应该都能找到自己能落地的部分。
2. 拆解SSRF:核心成因与“信任边界”是怎么被打破的
2.1 SSRF为什么叫“服务端请求伪造”
普通攻击者想访问内网资源,通常会被防火墙挡在外面。但SSRF不是直接攻击内网,而是“借刀杀人”:攻击者构造一个恶意URL,让受害服务器的后端代码替他去请求这个URL。因为请求是从受害服务器发起的,目标内网服务看到的是来自“自己人”的请求,于是信任就建立了。
这里有个关键点:所有请求都不是攻击者直接发出的,而是通过受害服务器中转。攻击者要做的事情只有一个——想办法让受害服务器把请求发到他指定的地址上去。所以严格来说,SSRF不是“伪造IP”,而是“伪造请求的发起者”。
我在实际项目里总结过,SSRF高发的位置基本集中在下面几类:
- 图片/文件抓取:用户传一个图片URL,服务端下载后处理。这是最常见的,没有之一。
- URL检测/域名验证:有些平台“验证回调URL是否有效”,会主动请求用户提交的地址。
- Webhook通知:业务触发事件后,往用户配置的地址发POST请求。
- PDF生成:把HTML或远程资源渲染成PDF,里面经常带
<img src="http://内网地址">这种引用。 - API代理转发:后台把请求转发给第三方服务,中转地址由用户控制。
只要看到参数里有url=、uri=、link=、src=、target=、callback=这类字眼,都值得警惕。
2.2 “信任内网”为什么风险更大
很多内网服务在设计时有一个默认前提:能访问到我的请求,一定是经过网络准入或者同样在内网环境里的,所以可以信任。这个假设在SSRF面前是致命的。
举个例子:内网有一个MySQL,监听在3306端口,防火墙策略是“仅允许内网网段访问”。外网攻击者打不进来,但如果他找到一个SSRF点,让内网的一台Web服务器去连MySQL的3306端口,MySQL看到来源地址是内网Web服务器的IP,自然放行。攻击者甚至不需要知道MySQL密码,如果这条服务用的是老版本、弱口令,后面就能通过协议构造直接执行命令。
所以“其他服务器信任该内部服务器”这句话,本质上是把“网络位置”当成了“安全身份”。SSRF打破的就是这层信任——因为请求的发起者确实是你信任的内网机器,但它背后操纵键盘的人,根本不是内网用户。
2.3 一个最小示例理解全流程
假设有一个接口长这样:
@app.route('/fetch_image') def fetch_image(): url = request.args.get('url') return requests.get(url).content正常用户传http://example.com/a.jpg,服务端去外网抓图,没问题。但攻击者传http://127.0.0.1:8080/manager,服务端就会去访问本机的8080端口。如果本机正好跑了一个Tomcat管理后台,那这个请求就会带着Web服务器的内网IP访问到管理员接口——外网攻击者通过浏览器直接敲这个URL是会被防火墙拦的,但服务端替他敲,防火墙拦不住。
这个例子极简,但控制点全在这:服务端发起请求时,是否校验了目标地址的合法性和协议类型。
3. 四种协议里真正危险的是谁:http/https、dict、ftp与gopher
3.1 http/https协议:最基础但也最被滥用
http和https是SSRF里最直观的利用方式。攻击者可以读取内网Web页面、访问管理后台、调用内网API接口,甚至通过代理隧道把内网页面内容“搬运”出来。
对于“无回显”的情况,http协议也很有用。比如让受害服务器请求一个攻击者控制的公网服务器,通过DNS解析日志、访问日志来判断请求确实发出去了,这就是常见的外带检测手段。我在测试时也经常用http://your-server/log?key=xxx这类地址,把参数带出去。
不过http协议有个特点:它只能按Web服务的“规则”去交互。比如内网跑了一个不支持HTTP的协议(比如Redis、MySQL),你用http去发一个普通GET请求,对方只会回一串错误信息,没法做深层次利用。这时候就需要看其他协议了。
3.2 dict协议:比http更“原始”的探测工具
dict协议最早是用于字典查询的,但它本质上是一个极简的TCP客户端,能够往任意主机的任意端口发送一段文本,并读取返回内容。在SSRF利用中,dict协议的典型价值主要有两个:
- 端口探测:拿
dict://目标IP:端口/去试,如果端口开放,通常会返回类似220 ...的banner;如果端口关闭,会报连接失败。这种方式比http更轻量,而且能探测到非HTTP端口。 - 简单交互:有些服务支持纯文本协议,dict可以代替nc/bash轮子发送类似
INFO这类指令,探测服务类型。
但dict也有明显局限性:它每条指令只能发一次,没有完整的交互握手过程。你想跟Redis做多轮命令交互,dict发完第一个命令后就断开了,根本没法做成完整的利用链。所以它更适合做“探测”而不是“利用”。
3.3 ftp协议:能扫描也能读文件,但局限同样明显
ftp在SSRF里的价值,首先还是端口探测。因为ftp服务在端口开放的情况下,通常会返回220开头的banner,这个反应速度和端口指纹都很好识别。很多内网扫描器都支持用ftp://来探测开放端口。
第二个价值是读取文件。如果内网某个FTP服务器允许匿名登录或弱口令登录,那么通过SSRF可以指向ftp://user:pass@内网IP/文件路径,让服务端去拉取FTP上的文件,并把内容回显到页面上。这在拿配置文件、密钥文件的时候非常有用。
但ftp也做不到复杂的交互。现代FTP有主动被动模式、有用户认证流程,很多SSRF实现用的库在处理FTP协议时并不完整,经常会出现连接超时或认证失败。所以实际利用时,ftp更多是一个“补充协议”,真正的高危大户还得看gopher。
3.4 gopher协议:最危险,因为它能构造任意TCP报文
gopher历史很老,最初是用于分布式文档检索的,但它有一个极其特别的能力:gopher URL里可以包含任意字节内容,并且客户端会把这些内容原样作为TCP数据发送给目标主机。这意味着,只要目标端口支持文本协议,攻击者就能用gopher构造出完整的协议交互过程。
我举个例子你就明白了。Redis默认监听6379端口,协议是纯文本的,你可以给Redis发送一条命令,比如SET key value。如果没有gopher,SSRF打Redis最多只能发一条命令,效果有限;但gopher可以把多条命令拼在一起,甚至模拟完整的认证和写入过程,这就等于把“拿到一个SSRF”升级成了“可能拿到内网一台机器权限”。
同样,MySQL、Memcached、FastCGI这类基于文本或简单协议的中间件,都在gopher的打击范围内。所以在看SSRF利用面的时候,我一直把gopher定义为“最坏情况”——因为它能让攻击者在一个看似不起眼的URL下载功能上,直接打通到内网横向移动的链条。
协议对比表格,方便你快速记忆:
| 协议 | 能探测端口 | 能交互 | 能读取文件 | 危险程度 |
|---|---|---|---|---|
| http/https | 是,但只针对HTTP服务 | 一般,受限于Web逻辑 | 可以,通过Web资源 | 中 |
| dict | 是 | 很弱,单次命令 | 否 | 中 |
| ftp | 是 | 弱,认证流程受限 | 可以,需认证 | 中低 |
| gopher | 是 | 强,可构造任意文本请求 | 视服务而定 | 高 |
4. 实操视角:测试一个SSRF入口时我在关注什么
4.1 怎么快速判断一个接口是不是SSRF隐患
拿到一个目标,我通常不会一上来就扫协议,而是先做下面四步:
- 找全参数:把所有带URL字样的参数收集出来,不只是
url,还有redirect、target、endpoint、host、ip、location等,甚至JSON字段里的link、href也要关注。 - 确认是服务端发起的:怎么判断?最简单是改成一个外部可控的地址,观察目标服务器有没有真的去请求。最稳的方法是用自己的公网服务器或者带自定义域名的DNS log服务,看有没有收到请求。
- 区分“直接HTTP”还是“SSRF”:如果目标服务器只是做一个
302跳转,那不算SSRF,因为请求是浏览器发起的;只有当请求是从服务端发出的,才是SSRF。 - 测试回显与盲打:把URL改成
http://127.0.0.1:一些常见端口,看返回内容里有没有包含响应体;如果没有回显,就改用时间延迟或DNS外带去判断。
4.2 绕过策略为什么要专门研究
很多系统做了基础防护,比如检查url是否以http://或https://开头,然后就放行了。这种防护形同虚设,因为攻击者有一百种方式绕过:
- IP混淆:用
127.1、0x7f000001、2130706433这种十进制整数、八进制、十六进制写法,很多简单的字符串匹配根本识别不出来。 - 域名解析绕过:先让服务端解析一个公网域名,但该域名在解析时会返回内网IP。只要检查是在“字符串层面”而不是“解析后IP层面”做的,就会被绕过去。
- 重定向绕过:先请求一个攻击者控制的公网URL,该URL返回
302 Location: http://192.168.1.1,如果服务端跟随重定向,就能打到内网。 - URL解析差异:不同库对
@、#、?、反斜杠的处理不一样,比如http://baidu.com@127.0.0.1,某些库取的是127.0.0.1,但字符串校验时以为域名是baidu.com。
我见过不少系统“防了跟没防一样”,就是因为在白名单里只校验前缀,没校验最终请求的目标IP。真正有效的绕过点,后面防御部分会讲。
4.3 盲打场景下怎么判断是否成功
不是所有SSRF都有回显,很多时候目标服务端会把请求结果直接丢弃,或者响应被包在不可见的地方。这种情况下我建议按下面的优先级收集证据:
- DNS外带:让目标请求一个攻击者控制的域名,比如
http://xxx.dnslog.cn/,只要DNS日志里有解析记录,基本就能确认SSRF存在。 - HTTP外带:在可控公网服务器上起一个HTTP监听,目标请求这个地址时就能看到来源IP、UA、时间,这些信息还能帮你判断服务器所在网络环境。
- 时间延迟:内网里有些服务响应慢,或者可以通过构造不同的端口来对比响应时间,间接判断端口是否开放。比如请求一个开放端口可能秒回,请求一个黑洞IP可能长时间无响应。
- 错误信息:有些框架会把请求异常回显到页面上,比如
Connection refused或者TTPError,根据错误类型也能推断出端口状态。
这里要补充一句:做测试时一定要遵守授权。SSRF可以探测内网,但一旦越界,性质就完全不同了。严格在授权范围内测试,才是一个从业者的基本底线。
5. 防御落地:怎么把这个口子彻底堵上
5.1 最有效的一条:先解析,再判断IP
很多防御方案都在“字符串过滤”层面较劲,结果被各种编码绕过打脸。真正的标准做法是先对目标URL做DNS解析,判断最终的IP是否为内网地址,如果是就拒绝。不是判断用户输入里有没有192.168,而是判断“请求实际会到达的IP”是不是内网。
我用Python写过一个简化的校验逻辑,思路可以参考:
import ipaddress import socket from urllib.parse import urlparse def is_private_ip(ip): return ipaddress.ip_address(ip).is_private or ipaddress.ip_address(ip).is_loopback def safe_request(url): host = urlparse(url).hostname ip = socket.gethostbyname(host) if is_private_ip(ip): raise Exception("blocked internal IP") # 这里还可以防止DNS rebinding,需要再次解析并校验 return requests.get(url, timeout=3)但要注意,只解析一次是不够的。攻击者可以用“DNS Rebinding”手法:第一次解析返回公网IP,通过校验;等真正发起请求时,再次解析却返回内网IP。所以更稳妥的做法是解析两次,然后对比结果,如果不一样就拒绝,或者直接使用那些内置了SSRF防护的HTTP客户端库。
5.2 协议白名单:比黑名单靠谱得多
在协议这一层,我强烈建议做白名单。默认只允许http和https,并且对gopher、dict、ftp、file、tftp等协议一律拒绝。原因很简单——日常业务真的需要用到gopher吗?几乎不需要。与其去修gopher的各种诡异编码问题,不如直接把它禁用。
在代码里就是说:解析URL时,强校验scheme必须落在允许列表内。不要只是“不允许某些开头”,而是“只允许约定好的几种”。
ALLOWED_SCHEMES = ("http", "https") scheme = urlparse(url).scheme.lower() if scheme not in ALLOWED_SCHEMES: raise Exception("blocked scheme: " + scheme)5.3 重定向与端口限制
服务端发起请求时,如果默认跟随重定向,那前面做的IP校验也可能被绕开。所以我建议在核心逻辑里关闭自动重定向,或者每次都重新校验重定向后的目标。如果业务上确实需要跟随重定向,就在每一次跳转之前都做一次完整的“解析+协议+IP”校验,把每一个跳转目标都当作新请求来对待。
端口层面也很重要。内网的Redis、MySQL、SSH等端口本来就不应该被Web服务器主动访问,做一个端口白名单限制,例如只允许80、443、8080等web端口,能大幅缩小攻击面。虽然有些协议(比如FastCGI)能跑在9000端口上,但总比一个都不限制强。
5.4 网络层防护:除了代码还能做什么
代码层校验是最后一道关,我更希望你在架构层面就先给它降权:
- 隔离网络:Web服务器所在网段,不应该能直接访问内网核心业务网段。把“能发起外网请求的服务”单独放进一个DMZ或者独立子网,通过防火墙策略限制它只能访问必要的地址。
- 出网管控:限制Web服务器只能通过代理访问外网,而不是让后端代码直接用公网IP海阔天空地乱跑。这样做既方便审计,也方便在代理层做URL过滤。
- 流量监控:为这类服务单独记录“出站请求日志”,一旦发现异常内网地址,能第一时间报警。
我遇到过不少公司,代码里各种安全组件都装了,但网络架构本身“内网通吃”,那就等于把所有安全寄托在开发者不写烂代码上,这显然不现实。
6. 常见问题与排查技巧实录
6.1 为什么SSRF请求经常报“502 Bad Gateway”
我在测试的时候,经常会看到unexpected status 502 bad gateway这种错误。很多人第一反应是“SSRF打不进去”。其实这个问题分两面看:
- 如果请求打到的是一个Web服务,但该服务返回异常,那么受害服务端在接收上游响应时确实可能表现为502。
- 更常见的情况是:你构造的目标服务(比如内网某Redis端口)根本不理解HTTP请求,它返回的内容不是合法HTTP响应,于是受害服务端在代理转发时直接报了Bad Gateway。
所以看到502别急着放弃,反而应该意识到:这很可能说明你的请求已经到达了目标端口,只是目标服务“不会说HTTP”。这时候改换gopher或dict协议,可能就有完全不同的效果。
6.2 为什么有时候内网端口开放了,但响应超时
SSRF请求超时是一个经典困惑点。原因可能是目标主机防火墙开启了“黑洞”策略——对未开放的端口直接丢包,而不是返回拒绝连接。这种情况下,开放端口反而可能因为服务响应而很快返回,未开放端口一直超时。当你发现某个协议迟迟不响应,可以先换一个基于“连接是否建立”的判断方法,而不依赖响应内容。
还有一个坑是“代理依赖”:有些后端HTTP库强制走系统代理,结果你构造的URL根本没到内网,而是被代理服务器拦了。排查时可以看超时时间、报错信息里有没有代理服务器地址,确认请求方向。
6.3 gopher协议构造时踩过的三个坑
gopher虽然强力,但实操中真的容易踩坑,我记在这里,供你参考:
- 编码问题:gopher URL中很多特殊字符需要URL编码,尤其是空格、换行、冒号。如果编码不彻底,目标服务收到的报文就不是你想象的那一串。我一般会先把要发送的内容整段构造好,再用工具统一做URL编码。
- 端口必须正确:gopher只负责“把数据发到某端口”,它不管你目标是什么服务。你要是把Redis的数据发到MySQL端口,得到的只会是一堆解析错误。
- 服务端库支持度不一:很多语言的老版本HTTP库对gopher支持很糟糕,甚至根本不支持。所以遇到gopher测试没反应,先确认底层库到底认不认识这个协议。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 请求直接报502 | 目标端口开放但非HTTP服务 | 换dict/gopher继续探测 |
| 长时间超时 | 目标端口防火墙丢包,或走了代理 | 检查请求方向和端口开放特征 |
| DNS外带有解析,但无HTTP回显 | SSRF存在,目标服务无有效HTTP响应 | 考虑盲打,结合时间延迟判断 |
| IP校验被绕过 | 只校验字符串,未校验解析后的IP | 增加真实IP解析与二次校验 |
| gopher无效果 | 编码错误或底层库不支持 | 确认URL编码及HTTP库对gopher的支持 |
7. 最后分享一点我自己的经验
做安全这一行,见多了“灭顶之灾往往来的不是大漏洞,而是一个看似人畜无害的URL下载功能”。SSRF的可怕之处不在它本身,而在于它打破了网络分区里最基础的信任模型。每一次看到url=参数,我都会本能地想到一句:如果这个参数落到坏人手里,他能让这台服务器替他去敲多少扇内网的门。
我个人在写代码和做审计时,已经形成了三条固定习惯,分享给你:
第一,凡是会发起服务端请求的接口,一律把“默认拒绝”写在最前面——只允许明确允许的协议和明确允许的IP段,而不是“不允许明显的坏东西”。这个思路几乎能挡住绝大多数常规攻击。
第二,不要只依赖代码层防护。网络隔离和出站规则才是最粗的那条腿,代码层校验只是锦上添花。把业务服务器的出站权限收窄,哪怕代码里有漏网之鱼,攻击者也走不出那一步。
第三,多留测试后门。这里说的后门是“可观测性”。为服务端请求保留完整日志,包括完整URL、目标IP、时间戳、响应状态。一旦出了事,你能用日志在十分钟内还原攻击路径,而不是在服务器上翻半天记录。
SSRF不是一个“学一次就会”的东西,因为协议更新、库的行为差异、网络环境变化,都会让它的边界一直漂移。但只要你抓住“谁发起的请求、发到哪里、经过了几层校验”这三点,思路就不会乱。希望这篇内容能帮你在开发或测试的时候,少走一些我当年走过的弯路。