Burst Lab|PHP 审计 10:PHP 网络请求与 SSRF 漏洞审计思路
-
- 前言
- [一、建立 SSRF 数据流模型](#一、建立 SSRF 数据流模型)
-
- [1. SSRF 不要求用户完整控制 URL](#1. SSRF 不要求用户完整控制 URL)
- [2. SSRF 不一定回显响应正文](#2. SSRF 不一定回显响应正文)
- [二、PHP 中需要重点关注的请求入口](#二、PHP 中需要重点关注的请求入口)
-
- [1. URL 文件包装器相关函数](#1. URL 文件包装器相关函数)
- [2. cURL 扩展](#2. cURL 扩展)
- [3. 第三方 HTTP 客户端(Guzzle / Laravel Http)](#3. 第三方 HTTP 客户端(Guzzle / Laravel Http))
- [4. 间接网络访问(易遗漏Sink,CTF非预期解)](#4. 间接网络访问(易遗漏Sink,CTF非预期解))
- 三、三类常见脆弱实现
-
- [3.1 直接请求用户输入](#3.1 直接请求用户输入)
- [3.2 只使用字符串黑名单](#3.2 只使用字符串黑名单)
- [3.3 只检查首次解析,却允许跳转](#3.3 只检查首次解析,却允许跳转)
- [四、SSRF 代码审计五步法](#四、SSRF 代码审计五步法)
-
- [4.1 定位 Sink](#4.1 定位 Sink)
- [4.2 反向追踪 Source](#4.2 反向追踪 Source)
- [4.3 分析 Transform](#4.3 分析 Transform)
- [4.4 确认服务器网络能力](#4.4 确认服务器网络能力)
- [4.5 判断反馈能力和业务影响](#4.5 判断反馈能力和业务影响)
- 五、审计必须覆盖的绕过面
-
- [5.1 协议限制](#5.1 协议限制)
- [5.2 主机名和 IP 规范化](#5.2 主机名和 IP 规范化)
- [5.3 用户信息和解析歧义](#5.3 用户信息和解析歧义)
- [5.4 端口限制](#5.4 端口限制)
- [5.5 重定向](#5.5 重定向)
- [5.6 DNS Rebinding 和多次解析](#5.6 DNS Rebinding 和多次解析)
- [六、更安全的 PHP cURL 防护骨架](#六、更安全的 PHP cURL 防护骨架)
- [七、比"校验任意 URL"更可靠的设计](#七、比“校验任意 URL”更可靠的设计)
-
- [7.1 不接收任意 URL](#7.1 不接收任意 URL)
- [7.2 使用精确域名允许名单](#7.2 使用精确域名允许名单)
- [7.3 隔离抓取服务](#7.3 隔离抓取服务)
- [7.4 建立统一安全请求组件](#7.4 建立统一安全请求组件)
- 八、授权环境中的验证思路
- 九、修复前后对比
- 十、审计报告实际怎么写
-
- [1. 漏洞位置](#1. 漏洞位置)
- [2. 梳理完整数据流](#2. 梳理完整数据流)
- [3. 触发条件](#3. 触发条件)
- [4. 安全影响](#4. 安全影响)
- [5. 修复建议](#5. 修复建议)
- [十一、PHP SSRF审计Checklist](#十一、PHP SSRF审计Checklist)
- 十二、实际审计中经常踩的误区
- 总结
-
- 免责声明
- [上一篇:Burst Lab | PHP 审计 09|文件上传代码审计:校验缺陷与多种绕过手段](#上一篇:Burst Lab | PHP 审计 09|文件上传代码审计:校验缺陷与多种绕过手段)
前言
PHP 业务中有大量场景需要服务端代替客户端向外发起网络请求:远程资源下载、网页内容抓取预览、Webhook 回调、第三方 API 调用、页面截图生成、接口健康检查、OIDC 回调校验等。
当攻击者能够直接或间接控制请求的目标地址,同时业务缺少协议、主机、端口、DNS 等完整校验逻辑,就会产生 SSRF 服务端请求伪造(Server‑Side Request Forgery)。
SSRF 请求:由 Web 服务器代为发出。服务器处在内网边界内侧,拥有外网用户不具备的网络访问权限。攻击者可以将服务器作为跳板,探测内网网段、访问本机回环服务、读取服务器本地文件、读取云实例元数据,甚至对内网业务系统发起攻击,极端条件下可造成内网接管。
⚠️ 免责声明:本文全部技术内容仅限授权环境下代码审计、安全学习使用,禁止对未授权目标实施任何测试行为,违反《网络安全法》《刑法》需要承担对应的法律责任。
代码审计阶段,判断一处功能是否具备可利用的 SSRF 风险,需要完整评估六个维度:
- 可控性:用户能否直接/间接干预请求目标;并非必须完整控制整个 URL,仅可控主机、端口同样存在风险。
- 输入链路:输入经历的解码、拼接、URL 解析、DNS 解析、重定向跳转完整流程。
- 请求载体:实际发起网络请求的底层函数、扩展、第三方组件。
- 网络边界:PHP 运行环境可访问网络域:公网、内网、回环地址、容器网络、云元数据地址。
- 请求上下文:请求是否附带服务端 Cookie、内部 Token、客户端证书等敏感凭据。
- 回显情况:响应正文、状态码、报错、耗时是否对外泄露;区分回显 SSRF、半回显 SSRF、盲 SSRF。
一、建立 SSRF 数据流模型
代码审计中,可以把 SSRF 抽象为一条完整数据流链:
text
用户可控输入(Source)
↓
拼接 / 解码 / URL 解析 / DNS 解析(Transform)
↓
网络请求函数或组件(Sink)
↓
内网、回环、链路本地或其他受保护服务(Target)
↓
正文 / 状态码 / 长度 / 时间差 / 错误(Feedback)
重点提示:审计不能只盯着 Source 和 Sink。出现可控输入加网络请求函数不等于就是漏洞。协议支持、端口范围、重定向行为、DNS解析结果、服务器出站权限、反馈输出能力,共同决定漏洞实际可利用性。
1. SSRF 不要求用户完整控制 URL
风险不一定需要用户传入一条完整 URL,仅控制主机、端口、部分路径同样可以构造攻击。
php
// 完整 URL 可控,最直观场景
$url = $_GET['url'];
// 主机可控,协议、路径服务端硬编码
$url = 'https://' . $_GET['host'] . '/api/info';
// 存储型输入:攻击者预先写入,后续业务自动触发(二阶 SSRF)
// 特点:输入点和漏洞触发点可以跨文件、跨服务
$url = $webhook['callback_url'];
// 上游接口返回值可控:第三方接口返回数据作为请求目标(二阶 SSRF)
$url = $thirdPartyResult['resource_url'];
因此,审计搜索不能只局限于 $_GET['url']。数据库字段、配置中心、消息队列消息、上传文件中的解析内容、第三方接口返回,都可能成为间接输入源。
2. SSRF 不一定回显响应正文
即使接口不会直接返回响应正文,攻击者仍可依靠侧信道信号判断目标状态:
- HTTP 状态码
- 连接成功、拒绝、超时行为差异
- 响应长度变化
- 页面抛出的错误、异常堆栈
- 异步任务执行结果
- 外部 DNS/HTTP 服务收到的外带请求
这类没有正文直接回显的场景称为 Blind SSRF(盲 SSRF)。
注意:不能因为页面没有输出就排除风险。盲SSRF虽拿不到返回内容,但仍可做内网探测、调用内网接口执行动作。
二、PHP 中需要重点关注的请求入口
PHP 的 SSRF 触发 Sink 分为两大类:一类是原生函数直接发起网络请求;另一类属于间接网络访问,PHP 本身不直接建立 Socket,仅把 URL 传给第三方库、外部进程完成请求,CTF 中后者经常作为非预期解出现。
关键区分:
allow_url_fopen仅对 PHP 流包装器系列函数生效,对 cURL、Guzzle 等组件完全无效 ;而file://本地文件协议不受allow_url_fopen开关控制。⚠️ 本节攻击样例仅用于靶场、CTF授权环境,严禁用于未授权业务系统。本章只展示基础攻击场景,复杂过滤绕过、协议深度利用将在后续章节展开。
1. URL 文件包装器相关函数
当 allow_url_fopen = On,文件操作函数借助 PHP 流包装器访问远程资源;支持 http://、https://、file://;不支持 gopher、dict 协议。
高危函数清单:
php
file_get_contents($url);
fopen($url, 'r');
readfile($url);
copy($url, $localPath);
get_headers($url);
典型危险代码:
php
<?php
$url = $_GET['url'] ?? '';
$content = file_get_contents($url);
echo $content;
攻击场景与基础Payload
- 攻击场景1:探测内网端口、访问内网Web服务
http://127.0.0.1:8080 - 攻击场景2:读取服务器本地敏感文件
file:///etc/passwd - 攻击场景3:利用30x重定向跳转访问内网目标(
file_get_contents默认跟随重定向)
http://attacker.com/redirect?target=http://127.0.0.1
审计&CTF提示:靶场可使用
stream_get_wrappers()查看已注册流包装器。重要限制:该套Sink不支持 gopher/dict,无法直接打Redis、FastCGI这类内网非HTTP服务。
2. cURL 扩展
cURL 不受 allow_url_fopen 控制,支持 http/https/file/gopher/dict/tftp 多种协议,是真实审计与CTF中SSRF出现最多的Sink。
php
$url = $_POST['target'] ?? '';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
echo $result;
审计重点配置(同时也是CTF出题考点)
php
CURLOPT_FOLLOWLOCATION // 是否跟随30x重定向;开启可用302跳转绕过IP、协议黑名单
CURLOPT_MAXREDIRS // 最大跳转次数
CURLOPT_PROTOCOLS // 限制初次请求协议,只管第一次请求
CURLOPT_REDIR_PROTOCOLS // 重定向后允许的协议,漏配置可302跳gopher/dict
CURLOPT_PROXY // 设置代理后DNS交给代理处理,PHP层IP过滤全部失效
CURLOPT_CONNECTTIMEOUT // 连接超时,内网端口爆破依靠该值控制时间
攻击场景与基础Payload
- 攻击场景1:读取本地文件
file:///etc/passwd - 攻击场景2:gopher协议调用内网Redis、FastCGI等非HTTP服务(CTF高频)
gopher://127.0.0.1:6379/_%2A1%0d%0a$8%0d%0aflushall%0d%0a - 攻击场景3:dict协议探测端口banner信息
dict://127.0.0.1:3306 - 攻击场景4:可控外部站点302跳转绕过防护
http://attacker.com/redirect_to_inner
CTF坑点:
CURLOPT_PROTOCOLS只管控第一次请求,如果开启重定向,没有设置CURLOPT_REDIR_PROTOCOLS,就可以用302跳转到 gopher/dict。
3. 第三方 HTTP 客户端(Guzzle / Laravel Http)
底层大多封装cURL,业务开发、CTF Web题高频出现;框架不会自带SSRF防护,安全性完全取决于开发者配置。
php
// Laravel Http
$response = Http::get($request->input('url'));
// 原生 Guzzle
$response = $client->get($userInputUrl);
审计&做题关注点:
- URL是完整可控,还是仅host部分可控;
- 是否开启自动重定向,重定向是否限制协议;
- 旧版本Guzzle存在URL解析绕过漏洞;
- 是否配置代理。
攻击场景提示
注意:Guzzle默认会禁用gopher、dict协议,很多CTF选手直接复制cURL的gopher payload会直接失败,做题需要先确认底层实际支持协议。可用场景多为内网探测、读取本地文件。
4. 间接网络访问(易遗漏Sink,CTF非预期解)
PHP本身没有直接网络调用函数,把URL传给外部进程/第三方库,由子进程发起网络请求。可用协议、网络权限和PHP‑FPM环境不完全一致。
涉及函数/组件:
getimagesize()、imagecreatefromgif()、imagecreatefrompng()- wkhtmltopdf、puppeteer网页截图
- ffmpeg处理远程资源
exec()/shell_exec()调用系统 curl/wget- SimpleXML、xml_parse XXE触发外部实体请求
攻击场景提示
getimagesize():传入内网http地址做内网端口探测,不支持gopher协议;- puppeteer/wkhtmltopdf:浏览器子进程,可访问内网、读取本地文件;
- 调用系统curl/wget:直接复用操作系统curl能力,gopher、file协议全部可用;
- XXE:可发起http内网探测,读取本地文件。
实操靶场提示:原版DVWA没有独立SSRF关卡。练习优先选用Pikachu靶场,内置两套SSRF环境:
ssrf_curl:底层cURL,完整支持gopher/dict;ssrf_filegetcontents:底层file_get_contents,协议能力受限。
小结:审计看到以上Sink,不能直接判定漏洞,仍要结合输入可控性、校验逻辑、网络边界综合判断;上面payload仅代表该函数具备的攻击能力,实际能否利用要看业务防护。
三、三类常见脆弱实现
3.1 直接请求用户输入
完全没有校验,直接把用户可控URL传入网络请求函数,属于最原始的SSRF漏洞。
php
public function preview(): void
{
$url = $_GET['url'] ?? '';
echo file_get_contents($url);
}
攻击场景
攻击者可以任意控制服务端请求目标:内网探测、读取本地文件、访问集群内部接口。
# 读取本地文件
?url=file:///etc/passwd
# 访问本机内网服务
?url=http://127.0.0.1:8080
若应用服务器能够访问回环地址、容器网段、集群服务、管理网络或基础设施接口,漏洞危害会被显著放大。
3.2 只使用字符串黑名单
仅对输入字符串做简单关键词过滤,只校验输入长什么样 ,不校验最终解析出来的IP地址,属于CTF最常见的无效防护。
php
$url = $_GET['url'] ?? '';
if (str_contains($url, '127.0.0.1') || str_contains($url, 'localhost')) {
exit('invalid url');
}
echo file_get_contents($url);
核心缺陷:同一目标主机可以通过多种字符串形式表达,黑名单很容易被绕过。
CTF常见绕过思路(点到即止)
- IP多种表示法:十进制整数、八进制、十六进制、简写IP
http://2130706433、http://0x7f.0.0.1、http://127.1 - 域名解析绕过:使用解析到127.0.0.1的外部域名
- 302跳转:外部VPS搭建跳转页面重定向至内网地址
某些特殊IP表示法是否生效,取决于操作系统解析器、libcurl版本、客户端组件。安全不能依赖"我的环境下该写法失效"做假设。
3.3 只检查首次解析,却允许跳转
先解析一次Host做IP校验,但开启CURLOPT_FOLLOWLOCATION自动跟随重定向,跳转之后不再做任何校验;同时存在TOCTOU(检查与竞争时间窗口),可配合DNS重绑定攻击。
php
$url = $_GET['url'] ?? '';
$host = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($host);
if (!isAllowedIp($ip)) {
exit('blocked');
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
]);
echo curl_exec($ch);
两类风险
-
重定向绕过:首次请求是公网合法地址,302跳转到内网地址,跳转后不再校验IP,防护直接失效。
Payload思路:传入攻击者可控公网地址,该地址返回
Location: http://127.0.0.1。 -
TOCTOU 时间竞争(DNS Rebinding DNS重绑定)
text
检查阶段:example.test → 返回公网IP,校验放行
连接阶段:短TTL域名重新DNS解析 → 返回私有内网IP
原理:检查DNS解析 和 curl建立连接是两次独立DNS查询,两次解析结果可以不一样,这就是DNS重绑定攻击在SSRF中的核心风险。
💡审计要点:只要开启自动重定向,必须每一次跳转都重新解析、重新校验IP;否则防护逻辑形同虚设。
四、SSRF 代码审计五步法
这套流程同时适用于真实业务代码审计与CTF代码审计题;走完五步,才可以判断是否构成可利用SSRF漏洞,不能只看到Sink就直接下漏洞结论。
4.1 定位 Sink
先在源码中找出所有会发起网络请求的危险函数/方法,这是审计起点。
搜索关键词:
text
file_get_contents(
fopen(
readfile(
copy(
get_headers(
curl_init(
curl_exec(
CURLOPT_URL
Client->get(
Client->request(
Http::get(
PowerShell批量搜索示例:
powershell
Get-ChildItem -Recurse -Include *.php |
Select-String -Pattern 'file_get_contents\s*\(|fopen\s*\(|readfile\s*\(|get_headers\s*\(|curl_init\s*\(|CURLOPT_URL|->request\s*\(|->get\s*\('
重要提醒:搜索结果仅仅是候选入口清单,不等于漏洞。后续还要看输入来源、校验逻辑、网络环境。
CTF提示:不要漏掉间接Sink,例如
getimagesize()、SoapClient、调用系统curl/wget。
4.2 反向追踪 Source
从Sink向上回溯,确认传入URL的数据来源,判断是否可控。
可控来源清单:
php
$_GET
$_POST
$_REQUEST
$request->input()
$request->query()
数据库字段
配置中心
消息队列消息
第三方接口响应
上传文件中解析提取出的 URL
示例(二阶/异步SSRF):
php
// 攻击者提交、保存Webhook回调地址(输入点)
$repository->saveCallbackUrl($userId, $_POST['callback']);
// 订单事件异步触发网络请求(漏洞触发点)
$client->post($user->callback_url, ['json' => $payload]);
审计&CTF考点:输入点和触发点可以不在同一个文件、不在同一个请求,甚至跨服务。这种二阶SSRF在代码审计题里经常出现,正向阅读很难发现。
4.3 分析 Transform
梳理URL传入Sink之前,经历的全部处理、校验、解析逻辑。
常见处理操作:
php
trim($url)
urldecode($url)
html_entity_decode($url)
base64_decode($url)
协议、主机、端口字符串拼接
parse_url($url)
IDN/Punycode 域名转码
DNS解析
代理转发
30x重定向跳转
核心坑点:解析器差异 Parser Differential
业务代码用
parse_url()做安全校验,但是底层cURL/libcurl使用另一套URL解析逻辑。二者解析结果不一致,造成防护绕过,这是CTF高频出题点。安全原则:校验逻辑尽量靠近实际网络连接动作,以最终实际连接的IP作为判断依据,不要信任前置解析结果。
4.4 确认服务器网络能力
结合部署环境,判断服务端能访问什么网络资源,直接决定漏洞危害等级。
需要确认:
- PHP‑FPM所在主机/容器出站可以访问哪些网段;
- 是否可达宿主机、容器网桥、K8s集群内部服务;
- DNS解析、TCP连接发生在PHP应用层,还是上层代理;
- 防火墙、安全组、NetworkPolicy出站策略;
- 请求是否会自动携带内部API Token、Cookie、客户端证书。
同一份PHP代码,在严格出站白名单环境和扁平化内网环境,风险等级完全不一样。CTF环境一般无防火墙限制;真实业务很多会做出站网络隔离。
4.5 判断反馈能力和业务影响
根据回显情况做分类,评估可实现的攻击行为,用于漏洞定级。
| 类型 | 特征 | 常见影响(审计&CTF攻击场景) |
|---|---|---|
| 有回显 SSRF | 响应正文直接返回页面 | 读取本地文件、获取内网接口返回数据、枚举内部Web服务 |
| 半回显 SSRF | 仅返回状态码、响应长度、报错信息 | 端口扫描,判断端口/服务是否存活,无法拿到完整返回包 |
| 盲 SSRF | 页面无任何直接输出 | 依靠DNSLog、时间差侧信道判断是否触发请求;很难直接拿数据 |
| 携带凭据 SSRF | 请求自动附加Cookie、Token、证书 | 以服务器身份调用内网敏感接口,危害最高 |
风险定级不能只看"能不能访问127.0.0.1",还要结合请求方法、认证上下文、回显能力、目标资产价值综合判断。
小结:五步法完整链路:找危险函数 → 追可控输入 → 分析处理与校验逻辑 → 评估网络可达性 → 评估回显与危害,全部条件满足,才是可利用SSRF。
五、审计必须覆盖的绕过面
本节面向代码审计与CTF,梳理开发编写防护逻辑时极易遗漏的点,同时也是SSRF高频出题绕过点。审计防御代码时,以下六个维度需要逐一核对,缺失任意一项都可能存在绕过风险。
5.1 协议限制
业务仅用于网页抓取场景,应当只放行 http、https。
正确示范,解析scheme后严格白名单比对:
php
$parts = parse_url($url);
$scheme = strtolower($parts['scheme'] ?? '');
if (!in_array($scheme, ['http', 'https'], true)) {
throw new InvalidArgumentException('Unsupported scheme');
}
错误做法:直接使用字符串查找是否包含http,gopher://http://xxx即可完成绕过。
补充审计要点:上层PHP完成协议校验之后,cURL客户端层面还需要通过CURLOPT_PROTOCOLS、CURLOPT_REDIR_PROTOCOLS再次做底层协议限制,实现双层防护。
5.2 主机名和 IP 规范化
IP校验不能仅简单拦截127.0.0.1字符串,需要完整覆盖各类IP形态:
- IPv4 私有、回环、链路本地和保留地址;
- IPv6 回环、链路本地和唯一本地地址;
- IPv4‑mapped IPv6 映射地址;
- 域名末尾的点、大小写和国际化域名;
- 一个域名同时返回多个 A/AAAA 记录;
- 空主机、解析失败和异常端口。
核心安全原则:只要DNS返回的任意一条解析记录属于禁止地址,直接拒绝整个请求,不能挑选其中一条安全IP继续请求。
CTF考点:各类IP进制表示、多解析记录域名,常被用于绕过简单IP黑名单。
5.3 用户信息和解析歧义
URL可以包含用户凭据信息:
text
https://user:password@example.com/path
手写正则解析URL很容易将@前的内容误判为主机,造成防护绕过。业务不需要URL凭据信息时,直接检测并拒绝。
php
$parts = parse_url($url);
if (isset($parts['user']) || isset($parts['pass'])) {
throw new InvalidArgumentException('URL credentials are not allowed');
}
审计提示:不建议手写正则解析URL,优先使用parse_url;同时留意解析器差异问题,PHP的parse_url与libcurl的URL解析逻辑并不完全一致。
5.4 端口限制
业务仅访问标准Web服务时,端口可以限定为 80、443,要求更严格的场景仅放行443。
开放全部端口会使SSRF变为内网端口探测入口,也为非HTTP协议交互创造条件。
5.5 重定向
默认关闭自动跳转:
php
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
业务必须支持跳转时,需要由应用层手动逐跳处理,流程如下:
- 读取响应头
Location; - 转换相对地址为完整URL;
- 对新URL重新校验协议、主机、端口、DNS解析结果;
- 请求时绑定已经过校验的IP;
- 限制最大跳转次数,防止无限重定向;
- 拒绝跨越信任边界的跳转。
只校验初始URL,开启自动跟随跳转,是SSRF防护最常见的失效点。
5.6 DNS Rebinding 和多次解析
仅做如下的单次解析校验是不充分的:
php
$ip = gethostbyname($host);
checkIp($ip);
curl_exec(curl_init($url));
相对可靠的防护思路:
- 获取域名全部 A/AAAA 解析记录;
- 逐条校验全部解析结果;
- 选用校验通过的地址;
- 请求时将域名固定绑定至该IP;
- 保留原始域名用于HTTP Host头部与TLS SNI;
- 重定向发生时,必须重新执行全套校验。
即便完成以上应用层防护,依旧不能替代网络层面的出站访问控制。
CTF考点:DNS重绑定利用两次DNS查询结果不一致绕过IP白名单;防护如果没有固定IP,该攻击方式即可生效。
六、更安全的 PHP cURL 防护骨架
下面的代码体现以下原则:
- 只允许 HTTP/HTTPS;
- 禁止 URL 用户信息;
- 限制端口;
- 检查全部 A/AAAA;
- 使用
CURLOPT_RESOLVE固定已验证 IP; - 禁止自动跳转;
- 限制超时和响应大小。
这是用于说明审计和设计原则的防护骨架。生产环境还应加入业务域名白名单、IDN 规范化、出站代理、日志、限流和网络 ACL。
php
<?php
declare(strict_types=1);
final class SafeHttpClient
{
private const ALLOWED_SCHEMES = ['http', 'https'];
private const ALLOWED_PORTS = [80, 443];
private const MAX_RESPONSE_BYTES = 2_000_000;
public function get(string $url): string
{
$parts = parse_url($url);
if ($parts === false) {
throw new InvalidArgumentException('Malformed URL');
}
$scheme = strtolower($parts['scheme'] ?? '');
$rawHost = $parts['host'] ?? '';
$host = strtolower(trim($rawHost, '[]'));
if (!in_array($scheme, self::ALLOWED_SCHEMES, true)) {
throw new InvalidArgumentException('Unsupported scheme');
}
if ($host === '') {
throw new InvalidArgumentException('Missing host');
}
if (str_ends_with($host, '.') || preg_match('/[^\x20-\x7E]/', $host)) {
// This example rejects trailing-dot and non-ASCII hosts; production code may canonicalize IDNs first.
throw new InvalidArgumentException('Host must be canonical ASCII');
}
if (isset($parts['user']) || isset($parts['pass'])) {
throw new InvalidArgumentException('URL credentials are forbidden');
}
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
if (!in_array($port, self::ALLOWED_PORTS, true)) {
throw new InvalidArgumentException('Port is not allowed');
}
$ips = $this->resolveAll($host);
if ($ips === []) {
throw new RuntimeException('DNS resolution failed');
}
foreach ($ips as $ip) {
if (!$this->isPublicIp($ip)) {
throw new RuntimeException('Non-public destination is forbidden');
}
}
// Pin a validated address for domain targets; IP literals need no DNS override.
$hostIsIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
$selectedIp = $ips[0];
$resolveAddress = str_contains($selectedIp, ':')
? '[' . $selectedIp . ']'
: $selectedIp;
$body = '';
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Unable to initialize cURL');
}
$options = [
CURLOPT_RETURNTRANSFER => false,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_NOSIGNAL => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_PROXY => '', // Do not inherit environment proxies without a matching proxy policy.
CURLOPT_HTTPGET => true,
CURLOPT_HEADER => false,
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$body): int {
if (strlen($body) + strlen($chunk) > self::MAX_RESPONSE_BYTES) {
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
];
if (!$hostIsIp) {
$options[CURLOPT_RESOLVE] = [
sprintf('%s:%d:%s', $host, $port, $resolveAddress),
];
}
// 常量可用时,在 cURL 层再次限制协议。
if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) {
$options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
}
curl_setopt_array($ch, $options);
try {
$ok = curl_exec($ch);
if ($ok === false) {
throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status < 200 || $status >= 300) {
throw new RuntimeException('Unexpected HTTP status: ' . $status);
}
} finally {
curl_close($ch);
}
return $body;
}
/** @return list<string> */
private function resolveAll(string $host): array
{
$literal = trim($host, '[]');
if (filter_var($literal, FILTER_VALIDATE_IP) !== false) {
return [$literal];
}
$records = dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
return [];
}
$ips = [];
foreach ($records as $record) {
if (isset($record['ip'])) {
$ips[] = $record['ip'];
}
if (isset($record['ipv6'])) {
$ips[] = $record['ipv6'];
}
}
return array_values(array_unique($ips));
}
private function isPublicIp(string $ip): bool
{
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
}
这段代码仍有哪些边界?
FILTER_FLAG_NO_PRIV_RANGE和FILTER_FLAG_NO_RES_RANGE是基础检查,高安全系统可维护更明确的特殊用途地址策略,并用测试覆盖 IPv4/IPv6。- 国际化域名应先统一 IDN 规范化,再执行白名单和 DNS 校验。
CURLOPT_RESOLVE行为依赖 libcurl,部署前应在目标 PHP/cURL 版本测试 IPv4、IPv6、代理和 TLS。- 使用显式代理时,DNS 可能在代理端完成,应用校验必须与代理策略配套。
- 示例拒绝所有重定向;需要跳转时必须逐跳验证。
- 还应限制并发、速率、响应头和总资源消耗,避免转化为拒绝服务。
- 最重要的是,网络层必须阻止应用访问不必要的内部地址。
七、比"校验任意 URL"更可靠的设计
7.1 不接收任意 URL
php
// 不推荐
fetch($_POST['url']);
// 推荐:用户只提交服务端可识别的资源标识
fetchFromKnownProvider($_POST['provider'], $_POST['resource_id']);
让用户选择受支持的提供方,由服务端构造固定上游,远比维护复杂黑名单可靠。
7.2 使用精确域名允许名单
php
$allowedHosts = [
'api.example.com',
'static.example-cdn.com',
];
if (!in_array($host, $allowedHosts, true)) {
throw new RuntimeException('Host is not allowed');
}
不要使用无边界的后缀判断:
php
// 错误:attacker-example.com 也可能通过
str_ends_with($host, 'example.com');
允许子域时至少要求点边界:
php
$allowed = $host === 'example.com'
|| str_ends_with($host, '.example.com');
域名白名单仍要配合 DNS 和网络层策略,防止受信域名被错误配置、接管或解析到受保护地址。
7.3 隔离抓取服务
高风险抓取能力可单独部署:
text
业务服务
↓ 仅提交受控任务
抓取服务(低权限、无业务凭据、资源受限)
↓ 仅允许通过受控公网出口
互联网
抓取服务应具备:
- 独立容器或主机;
- 无业务数据库凭据和云管理权限;
- 严格出站 ACL;
- CPU、内存、文件和下载大小限制;
- 完整请求审计日志。
7.4 建立统一安全请求组件
不要让每个业务各写一套 URL 校验。可以封装:
php
$client->requestPublicUrl($url);
$client->requestTrustedPartner('payment-api', '/v1/query');
两种接口采用不同安全策略:
requestPublicUrl():严格限制协议、地址、端口、跳转和响应大小;requestTrustedPartner():目标从配置选择,禁止用户控制主机,并按合作方隔离认证信息。
建议记录规范化主机、最终连接 IP、端口、跳转次数、调用业务和拦截原因,但不要记录完整 Cookie、Token 或敏感查询参数。
八、授权环境中的验证思路
建议使用自己控制的本地服务或隔离靶场:
text
PHP 业务服务:127.0.0.1:8080
模拟内部服务:127.0.0.1:8081
模拟内部服务只返回固定内容:
php
<?php
header('Content-Type: text/plain');
echo 'internal-service-only';
在自建环境中验证:
- 外部输入能否控制请求目标;
- IPv4/IPv6 回环是否被阻止;
- 域名解析到私有地址时是否被阻止;
- 公网入口跳转到内部服务时是否被阻止;
- DNS 多记录是否只检查第一个地址;
- 超时、响应大小和并发限制是否生效;
- 日志是否记录规范化主机和最终连接 IP。
建议建立测试矩阵:
| 维度 | 覆盖类型 |
|---|---|
| 协议 | HTTP、HTTPS、非预期协议 |
| 地址族 | IPv4、IPv6、IPv4-mapped IPv6 |
| 地址范围 | 公网、私有、回环、链路本地、保留地址 |
| DNS | 单 A、多 A、AAAA、混合记录、解析变化 |
| URL 结构 | 用户信息、显式端口、尾点、编码字符 |
| 跳转 | 同域、跨域、公网到内网 |
| 资源限制 | 慢响应、大响应、持续流、连接不关闭 |
把矩阵固化为自动化测试,比依赖人工复测更可靠。
九、修复前后对比
修复前
php
$url = $_POST['url'];
echo file_get_contents($url);
不充分的修复
php
if (str_contains($url, 'localhost') || str_contains($url, '127.0.0.1')) {
exit('blocked');
}
问题包括:
- 黑名单无法覆盖地址变体;
- 未限制协议和端口;
- 未检查全部 DNS 结果;
- 未处理重定向;
- 未限制超时和响应大小;
- 无网络层隔离。
推荐纵深防御
text
业务层:不接受任意 URL,优先使用资源 ID 或合作方标识
↓
解析层:规范化 URL,限制 scheme、host、port、userinfo
↓
DNS 层:校验全部 A/AAAA,固定已验证连接 IP
↓
HTTP 层:禁用自动跳转,限制方法、协议、超时、响应大小
↓
身份层:不向不可信目标携带内部凭据
↓
网络层:出站 ACL / 受限代理 / 隔离抓取服务
↓
监控层:结构化日志、告警、限流和异常目标检测
SSRF 防护的核心不是找到"完美正则",而是建立多层安全边界。
十、审计报告实际怎么写
写报告不要堆砌理论,直接写清楚漏洞在哪、数据怎么走、什么条件能触发、实际能造成什么后果,最后给出可落地修复。很多新手报告只写"存在SSRF漏洞",这种交给开发基本看不懂。
1. 漏洞位置
直接贴文件行号,定位到代码片段。
Controller/PreviewController.php:42
Service/RemoteFetcher.php:87
2. 梳理完整数据流
把污点传播链路写明白,别人一眼看懂数据怎么走过来。
$request->input('url')
→ PreviewController::preview()
→ RemoteFetcher::fetch()
→ curl_setopt(CURLOPT_URL)
→ curl_exec()
3. 触发条件
把复现的前置条件列全,不要省略环境因素:
- 什么权限的用户可以调用该接口;
- URL参数是完全可控,还是仅host部分可控;
- 是否走异步任务、消息队列触发(二阶漏洞);
- 代码是否开启自动重定向;
- 服务器网络环境,是否能访问内网网段。
4. 安全影响
不要只写"存在SSRF漏洞",写实际能做到的行为:
- 服务端可以向外发起任意HTTP请求;
- 可访问本机回环地址、内网业务、集群内部接口;
- 接口会直接返回请求响应内容(有回显);
- 请求会自动带上服务端内部Token、Cookie;
- 没有设置连接超时、响应体大小限制。
5. 修复建议
给出开发可以直接照着改的方案,不要写空话。
- 优先设计资源映射,不要直接接收用户传入的完整URL;
- 协议、域名、端口做白名单放行;
- DNS解析拿到全部IP,全部做校验,请求时绑定校验后的IP;
- 关闭自动重定向;业务必须跳转则每一跳重新校验;
- 去掉不可信URL请求里的内部认证头、Cookie;
- 设置连接超时、最大响应大小;
- 网络层面通过防火墙、安全组做应用出站限制。
十一、PHP SSRF审计Checklist
做白盒审计的时候,拿着这个逐项打勾,避免漏点,CTF代码审计题也可以拿来对照。
输入与数据流
- 是否存在用户可控的URL、host、port、path
- 数据来源是否为数据库、消息队列、配置、第三方接口返回(二阶风险)
- 是否存在异步、跨服务触发,输入点和触发点不在一处
- 参数在校验前后是否存在解码、拼接,前后处理逻辑不一致
请求组件
- 是否使用
file_get_contents、cURL、Guzzle这类网络组件 - 是否有图片处理、PDF生成、截图这类间接发起网络请求的函数
- 是否经过代理;DNS解析发生在PHP应用层还是代理侧
URL校验逻辑
- 是否做scheme白名单,仅放行http/https
- 是否拒绝URL中的user:password@账号密码段
- 是否对访问端口做限制
- 处理域名是否考虑尾点
.、IDN域名大小写问题 - [Pv4、IPv6所有DNS返回记录是否全部校验
- 是否防范DNS重绑定,防止校验和连接两次解析结果不一样
- 开启重定向的场景,是否每一跳都重新完整校验
请求资源限制
- 设置连接超时、总超时、响应体最大大小
- 是否限制HTTP请求方法
- 请求会不会自动带上Cookie、Token、客户端证书等内部凭据
网络与架构
- 服务器是否可达回环、内网、管理网段
- 是否有出站ACL、受限代理做网络层兜底
- 高危的URL抓取功能是否做网络隔离
- 日志是否记录最终实际连接的IP、端口、跳转链路
十二、实际审计中经常踩的误区
很多防护看着没问题,实际全是坑,不管做题还是挖SRC都要避开这些惯性思维。
误区1:FILTER_VALIDATE_URL校验通过就安全
这个函数只校验URL语法格式。不会判断目标IP是不是内网、回环地址,格式合法不代表目标地址可信。
误区2:过滤掉localhost、127.0.0.1就万事大吉
内网地址不止这两个。IPv6地址、各类私有网段、链路本地地址,还有域名解析出来的内网IP,全部可以绕过简单字符串过滤。
误区3:只允许HTTPS就不会出现SSRF
HTTPS只负责传输加密,不代表访问的目标是可信的。内网大量服务也是HTTPS,依旧可以被SSRF打到。
误区4:没有响应回显就没有危害
盲SSRF拿不到返回包,但依旧可以扫描内网端口、调用内网接口执行业务动作,配合DNSLog做外带,风险不能忽略。
误区5:框架自带HTTP客户端自带SSRF防护
Guzzle、Laravel Http只是帮你封装HTTP请求逻辑。框架不会自动判断哪些内网地址不能访问,防护逻辑完全要业务自己写。
误区6:应用层代码校验可以完全替代防火墙
URL解析、DNS、代理、重定向到处都有坑,代码逻辑总有考虑不到的边界。高风险业务,网络层出站隔离是必须的兜底手段。
总结
审计SSRF,不能看到curl_exec就直接下漏洞结论。完整还原整个请求链路才是核心。
输入数据来源在哪?
↓
URL经过哪些拼接、解码、校验?
↓
DNS解析出来哪些IP?有没有多条记录?
↓
客户端最终真实连接到哪个地址?
↓
中间有没有发生重定向,跳转有没有重新校验?
↓
请求带上了什么身份凭证?
↓
响应数据以什么形式反馈给攻击者?
几条核心经验:
- 做完整数据流追踪,不能只看单行危险函数。
- 不要用字符串黑名单判断目标网络地址。
- 校验以最终连接IP为准,覆盖DNS、IPv6、重定向全部场景。
- 优先业务白名单设计,尽量不要接收用户任意传入的URL。
- 代码校验不能单打独斗,要结合网络隔离、请求资源限制一起做防护。
真正靠谱的SSRF防护,是业务设计 + URL规范化 + DNS约束 + HTTP客户端配置 + 网络隔离 + 日志监控一套组合。靠一段正则想完全挡住SSRF,基本不可能。
免责声明
本文档全部内容仅用于网络安全学习、CTF竞赛靶场练习、授权环境下的代码审计研究。
文中涉及的漏洞原理、函数样例、Payload、绕过思路,仅用于理解漏洞产生原因与防御方案。严禁直接或间接用于未经授权的业务系统、公网站点、非授权设备进行测试、扫描、探测、攻击行为。
未经授权开展网络测试,会违反《中华人民共和国网络安全法》《中华人民共和国刑法》等相关法律法规,行为人需要自行承担全部法律责任。
阅读者使用本文档产生的一切后果,由使用者本人承担,本文作者不承担任何连带责任。如果需要对业务系统做安全测试,请务必提前取得目标系统书面授权。