1
月
在社区成长 1 月
LEVEL 1
1 XP
一、背景
服务端请求伪造(SSRF)并不只出现在传统的“输入一个网址,服务器代为抓取”功能中。头像导入、Webhook 测试、网页预览、远程文件转换、图片代理、第三方接口回调和云资源同步,都可能让用户间接控制服务端发出的网络请求。
这类问题的关键不在于服务器能否访问互联网,而在于攻击者能否借助服务器的网络位置,访问原本不应由外部用户接触的地址,例如本机管理端口、容器内部服务、内网 API 或云环境元数据服务。本文只在本机授权靶场中演示,用一个模拟内部服务代替任何真实内网及云接口,重点建立一套可复现、可测试的 SSRF 防御方法。
二、原理与常见误区
一个典型流程是:用户提交 URL,业务服务器解析 URL,随后由 HTTP 客户端发起请求并返回结果。如果程序只检查字符串是否包含 localhost,防线很容易因为以下差异失效:
1. 同一地址可能存在多种表示形式,如 IPv4、IPv6、主机名和解析后的 IP。
2. 域名在检查时与连接时可能解析出不同结果,形成 DNS 重绑定风险。
3. 首次请求访问的是允许地址,但服务端返回 30x 跳转到内网地址。
4. URL 中的用户信息、编码、大小写和解析器差异可能让字符串规则产生歧义。
5. 即使禁止 127.0.0.1,仍可能遗漏 RFC1918 私网、链路本地地址、IPv6 回环和 IPv4 映射 IPv6 地址。
6. 应用层校验只能覆盖已知路径;如果服务器本身拥有不必要的内网访问权限,单点校验失败后仍会造成较大影响。
因此,可靠防线应当同时包含规范化解析、地址分类、重定向复检、连接阶段约束、出站网络隔离、超时与响应上限,以及审计告警。
三、前置条件
实验环境建议使用一台与生产网络隔离的 Linux 虚拟机或个人测试机,准备 Python 3.10 以上版本。以下命令仅监听本机回环地址,不应部署到公网。
创建虚拟环境:
mkdir ssrf-lab && cd ssrf-lab
python3 -m venv .venv
source .venv/bin/activate
pip install flask requests
实验包含两个进程:9000 端口模拟“仅供内部访问”的服务,8000 端口模拟带 URL 抓取功能的业务应用。
四、分步骤实践
步骤 1:创建模拟内部服务
新建 internal.py:
from flask import Flask, jsonify
app = Flask(__name__)
@app.get('/internal/status')
def status():
return jsonify(service='mock-internal', status='ok', note='local-lab-only')
app.run(host='127.0.0.1', port=9000)
启动它:
python internal.py
另开终端验证:
curl -i http://127.0.0.1:9000/internal/status
这里没有放置真实凭据,返回内容只是靶场标记。这样既能观察 SSRF 效果,也避免复制真实敏感接口。
步骤 2:建立最小化的不安全样例
新建 vulnerable.py:
from flask import Flask, request, Response
import requests
app = Flask(__name__)
@app.get('/fetch')
def fetch():
url = request.args.get('url', '')
try:
r = requests.get(url, timeout=3)
return Response(r.content, status=r.status_code, content_type='text/plain')
except requests.RequestException as e:
return {'error': type(e).__name__}, 502
app.run(host='127.0.0.1', port=8000)
运行并在本地验证:
python vulnerable.py
curl --get 'http://127.0.0.1:8000/fetch' --data-urlencode 'url=http://127.0.0.1:9000/internal/status'
如果响应中出现 mock-internal,说明外部输入已经让 8000 端口的应用代为访问了 9000 端口。这里需要注意:风险成立的依据不是“返回了多少数据”,而是用户获得了控制服务端请求目的地的能力。即使业务不回显响应,也可能形成盲 SSRF,例如通过请求日志、时间差或副作用被观察到。
步骤 3:实现地址解析与分类校验
更稳妥的第一层控制是只允许 http 和 https,拒绝凭据型 URL,解析全部地址,并使用标准库判断 IP 属性。新建 safe_fetch.py:
from flask import Flask, request, jsonify
from urllib.parse import urlsplit
import ipaddress
import socket
import requests
app = Flask(__name__)
MAX_BYTES = 1024 * 1024
ALLOWED_PORTS = {80, 443}
def resolve_and_validate(raw_url):
parsed = urlsplit(raw_url)
if parsed.scheme not in {'http', 'https'}:
raise ValueError('scheme_not_allowed')
if not parsed.hostname:
raise ValueError('missing_hostname')
if parsed.username is not None or parsed.password is not None:
raise ValueError('userinfo_not_allowed')
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
if port not in ALLOWED_PORTS:
raise ValueError('port_not_allowed')
records = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
addresses = {item[4][0] for item in records}
if not addresses:
raise ValueError('dns_no_result')
for text in addresses:
ip = ipaddress.ip_address(text)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
raise ValueError('non_public_address')
return parsed
@app.get('/fetch')
def fetch():
raw_url = request.args.get('url', '')
try:
resolve_and_validate(raw_url)
with requests.get(
raw_url,
timeout=(2, 4),
allow_redirects=False,
stream=True,
headers={'User-Agent': 'Local-SSRF-Lab/1.0'}
) as r:
data = bytearray()
for chunk in r.iter_content(16384):
data.extend(chunk)
if len(data) > MAX_BYTES:
raise ValueError('response_too_large')
return jsonify(status=r.status_code, bytes=len(data))
except ValueError as e:
return jsonify(error=str(e)), 400
except requests.RequestException:
return jsonify(error='upstream_request_failed'), 502
app.run(host='127.0.0.1', port=8000)
停止旧进程后启动 safe_fetch.py,再提交相同测试地址。预期应返回 non_public_address。这个版本还主动关闭了自动重定向,因为如果 HTTP 客户端自动跟随 30x,初始 URL 的检查结果无法覆盖后续目标。
步骤 4:处理重定向
如果业务必须支持重定向,不要简单地打开 allow_redirects。应设置很小的跳转次数上限,每收到一次 Location 都使用 urljoin 生成绝对地址,然后重新执行完整校验。还要记录每一跳的原始 URL、目标主机和解析地址。建议最多允许 3 次跳转,并禁止从 HTTPS 降级到 HTTP。
需要特别补充:上面的 Python 示例展示的是应用层思路,但“校验时解析一次、HTTP 客户端连接时再次解析”之间仍可能存在竞态。更严格的实现应在连接阶段绑定已验证的 IP,同时正确设置 Host 与 TLS SNI,并验证证书;这通常需要定制连接适配器或使用具备目标锁定能力的内部代理。不要为了固定 IP 而关闭 TLS 证书验证。
步骤 5:增加网络层兜底
应用校验不是边界。生产环境应让抓取组件运行在独立容器、网络命名空间或专用出站代理之后,默认拒绝访问回环、私网、链路本地、管理网段以及不需要的端口,仅开放明确的互联网出口。
如果业务只需要访问少数第三方 API,优先采用域名和目标 IP 双重维护的允许列表,并由统一代理执行解析、连接和审计。若第三方使用动态 CDN,需要建立自动同步与变更审核机制,不能随意扩大为“允许全部公网”。
还应从架构上撤销抓取服务不需要的云权限、内部服务凭据和服务账号权限。这样即使 SSRF 校验出现遗漏,也能降低后续影响。
步骤 6:建立回归测试
至少覆盖以下测试类别:
1. 回环地址和本地主机名必须拒绝。
2. RFC1918 私网地址必须拒绝。
3. IPv6 回环、链路本地地址必须拒绝。
4. 非 HTTP 协议、异常端口、带用户名密码的 URL 必须拒绝。
5. 公网地址跳转到禁用地址时必须拒绝。
6. DNS 返回多个地址时,只要其中一个属于禁用范围,就应采取保守策略拒绝。
7. 连接超时、读取超时、超大响应和持续数据流必须被中止。
8. 日志中应记录拒绝原因,但不得记录 URL 中可能存在的令牌、查询参数秘密或响应正文。
可以先对纯校验函数编写单元测试,使用 mock 替代真实 DNS,以保证测试稳定,避免依赖外部网络。
五、风险与注意事项
第一,不要直接把这个靶场连接到办公网、云生产 VPC 或宿主机管理网络。容器中的 127.0.0.1 指向容器自身,但容器网关、共享网络和特殊主机名仍可能触达宿主机,测试前要确认网络边界。
第二,ipaddress 的分类结果会随 Python 版本和地址标准变化。生产环境应固定运行时版本,并为关键特殊地址建立显式测试,不应只依赖一条 is_private 判断。
第三,响应大小限制必须采用流式读取。若先完整下载再判断长度,内存和带宽资源已经被消耗,可能演变为拒绝服务问题。
第四,禁止自动重定向只是安全默认值,不等于完整防线。DNS 重绑定、代理环境变量、HTTP 客户端解析差异和 IPv4 映射 IPv6 都需要结合实际技术栈验证。
第五,若使用 requests,应审查环境中的 HTTP_PROXY、HTTPS_PROXY 与 NO_PROXY。非预期代理可能改变真实连接路径;专用抓取服务最好显式控制是否信任环境代理配置。
六、常见问题
问:只禁止 localhost 和 127.0.0.1 是否足够?
答:不够。还需要覆盖私网、链路本地、IPv6、保留地址、多结果 DNS、重定向及其他地址表示形式。
问:只返回状态码,不返回响应正文,还有风险吗?
答:有。攻击者仍可能根据状态、延迟和服务副作用探测内部资源,或者触发内部 GET 接口,因此必须限制请求目的地。
问:用正则校验 URL 可以吗?
答:不建议。URL 语法、编码和主机表示较复杂,应使用成熟解析器完成规范化,再校验结构化字段及解析后的全部 IP。
问:允许列表一定比拒绝列表好吗?
答:业务目标固定时,允许列表通常更可靠;但还要处理 DNS 变化、跳转和连接阶段目标一致性。开放式网页预览无法只靠少数域名允许列表,此时必须叠加地址分类与网络隔离。
问:为什么不能为了固定目标 IP 而关闭 TLS 校验?
答:关闭证书验证会引入中间人攻击风险。正确做法是保留原始主机名用于 Host、SNI 和证书验证,同时让底层连接使用已验证的地址,或交由受控出站代理完成。
七、总结
SSRF 防御不能归结为一条字符串黑名单。可落地的方法是:先明确业务允许访问什么,再对 URL 做标准化解析;校验协议、凭据、端口和全部 DNS 结果;对每次重定向重新检查;在连接层减少解析竞态;设置超时、大小和跳转上限;最后通过独立网络、最小权限和统一出站代理提供兜底。
本地靶场的价值不只是证明“能访问 127.0.0.1”,而是把每项控制转化为可重复的失败用例和回归测试。只有当应用层与网络层同时拒绝不该出现的请求,并能从审计日志解释拒绝原因时,这条防线才算真正可验证。
服务端请求伪造(SSRF)并不只出现在传统的“输入一个网址,服务器代为抓取”功能中。头像导入、Webhook 测试、网页预览、远程文件转换、图片代理、第三方接口回调和云资源同步,都可能让用户间接控制服务端发出的网络请求。
这类问题的关键不在于服务器能否访问互联网,而在于攻击者能否借助服务器的网络位置,访问原本不应由外部用户接触的地址,例如本机管理端口、容器内部服务、内网 API 或云环境元数据服务。本文只在本机授权靶场中演示,用一个模拟内部服务代替任何真实内网及云接口,重点建立一套可复现、可测试的 SSRF 防御方法。
二、原理与常见误区
一个典型流程是:用户提交 URL,业务服务器解析 URL,随后由 HTTP 客户端发起请求并返回结果。如果程序只检查字符串是否包含 localhost,防线很容易因为以下差异失效:
1. 同一地址可能存在多种表示形式,如 IPv4、IPv6、主机名和解析后的 IP。
2. 域名在检查时与连接时可能解析出不同结果,形成 DNS 重绑定风险。
3. 首次请求访问的是允许地址,但服务端返回 30x 跳转到内网地址。
4. URL 中的用户信息、编码、大小写和解析器差异可能让字符串规则产生歧义。
5. 即使禁止 127.0.0.1,仍可能遗漏 RFC1918 私网、链路本地地址、IPv6 回环和 IPv4 映射 IPv6 地址。
6. 应用层校验只能覆盖已知路径;如果服务器本身拥有不必要的内网访问权限,单点校验失败后仍会造成较大影响。
因此,可靠防线应当同时包含规范化解析、地址分类、重定向复检、连接阶段约束、出站网络隔离、超时与响应上限,以及审计告警。
三、前置条件
实验环境建议使用一台与生产网络隔离的 Linux 虚拟机或个人测试机,准备 Python 3.10 以上版本。以下命令仅监听本机回环地址,不应部署到公网。
创建虚拟环境:
mkdir ssrf-lab && cd ssrf-lab
python3 -m venv .venv
source .venv/bin/activate
pip install flask requests
实验包含两个进程:9000 端口模拟“仅供内部访问”的服务,8000 端口模拟带 URL 抓取功能的业务应用。
四、分步骤实践
步骤 1:创建模拟内部服务
新建 internal.py:
from flask import Flask, jsonify
app = Flask(__name__)
@app.get('/internal/status')
def status():
return jsonify(service='mock-internal', status='ok', note='local-lab-only')
app.run(host='127.0.0.1', port=9000)
启动它:
python internal.py
另开终端验证:
curl -i http://127.0.0.1:9000/internal/status
这里没有放置真实凭据,返回内容只是靶场标记。这样既能观察 SSRF 效果,也避免复制真实敏感接口。
步骤 2:建立最小化的不安全样例
新建 vulnerable.py:
from flask import Flask, request, Response
import requests
app = Flask(__name__)
@app.get('/fetch')
def fetch():
url = request.args.get('url', '')
try:
r = requests.get(url, timeout=3)
return Response(r.content, status=r.status_code, content_type='text/plain')
except requests.RequestException as e:
return {'error': type(e).__name__}, 502
app.run(host='127.0.0.1', port=8000)
运行并在本地验证:
python vulnerable.py
curl --get 'http://127.0.0.1:8000/fetch' --data-urlencode 'url=http://127.0.0.1:9000/internal/status'
如果响应中出现 mock-internal,说明外部输入已经让 8000 端口的应用代为访问了 9000 端口。这里需要注意:风险成立的依据不是“返回了多少数据”,而是用户获得了控制服务端请求目的地的能力。即使业务不回显响应,也可能形成盲 SSRF,例如通过请求日志、时间差或副作用被观察到。
步骤 3:实现地址解析与分类校验
更稳妥的第一层控制是只允许 http 和 https,拒绝凭据型 URL,解析全部地址,并使用标准库判断 IP 属性。新建 safe_fetch.py:
from flask import Flask, request, jsonify
from urllib.parse import urlsplit
import ipaddress
import socket
import requests
app = Flask(__name__)
MAX_BYTES = 1024 * 1024
ALLOWED_PORTS = {80, 443}
def resolve_and_validate(raw_url):
parsed = urlsplit(raw_url)
if parsed.scheme not in {'http', 'https'}:
raise ValueError('scheme_not_allowed')
if not parsed.hostname:
raise ValueError('missing_hostname')
if parsed.username is not None or parsed.password is not None:
raise ValueError('userinfo_not_allowed')
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
if port not in ALLOWED_PORTS:
raise ValueError('port_not_allowed')
records = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
addresses = {item[4][0] for item in records}
if not addresses:
raise ValueError('dns_no_result')
for text in addresses:
ip = ipaddress.ip_address(text)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
raise ValueError('non_public_address')
return parsed
@app.get('/fetch')
def fetch():
raw_url = request.args.get('url', '')
try:
resolve_and_validate(raw_url)
with requests.get(
raw_url,
timeout=(2, 4),
allow_redirects=False,
stream=True,
headers={'User-Agent': 'Local-SSRF-Lab/1.0'}
) as r:
data = bytearray()
for chunk in r.iter_content(16384):
data.extend(chunk)
if len(data) > MAX_BYTES:
raise ValueError('response_too_large')
return jsonify(status=r.status_code, bytes=len(data))
except ValueError as e:
return jsonify(error=str(e)), 400
except requests.RequestException:
return jsonify(error='upstream_request_failed'), 502
app.run(host='127.0.0.1', port=8000)
停止旧进程后启动 safe_fetch.py,再提交相同测试地址。预期应返回 non_public_address。这个版本还主动关闭了自动重定向,因为如果 HTTP 客户端自动跟随 30x,初始 URL 的检查结果无法覆盖后续目标。
步骤 4:处理重定向
如果业务必须支持重定向,不要简单地打开 allow_redirects。应设置很小的跳转次数上限,每收到一次 Location 都使用 urljoin 生成绝对地址,然后重新执行完整校验。还要记录每一跳的原始 URL、目标主机和解析地址。建议最多允许 3 次跳转,并禁止从 HTTPS 降级到 HTTP。
需要特别补充:上面的 Python 示例展示的是应用层思路,但“校验时解析一次、HTTP 客户端连接时再次解析”之间仍可能存在竞态。更严格的实现应在连接阶段绑定已验证的 IP,同时正确设置 Host 与 TLS SNI,并验证证书;这通常需要定制连接适配器或使用具备目标锁定能力的内部代理。不要为了固定 IP 而关闭 TLS 证书验证。
步骤 5:增加网络层兜底
应用校验不是边界。生产环境应让抓取组件运行在独立容器、网络命名空间或专用出站代理之后,默认拒绝访问回环、私网、链路本地、管理网段以及不需要的端口,仅开放明确的互联网出口。
如果业务只需要访问少数第三方 API,优先采用域名和目标 IP 双重维护的允许列表,并由统一代理执行解析、连接和审计。若第三方使用动态 CDN,需要建立自动同步与变更审核机制,不能随意扩大为“允许全部公网”。
还应从架构上撤销抓取服务不需要的云权限、内部服务凭据和服务账号权限。这样即使 SSRF 校验出现遗漏,也能降低后续影响。
步骤 6:建立回归测试
至少覆盖以下测试类别:
1. 回环地址和本地主机名必须拒绝。
2. RFC1918 私网地址必须拒绝。
3. IPv6 回环、链路本地地址必须拒绝。
4. 非 HTTP 协议、异常端口、带用户名密码的 URL 必须拒绝。
5. 公网地址跳转到禁用地址时必须拒绝。
6. DNS 返回多个地址时,只要其中一个属于禁用范围,就应采取保守策略拒绝。
7. 连接超时、读取超时、超大响应和持续数据流必须被中止。
8. 日志中应记录拒绝原因,但不得记录 URL 中可能存在的令牌、查询参数秘密或响应正文。
可以先对纯校验函数编写单元测试,使用 mock 替代真实 DNS,以保证测试稳定,避免依赖外部网络。
五、风险与注意事项
第一,不要直接把这个靶场连接到办公网、云生产 VPC 或宿主机管理网络。容器中的 127.0.0.1 指向容器自身,但容器网关、共享网络和特殊主机名仍可能触达宿主机,测试前要确认网络边界。
第二,ipaddress 的分类结果会随 Python 版本和地址标准变化。生产环境应固定运行时版本,并为关键特殊地址建立显式测试,不应只依赖一条 is_private 判断。
第三,响应大小限制必须采用流式读取。若先完整下载再判断长度,内存和带宽资源已经被消耗,可能演变为拒绝服务问题。
第四,禁止自动重定向只是安全默认值,不等于完整防线。DNS 重绑定、代理环境变量、HTTP 客户端解析差异和 IPv4 映射 IPv6 都需要结合实际技术栈验证。
第五,若使用 requests,应审查环境中的 HTTP_PROXY、HTTPS_PROXY 与 NO_PROXY。非预期代理可能改变真实连接路径;专用抓取服务最好显式控制是否信任环境代理配置。
六、常见问题
问:只禁止 localhost 和 127.0.0.1 是否足够?
答:不够。还需要覆盖私网、链路本地、IPv6、保留地址、多结果 DNS、重定向及其他地址表示形式。
问:只返回状态码,不返回响应正文,还有风险吗?
答:有。攻击者仍可能根据状态、延迟和服务副作用探测内部资源,或者触发内部 GET 接口,因此必须限制请求目的地。
问:用正则校验 URL 可以吗?
答:不建议。URL 语法、编码和主机表示较复杂,应使用成熟解析器完成规范化,再校验结构化字段及解析后的全部 IP。
问:允许列表一定比拒绝列表好吗?
答:业务目标固定时,允许列表通常更可靠;但还要处理 DNS 变化、跳转和连接阶段目标一致性。开放式网页预览无法只靠少数域名允许列表,此时必须叠加地址分类与网络隔离。
问:为什么不能为了固定目标 IP 而关闭 TLS 校验?
答:关闭证书验证会引入中间人攻击风险。正确做法是保留原始主机名用于 Host、SNI 和证书验证,同时让底层连接使用已验证的地址,或交由受控出站代理完成。
七、总结
SSRF 防御不能归结为一条字符串黑名单。可落地的方法是:先明确业务允许访问什么,再对 URL 做标准化解析;校验协议、凭据、端口和全部 DNS 结果;对每次重定向重新检查;在连接层减少解析竞态;设置超时、大小和跳转上限;最后通过独立网络、最小权限和统一出站代理提供兜底。
本地靶场的价值不只是证明“能访问 127.0.0.1”,而是把每项控制转化为可重复的失败用例和回归测试。只有当应用层与网络层同时拒绝不该出现的请求,并能从审计日志解释拒绝原因时,这条防线才算真正可验证。