PHP 的 filter_var 函数配合 FILTER_VALIDATE_URL 过滤器,常被开发者视为 URL 校验的标准方案。但它的底层实现基于 RFC 2396 和 RFC 2732 的宽松解析逻辑,对现代 Web 攻击面覆盖严重不足。直接后果是,攻击者可以构造完全通过 filter_var 校验的恶意 URL,实现 SSRF、开放重定向和钓鱼攻击。下面直接拆解这些绕过手法,并给出真正有效的补充验证策略。

核心缺陷:协议检查的盲区

FILTER_VALIDATE_URL 默认不限制协议,它接受任何符合 URL 语法的 scheme。这意味着 javascript:、data:、vbscript: 等危险协议都能顺利通过校验。测试以下代码:

$url = "javascript:alert(1)";
if (filter_var($url, FILTER_VALIDATE_URL)) {
    echo "通过校验"; // 会输出
}

攻击者可以在重定向功能中注入 javascript: 伪协议,当用户被重定向到该 URL 时,浏览器会执行脚本。data: 协议同样危险,它能承载 base64 编码的恶意内容。解决这个问题的第一步,就是显式限定允许的协议白名单。不要依赖黑名单,因为新协议和变种层出不穷。

主机名部分的解析漏洞

filter_var 对主机名的解析存在多个可利用点。首先是 IP 地址的八进制和十进制混淆。它认为 http://0177.0.0.1/ 是合法 URL,而某些底层网络库会将八进制 0177 解析为十进制的 127,这就绕过了针对 127.0.0.1 的内网 IP 黑名单。更隐蔽的是十进制整数表示法,http://2130706433/ 等价于 http://127.0.0.1/,filter_var 对这种格式照单全收。

其次是用户名和密码字段的滥用。URL 规范允许在主机名前携带 userinfo,格式为 http://user:pass@host/。filter_var 完全认可这种格式。攻击者可以构造 http://legit.com@evil.com/,让粗心的开发者误以为主机是 legit.com,但实际请求会发往 evil.com。这种手法在开放重定向和 SSRF 中极其有效,因为部分 URL 解析器在提取主机时会优先取最后一个 @ 符号之后的部分,而 filter_var 的校验逻辑并未强制要求理解这种歧义。

Unicode 与 IDN 同形异义攻击

FILTER_VALIDATE_URL 对国际化域名(IDN)的处理非常原始。它不阻止包含 Unicode 字符的域名,也不做任何同形异义字符的比对。攻击者注册一个使用西里尔字母 а(U+0430)的域名,看起来和拉丁字母 a(U+0061)完全一样。当 URL 显示在界面上时,用户无法区分真假。filter_var 认为这种 URL 格式正确,直接放行。更隐蔽的是,某些 Unicode 字符在标准化过程中会被转换为其他字符,导致解析后的主机名与显示的主机名不同。

路径和查询参数的空白符绕过

URL 标准允许在路径、查询字符串和片段标识符中出现某些特殊字符,filter_var 的校验过于宽松。换行符 %0a、回车符 %0d 经常被用于 HTTP 响应头注入。如果后端代码将用户输入的 URL 直接拼接到 Location 头中,这些空白符可能导致 HTTP 头分裂。filter_var 对 %0a、%0d 不做过滤,认为它们是合法的 percent-encoding 字符。

另一个问题是反斜杠和斜杠的混用。在 Windows 系统中,部分网络库会将反斜杠 \ 视为路径分隔符。filter_var 允许 URL 中出现反斜杠,这可能导致路径遍历或 SSRF 中的请求走私。例如 http://example.com\@evil.com/ 在某些解析器下会被错误处理。

FILTER_FLAG 标志位的局限性

开发者常添加 FILTER_FLAG_HOST_REQUIRED 来强制要求主机名,但这只能防止协议后直接跟路径的情况。FILTER_FLAG_PATH_REQUIRED 和 FILTER_FLAG_QUERY_REQUIRED 同样只做表面检查。这些标志位不解决协议白名单问题,不处理 IP 混淆,也不防御 Unicode 攻击。更糟糕的是,FILTER_FLAG_QUERY_REQUIRED 反而可能被利用,因为攻击者只需在恶意 URL 后添加任意查询参数即可通过校验。

补充验证的第一层:协议白名单与域名结构校验

在 filter_var 通过后,必须立即检查协议。使用 parse_url 提取 scheme,与白名单比对。白名单通常只包含 http 和 https,特殊业务才考虑 ftp 或 mailto。注意 parse_url 本身也有解析缺陷,但它与 filter_var 结合使用可以互补。提取 scheme 后转为小写再比较,避免大小写绕过。

接着对主机名进行严格的结构验证。如果是 IP 地址,必须用 filter_var 配合 FILTER_VALIDATE_IP 重新校验,且只接受点分十进制格式,拒绝八进制、十进制整数和十六进制表示。同时检查 IP 是否属于内网段、回环地址和链路本地地址。如果是域名,则要求它符合 LDH(字母、数字、连字符)规则,且不包含任何非 ASCII 字符。对于确实需要支持 IDN 的业务,必须先将域名转换为 Punycode 编码,再检查编码后的字符串是否包含混淆字符。

补充验证的第二层:解析一致性检查

