SSRF漏洞防线:服务端请求伪造的六层治理

SSRF(Server-Side Request Forgery,服务端请求伪造)是个很别扭的漏洞:它跟 XSS、CSRF 不一样,攻击者不需要诱导你的用户点任何东西,也不需要知道你的后台密码。他只需要找到一个「你服务器会主动去请求一个地址」的功能,把那个地址换成他想让服务器内网访问的目标就行。

这类功能在自建站里比想象中多得多:网页采集、RSS 抓取、远程图片下载、网站搬家导入、支付回调验证、Webhook 测试、友链检测、在线缩略图生成……每一个都是「后端拿着用户给的 URL 去发请求」,也就是每一个都天然是一个 SSRF 入口。

本文按「先看清危害 → 再逐层设防 → 最后建成常态化检查」的顺序写,代码以 PHP 5.6 的常见写法为背景(本站环境),但思路对任何语言都成立。

一、SSRF 的危害不是「访问了一个网址」

很多人第一次接触 SSRF 时的反应是:不就是让服务器去访问一个网址吗,能有什么危害?问题在于,你的服务器所在的网络位置和你手里的浏览器完全不同。

你的服务器通常可以访问:127.0.0.1 上的本机服务(只监听本地、没有对外暴露的 MySQL、Redis、Memcached、Elasticsearch、管理后台)、同机房的私网地址段(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16)、云厂商的内部元数据接口(169.254.169.254),以及只信任内网来源、不校验身份的各类运维接口。

把这些串起来,SSRF 的典型利用链条是这样的:先用 http://127.0.0.1:6379 探到 Redis 开着,再用 Redis 的协议特性往里写文件(写 crontab、写 WebShell、写 authorized_keys),一步到位拿到服务器权限;或者读云元数据接口拿到临时凭证,进而接管整台实例;或者拿 file:// 协议直接读 /etc/passwd、config.inc.php。

所以我给 SSRF 定的优先级是「高危」,和文件上传漏洞同一个级别。它不需要用户配合,一旦可写可读,往往直接就是服务器失守。

二、最危险的不是 URL 参数,是「协议」

大部分人的防 SSRF 思路是「判断这个 URL 是不是内网地址」,但漏掉了一半:很多 HTTP 客户端支持不止 HTTP 协议。以 PHP 的 cURL 为例,默认就支持 file://、gopher://、dict://、ftp://、ldap://。

其中 gopher:// 是最凶的,因为它能构造任意 TCP 数据包,等于给攻击者一个可以跟任意端口对话的客户端。对 Redis、MySQL、Memcached 这类明文协议,gopher 加一个精心构造的 payload 就能直接执行命令。

所以第一道防线不是 IP 判断,而是协议白名单:只允许 http 和 https,其余全部拒绝,并且在 cURL 里显式限制:

$ch = curl_init($url);
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);

CURLOPT_PROTOCOLS 管首次请求,CURLOPT_REDIR_PROTOCOLS 管跳转后的请求,两个都要设。只设前者的话,攻击者给一个 http:// 开头的地址,服务器响应 302 跳到 file:///etc/passwd,一样中招。

三、IP 判断必须做在「解析之后」

第二道防线是地址判断,但这里有个几乎人人都踩的坑:你校验的是域名,服务器请求的却是域名解析出来的 IP。这两件事之间有一个时间窗,攻击者可以控制一个自己的域名,第一次解析返回公网 IP 通过校验,第二次解析返回 127.0.0.1。这叫 DNS Rebinding。

正确的做法是:先把主机名解析成 IP,校验 IP,然后用这个 IP 去连接,而不是让 cURL 再解析一次。PHP 5.6 里可以用 CURLOPT_RESOLVE 把域名强行绑到校验过的 IP 上:

function resolve_and_check($host) {
    $ips = gethostbynamel($host);
    if (!$ips) {
        throw new Exception('域名无法解析');
    }
    foreach ($ips as $ip) {
        if (!is_public_ip($ip)) {
            throw new Exception('目标地址不允许');
        }
    }
    return $ips[0];
}

