查询 24 次,耗时 0.3310 秒

从“能拦截内网地址”到可验证防线:本地授权靶场中的 SSRF 原理、复现与分层加固

gs328772

New member

gs328772 Rep
0
0
0
Rep
0
gs328772 Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
gs328772 获得
0
0
0
获得
0
gs328772 凭证
0
0
0
凭证
0
主题
6
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
【背景】

服务端请求伪造(SSRF)常见于网页预览、Webhook 校验、图片抓取、文件导入和第三方接口代理等功能。问题的核心不是“服务器会发 HTTP 请求”,而是外部输入能够影响请求目的地,同时系统没有在解析、解析后连接、重定向等环节建立一致的信任边界。

很多修复只检查 URL 字符串是否包含 127.0.0.1 或 localhost。这类黑名单容易被地址规范化差异、IPv6、重定向、DNS 解析结果变化等因素绕过。更可靠的方法是:默认拒绝、优先使用业务允许列表、对解析后的每一个 IP 做分类、禁止自动重定向或逐跳复核,并在网络出口实施第二层限制。

本文只在本机 Docker 网络中搭建授权实验环境,用于理解漏洞成因并验证防御,不涉及任何真实目标。

【原理与威胁模型】

实验环境包含两个服务:fetcher 是对外提供 URL 抓取功能的应用;internal 是仅供容器内部访问的模拟管理接口。如果 fetcher 不校验目标地址,访问者便可能借它读取 internal 的响应。

一条完整的服务端请求通常经历:接收 URL、解析 scheme 和 hostname、DNS 解析、选择 IP、建立连接、处理重定向、读取响应。只在第一步检查原始字符串并不充分。例如 hostname 解析出的结果可能包含环回、私网、链路本地或 IPv6 特殊地址;第一次请求看似安全,后续重定向却可能进入内部网络。因此,安全判断必须尽量靠近实际连接行为。

【前置条件】

1. 一台安装了 Docker 与 Docker Compose 的本地测试机。
2. 仅绑定到 127.0.0.1,不要把实验端口暴露到公网。
3. Python 基础知识,能够阅读 Flask 示例。
4. 所有操作只针对下面自建的容器网络。

创建目录:

mkdir -p ssrf-lab/fetcher ssrf-lab/internal
cd ssrf-lab

【步骤一:构建模拟内部服务】

新建 internal/app.py:

from flask import Flask, jsonify

app = Flask(__name__)

@app.get('/admin/status')
def status():
return jsonify(service='internal-admin', state='healthy', secret='LAB_ONLY_TOKEN')

app.run(host='0.0.0.0', port=8080)

新建 internal/Dockerfile:

FROM python:3.12-slim
RUN pip install --no-cache-dir flask==3.0.3
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]

这里的 LAB_ONLY_TOKEN 只是靶场标记,不要放置任何真实凭据。

【步骤二:构建存在 SSRF 风险的抓取服务】

新建 fetcher/app.py:

from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.get('/fetch')
def fetch():
url = request.args.get('url', '')
try:
r = requests.get(url, timeout=(2, 3))
return jsonify(status=r.status_code, body=r.text[:1000])
except requests.RequestException as e:
return jsonify(error=type(e).__name__), 400

app.run(host='0.0.0.0', port=5000)

新建 fetcher/Dockerfile:

FROM python:3.12-slim
RUN pip install --no-cache-dir flask==3.0.3 requests==2.32.3
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]

新建 compose.yaml:

services:
internal:
build: ./internal
networks: [lab]
fetcher:
build: ./fetcher
ports:
- "127.0.0.1:5000:5000"
networks: [lab]
networks:
lab:
internal: true

启动并查看状态:

docker compose up --build -d
docker compose ps

注意:internal 没有映射宿主机端口,但 fetcher 与它位于同一 Docker 网络,所以 fetcher 可以通过服务名访问它。

【步骤三:在授权靶场中验证风险】

执行:

curl -G 'http://127.0.0.1:5000/fetch' --data-urlencode 'url=http://internal:8080/admin/status'

