适用场景
Host 头攻击(Host Header Attack)是 Web 应用中最容易被忽视的一类输入信任缺陷。反向代理只做转发、后端框架默认信任 HTTP_HOST,攻击者篡改请求头中的 Host 字段即可影响业务逻辑。典型受害场景包括:
- 密码重置链接投毒:系统用
$_SERVER['HTTP_HOST']拼接重置链接,攻击者提交他人邮箱并伪造 Host,收到的邮件中链接指向攻击者域名,用户点击后令牌被窃取。 - 缓存投毒与虚拟主机混淆:CDN 按 Host 分区缓存,脏缓存被投放到正常域名下。
- 绕过基于 Host 的访问控制:内网管理系统只允许
admin.internal访问,攻击者直接改 Host 头即可穿透基于域名的限制。 - 邮件头注入与 SSRF 跳板:Host 值被写入邮件模板或用于构造后端调用地址。
本文给出 Nginx 层、后端框架层、反向代理链路三处的可复制配置,并以 curl 构造多种畸形 Host 完成验证,适用于 Nginx + PHP / Java / Node 的常见部署结构。
前置条件
- 服务器已部署 Nginx(本文以 1.24+ 为例),具备
/etc/nginx/conf.d/写权限 - 已明确业务实际使用的全部域名清单,包含主域名、www 域名、API 子域、测试域名,这是白名单的基础
- 后端应用可修改配置(PHP、Django、Spring Boot、Express 任一)
- 具备一台可访问源站的测试机,且已安装 curl 7.55+,用于构造自定义 Host 头
- 若源站位于 CDN 之后,需同时掌握回源 Host 的设定方式(多数 CDN 默认保持用户 Host 回源)
原理说明
HTTP/1.1 规定 Host 头由客户端自行填写,服务端无法验证其真实性,只能校验其是否属于允许集合。Nginx 提供两个变量,行为差异是配置正确与否的关键:
$http_host:原样保留客户端发送的 Host 头,包含多余空格外的原始大小写与端口,例如Evil.com:443会完整保留。$host:取自请求行中的主机名或 Host 头,统一转小写并去除端口,若两者均不存在则回退为匹配到的server_name。
由于 $host 在缺少 Host 头时会回退为 server_name,攻击者仍可用无 Host 头的 HTTP/1.0 请求触发回退路径,因此白名单校验必须基于 $http_host 做严格全等匹配,而不是依赖 $host 是否为空。
链路层面还有一类隐藏入口:反向代理与 CDN 会追加 X-Forwarded-Host、X-Original-Host 等头。如果后端框架优先读取这些头,那么仅校验 Host 就形同虚设。防御要覆盖”客户端可控的所有主机类请求头”。
操作步骤
1. 配置默认虚拟主机,丢弃未知 Host
第一个匹配的 default_server 会接住所有未命中 server_name 的请求。将它配置为直接断开连接,可以从入口过滤掉绝大多数扫描与畸形 Host 请求:
# /etc/nginx/conf.d/00-default-reject.conf
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/ssl/default.crt;
ssl_certificate_key /etc/nginx/ssl/default.key;
# 444 为 Nginx 私有指令:直接关闭连接,不回任何响应
return 444;
}
注意 return 444 不返回响应体,因此 CDN 健康检查若使用 IP 直连并携带真实 Host,仍能正常命中业务 server 块;只有完全未知的 Host 才会被断开。
2. 在业务 server 块做白名单硬校验
用 map 建立允许集合,再在 server 层拦截。相比在每个 location 里写 if,map 的匹配在请求处理早期完成,开销更低:
# /etc/nginx/conf.d/host-whitelist.conf
map $http_host $host_allowed {
default 0;
"www.example.com" 1;
"example.com" 1;
"api.example.com" 1;
# 显式列出带端口的常见写法,避免漏判
"www.example.com:443" 1;
"example.com:443" 1;
~^www\.example\.com:\d+$ 1;
}
server {
listen 443 ssl;
server_name www.example.com example.com api.example.com;
# 421 Misdirected Request:语义上准确表达"请求被导向了错误的服务器"
if ($host_allowed = 0) {
return 421;
}
...
}
关键参数说明:
map的 default 必须是 0(拒绝),白名单模式不允许兜底放行。- 含端口的 Host 必须显式覆盖,否则标准浏览器在某些代理场景下会发送带端口的 Host 而被误杀。
- 若同时存在
X-Forwarded-Host,可再建一个 map 一并校验:if ($http_x_forwarded_host) { return 421; },前提是链路中没有 CDN 依赖该头。
3. 反代场景清除伪造的主机类请求头
逐跳转发时,客户端自带的 X-Forwarded-* 必须被覆盖而非追加,否则后端会读到伪造值:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 关键:用 $remote_addr 覆盖,而不是 $proxy_add_x_forwarded_for(后者会拼接客户端值)
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
如果业务确实位于多级代理之后,X-Forwarded-For 需要使用真实 IP 模块(realip)先还原可信链,再传给后端,避免直接拼接不可信输入。
4. 后端框架层校验
Nginx 只能保护经过它的流量。内网直连、容器间调用、本地调试端口都可能绕过 Nginx,因此后端必须自行校验:
# PHP:所有生成绝对 URL 的地方统一走白名单
$allowed = ['www.example.com', 'example.com', 'api.example.com'];
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
if (!in_array($host, $allowed, true)) {
http_response_code(421);
exit('Invalid Host');
}
define('SITE_URL', 'https://www.example.com'); // 绝对 URL 用固定配置,禁止用 Host 拼接
# settings.py(Django)
ALLOWED_HOSTS = ["www.example.com", "example.com", "api.example.com"]
# 生产环境禁止使用通配符 "*";CSRF 校验域名同样必须显式列出
CSRF_TRUSTED_ORIGINS = ["https://www.example.com", "https://example.com"]
# application.yml(Spring Boot)
server:
port: 8080
tomcat:
# 严格模式:Tomcat 会校验 Host 头与 serverName 是否一致
use-relative-redirects: true
forward-headers-strategy: framework # 仅在确定有可信代理时开启
# 自定义过滤器示例:校验后写入固定 baseUrl,用于生成邮件链接
app:
public-base-url: https://www.example.com
核心原则:凡是用于生成对外链接、跳转地址、邮件内容的域名,一律取服务端固定配置,绝不从请求头推导。这是从业务逻辑上根除 Host 头投毒的最有效手段。
配置验证
1. 语法与重载
nginx -t && nginx -s reload
2. 构造畸形 Host 请求
# 正常域名:期望 200
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: www.example.com" https://SERVER_IP/
# 未授权域名:期望 421
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.com" https://SERVER_IP/
# 未授权域名 + 白名单域名作为前缀:期望 421
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.com.www.example.com" https://SERVER_IP/
# 无 Host 的 HTTP/1.0 请求:期望 444 或 421,不得返回业务页面
curl -s -o /dev/null -w "%{http_code}\n" --http1.0 https://SERVER_IP/ -H "Host: "
# 伪造 X-Forwarded-Host:期望 421
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: www.example.com" \
-H "X-Forwarded-Host: evil.com" https://SERVER_IP/
验证通过的标准是:只有白名单内的域名返回 200,其余全部返回 421 或 444,且响应体中不包含任何业务内容。
3. 业务级验证(最关键一步)
# 触发密码重置流程,抓取邮件中的链接
curl -s -X POST https://www.example.com/api/password/reset \
-H "Host: evil.com" \
-d "email=test@example.com"
检查邮件正文中的重置链接。要求链接域名恒为 https://www.example.com/。若出现 evil.com,说明应用层仍在拼接 Host,需回到第 4 步修正。
4. 日志侧确认
# 统计被拒绝的 Host,观察是否有持续的扫描行为
grep -E 'return 421|return 444' /var/log/nginx/error.log | tail -50
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
常见问题
FAQ 1:已经部署了云 WAF,源站还需要校验 Host 吗?
需要。WAF 只处理经过它的流量,而源站 IP 一旦泄露,攻击者可直连源站并完全绕过 WAF。通过 CDN 回源白名单(仅允许 CDN 回源段访问 443)可以大幅收敛风险,但回源请求中的 Host 依然由攻击者控制。两者叠加才完整:网络层限制来源 IP,应用层校验 Host。
FAQ 2:校验 $host 还是 $http_host?为什么结果不一样?
必须用 $http_host。举例:客户端发送 Host: WWW.EXAMPLE.COM:443,$http_host 得到原值,$host 得到 www.example.com。反过来说,$host 的”规范化”看似便利,但当请求完全没有 Host 头时它会回退为 server_name,此时 $host 恒等于合法域名,校验形同虚设。$http_host 为空则明确表示异常请求,可以直接拒绝。
FAQ 3:加了严格白名单后监控探针大量报 503,如何排查?
优先看探针使用的 Host。常见原因有三类:一是探针用 IP 直连但未设置 Host,被 default_server 的 444 断开;二是探针使用了内部服务名(如 svc-mesh.local)作为 Host,该名称未在白名单中;三是负载均衡健康检查使用带端口写法且未在 map 中覆盖。解决方式是在 map 中为内部名称与带端口写法补充条目,或让探针改用业务域名。切勿为了省事把 default 改成 1。
总结
Host 头攻击的根因是”服务端把不可信输入当作了可信配置”。防御按三层展开,缺一不可:
- 入口层:
default_server返回 444,丢弃未声明的域名。 - 代理层:基于
$http_host的白名单 map 严格全等匹配,返回 421;同时覆盖X-Forwarded-*而非追加。 - 应用层:框架配置显式域名清单,所有对外链接的域名取服务端固定配置。
验证环节必须包含业务级断言——检查密码重置邮件中的链接域名,只有这一项通过,才能认为 Host 头注入的业务风险被真正消除。