$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RESOLVE, array($host . ':443:' . $resolved_ip));

更进一步,如果你的 PHP 版本允许,直接把解析交给 curl(CURLOPT_DNS_USE_GLOBAL_CACHE 关掉、配合 CURLOPT_CONNECT_TO)会更稳妥。

判断「是不是公网地址」这个函数,不能只写 filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE)。这个过滤器确实能挡住 10.x、172.16-31.x、192.168.x、127.x,但挡不住 169.254.169.254(链路本地/云元数据)、挡不住 0.0.0.0、挡不住 IPv6 的 ::1 和 fe80::,也挡不住各种变形写法:http://2130706433/(十进制)、http://0x7f000001/(十六进制)、http://127.1/(省略段)。

稳妥的写法是先规范化再判断:

function is_public_ip($ip) {
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
        return false;
    }
    // 额外黑名单:云元数据、组播、保留段
    $blocked = array('169.254.', '0.', '100.64.', '192.0.0.', '198.18.', '224.', '240.');
    foreach ($blocked as $p) {
        if (strpos($ip, $p) === 0) return false;
    }
    return true;
}

这里要注意 FILTER_FLAG_NO_RES_RANGE 是必须加的,它负责挡掉 0.0.0.0/8、169.254.0.0/16、127.0.0.0/8 这些保留地址。100.64.0.0/10(运营商级 NAT)和 198.18.0.0/15 这类虽然少见,但在某些云环境里确实能路由到内部,一并挡掉更省心。

四、重定向是最容易被忽略的绕过口

就算你把协议、IP 都校验了,只要允许跟随跳转,攻击者依然有办法:他准备一个公网域名指向自己的服务器,服务器响应 302 Location: http://127.0.0.1:6379/,cURL 自己跟过去,绕过了你所有的前置校验。

两种处理方式,选一个并坚持:

第一种是不跟随跳转,把 CURLOPT_FOLLOWLOCATION 设为 false,一旦收到 3xx 就取 Location 头,自己再走一遍完整的校验流程(协议白名单 + 解析 + IP 判断),最多跳 3 次。这是最严谨的做法。

第二种是跟随跳转,但把 CURLOPT_REDIR_PROTOCOLS 限制到 http/https,并且用 cURL 的 CURLOPT_OPENSOCKETFUNCTION 在真正建立连接那一刻做 IP 校验。第二种实现复杂,PHP 5.6 下也不太好写,所以小站建议直接选第一种。

五、白名单是终极答案

前面四层都是「黑名单思维」:我说什么是坏的,其余放行。黑名单的问题在于永远有你想不到的第 N 种写法。真正的自建站,如果这个抓取功能只是为了采集固定的几个来源,最省事也最安全的做法是域名白名单:

$allow = array('api.example.com', 'cdn.example.net');
if (!in_array($host, $allow, true)) {
    throw new Exception('来源不在白名单内');
}

