Burst Lab|PHP 审计 10:PHP 网络请求与 SSRF 漏洞审计思路

Burst Lab|PHP 审计 10:PHP 网络请求与 SSRF 漏洞审计思路


前言

PHP 业务中有大量场景需要服务端代替客户端向外发起网络请求:远程资源下载、网页内容抓取预览、Webhook 回调、第三方 API 调用、页面截图生成、接口健康检查、OIDC 回调校验等。

当攻击者能够直接或间接控制请求的目标地址,同时业务缺少协议、主机、端口、DNS 等完整校验逻辑,就会产生 SSRF 服务端请求伪造(Server‑Side Request Forgery)

SSRF 请求:由 Web 服务器代为发出。服务器处在内网边界内侧,拥有外网用户不具备的网络访问权限。攻击者可以将服务器作为跳板,探测内网网段、访问本机回环服务、读取服务器本地文件、读取云实例元数据,甚至对内网业务系统发起攻击,极端条件下可造成内网接管。

⚠️ 免责声明:本文全部技术内容仅限授权环境下代码审计、安全学习使用,禁止对未授权目标实施任何测试行为,违反《网络安全法》《刑法》需要承担对应的法律责任。

代码审计阶段,判断一处功能是否具备可利用的 SSRF 风险,需要完整评估六个维度:

  1. 可控性:用户能否直接/间接干预请求目标;并非必须完整控制整个 URL,仅可控主机、端口同样存在风险。
  2. 输入链路:输入经历的解码、拼接、URL 解析、DNS 解析、重定向跳转完整流程。
  3. 请求载体:实际发起网络请求的底层函数、扩展、第三方组件。
  4. 网络边界:PHP 运行环境可访问网络域:公网、内网、回环地址、容器网络、云元数据地址。
  5. 请求上下文:请求是否附带服务端 Cookie、内部 Token、客户端证书等敏感凭据。
  6. 回显情况:响应正文、状态码、报错、耗时是否对外泄露;区分回显 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触发外部实体请求

攻击场景提示

  1. getimagesize():传入内网http地址做内网端口探测,不支持gopher协议;
  2. puppeteer/wkhtmltopdf:浏览器子进程,可访问内网、读取本地文件;
  3. 调用系统curl/wget:直接复用操作系统curl能力,gopher、file协议全部可用;
  4. 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://2130706433http://0x7f.0.0.1http://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);

两类风险

  1. 重定向绕过:首次请求是公网合法地址,302跳转到内网地址,跳转后不再校验IP,防护直接失效。

    Payload思路:传入攻击者可控公网地址,该地址返回Location: http://127.0.0.1

  2. 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 协议限制

业务仅用于网页抓取场景,应当只放行 httphttps

正确示范,解析scheme后严格白名单比对:

php 复制代码
$parts = parse_url($url);
$scheme = strtolower($parts['scheme'] ?? '');
if (!in_array($scheme, ['http', 'https'], true)) {
    throw new InvalidArgumentException('Unsupported scheme');
}

错误做法:直接使用字符串查找是否包含httpgopher://http://xxx即可完成绕过。

补充审计要点:上层PHP完成协议校验之后,cURL客户端层面还需要通过CURLOPT_PROTOCOLSCURLOPT_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服务时,端口可以限定为 80443,要求更严格的场景仅放行443

开放全部端口会使SSRF变为内网端口探测入口,也为非HTTP协议交互创造条件。


5.5 重定向

默认关闭自动跳转:

php 复制代码
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);

业务必须支持跳转时,需要由应用层手动逐跳处理,流程如下:

  1. 读取响应头 Location
  2. 转换相对地址为完整URL;
  3. 对新URL重新校验协议、主机、端口、DNS解析结果;
  4. 请求时绑定已经过校验的IP;
  5. 限制最大跳转次数,防止无限重定向;
  6. 拒绝跨越信任边界的跳转。

只校验初始URL,开启自动跟随跳转,是SSRF防护最常见的失效点。


5.6 DNS Rebinding 和多次解析

仅做如下的单次解析校验是不充分的:

php 复制代码
$ip = gethostbyname($host);
checkIp($ip);
curl_exec(curl_init($url));

相对可靠的防护思路:

  1. 获取域名全部 A/AAAA 解析记录;
  2. 逐条校验全部解析结果;
  3. 选用校验通过的地址;
  4. 请求时将域名固定绑定至该IP;
  5. 保留原始域名用于HTTP Host头部与TLS SNI;
  6. 重定向发生时,必须重新执行全套校验。

