SSRF:服务端请求伪造¶
本文只讨论自有系统、明确授权环境和本地 CTF 靶场中的安全测试。示例不会提供云凭据获取、
gopher利用链或针对真实内网的操作步骤。
一、SSRF 的本质¶
SSRF(Server-Side Request Forgery,服务端请求伪造)发生在应用根据用户可控数据,由**服务器**向另一个地址发起请求时。攻击者不能直接访问的目标,可能对应用服务器可见,因此服务端请求跨越了原本的网络信任边界。
可以将数据流概括为:
SSRF 与命令注入不同:前者的危险汇点是网络客户端,后者的危险汇点是 Shell 或解释器。若网络请求结果又被交给命令行工具处理,风险可能串联,参见命令执行漏洞。
二、Source 与 Sink¶
2.1 常见输入源(Source)¶
- 图片、头像、文档“从 URL 导入”功能。
- Webhook、回调地址、通知地址和 OAuth 配置。
- 链接预览、网页截图、PDF 转换和爬虫。
- 代理、镜像、包下载、Feed/RSS 导入。
- 用户可控的
Host、X-Forwarded-Host或重定向目标。 - XML、媒体文件、模板等内容中间接引用的外部资源。
- 只允许输入“主机名”或“路径”,但最终仍被拼成 URL 的字段。
输入不一定直接来自 HTTP 参数。数据库记录、消息队列、上传文件中的 URL 以及第三方 API 返回值,都可能是不可信数据。
2.2 常见危险汇点(Sink)¶
- PHP cURL、
file_get_contents()的 URL 包装器、Guzzle 等 HTTP 客户端。 - Python
requests、Node.jsfetch、Java URL/HTTP 客户端。 - Headless 浏览器、截图服务和文档渲染器。
- 调用
curl、wget等外部程序的封装。 - 支持多种协议的通用 URL 打开函数。
审计时要从输入一路追踪到最终建立连接的位置,同时检查代理、DNS 缓存、重试和自动重定向行为。
三、回显型与盲 SSRF¶
3.1 回显型(In-band)¶
服务端把目标响应的正文、状态码、响应头或错误信息返回给用户。测试者可直接比较结果,风险也更容易被发现。即使正文不返回,响应时间和错误类型仍可能泄露网络信息。
3.2 盲 SSRF(Blind)¶
服务端发起请求,但前端只显示“任务已提交”或统一错误。是否发生请求需要通过自有的本地监听服务、应用日志、关联 ID 或测试环境中的网络遥测确认。
盲 SSRF 不代表危害较低。它仍可能触发内部有副作用的 GET 接口、向受信任服务发送请求或造成资源消耗。测试时应使用自己控制的本地接收端,并限制请求次数和超时。
四、主要风险¶
4.1 内部服务¶
应用服务器往往能访问数据库管理面板、容器服务、监控端点、服务发现或只监听回环地址的管理接口。这些服务可能依赖“网络位置可信”,缺少强认证。SSRF 会破坏这一假设。
防御上不能只依赖内部服务“不可从公网访问”。内部服务仍应认证、授权并使用 TLS;网络层还应限制发起请求的工作负载能够访问哪些目的地。
4.2 云实例元数据¶
主流云平台通常提供链路本地的实例元数据服务,用于实例配置和短期身份。若应用能任意请求该服务,可能导致敏感信息暴露。本文不提供访问路径或凭据获取步骤。
应采用云平台提供的强化元数据协议、禁用不需要的元数据访问、限制工作负载到元数据地址的网络路径,并为实例身份授予最小权限。即使凭据被暴露,权限边界和短有效期也应限制影响。
4.3 其他影响¶
- 绕过基于来源 IP 的访问控制。
- 探测内部网络服务和端口状态。
- 访问本机或编排平台控制面。
- 消耗连接、带宽、CPU 和存储,形成拒绝服务。
- 将响应内容带入解析器,引发后续漏洞。
五、URL 解析与规范化¶
“URL 是字符串”的直觉是 SSRF 防护失败的常见原因。一个 URL 至少包含协议、用户信息、主机、端口、路径、查询和片段,不同解析器对边界与非法字符的处理可能不同。
以下模式都需要在设计和测试中考虑,但不应通过手写字符串规则解决:
- 用户信息段让
@前后的文本产生视觉混淆。 - 反斜杠、重复斜杠、控制字符和百分号编码造成解析差异。
- 主机名大小写、末尾点、国际化域名及 Unicode 同形字符。
- 空端口、默认端口、超大端口和协议相对 URL。
- URL 解析器与实际 HTTP 客户端采用不同语法。
- 应用先解码一次,后续组件再次解码。
可靠流程是:只解析一次,拒绝歧义输入,提取结构化字段,规范化后按字段验证,再把验证结果绑定到实际连接。 不要用 startsWith("https://trusted.example") 或正则替代 URL 解析器。
六、地址表示与绕过类别¶
只拦截字符串 127.0.0.1 或 localhost 远远不够。IP 地址可能存在多种表示和映射关系:
- IPv4 的十进制、历史上的整数/八进制/十六进制或省略段表示。
- IPv6 压缩写法、IPv4 映射 IPv6、链路本地地址和作用域标识。
- 主机名解析到回环、私有、链路本地、保留、多播或不可路由地址。
- 公网主机名包含多个 A/AAAA 记录,其中一个指向禁止网段。
- 国际化域名、大小写和末尾点在不同层被规范化。
不要尝试枚举“危险字符串”。应让成熟的 URL/DNS/IP 库完成解析,把结果转换为二进制 IP,再根据地址分类和业务允许范围判定。IPv4 与 IPv6 必须采用同一安全策略。
七、重定向、DNS Rebinding 与 TOCTOU¶
7.1 重定向¶
初始 URL 可以是允许的公网地址,但它返回的 Location 可能指向禁止地址。若客户端自动跟随重定向,只检查第一次 URL 就失去意义。
应关闭自动重定向,逐跳解析相对地址并重新执行完整验证,同时限制最大跳数。协议降级、跨主机跳转和非常规端口应按业务策略拒绝。
7.2 DNS Rebinding¶
域名第一次解析为允许地址,随后又解析为内部地址,这类“检查时安全、使用时危险”的变化可绕过先解析后请求的代码。低 TTL、多记录轮换以及应用与代理各自解析都可能造成类似效果。
7.3 TOCTOU¶
TOCTOU(Time Of Check To Time Of Use)指验证和实际连接之间存在时间或组件差异。仅执行一次 gethostbyname() 再把原域名交给 HTTP 客户端,会让客户端重新解析。
防御方法是:
- 解析主机的全部 A/AAAA 地址,任一地址不允许就整体拒绝。
- 从已验证地址中选择一个,并将该地址固定到本次连接。
- TLS 仍使用原始规范化主机名完成 SNI 与证书验证。
- 每次重定向都重新解析、验证并固定。
- 若请求经过代理,确保代理执行等价验证,或只允许访问代理的受控目标。
八、协议与目标允许策略¶
协议应采用允许列表,通常业务只需要 https,本地开发最多再允许 http。明确拒绝文件、数据、FTP 以及 HTTP 客户端支持的其他协议。不要依靠一个不断扩大的协议黑名单。
目标策略按业务需求从强到弱可分为:
- 固定目标映射:用户提交资源 ID,服务端映射到预定义 URL,最可靠。
- 精确主机允许列表:仅允许已登记供应商域名、HTTPS 和固定端口。
- 任意公网 HTTP(S):只在链接预览等确有需要的功能使用,并配合 IP 分类、重定向复验、大小/时间限制和网络出口控制。
如果业务根本不需要用户选择主机,就不要接受完整 URL。
九、安全 PHP 客户端示例¶
下面示例适合本地教学,展示“解析 URL、解析全部 IP、拒绝非公网地址、固定连接 IP、逐跳验证”的基本结构。生产环境还需结合代理架构、DNSSEC/可信解析器、响应大小限制、日志脱敏和业务允许列表。
示例故意只允许 http/https,拒绝用户信息和非常规端口,并禁用自动重定向:
<?php
function isPublicIp(string $ip): bool
{
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
function resolveAndValidate(string $host): array
{
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
$ips = [$host];
} else {
// 教学示例拒绝非 ASCII 域名;生产环境应明确执行 IDNA 规范化。
if (!preg_match('/\A(?=.{1,253}\z)[A-Za-z0-9.-]+\z/', $host)) {
throw new InvalidArgumentException('Invalid hostname');
}
$records = dns_get_record($host, DNS_A | DNS_AAAA);
$ips = [];
foreach ($records as $record) {
if (isset($record['ip'])) {
$ips[] = $record['ip'];
}
if (isset($record['ipv6'])) {
$ips[] = $record['ipv6'];
}
}
}
$ips = array_values(array_unique($ips));
if ($ips === []) {
throw new RuntimeException('Hostname did not resolve');
}
foreach ($ips as $ip) {
if (!isPublicIp($ip)) {
throw new RuntimeException('Non-public destination rejected');
}
}
return $ips;
}
function validateUrl(string $url): array
{
if (strlen($url) > 2048 || preg_match('/[\x00-\x20\x7f]/', $url)) {
throw new InvalidArgumentException('Invalid URL characters');
}
$parts = parse_url($url);
if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
throw new InvalidArgumentException('Absolute URL required');
}
if (isset($parts['user']) || isset($parts['pass'])) {
throw new InvalidArgumentException('User information is not allowed');
}
$scheme = strtolower($parts['scheme']);
$host = strtolower(rtrim($parts['host'], '.'));
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
throw new InvalidArgumentException('Only HTTP(S) is allowed');
}
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
if (!in_array($port, [80, 443], true)) {
throw new InvalidArgumentException('Port is not allowed');
}
return [$scheme, $host, $port, resolveAndValidate($host)];
}
function absoluteRedirect(string $baseUrl, string $location): string
{
if (parse_url($location, PHP_URL_SCHEME) !== null) {
return $location;
}
$base = parse_url($baseUrl);
if (str_starts_with($location, '//')) {
return $base['scheme'] . ':' . $location;
}
$origin = $base['scheme'] . '://' . $base['host'];
if (isset($base['port'])) {
$origin .= ':' . $base['port'];
}
if (str_starts_with($location, '/')) {
return $origin . $location;
}
$path = $base['path'] ?? '/';
return $origin . substr($path, 0, strrpos($path, '/') + 1) . $location;
}
function safeFetch(string $url, int $maxRedirects = 3): string
{
for ($hop = 0; $hop <= $maxRedirects; $hop++) {
[$scheme, $host, $port, $ips] = validateUrl($url);
$ip = $ips[0];
$pinnedIp = str_contains($ip, ':') ? "[$ip]" : $ip;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_RESOLVE => ["{$host}:{$port}:{$pinnedIp}"],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_USERAGENT => 'SafeFetcher/1.0',
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headers = substr($raw, 0, $headerSize);
$body = substr($raw, $headerSize);
curl_close($ch);
if ($status < 300 || $status >= 400) {
if (strlen($body) > 1024 * 1024) {
throw new RuntimeException('Response is too large');
}
return $body;
}
if (!preg_match('/^Location:\s*(.+)$/mi', $headers, $match)) {
throw new RuntimeException('Redirect without Location');
}
$url = absoluteRedirect($url, trim($match[1]));
}
throw new RuntimeException('Too many redirects');
}
这个示例的关键点不是某个正则,而是连接过程与验证结果绑定。生产代码更适合使用经过安全评审的集中式出站请求服务;相对 URL 解析也应交给符合 RFC 的 URI 库,而不是自行拼接。
十、网络与运行时防御¶
应用层验证可能因库升级、代理或解析差异失效,必须配合基础设施控制:
- 默认拒绝工作负载的任意出站访问,只开放业务必需目标和端口。
- 通过受控 egress proxy 发起外部请求,并在代理层重复执行域名和 IP 策略。
- 将管理面、数据库、编排控制面和元数据服务放在独立网络边界。
- 内部服务实施双向 TLS、服务身份和细粒度授权,不信任来源 IP 本身。
- 限制连接超时、总超时、响应大小、重定向次数、并发和下载速率。
- 不把目标的任意响应头原样转发给客户端,避免 Cookie 或安全头混淆。
- 记录规范化目标、解析 IP、最终 IP、重定向链和策略拒绝原因,但不记录敏感查询参数。
十一、本地测试清单¶
仅使用自有监听器和隔离靶场:
- 列出所有直接或间接接收 URL、主机、端口、路径的功能。
- 确认只允许业务需要的协议、域名和端口。
- 测试用户信息段、末尾点、大小写、编码、超长 URL 和控制字符是否被一致拒绝。
- 覆盖 IPv4、IPv6、IPv4 映射 IPv6 和各种非公网地址分类。
- 确认解析并检查全部 A/AAAA 记录,而不是只检查第一个。
- 确认实际连接固定到已验证 IP,不会发生二次 DNS 解析。
- 测试每一跳重定向都重新验证,且存在明确跳数上限。
- 检查 HTTP 客户端、代理和 Headless 浏览器是否支持额外协议或自动跳转。
- 用本地接收端验证盲请求,并检查超时、并发和响应大小限制。
- 确认网络出口策略能独立阻断回环、私有、链路本地和控制面网段。
- 确认内部服务仍要求认证授权,不以“仅内网可达”作为安全边界。
- 检查日志是否足以关联请求,又不会保存令牌、凭据和敏感 URL 参数。
十二、总结¶
SSRF 防护不能简化为拦截几个 IP 字符串。正确模型是控制完整的服务端连接生命周期:缩小可选目标,结构化解析 URL,解析并分类全部地址,将已验证 IP 固定到连接,逐跳复验重定向,再用网络出口策略和内部服务认证提供独立保护。