白名单的几个实操细节:比较时大小写统一、去掉结尾的点(example.com. 和 example.com 等价)、不允许 @(http://evil.com@127.0.0.1/ 这种 userinfo 写法)、不允许端口不是 80/443。

如果这个功能必须支持「任意用户给的地址」(比如在线缩略图、网页预览),那就退一步:只允许公网 HTTP/HTTPS、不跟随跳转、限制响应体大小(CURLOPT_MAXFILESIZE 或读的时候按块累计超过 2MB 直接中断)、限制响应类型(只接受 image/* 或 text/html)、加超时,并把这类请求发到一台没有内网权限的独立小机器上去做。

六、网络层兜底:应用层挡不住的,由防火墙挡

代码写得再严,也会有某个新加的功能忘了走统一的抓取函数。所以网络层还要有一道:跑抓取任务的机器,出站流量必须限制。

用 iptables 拦掉到内网和元数据地址的出站连接,只放行 DNS 到指定服务器和公网 80/443:

iptables -A OUTPUT -d 169.254.169.254 -j REJECT
iptables -A OUTPUT -d 10.0.0.0/8 -j REJECT
iptables -A OUTPUT -d 172.16.0.0/12 -j REJECT
iptables -A OUTPUT -d 192.168.0.0/16 -j REJECT
iptables -A OUTPUT -d 127.0.0.0/8 -m owner ! --uid-owner www-data -j REJECT

这里最后一条要小心:拦 127.0.0.1 之前先确认没有本地服务依赖(比如 PHP 连本机 MySQL)。更细的做法是按用户区分:Web 进程所属的 uid 禁止访问内网,运维账号不受限。这样即使应用层漏了,攻击者从 Web 进程也打不到 Redis。

云主机上还有一个专项开关:元数据服务是否强制要求 token(AWS 的 IMDSv2、阿里云的加固模式)。开启之后,纯 SSRF 拿不到凭证,攻击价值直接降一档。这个开关在控制台一次就能开,成本极低,务必打开。

七、把这些检查固化成流程

做完上面几层,还要防止后续新代码重新引入漏洞。三条很朴素但有效的常态化动作:

第一,统一入口。 所有「服务器主动发请求」的地方,一律调用同一个 safe_fetch($url) 函数,禁止业务代码里直接 curl_init。可以在 CI 或者发布脚本里加一条 grep:除了白名单文件,其他文件出现 curl_init、file_get_contents('http、fsockopen 就报警。

第二,日志留痕。 每次抓取记录「请求方 IP、原始 URL、最终解析 IP、最终 URL、状态码、耗时、响应字节数」。事后排查入侵时,这几列就是唯一的线索。日志里出现 127.、10.、169.254. 的请求,无论成功失败都值得单独告警。

第三,定期自测。 拿三个经典地址自己打一遍:http://127.0.0.1:6379/、http://169.254.169.254/latest/meta-data/、file:///etc/passwd。三个都被挡住才算及格。这类自测最好写进一个脚本,每次发布后跑一次。

八、几个真实的踩坑记录

第一个坑:只校验了首次输入的 URL,忘了跳转。某次采集功能上线后,攻击者用一个 302 指向内网地址,直接把 Redis 的 config set dir 打进去了。修复只需要一行 CURLOPT_REDIR_PROTOCOLS,但那行当初没人写。

第二个坑:filter_var 的 NO_PRIV_RANGE 单独用,漏了 169.254。这个地址不属于 RFC1918 私网段,属于链路本地,所以 NO_PRIV_RANGE 不管你,必须补 NO_RES_RANGE。云主机上这个漏了就等于把凭证交出去。

第三个坑:PHP 的 gethostbynamel 只返回 IPv4 地址,纯 IPv6 的目标它返回 false,于是代码里写了「解析失败就放行」的兜底——这个兜底把一整层防护废了。解析失败必须拒绝,不能放行。

第四个坑:抓取功能被放进了「只要登录用户才能用」的接口,开发觉得「登录了就不是攻击面」。但这类接口往往还会被前台评论、友链申请、导入功能反向调用,等于一条链路绕到了未登录。校验要绑在「发请求」这个动作上,而不是「谁调用的」。

小结

SSRF 的防御顺序是有讲究的:协议白名单 → 解析后校验 IP → 禁止或自己接管跳转 → 域名白名单 → 网络层出站限制 → 元数据服务加固。前四层是应用层的「尽力而为」,后两层是万一漏了也不会失守的兜底。只做前面不做后面,总有一天下一次新增功能会把口子重新开出来。

判断一个自建站是否已经处理好 SSRF,有一个很简单的检验方式:把「所有会发外部请求的地方」列成一张表,看看每一行是不是都走同一个函数、那个函数里协议和 IP 两道判断是不是都在、跳转是不是被拦住了。如果表格列不全,或者某一行的实现和别人不一样,那就是下一个漏洞的位置。

Last modification:November 1st, 2026 at 12:25 pm

Leave a Comment