适用场景
CRLF 注入(HTTP 响应拆分,HTTP Response Splitting)是指应用程序把用户可控数据未经净化就写入 HTTP 响应头,攻击者提交 %0d%0a 换行序列即可截断当前头部、伪造新的响应内容与缓存副本。凡是满足「用户输入 → 响应头字段」这一数据流的接口都属于高风险面,典型包括:302 跳转接口把 ?url= 参数直接写入 Location;登录接口用 Cookie 记录来源页并写入 Set-Cookie;追踪接口把 Referer 回写到 X-Forwarded-For、X-Debug-Info 等自定义头;文件下载接口用文件名回写 Content-Disposition。
当站点前置 Nginx 或 CDN 且开启响应缓存时,一次成功的响应拆分还会把伪造内容写进缓存,影响后续所有用户,因此这类漏洞的优先级高于普通反射型 XSS。本文给出 Nginx 层拦截、ModSecurity 规则、后端语言净化函数与验证用例四层配置,全部可复制执行。
前置条件
- Web 服务器:Nginx 1.18+(使用
map、if与return指令),或 Apache 2.4 + ModSecurity 2.9。 - 后端:PHP 7.4+ / Java 8+ 任一,示例代码分别给出。
- 权限:可编辑站点 vhost 配置文件(如
/etc/nginx/conf.d/*.conf)并可执行nginx -t与systemctl reload nginx。 - 工具:
curl(用于构造含%0d%0a的验证请求)、grep(用于日志审计)。 - 测试环境需开启响应缓存复现(Nginx
proxy_cache或 CDN 缓存),生产环境请先在灰度节点验证规则。
原理说明
CRLF 注入的成因
HTTP/1.1 报文以 CRLF(\r\n,即 0x0D 0x0A)作为头部行分隔符,以连续两个 CRLF 作为头部与响应体的分界。如果应用把包含 \r\n 的字符串写入头部,服务器就会把注入内容解析为新的头部行;若注入 \r\n\r\n,则直接结束头部并开始构造响应体,攻击者由此控制完整报文体。
GET /redirect?url=a%0d%0aSet-Cookie:%20sessionid=attacker HTTP/1.1
Host: www.example.com
# 服务端若直接回写:
Location: a\r\nSet-Cookie: sessionid=attacker\r\n\r\n<html>伪造页面</html>
为什么过滤 %0d%0a 字符串不够
只做一次字符串替换会被多重编码绕过:%250d%250a(双重 URL 编码)、\u000d\u000a(Unicode 转义)、UTF-8 变体 %E5%98%8A%E5%98%8D(某些老旧 Tomcat/中间件会将其归一化为 CRLF)。因此防护必须在解码后的最终写入点做白名单校验,而不是在入口做黑名单替换。
完整攻击链示例
以跳转接口为例,一次成功的响应拆分包含四个环节,理解这条链就能理解每一层防护在拦什么:
- 注入:请求
/redirect?url=a%0d%0aSet-Cookie:%20sid=atk%0d%0a,应用未净化即写入Location。 - 拆分:服务端输出
Location: a\r\nSet-Cookie: sid=atk\r\n\r\n,中间的 CRLF 使后端把余下内容识别为新的头部,头部与体的边界被提前。 - 投毒:若响应被 Nginx
proxy_cache或 CDN 缓存(缓存键只含 URL,不含请求头),伪造的Set-Cookie会被缓存并在后续所有用户的响应中复用,形成会话固定。 - 利用:攻击者再用同一畸形 URL 诱导受害者访问,浏览器按缓存内容设置攻击者指定会话标识,即可在其登录后劫持会话。
因此防护重点有两个:一是切断「用户输入到响应头」的写入路径,二是禁止缓存含 Set-Cookie 与 3xx 跳转的响应,避免单次注入被放大为持久影响。
操作步骤
步骤 1:Nginx 层拦截含换行的请求 URI 与参数
Nginx 默认会拒绝 URI 中的原始换行,但对已解码的查询串回写场景无保护。先在入口用 map 做一次编码检测,命中即返回 400。
# /etc/nginx/conf.d/crlf-guard.conf
map $request_uri $crlf_bad {
default 0;
"~*%0d" 1; # 单次编码 CR
"~*%0a" 1; # 单次编码 LF
"~*%250d" 1; # 双重编码
"~*%250a" 1;
"~*%5cr" 1; # 反斜杠 r 变体
"~*%5cn" 1;
"~*%e5%98%8a" 1; # UTF-8 变体 CR
"~*%e5%98%8d" 1;
}
server {
listen 80;
server_name www.example.com;
if ($crlf_bad = 1) {
return 400;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 关键:剥离上游可能回写的换行,避免经反代二次放大
proxy_hide_header X-Debug-Info;
}
}
关键点:map 匹配的是 $request_uri(原始未解码串),因此 %250d 这类双重编码同样会被捕获;proxy_hide_header 用于屏蔽调试头回写通道。
步骤 2:ModSecurity 规则拦截响应头注入
若站点已启用 ModSecurity + OWASP CRS,可直接补充一条响应阶段规则,检测响应头中是否出现非法换行或注入的 Set-Cookie。
# /etc/nginx/modsec/crlf-injection.conf
SecRule REQUEST_URI "@rx (?i)(%0d|%0a|%5cr|%5cn|%250d|%250a)" \
"id:1009001,phase:1,deny,status:400,\
msg:'CRLF Injection Attempt in URI',\
tag:'OWASP_CRS/INJECTION/CRLF',\
logdata:'uri=%{REQUEST_URI}'"
SecRule ARGS_NAMES|ARGS "@rx (?i)(\r|\n|%0d|%0a)" \
"id:1009002,phase:2,deny,status:400,\
msg:'CRLF Injection Attempt in Parameters',\
tag:'OWASP_CRS/INJECTION/CRLF'"
加载方式:在主配置文件 modsecurity.conf 末尾追加 Include /etc/nginx/modsec/crlf-injection.conf,然后执行 nginx -t && systemctl reload nginx。
步骤 3:后端输出净化(这是唯一可靠的一层)
PHP 使用 header() 前必须剥离 \r、\n 与 NUL:
<?php
function safe_header_value(string $v): string {
// 1) 去除控制字符(含 CR/LF/NUL/TAB 以外的 C0 控制符)
$v = preg_replace('/[\x00-\x08\x0A-\x1F\x7F]/u', '', $v);
// 2) 强制单行,最长 512 字节,超长截断防头注入
$v = mb_substr($v, 0, 512, 'UTF-8');
return $v;
}
$target = $_GET['url'] ?? '/';
// 白名单校验:只允许站内相对路径
if (!preg_match('#^/[A-Za-z0-9\-._~/]*$#', $target)) {
http_response_code(400);
exit('invalid redirect target');
}
header('Location: ' . safe_header_value($target), true, 302);
Java(Servlet / Spring)推荐直接使用容器 API,并显式过滤:
public static String sanitizeHeader(String v) {
if (v == null) return "";
// 剔除 CR / LF / NUL,保留可打印 ASCII 与中文
String s = v.replaceAll("[\\r\\n\\u0000]", "");
return s.length() > 512 ? s.substring(0, 512) : s;
}
// Controller 中:
String url = sanitizeHeader(request.getParameter("url"));
if (!url.startsWith("/") || url.contains("://")) { // 禁止协议跳转,兼顾开放重定向
response.setStatus(400);
return;
}
response.setHeader("Location", url);
关键点:白名单(只允许 / 开头的站内路径)比黑名单更稳;同时顺手封掉开放重定向。
步骤 4:缓存侧兜底
Nginx 代理缓存应避免缓存带 302 跳转与含 Set-Cookie 的响应,否则一次注入会被持久化:
proxy_cache_path /var/cache/nginx/crlf levels=1:2 keys_zone=crlfzone:10m inactive=10m;
location /redirect {
proxy_pass http://127.0.0.1:8080;
proxy_cache crlfzone;
proxy_cache_valid 200 1m;
proxy_cache_key "$scheme$host$uri$is_args$args";
proxy_ignore_headers Set-Cookie; # 不因 Set-Cookie 影响缓存键
proxy_no_cache $upstream_http_set_cookie; # 含 Set-Cookie 的响应不入缓存
add_header X-Cache-Status $upstream_cache_status;
}
配置验证
用 curl 逐条验证拦截是否生效,注意 --path-as-is 保证 curl 不自行归一化路径:
# 1) 单次编码 CRLF,应返回 400
curl -s -o /dev/null -w "%{http_code}\n" \
"http://www.example.com/redirect?url=a%0d%0aSet-Cookie:%20x=1"
# 2) 双重编码,应返回 400
curl -s -o /dev/null -w "%{http_code}\n" \
"http://www.example.com/redirect?url=a%250d%250aSet-Cookie:%20x=1"
# 3) 正常路径,应返回 302 且 Location 干净
curl -si "http://www.example.com/redirect?url=/dashboard" | grep -i '^location'
# 4) 检查响应头是否被注入(应无输出)
curl -si "http://www.example.com/redirect?url=/a%0d%0aX-Injected:%20yes" \
| grep -i '^x-injected'
# 5) 统计 24 小时内被拦截的请求数
grep -c 'CRLF Injection' /var/log/nginx/error.log
预期结果:第 1、2 条返回 400;第 3 条返回 302 且 Location 只有一行;第 4 条无任何输出。若第 4 条出现 X-Injected,说明后端写入点未净化,需回到步骤 3 处理。
常见问题
FAQ 1:Nginx 的 if ($crlf_bad = 1) 写在 server 块里安全吗?
if 在 server 级别只包含 return 时是官方认可的安全用法(”if is evil” 问题主要出在 location 块内与 try_files、proxy_pass 混用)。本例只做 return 400,不涉及内容处理阶段,可安全使用。若想彻底避开 if,可用 map 生成变量后配合 error_page 418 = @crlf_deny; 转发。
FAQ 2:只做了 Nginx 拦截,还会有绕过风险吗?
会。攻击者可通过内网直连应用端口(绕过 Nginx)、或利用 Nginx 已解码变量(如 $arg_x)在重写阶段被再次写入上游请求头的方式绕过。因此 Nginx 与 ModSecurity 属于「降低投递成功率」,后端净化才是根因修复;建议同时限制应用端口只监听 127.0.0.1。
FAQ 3:如何确认站点是否已存在历史注入痕迹?
检索访问日志中的编码特征即可:
grep -Ei '%0d|%0a|%250d|%250a|%5cr|%5cn' /var/log/nginx/access.log \
| awk '{print $1, $6, $7}' | sort | uniq -c | sort -rn | head -20
若同一 IP 在短时间内大量出现该特征,应同步做封禁处理并排查是否有真实跳转发生。
FAQ 4:有多少接口需要排查?能否自动化定位?
可按「响应头是否由用户输入构造」的代码特征批量定位。源码级检索以下关键字即可缩小范围:
# PHP:检索 header() 调用中拼接外部变量的位置
grep -rnE 'header\s*\(\s*["'"'"'].*\$_(GET|POST|REQUEST|COOKIE|SERVER)' --include='*.php' /var/www/
# Java:检索 setHeader / addHeader / sendRedirect
grep -rnE '(setHeader|addHeader|sendRedirect)\s*\(\s*[^,)]*\+' --include='*.java' /opt/app/src
# 静态列表:把可疑接口写进待测清单
grep -rnE 'setHeader\s*\(\s*"Location"' --include='*.java' /opt/app/src | wc -l
得到候选清单后,用本文步骤 1 的 curl 用例逐条回归,即可在半小时内圈定真实存在问题的接口,避免全站盲扫。
总结
CRLF 注入的根因是「用户输入被写入响应头且未做解码后校验」。防护顺序建议为:后端白名单净化(根因修复)→ 应用端口仅监听回环地址 → Nginx map 编码检测返回 400 → ModSecurity 响应阶段规则 → 缓存侧禁止缓存含 Set-Cookie 的跳转响应。五层叠加后,单点绕过不再能直接导致响应拆分。部署完成后请务必用本文的 curl 用例回归一次,并保留拦截日志用于后续攻击画像。