2
月
在社区成长 2 月
LEVEL 50
100 XP
## 一、背景
不少 Web 应用提供 URL 预览、远程图片导入、Webhook 测试、PDF 生成、站点截图或第三方接口同步功能。这些功能的共同点是:服务器会根据用户提交的地址主动发起网络请求。
如果应用只检查 URL 的字符串形式,却没有限制最终连接的目标地址,攻击者就可能借助服务器访问原本无法从外部直接访问的内部服务。这类问题通常称为服务端请求伪造(SSRF)。
SSRF 的风险不只在于“读取一个内网页面”。在实际防御中,还要考虑端口探测、内部管理接口暴露、重定向绕过、DNS 重绑定、IPv4/IPv6 表示差异、代理继承以及响应内容回显等边界条件。本文仅在本机授权环境中复现,不涉及任何真实目标。
## 二、漏洞原理
一个典型的易受影响流程如下:
1. 用户提交 `url` 参数;
2. Web 应用调用 HTTP 客户端请求该 URL;
3. 请求从服务器所在网络发出;
4. 应用将响应状态、正文或错误信息返回给用户。
问题的根源不是“使用了 requests 或 curl”,而是把不可信输入直接转换成了具有网络访问能力的工具调用。服务器通常比外部用户拥有更大的网络可达范围,因此这里实际发生了权限边界跨越。
仅使用 `startswith("http")`、域名黑名单或字符串禁止 `127.0.0.1` 并不足够。URL 可能包含重定向、用户信息段、IPv6 地址、整数形式 IP、混合编码或解析歧义;域名解析结果也可能随时间变化。
## 三、前置条件与实验边界
实验环境:
- Python 3.10 或更高版本;
- Flask 与 requests;
- Linux、macOS 或 Windows 本机;
- 所有请求仅指向 `127.0.0.1`;
- 不要把示例中的易受影响服务暴露到公网或办公网络。
创建虚拟环境并安装依赖:
```bash
python3 -m venv venv
source venv/bin/activate
pip install flask requests
```
Windows PowerShell 可使用:
```powershell
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install flask requests
```
## 四、分步骤搭建本地靶场
### 1. 创建仅供本机访问的内部服务
新建 `internal_service.py`:
```python
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/internal/status")
def status():
return jsonify({
"service": "local-demo",
"visibility": "should-not-be-fetched-by-preview"
})
app.run(host="127.0.0.1", port=9001, debug=False)
```
启动它:
```bash
python internal_service.py
```
该服务只监听回环地址,用来模拟不能由普通外部用户直接访问的内部接口。示例数据完全是虚构的,不要在测试服务中放置真实凭据。
### 2. 创建存在缺陷的 URL 预览接口
新建 `vulnerable_preview.py`:
```python
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
@app.get("/preview")
def preview():
target = request.args.get("url", "")
if not target.startswith(("http://", "https://")):
return jsonify({"error": "unsupported scheme"}), 400
try:
response = requests.get(target, timeout=3)
return jsonify({
"status": response.status_code,
"body": response.text[:500]
})
except requests.RequestException as exc:
return jsonify({"error": str(exc)}), 502
app.run(host="127.0.0.1", port=8000, debug=False)
```
另开终端启动:
```bash
python vulnerable_preview.py
```
这个实现虽然限制了协议,但没有验证主机解析后的 IP,也没有限制重定向和响应内容,因此仍然存在 SSRF。
### 3. 在授权环境中验证
请求预览接口,让它访问本机内部服务:
```bash
curl -G "http://127.0.0.1:8000/preview" \
--data-urlencode "url=http://127.0.0.1:9001/internal/status"
```
如果响应中出现 `local-demo`,说明请求不是由 curl 直接访问 9001,而是由 8000 端口上的 Web 应用代为发起。至此已经完成最小化 SSRF 复现。
测试完成后可使用 `Ctrl+C` 停止两个进程。不要把目标地址替换成未经授权的内网、云环境或互联网主机。
## 五、如何检测同类问题
### 1. 代码审计入口
优先搜索以下能力点:
- `requests.get/post`、`httpx`、`urllib`、`curl` 等出站请求;
- 图片下载、文件导入、Webhook 校验、URL 转 PDF;
- Headless Browser 截图或页面渲染;
- XML、媒体处理器等能够间接加载外部资源的组件;
- Agent 或工作流系统中的 HTTP 工具、浏览器工具和 MCP 网络工具。
审计时应沿数据流确认:URL 是否由用户控制、是否经过统一策略层、是否允许重定向、解析后的地址是否重新校验、响应是否回显。
### 2. 动态测试检查表
在本地或授权靶场中验证:
- 回环地址是否被拒绝;
- RFC1918 私网地址和链路本地地址是否被拒绝;
- IPv6 回环及 IPv4 映射 IPv6 是否被拒绝;
- 域名解析到多个地址时,是否检查全部结果;
- 公网地址重定向到本地地址时,是否再次验证;
- HTTP 客户端是否自动读取系统代理配置;
- 是否存在过大的响应体、过长超时或无限重定向;
- 错误信息是否泄露连接成功、拒绝、超时等网络侧信号。
建议在测试环境记录结构化日志,包括调用者、原始 URL、规范化主机、解析结果、最终连接地址、重定向次数、策略命中原因和响应大小。日志中应移除 URL 查询参数里的令牌。
## 六、应用层修复示例
下面是一个用于教学的基线校验器。它限制协议、禁止 URL 凭据、解析主机,并拒绝非全局地址:
```python
from urllib.parse import urlsplit
import ipaddress
import socket
ALLOWED_PORTS = {80, 443}
def validate_target(raw_url: str) -> str:
parsed = urlsplit(raw_url)
if parsed.scheme not in {"http", "https"}:
raise ValueError("scheme is not allowed")
if not parsed.hostname:
raise ValueError("hostname is required")
if parsed.username is not None or parsed.password is not None:
raise ValueError("URL credentials are not allowed")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if port not in ALLOWED_PORTS:
raise ValueError("port is not allowed")
records = socket.getaddrinfo(
parsed.hostname,
port,
type=socket.SOCK_STREAM
)
if not records:
raise ValueError("hostname cannot be resolved")
for record in records:
address = ipaddress.ip_address(record[4][0])
if not address.is_global:
raise ValueError("destination address is not public")
return raw_url
```
调用时还应关闭自动重定向,并限制资源消耗:
```python
target = validate_target(user_url)
response = requests.get(
target,
timeout=(2, 5),
allow_redirects=False,
stream=True,
proxies={"http": None, "https": None}
)
```
需要注意,这仍不是完整的生产级解决方案。校验 DNS 后再由 HTTP 客户端重新解析,会产生检查时与使用时不一致的问题。更稳健的方式是使用受控出站代理:代理完成 DNS 解析、IP 分类、实际连接和每一跳重定向校验,应用只能访问代理,网络层则禁止应用容器直接出网。
## 七、推荐的纵深防御
1. **优先使用允许列表**:如果业务只需要访问固定合作方,应限制到明确域名、端口和路径,而不是试图维护庞大的禁止列表。
2. **隔离网络权限**:URL 预览和截图任务放入独立容器,禁止访问管理网段、数据库网段和宿主机接口。
3. **统一出口代理**:所有 HTTP 工具通过策略代理出站,避免不同开发团队各写一套不一致的校验。
4. **逐跳验证重定向**:不能只验证初始 URL;每个 `Location` 都要重新解析和授权。
5. **限制响应**:设置连接与读取超时、最大响应体、允许的 MIME 类型和重定向次数。
6. **最小化回显**:预览功能只返回必要字段,不直接返回任意正文、响应头或底层连接错误。
7. **工具调用授权**:在 Agent 或自动化工作流中,不要让模型生成的 URL 直接进入网络工具。策略层应独立于提示词,并记录每次允许或拒绝的原因。
## 八、风险与注意事项
修复 SSRF 时最常见的误区是“加一条正则就结束”。URL 解析应使用成熟解析库,不要自行按冒号、斜杠或 `@` 分割。还要避免仅检查域名文本,因为域名最终连接的是 IP。
生产环境若确实需要访问私有服务,不应简单放开全部私网地址。应建立按业务身份授权的服务清单,例如只允许预览服务访问指定服务名和端口。否则一次应用层绕过就会重新获得横向访问能力。
扫描和复现也必须遵守授权范围。即使只是验证 URL 获取功能,批量请求端口、读取内部接口或使用外部回连平台,都可能超出测试边界。建议在测试计划中事先写明允许的主机、端口、请求频率和数据保留方式。
## 九、常见问题
### Q1:禁止 `127.0.0.1` 是否足够?
不够。还需处理 IPv6 回环、私网、链路本地、保留地址、IPv4 映射 IPv6、域名解析结果和重定向后的新地址。使用 IP 地址库判断属性比字符串匹配可靠。
### Q2:只允许 HTTPS 能否阻止 SSRF?
不能。HTTPS 只保护传输,并不决定目标是否被授权。内部服务同样可能提供 HTTPS,公网 HTTPS 地址也可能重定向到受限位置。
### Q3:不回显响应正文是否就没有风险?
不是。攻击者仍可能根据状态码、响应时间和错误差异推断网络状态,这类情况常被称为盲 SSRF。防御重点仍应是限制请求是否能够发出。
### Q4:为什么建议关闭自动重定向?
因为初始 URL 可能通过 30x 跳转到本地或私网地址。如果业务必须跟随重定向,应逐跳校验,并设置很小的最大次数。
### Q5:Agent 工具层已经写了“不要访问内网”的提示词,还需要代码校验吗?
需要。提示词属于行为引导,不是安全边界。网络访问策略必须在确定性的工具网关、代理和网络 ACL 中执行,并采用失败即拒绝的默认策略。
## 十、总结
SSRF 本质上是将用户可控输入连接到服务器网络权限后形成的越权工具调用。可靠修复不能只依赖 URL 字符串过滤,而应覆盖协议、端口、DNS 解析结果、实际连接地址、重定向、代理、超时和响应回显。
在工程实践中,最推荐的组合是:业务允许列表、统一出站代理、网络隔离、逐跳验证、资源限制和可审计日志。对于带有浏览器、HTTP 或 MCP 工具的 Agent 系统,还应把网络策略放在模型之外,使任何失败、歧义或无法解析的请求默认被拒绝。
不少 Web 应用提供 URL 预览、远程图片导入、Webhook 测试、PDF 生成、站点截图或第三方接口同步功能。这些功能的共同点是:服务器会根据用户提交的地址主动发起网络请求。
如果应用只检查 URL 的字符串形式,却没有限制最终连接的目标地址,攻击者就可能借助服务器访问原本无法从外部直接访问的内部服务。这类问题通常称为服务端请求伪造(SSRF)。
SSRF 的风险不只在于“读取一个内网页面”。在实际防御中,还要考虑端口探测、内部管理接口暴露、重定向绕过、DNS 重绑定、IPv4/IPv6 表示差异、代理继承以及响应内容回显等边界条件。本文仅在本机授权环境中复现,不涉及任何真实目标。
## 二、漏洞原理
一个典型的易受影响流程如下:
1. 用户提交 `url` 参数;
2. Web 应用调用 HTTP 客户端请求该 URL;
3. 请求从服务器所在网络发出;
4. 应用将响应状态、正文或错误信息返回给用户。
问题的根源不是“使用了 requests 或 curl”,而是把不可信输入直接转换成了具有网络访问能力的工具调用。服务器通常比外部用户拥有更大的网络可达范围,因此这里实际发生了权限边界跨越。
仅使用 `startswith("http")`、域名黑名单或字符串禁止 `127.0.0.1` 并不足够。URL 可能包含重定向、用户信息段、IPv6 地址、整数形式 IP、混合编码或解析歧义;域名解析结果也可能随时间变化。
## 三、前置条件与实验边界
实验环境:
- Python 3.10 或更高版本;
- Flask 与 requests;
- Linux、macOS 或 Windows 本机;
- 所有请求仅指向 `127.0.0.1`;
- 不要把示例中的易受影响服务暴露到公网或办公网络。
创建虚拟环境并安装依赖:
```bash
python3 -m venv venv
source venv/bin/activate
pip install flask requests
```
Windows PowerShell 可使用:
```powershell
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install flask requests
```
## 四、分步骤搭建本地靶场
### 1. 创建仅供本机访问的内部服务
新建 `internal_service.py`:
```python
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/internal/status")
def status():
return jsonify({
"service": "local-demo",
"visibility": "should-not-be-fetched-by-preview"
})
app.run(host="127.0.0.1", port=9001, debug=False)
```
启动它:
```bash
python internal_service.py
```
该服务只监听回环地址,用来模拟不能由普通外部用户直接访问的内部接口。示例数据完全是虚构的,不要在测试服务中放置真实凭据。
### 2. 创建存在缺陷的 URL 预览接口
新建 `vulnerable_preview.py`:
```python
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
@app.get("/preview")
def preview():
target = request.args.get("url", "")
if not target.startswith(("http://", "https://")):
return jsonify({"error": "unsupported scheme"}), 400
try:
response = requests.get(target, timeout=3)
return jsonify({
"status": response.status_code,
"body": response.text[:500]
})
except requests.RequestException as exc:
return jsonify({"error": str(exc)}), 502
app.run(host="127.0.0.1", port=8000, debug=False)
```
另开终端启动:
```bash
python vulnerable_preview.py
```
这个实现虽然限制了协议,但没有验证主机解析后的 IP,也没有限制重定向和响应内容,因此仍然存在 SSRF。
### 3. 在授权环境中验证
请求预览接口,让它访问本机内部服务:
```bash
curl -G "http://127.0.0.1:8000/preview" \
--data-urlencode "url=http://127.0.0.1:9001/internal/status"
```
如果响应中出现 `local-demo`,说明请求不是由 curl 直接访问 9001,而是由 8000 端口上的 Web 应用代为发起。至此已经完成最小化 SSRF 复现。
测试完成后可使用 `Ctrl+C` 停止两个进程。不要把目标地址替换成未经授权的内网、云环境或互联网主机。
## 五、如何检测同类问题
### 1. 代码审计入口
优先搜索以下能力点:
- `requests.get/post`、`httpx`、`urllib`、`curl` 等出站请求;
- 图片下载、文件导入、Webhook 校验、URL 转 PDF;
- Headless Browser 截图或页面渲染;
- XML、媒体处理器等能够间接加载外部资源的组件;
- Agent 或工作流系统中的 HTTP 工具、浏览器工具和 MCP 网络工具。
审计时应沿数据流确认:URL 是否由用户控制、是否经过统一策略层、是否允许重定向、解析后的地址是否重新校验、响应是否回显。
### 2. 动态测试检查表
在本地或授权靶场中验证:
- 回环地址是否被拒绝;
- RFC1918 私网地址和链路本地地址是否被拒绝;
- IPv6 回环及 IPv4 映射 IPv6 是否被拒绝;
- 域名解析到多个地址时,是否检查全部结果;
- 公网地址重定向到本地地址时,是否再次验证;
- HTTP 客户端是否自动读取系统代理配置;
- 是否存在过大的响应体、过长超时或无限重定向;
- 错误信息是否泄露连接成功、拒绝、超时等网络侧信号。
建议在测试环境记录结构化日志,包括调用者、原始 URL、规范化主机、解析结果、最终连接地址、重定向次数、策略命中原因和响应大小。日志中应移除 URL 查询参数里的令牌。
## 六、应用层修复示例
下面是一个用于教学的基线校验器。它限制协议、禁止 URL 凭据、解析主机,并拒绝非全局地址:
```python
from urllib.parse import urlsplit
import ipaddress
import socket
ALLOWED_PORTS = {80, 443}
def validate_target(raw_url: str) -> str:
parsed = urlsplit(raw_url)
if parsed.scheme not in {"http", "https"}:
raise ValueError("scheme is not allowed")
if not parsed.hostname:
raise ValueError("hostname is required")
if parsed.username is not None or parsed.password is not None:
raise ValueError("URL credentials are not allowed")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if port not in ALLOWED_PORTS:
raise ValueError("port is not allowed")
records = socket.getaddrinfo(
parsed.hostname,
port,
type=socket.SOCK_STREAM
)
if not records:
raise ValueError("hostname cannot be resolved")
for record in records:
address = ipaddress.ip_address(record[4][0])
if not address.is_global:
raise ValueError("destination address is not public")
return raw_url
```
调用时还应关闭自动重定向,并限制资源消耗:
```python
target = validate_target(user_url)
response = requests.get(
target,
timeout=(2, 5),
allow_redirects=False,
stream=True,
proxies={"http": None, "https": None}
)
```
需要注意,这仍不是完整的生产级解决方案。校验 DNS 后再由 HTTP 客户端重新解析,会产生检查时与使用时不一致的问题。更稳健的方式是使用受控出站代理:代理完成 DNS 解析、IP 分类、实际连接和每一跳重定向校验,应用只能访问代理,网络层则禁止应用容器直接出网。
## 七、推荐的纵深防御
1. **优先使用允许列表**:如果业务只需要访问固定合作方,应限制到明确域名、端口和路径,而不是试图维护庞大的禁止列表。
2. **隔离网络权限**:URL 预览和截图任务放入独立容器,禁止访问管理网段、数据库网段和宿主机接口。
3. **统一出口代理**:所有 HTTP 工具通过策略代理出站,避免不同开发团队各写一套不一致的校验。
4. **逐跳验证重定向**:不能只验证初始 URL;每个 `Location` 都要重新解析和授权。
5. **限制响应**:设置连接与读取超时、最大响应体、允许的 MIME 类型和重定向次数。
6. **最小化回显**:预览功能只返回必要字段,不直接返回任意正文、响应头或底层连接错误。
7. **工具调用授权**:在 Agent 或自动化工作流中,不要让模型生成的 URL 直接进入网络工具。策略层应独立于提示词,并记录每次允许或拒绝的原因。
## 八、风险与注意事项
修复 SSRF 时最常见的误区是“加一条正则就结束”。URL 解析应使用成熟解析库,不要自行按冒号、斜杠或 `@` 分割。还要避免仅检查域名文本,因为域名最终连接的是 IP。
生产环境若确实需要访问私有服务,不应简单放开全部私网地址。应建立按业务身份授权的服务清单,例如只允许预览服务访问指定服务名和端口。否则一次应用层绕过就会重新获得横向访问能力。
扫描和复现也必须遵守授权范围。即使只是验证 URL 获取功能,批量请求端口、读取内部接口或使用外部回连平台,都可能超出测试边界。建议在测试计划中事先写明允许的主机、端口、请求频率和数据保留方式。
## 九、常见问题
### Q1:禁止 `127.0.0.1` 是否足够?
不够。还需处理 IPv6 回环、私网、链路本地、保留地址、IPv4 映射 IPv6、域名解析结果和重定向后的新地址。使用 IP 地址库判断属性比字符串匹配可靠。
### Q2:只允许 HTTPS 能否阻止 SSRF?
不能。HTTPS 只保护传输,并不决定目标是否被授权。内部服务同样可能提供 HTTPS,公网 HTTPS 地址也可能重定向到受限位置。
### Q3:不回显响应正文是否就没有风险?
不是。攻击者仍可能根据状态码、响应时间和错误差异推断网络状态,这类情况常被称为盲 SSRF。防御重点仍应是限制请求是否能够发出。
### Q4:为什么建议关闭自动重定向?
因为初始 URL 可能通过 30x 跳转到本地或私网地址。如果业务必须跟随重定向,应逐跳校验,并设置很小的最大次数。
### Q5:Agent 工具层已经写了“不要访问内网”的提示词,还需要代码校验吗?
需要。提示词属于行为引导,不是安全边界。网络访问策略必须在确定性的工具网关、代理和网络 ACL 中执行,并采用失败即拒绝的默认策略。
## 十、总结
SSRF 本质上是将用户可控输入连接到服务器网络权限后形成的越权工具调用。可靠修复不能只依赖 URL 字符串过滤,而应覆盖协议、端口、DNS 解析结果、实际连接地址、重定向、代理、超时和响应回显。
在工程实践中,最推荐的组合是:业务允许列表、统一出站代理、网络隔离、逐跳验证、资源限制和可审计日志。对于带有浏览器、HTTP 或 MCP 工具的 Agent 系统,还应把网络策略放在模型之外,使任何失败、歧义或无法解析的请求默认被拒绝。