即便完成以上应用层防护,依旧不能替代网络层面的出站访问控制。

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;
    }
}

这段代码仍有哪些边界?

  1. FILTER_FLAG_NO_PRIV_RANGEFILTER_FLAG_NO_RES_RANGE 是基础检查,高安全系统可维护更明确的特殊用途地址策略,并用测试覆盖 IPv4/IPv6。
  2. 国际化域名应先统一 IDN 规范化,再执行白名单和 DNS 校验。
  3. CURLOPT_RESOLVE 行为依赖 libcurl,部署前应在目标 PHP/cURL 版本测试 IPv4、IPv6、代理和 TLS。
  4. 使用显式代理时,DNS 可能在代理端完成,应用校验必须与代理策略配套。
  5. 示例拒绝所有重定向;需要跳转时必须逐跳验证。
  6. 还应限制并发、速率、响应头和总资源消耗,避免转化为拒绝服务。
  7. 最重要的是,网络层必须阻止应用访问不必要的内部地址。

七、比"校验任意 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';

在自建环境中验证:

  1. 外部输入能否控制请求目标;
  2. IPv4/IPv6 回环是否被阻止;
  3. 域名解析到私有地址时是否被阻止;
  4. 公网入口跳转到内部服务时是否被阻止;
  5. DNS 多记录是否只检查第一个地址;
  6. 超时、响应大小和并发限制是否生效;
  7. 日志是否记录规范化主机和最终连接 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:过滤掉localhost127.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?有没有多条记录?
    ↓
客户端最终真实连接到哪个地址?
    ↓
中间有没有发生重定向,跳转有没有重新校验?
    ↓
请求带上了什么身份凭证?
    ↓
响应数据以什么形式反馈给攻击者?

几条核心经验:

  1. 做完整数据流追踪,不能只看单行危险函数。
  2. 不要用字符串黑名单判断目标网络地址。
  3. 校验以最终连接IP为准,覆盖DNS、IPv6、重定向全部场景。
  4. 优先业务白名单设计,尽量不要接收用户任意传入的URL。
  5. 代码校验不能单打独斗,要结合网络隔离、请求资源限制一起做防护。

真正靠谱的SSRF防护,是业务设计 + URL规范化 + DNS约束 + HTTP客户端配置 + 网络隔离 + 日志监控一套组合。靠一段正则想完全挡住SSRF,基本不可能。


免责声明

本文档全部内容仅用于网络安全学习、CTF竞赛靶场练习、授权环境下的代码审计研究。

文中涉及的漏洞原理、函数样例、Payload、绕过思路,仅用于理解漏洞产生原因与防御方案。严禁直接或间接用于未经授权的业务系统、公网站点、非授权设备进行测试、扫描、探测、攻击行为

未经授权开展网络测试,会违反《中华人民共和国网络安全法》《中华人民共和国刑法》等相关法律法规,行为人需要自行承担全部法律责任。

阅读者使用本文档产生的一切后果,由使用者本人承担,本文作者不承担任何连带责任。如果需要对业务系统做安全测试,请务必提前取得目标系统书面授权。


上一篇:Burst Lab | PHP 审计 09|文件上传代码审计:校验缺陷与多种绕过手段

Burst Lab | PHP 审计 09|文件上传代码审计:校验缺陷与多种绕过手段

相关推荐
BingoGo3 小时前
PHP 8.6 新特性一览
后端·php
木由里予3 小时前
TEE(Trusted Execution Environment)技术详解
java·linux·开发语言·网络·嵌入式硬件·android-studio
lemon_sjdk3 小时前
Spring 容器与 Bean 深度解析:从概念到实践
java·网络·spring·webflux
Yan_chen6664 小时前
HTTP和HTTPS 完整指南
网络协议·web安全·http·网络安全·https
艺杯羹4 小时前
从攻击者视角拆解12306:SYN泛洪、CC攻击与候补机制的攻防博弈
网络·web安全·网络安全·网络攻击模型·攻防
2603_954708314 小时前
微能网协调控制箱的核心价值:让多种能源“协同作战”
大数据·运维·网络·人工智能·架构·能源
星核0penstarry4 小时前
OpenAI 模型第三方网络安全评估事件解析:背景、原理与改进路径(2)
安全·web安全·云原生
JaguarJack4 小时前
对标 npx 的 CPX PHP 的 Composer 包执行器
后端·php·服务端
BingoGo4 小时前
对标 npx 的 CPX PHP 的 Composer 包执行器
后端·php