查询 24 次,耗时 0.4887 秒

授权环境下复现与治理 Webhook 型 SSRF:从 URL 校验、重定向绕过到出站网络隔离

3100953

New member

3100953 Rep
0
0
0
Rep
0
3100953 Vouches
Vouches
0
2 MONTHS
2 2 MONTHS OF SERVICE
3100953 获得
0
0
0
获得
0
3100953 凭证
0
0
0
凭证
0
主题
14
点赞
0
HakB
2 月
在社区成长 2 月
LEVEL 50 100 XP
 
一、背景

Webhook、网页预览、远程图片下载、PDF 生成、代理转发等功能,都会允许服务端根据用户输入访问一个 URL。如果应用直接把该 URL 交给 HTTP 客户端,攻击者就可能借助服务器的网络位置访问原本无法从外部触达的地址,这类问题通常归为服务端请求伪造(SSRF)。

SSRF 的风险不只在于读取响应内容。即便接口不回显正文,攻击者仍可能通过状态码、响应时间、错误差异判断内网服务是否存在,形成“盲 SSRF”。在云环境、微服务或容器网络中,应用服务器往往拥有比普通用户更宽的网络访问权限,因此修复重点不能仅停留在字符串黑名单。

本文全部操作限定在本机授权实验环境。不要对未经授权的网站、内网地址或云服务进行测试。

二、漏洞原理

一个典型的危险实现如下:

from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.get('/fetch')
def fetch():
target = request.args.get('url', '')
r = requests.get(target, timeout=3)
return jsonify(status=r.status_code, body=r.text[:200])

问题在于应用没有区分“用户希望访问的业务域名”和“服务器实际连接的目标地址”。完整利用链通常包含以下环节:

1. 接口接受用户可控 URL。
2. 服务端解析域名并建立连接。
3. 目标地址可能是回环、私网、链路本地地址或内部服务。
4. HTTP 客户端可能自动跟随 30x 重定向,从允许域名跳转到禁止地址。
5. DNS 解析结果可能在校验与连接之间变化,产生 DNS rebinding 或检查与使用时序差异。
6. 响应正文、状态码、耗时或错误信息向调用者泄露内部网络状态。

因此,单纯判断字符串中是否出现“127.0.0.1”并不可靠。IPv6、域名解析、重定向、不同 IP 表示形式和代理配置都可能绕过这种检查。

三、前置条件

实验环境需要:

1. Python 3.10 或更高版本。
2. Flask 与 requests:pip install flask requests。
3. curl。
4. 仅绑定本机回环地址,不开放到公网。
5. 两个终端,分别运行内部模拟服务和待测 Web 应用。

建议创建独立虚拟环境:

python3 -m venv .venv
source .venv/bin/activate
pip install flask requests

Windows PowerShell 可使用:.venv\Scripts\Activate.ps1。

四、分步骤实践

步骤 1:建立只能从本机访问的内部模拟服务

创建 internal.py:

from flask import Flask, jsonify

app = Flask(__name__)

@app.get('/internal-status')
def status():
return jsonify(service='demo-internal', state='ok', secret='LAB-ONLY')

app.run(host='127.0.0.1', port=9001)

运行:

python internal.py

先确认服务正常:

curl http://127.0.0.1:9001/internal-status

这里的 secret 只是实验字符串,不要使用真实凭据。

步骤 2:建立存在 SSRF 的测试应用

创建 vulnerable.py:

from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.get('/fetch')
def fetch():
target = request.args.get('url', '')
try:
r = requests.get(target, timeout=3)
return jsonify(status=r.status_code, body=r.text[:300])
except requests.RequestException as e:
return jsonify(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:9001/internal-status'

如果返回 demo-internal 和 LAB-ONLY,说明该接口允许用户驱动服务端访问回环地址,实验中的 SSRF 已复现。

步骤 3:验证重定向风险

实际工程中,开发者可能只校验初始 URL,却让 requests、urllib、axios 或其他客户端自动跟随重定向。可在 internal.py 中增加一个实验跳转接口,并将其运行在另一个本地端口:

from flask import redirect

@app.get('/jump')
def jump():
return redirect('http://127.0.0.1:9001/internal-status', code=302)

测试时应观察 HTTP 客户端是否自动跟随 302。防御逻辑必须对每一跳重新解析、重新做 DNS 与 IP 校验,而不是只检查用户首次提交的地址。

步骤 4:建立基本检测清单

在授权代码审计中,应搜索以下调用点:

rg 'requests\.(get|post)|urllib\.request|httpx\.|aiohttp|axios|fetch\(' .

随后沿数据流确认 URL 是否来自查询参数、JSON、Webhook 配置、导入文件或数据库中的用户可控字段。重点检查:

1. 是否限制协议为 http 或 https。
2. 是否存在业务级域名白名单。
3. 是否解析并检查全部 A、AAAA 记录。
4. 是否禁止回环、私网、链路本地、组播、保留和未指定地址。
5. 是否自动跟随重定向。
6. 是否继承 HTTP_PROXY、HTTPS_PROXY、NO_PROXY 等环境变量。
7. 是否限制连接超时、读取超时、响应大小和下载次数。
8. 是否记录最终连接 IP,而不仅是原始 URL。

步骤 5:实现基础校验器

下面的代码适合作为防御基线,但不能替代网络层出站控制:

from urllib.parse import urlsplit
import socket
import ipaddress

ALLOWED_HOSTS = {'example.test', 'assets.example.test'}

def validate_target(raw_url):
p = urlsplit(raw_url)
if p.scheme not in {'http', 'https'}:
raise ValueError('unsupported scheme')
if not p.hostname or p.username or p.password:
raise ValueError('invalid authority')
host = p.hostname.rstrip('.').lower()
if host not in ALLOWED_HOSTS:
raise ValueError('host not allowed')

infos = socket.getaddrinfo(host, p.port or (443 if p.scheme == 'https' else 80), type=socket.SOCK_STREAM)
if not infos:
raise ValueError('dns resolution failed')

for info in infos:
ip = ipaddress.ip_address(info[4][0])
if not ip.is_global:
raise ValueError('non-global address blocked')
return p

这里使用“明确允许的业务域名”优先于“禁止若干危险域名”。ipaddress.is_global 能减少遗漏,但仍应结合运行时版本、组织网络范围和实际策略进行测试。域名白名单匹配必须按规范化后的完整主机名处理,不能使用简单的 endswith('example.test'),否则可能错误接受 notevil-example.test。若允许子域,应使用 host == base 或 host.endswith('.' + base) 的边界匹配,并确认业务确实需要开放所有子域。

步骤 6:安全发起请求

建议关闭自动重定向并逐跳处理:

r = requests.get(url, timeout=(2, 5), allow_redirects=False, stream=True)

若响应为 301、302、303、307 或 308,应读取 Location,使用 urljoin 基于当前 URL 生成下一跳 URL,然后再次执行同样的协议、域名、DNS 和 IP 校验。设置最大跳转次数,例如 3 次,避免循环重定向。

读取响应时也要限制大小。例如按块读取,累计超过 1 MB 立即终止,防止大文件消耗内存。不要把底层异常、解析后的内部 IP 或完整响应直接返回给普通用户。

需要注意:先调用 socket.getaddrinfo 校验,再让 requests 自行解析域名,仍存在“校验时解析结果”和“连接时解析结果”不一致的问题。高风险系统应通过受控出站代理完成解析和连接,或使用能够把已验证 IP 固定到实际连接的客户端实现,同时正确处理 TLS 的 SNI 与证书主机名验证。不要为了连接固定 IP 而关闭 HTTPS 证书校验。

步骤 7:增加网络层隔离

应用层校验容易受解析器差异、库升级和边界遗漏影响。更可靠的方案是默认拒绝应用容器访问内网,仅放行必要的外部代理或固定目的地址。

在生产环境可采用:

1. 独立的 URL Fetcher 服务,权限最小化。
2. 强制所有外部请求经过出站代理。
3. 在防火墙、Kubernetes NetworkPolicy 或云安全组上拒绝访问回环之外的组织内网、链路本地网段和管理平面。
4. 将下载任务放入低权限、无敏感凭据的隔离容器。
5. 对目标域名、解析 IP、最终连接 IP、重定向链、响应大小和耗时进行审计。

网络策略应先在测试环境观察业务流量。直接封禁全部私网地址可能影响数据库、消息队列或服务发现,因此更合理的做法是把“需要访问外部 URL”的组件单独拆分,而不是给整个主应用配置宽泛出站权限。

五、验证修复效果

修复后至少执行以下测试用例:

1. 合法白名单 HTTPS URL,应成功。
2. http://127.0.0.1:9001,应拒绝。
3. http://localhost:9001,应拒绝。
4. IPv6 回环地址,应拒绝。
5. 指向私网地址的测试域名,应拒绝。
6. 初始地址合法但 302 跳转到本地地址,应拒绝下一跳。
7. 非 HTTP 协议,例如 file、ftp,应拒绝。
8. 包含 userinfo 的 URL,例如 user@host,应拒绝或严格规范化。
9. 超大响应、慢响应和循环重定向,应在限制内终止。
10. DNS 返回多个地址且其中一个为非公网地址时,应整体拒绝,避免客户端选择未校验地址。

自动化测试不应只断言 HTTP 状态码,还应检查内部模拟服务是否收到请求。可以在 internal.py 中加入计数日志;修复后的恶意测试用例应在应用层被拒绝,内部服务日志中不应出现对应访问。

六、风险与注意事项

1. 不要在真实云环境中尝试访问元数据地址;本地模拟足以验证控制逻辑。
2. URL 解析必须统一使用标准库,避免正则表达式自行拆分协议、主机和端口。
3. 禁止关闭 TLS 证书验证。否则即使解决 SSRF,也会引入中间人攻击风险。
4. 谨慎处理环境代理。requests 默认可能读取代理环境变量,实际连接路径可能与代码审计结论不同。
5. 记录日志时对查询参数、令牌和用户信息脱敏,避免把敏感 URL 写入日志系统。
6. 超时不是 SSRF 修复措施,只是资源保护措施。
7. 不回显正文也不等于安全,时间差和错误差异仍可构成盲探测通道。
8. 修复失败时应采用“默认拒绝”策略,不要在 DNS 异常、解析超时或代理故障时降级为直连。

七、常见问题

问题 1:只允许 HTTPS 是否足够?

不够。内部服务也可能支持 HTTPS,且域名仍可能解析到非预期地址。HTTPS 解决传输认证与加密,不负责限制目的网络。

问题 2:屏蔽 localhost 和 127.0.0.1 是否足够?

不够。还需要考虑 IPv6、私网、链路本地、保留地址、域名解析、多地址返回和重定向。应对解析后的地址对象做统一分类,而不是维护字符串列表。

问题 3:业务必须支持任意外部图片 URL,无法建立域名白名单怎么办?

应把下载能力放入隔离的 Fetcher,限制其网络出口、响应大小、内容类型、超时和并发,并通过出站代理禁止访问组织内网及管理平面。任意 URL 场景不能只依赖应用层黑名单。

问题 4:为什么需要记录最终连接 IP?

原始域名只能说明用户输入,不能证明服务实际连接到了哪里。故障调查和安全审计需要同时保留规范化域名、DNS 结果、最终连接地址及重定向链,并对日志中的敏感参数做脱敏。

问题 5:修复后偶发失败应该自动放宽策略吗?

不应该。DNS 失败、代理不可用、证书错误都应明确失败。自动改为直连、忽略证书或跳过地址检查,会把可观测的可用性故障转化为安全漏洞。合理做法是有限重试、熔断、记录失败阶段,并由运维排查。

八、总结

Webhook 型 SSRF 的核心并不是“某个危险字符串没有过滤”,而是应用把用户提供的网络位置当成了可信调用目标。可靠治理需要多层组合:业务域名允许列表、标准化 URL 解析、DNS 与 IP 分类、逐跳重定向校验、响应资源限制、固定或代理化连接、默认拒绝的失败策略,以及网络层出站隔离。

在授权测试中,建议同时观察输入、解析、连接和响应四个阶段。只有确认禁止目标没有收到任何请求,才能说明控制点真正位于连接之前,而不是仅隐藏了响应。对于高权限主应用,将远程资源获取拆分成低权限、可审计、可限流的独立组件,通常比不断扩充黑名单更稳定,也更便于后续故障恢复和安全审计。
 
顶部