如果返回 internal-admin 和 LAB_ONLY_TOKEN,说明抓取服务跨越了原本预期的访问边界。这个实验也说明:仅仅“不把内部服务端口映射到宿主机”并不能阻止同网络中的应用访问它。

可以记录以下观测项,便于后续对比:请求 URL、最终解析 IP、是否发生重定向、响应状态、响应体长度、耗时和拒绝原因。不要在生产日志中完整记录 URL 查询参数,因为其中可能含有令牌。

【步骤四:实现基础的地址分类防御】

将 fetcher/app.py 替换为下面的加固版本:

from flask import Flask, request, jsonify
from urllib.parse import urlsplit
import ipaddress
import socket
import requests

app = Flask(__name__)

ALLOWED_SCHEMES = {'http', 'https'}
MAX_BODY = 1024 * 1024

def resolve_public(host, port):
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError('dns_resolution_failed')

addresses = set()
for _, _, _, _, sockaddr in infos:
ip = ipaddress.ip_address(sockaddr[0])
addresses.add(ip)

if not addresses:
raise ValueError('no_address')

for ip in addresses:
if not ip.is_global:
raise ValueError('non_global_address')
return addresses

def validate_url(raw):
if len(raw) > 2048:
raise ValueError('url_too_long')
parsed = urlsplit(raw)
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise ValueError('scheme_not_allowed')
if not parsed.hostname:
raise ValueError('hostname_required')
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.lower() == 'https' else 80)
resolve_public(parsed.hostname, port)
return parsed

@app.get('/fetch')
def fetch():
raw = request.args.get('url', '')
try:
validate_url(raw)
with requests.get(
raw,
timeout=(2, 4),
allow_redirects=False,
stream=True,
headers={'User-Agent': 'SafeFetcher/1.0'}
) as r:
data = bytearray()
for chunk in r.iter_content(16384):
data.extend(chunk)
if len(data) > MAX_BODY:
raise ValueError('response_too_large')
return jsonify(
status=r.status_code,
content_type=r.headers.get('Content-Type', ''),
bytes=len(data)
)
except ValueError as e:
return jsonify(error=str(e)), 400
except requests.RequestException as e:
return jsonify(error=type(e).__name__), 502

app.run(host='0.0.0.0', port=5000)

重新构建:

docker compose up --build -d fetcher

再次请求内部服务:

curl -G 'http://127.0.0.1:5000/fetch' --data-urlencode 'url=http://internal:8080/admin/status'

预期结果为 non_global_address。这里使用 ipaddress.is_global 作为保守策略,环回、私网、链路本地、未指定地址以及多数特殊用途地址都会被拒绝。安全场景中,拒绝范围通常应该比“只拒绝 RFC1918 私网”更宽。

【步骤五:处理重定向与 DNS 解析竞态】

示例已经关闭自动重定向,这是重要的安全默认值。如果业务确实需要跟随重定向,应自行实现有限跳转:最多三跳;每一跳重新解析 Location;重新检查 scheme、用户信息、端口和全部 DNS 结果;拒绝相对协议异常和循环跳转;不要把 Authorization、Cookie 等敏感请求头转发到新主机。

还要注意 DNS 检查与实际连接之间可能存在时间差:应用校验得到一个公网 IP,HTTP 库建立连接时再次解析域名,结果可能已经变化。更严格的实现应将通过校验的 IP 固定为实际连接地址,同时为 HTTPS 保留原始 hostname 用于 SNI 和证书校验,并校验证书与原始主机名一致。直接把 HTTPS URL 改写成 IP 往往会破坏证书校验,因此不建议用字符串替换冒充“固定解析”。生产实现可使用支持自定义解析器或连接适配器的 HTTP 客户端,并通过集成测试确认连接确实落在已审核 IP 上。

【步骤六:优先采用业务允许列表】

如果功能只需访问少数 Webhook 或对象存储域名,允许列表比“允许整个公网”可靠。建议校验规范化后的完整主机名,不使用简单的 endswith。例如允许 example.com 的子域时,应判断 host == 'example.com' 或 host.endswith('.example.com'),否则 notexample.com 也可能被误放行。

