上个月帮一个朋友审计他手里那个历史遗留的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['