这是防止 userinfo 欺骗和 @ 符号混淆的关键步骤。使用 parse_url 提取 host 字段后,再对原始 URL 字符串进行正则匹配,确保 @ 符号只出现在 userinfo 应有的位置。更可靠的做法是,将 URL 交给 curl 或底层 socket 库做一次模拟解析,获取实际连接的目标主机,然后与 parse_url 提取的主机进行比对。如果两者不一致,说明存在解析歧义,必须拒绝该 URL。

对于 SSRF 防御,还需要在 DNS 解析层面做检查。先对域名进行 DNS 查询获取真实 IP,再检查该 IP 是否属于内网。注意防止 DNS 重绑定攻击,即攻击者域名第一次解析返回公网 IP,第二次解析返回内网 IP。解决方案是在业务请求发起前,再次解析域名并与之前的结果比对,或者使用固定 IP 缓存策略。

补充验证的第三层:字符级过滤与规范化

对 URL 中所有 percent-encoding 字符进行解码后重新检查。某些攻击者会使用双重编码绕过,例如 %252f 解码一次变成 %2f,再解码变成 /。必须循环解码直到字符串不再变化,然后检查解码后的内容是否包含换行符、空字节等危险字符。对于路径部分,要拒绝包含 %00 到 %1f 以及 %7f 到 %9f 的控制字符。对于查询字符串,如果业务不需要,可以直接丢弃整个 query 部分,只保留 scheme、host 和 path。

规范化 URL 是另一个容易被忽视的环节。移除 URL 中的默认端口(80 和 443)、标准化路径中的 /./ 和 /../、将主机名转为小写、按字母排序查询参数。规范化后再做一次完整校验,能拦截大量基于解析差异的攻击。

实际代码示例:多层校验函数

以下是一个生产环境可用的 URL 校验函数框架,它展示了如何组合上述检查。这个函数不是简单的堆砌,而是按照从轻到重的顺序排列检查项,尽早拒绝明显恶意的输入。

function validateUrl(string $url): bool {
    // 第一关:filter_var 基础格式检查
    if (!filter_var($url, FILTER_VALIDATE_URL)) {
        return false;
    }
    
    // 第二关:协议白名单
    $scheme = strtolower(parse_url($url, PHP_URL_SCHEME));
    if (!in_array($scheme, ['http', 'https'], true)) {
        return false;
    }
    
    // 第三关:提取主机并检查 userinfo 欺骗
    $host = parse_url($url, PHP_URL_HOST);
    if (!$host || strpos($host, '@') !== false) {
        return false;
    }
    
    // 第四关:禁止 Unicode 域名(除非业务需要并做了 IDN 映射)
    if (preg_match('/[^\x20-\x7e]/', $host)) {
        return false;
    }
    
    // 第五关:IP 地址专项检查
    if (filter_var($host, FILTER_VALIDATE_IP)) {
        // 拒绝非点分十进制表示
        if (!preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $host)) {
            return false;
        }
        $ip = ip2long($host);
        // 拒绝内网和回环地址
        $privateRanges = [
            ['10.0.0.0', '10.255.255.255'],
            ['172.16.0.0', '172.31.255.255'],
            ['192.168.0.0', '192.168.255.255'],
            ['127.0.0.0', '127.255.255.255'],
            ['0.0.0.0', '0.255.255.255'],
        ];
        foreach ($privateRanges as $range) {
            if ($ip >= ip2long($range[0]) && $ip <= ip2long($range[1])) {
                return false;
            }
        }
    }
    
    // 第六关:控制字符和危险编码检查
    $decoded = rawurldecode($url);
    if (preg_match('/[\x00-\x1f\x7f]/', $decoded)) {
        return false;
    }
    
    return true;
}

这个函数仍然需要根据实际业务场景调整。例如允许特定内网 IP 的白名单、支持 IPv6 地址、处理 IDN 域名等。但它已经消除了 filter_var 最常见的绕过路径。

DNS 解析与请求上下文的安全闭环

即使 URL 通过了所有静态校验,在发起实际请求前还需要做一次动态检查。使用 curl 的 CURLOPT_PROTOCOLS 选项限制协议,用 CURLOPT_FOLLOWLOCATION 时设置 CURLOPT_MAXREDIRS 防止无限重定向,用 CURLOPT_PROXY 避免直接访问内部服务。更重要的是,设置 CURLOPT_RESOLVE 或自定义 DNS 解析函数,在请求前再次确认目标 IP 不指向内网。这个环节能防御 DNS 重绑定和时间差攻击。

对于开放重定向功能,除了校验目标 URL,还要在重定向响应中使用 302 而非 301,因为 301 会被浏览器缓存,一旦被恶意利用,用户后续访问正常 URL 也会被重定向到恶意站点。同时设置 Referrer-Policy 头为 no-referrer 或 strict-origin,减少信息泄露。

日志记录与监控告警

所有未通过校验的 URL 都应记录到日志,包含原始输入、失败原因和时间戳。定期分析这些日志可以发现新的#攻击模式和新的绕过尝试。对于通过校验但包含可疑特征的 URL(如 @ 符号、十进制 IP、过长域名),也应记录为警告级别日志。这些数据是持续优化校验规则的基础。

filter_var 的 URL 校验是一个起点,不是终点。理解它的解析逻辑与底层网络库之间的差异,才能构建真正可靠的防御。安全校验的本质不是信任单个函数,而是用多个独立的方法交叉验证同一个输入,让攻击者必须同时绕过所有检查点,这在实际攻击中难度呈指数级上升。