允许列表还应配合固定端口、固定协议和固定路径前缀。例如只允许 https、443 端口以及 /hooks/ 路径。域名的所有 A 与 AAAA 结果都必须符合地址策略,不能只挑选其中一个安全结果。

【步骤七:增加网络出口兜底】

应用层校验不是唯一防线。生产环境应通过防火墙、Kubernetes NetworkPolicy、服务网格或出口代理限制该工作负载能访问的目标。尤其应阻止访问环回网段、RFC1918 私网、链路本地地址、云环境元数据地址以及不必要的管理网段。

本文的 Docker internal 网络用于演示,因此没有配置公网出口。真实部署中可以让抓取服务只能连接专用出口代理,由代理执行域名允许列表、DNS 解析审计、响应大小限制和访问日志记录。这样即使应用校验出现回归,网络层仍能阻断高风险路径。

【验证清单】

建议为防御代码建立自动化用例,至少覆盖:空 URL;超长 URL;缺少 hostname;非 HTTP 协议;URL 中包含用户名密码;IPv4 与 IPv6 环回地址;私网地址;链路本地地址;解析失败;同时返回公网和非公网地址的域名;重定向到内网;重定向循环;超大响应;连接超时;读取超时;异常端口;国际化域名规范化。

验收标准不只是“请求失败”,还应确认失败发生在建立危险连接之前,并且日志中有稳定的原因码,例如 scheme_not_allowed、non_global_address 和 response_too_large。

【风险与注意事项】

1. 不要将本实验中的 fetcher 暴露到公网,它本质上仍是一个可代发请求的服务。
2. requests 的 timeout 不是整个请求的绝对总时限,应在网关或任务执行器中增加整体截止时间。
3. 限制响应体大小时应使用流式读取,直接访问 r.content 可能先把大响应全部载入内存。
4. 不要返回抓取到的完整正文;生产系统应按业务需要提取有限字段,并过滤敏感响应头。
5. 禁止 file、ftp、gopher 等非必要协议,同时防止底层库根据内容自动切换协议。
6. 记录拒绝事件时对 URL 做脱敏,避免日志成为凭据泄露渠道。
7. 应限制并发、速率和单用户配额,否则即使目标地址合法,也可能形成资源消耗或代理滥用。
8. 容器网络隔离并不等于应用授权,网络可达性与业务可访问性必须分别控制。

【常见问题】

问:屏蔽 127.0.0.1 和 192.168.0.0/16 是否足够?
答:不够。还需考虑全部私网、链路本地、IPv6 环回与唯一本地地址、未指定地址及特殊用途地址,并对 DNS 返回的所有结果进行判断。

问:为什么不使用正则表达式判断 URL?
答:URL 语法存在用户信息、端口、IPv6 方括号、编码和规范化等细节。应使用成熟解析器拆分结构,再分别验证字段,避免字符串规则与客户端实际解析结果不一致。

问:关闭重定向会不会影响业务?
答:可能会,但这是更安全的默认值。确需重定向时,应限制跳数并逐跳执行完整验证,而不是直接启用自动跟随。

问:地址校验通过后是否就安全?
答:仍不完全安全。还存在 DNS 竞态、客户端二次解析、代理环境变量、响应过大和超时等问题,需要连接绑定、出口控制和资源限制共同处理。

问:开发环境需要访问内网测试接口怎么办?
答:为测试环境建立明确的目标允许列表,不要通过关闭全部校验解决。测试策略与生产策略应分别配置,并在启动时打印当前策略摘要,避免配置误带入生产。

【总结】

SSRF 防御的关键不是堆叠几个字符串黑名单,而是围绕真实请求链路建立可验证的控制:严格解析 URL、限制协议和端口、校验全部解析地址、拒绝非全局地址、关闭或逐跳验证重定向、消除 DNS 与连接之间的不一致、限制响应资源,并通过网络出口策略兜底。对于目标集合固定的业务,应优先采用精确允许列表。最后用自动化测试和结构化日志持续验证防线,才能避免一次修复后在依赖升级或功能改造中悄然回归。

实验结束后清理环境:

docker compose down --volumes --remove-orphans
 
顶部