发布于6小时前6小时 背景最近在做一个内部 Web 系统的代码审计和联调测试时,发现一个比较典型但容易被忽略的问题:业务服务本身没有直接暴露公网,前面挂了 Nginx 做反向代理,但后端应用在生成重置密码链接、回调地址、跳转地址时,直接信任了请求中的 Host 或 X-Forwarded-Host。这类问题通常不会直接导致 RCE,但在账号体系、邮件链接、OAuth 回调、支付回调等场景里,影响并不低。攻击者可以构造恶意 Host,让系统生成指向攻击者域名的链接,配合钓鱼、令牌泄露、缓存污染等方式进一步利用。下面以一个简化环境复现 Host 头注入问题,并给出相对稳妥的修复方式。技术分析常见的错误写法是应用层通过请求头拼接绝对 URL:String baseUrl = request.getScheme() + "://" + request.getHeader("Host"); String resetLink = baseUrl + "/reset?token=" + token;如果前端代理没有规范化 Host,或者后端框架在开启代理信任后读取了 X-Forwarded-Host,攻击者就可以控制生成出来的链接。典型请求如下:POST /forgot-password HTTP/1.1 Host: attacker.example Content-Type: application/x-www-form-urlencoded [email protected]如果后端直接使用 Host 生成邮件链接,受害者收到的重置密码链接可能变成: Host,但保留或转发了 X-Forwarded-Host:GET / HTTP/1.1 Host: real.example.com X-Forwarded-Host: attacker.example部分框架或中间件在配置了 trust proxy 后,会优先使用 X-Forwarded-Host 生成外部地址。如果没有白名单校验,也会出现同样的问题。复现环境这里使用 Flask 写一个最小示例,模拟业务系统生成重置密码链接。from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/forgot-password', methods=['POST']) def forgot_password(): email = request.form.get('email') token = 'demo-token-123456' # 漏洞点:直接信任 Host 头 base_url = request.scheme + '://' + request.headers.get('Host') reset_link = base_url + '/reset?token=' + token return jsonify({ 'email': email, 'reset_link': reset_link }) if __name__ == '__main__': app.run(host='127.0.0.1', port=5000)启动服务后,正常请求:curl -i -X POST ' \ -H 'Host: app.example.com' \ -d '[email protected]'返回内容:{ "email": "[email protected]", "reset_link": " }构造恶意 Host:curl -i -X POST ' \ -H 'Host: evil.example.net' \ -d '[email protected]'可以看到链接被污染:{ "email": "[email protected]", "reset_link": " }如果这个链接真实发送到邮箱,用户点击后就会把重置 token 带到攻击者控制的域名上。Nginx 代理场景中的问题点很多线上系统存在类似配置:server { listen 80; server_name app.example.com; location / { proxy_pass proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }这里的 $host 会经过 Nginx 规范化处理,但如果 server_name 匹配不严格,或者存在默认站点兜底,仍可能把异常 Host 转发到后端。更危险的是类似配置:proxy_set_header Host $http_host;$http_host 基本取自原始请求头,包含端口,也更容易携带异常值。除非有明确需求,否则不建议直接把 $http_host 透传给后端。操作步骤:如何排查实际测试时,可以按下面几个方向检查。1. 测试 Host 是否可控curl -i ' \ -H 'Host: evil.example.net'观察返回内容、跳转地址、页面中的绝对 URL、接口返回字段是否出现 evil.example.net。2. 测试 X-Forwarded-Hostcurl -i ' \ -H 'X-Forwarded-Host: evil.example.net' \ -H 'X-Forwarded-Proto: https'如果响应里的链接、Location 头、邮件预览、OAuth redirect_uri 出现该域名,需要重点关注。3. 测试密码重置、邮箱验证等功能Host 头注入在普通页面可能看不出危害,但在这些功能里影响更明显:找回密码链接邮箱验证链接邀请注册链接SSO/OAuth 回调地址支付成功/失败跳转地址后台导出文件下载地址4. 关注缓存污染如果前面还有 CDN 或反向代理缓存,可以测试响应是否被缓存。一个错误的 Host 生成结果如果进入缓存,可能导致其他用户看到被污染的链接。修复方案1. 应用层不要直接信任请求头生成外部地址更推荐使用配置项维护站点外部地址:PUBLIC_BASE_URL= os from flask import Flask, request, jsonify app = Flask(__name__) PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', ' @app.route('/forgot-password', methods=['POST']) def forgot_password(): email = request.form.get('email') token = 'demo-token-123456' reset_link = PUBLIC_BASE_URL.rstrip('/') + '/reset?token=' + token return jsonify({ 'email': email, 'reset_link': reset_link })这样生成链接不再依赖用户输入的 Host。2. Nginx 层限制合法 Host建议给默认站点直接丢弃非法请求:server { listen 80 default_server; server_name _; return 444; }业务站点只接受明确域名:server { listen 80; server_name app.example.com; if ($host != 'app.example.com') { return 444; } location / { proxy_pass proxy_set_header Host app.example.com; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }这里直接把传给后端的 Host 固定为可信域名,避免后端误用。3. 清理不必要的转发头如果后端不需要 X-Forwarded-Host,建议不要转发,或者覆盖为空:proxy_set_header X-Forwarded-Host "";如果确实需要,也应该只由可信代理设置,不能让客户端传入的值原样进入后端。4. 框架层配置可信代理例如在 Express、Django、Spring Boot 等框架里,很多项目会开启 trust proxy 或 forward headers 支持。开启前要确认只有可信代理可以访问后端服务,否则客户端可以直接伪造转发头。后端服务应只监听内网地址,例如:app.run(host='127.0.0.1', port=5000)容器环境中则建议通过安全组、NetworkPolicy 或防火墙限制访问来源。注意事项不要只在前端页面做域名限制,邮件链接、接口返回、跳转响应都要检查。不要把 Host 校验写成简单 contains,例如 example.com.evil.net 也可能绕过。多域名业务要使用白名单数组精确匹配,而不是正则随意放宽。HTTPS 场景下还要注意 X-Forwarded-Proto,否则可能生成错误协议的链接。修复后建议清理缓存,尤其是 CDN、反向代理缓存和应用内部缓存。总结Host 头注入本质上是“把客户端可控输入当成可信站点地址”使用。这个问题不一定有醒目的报错,也不一定能在普通页面直接体现,但在账号安全链路里很容易变成实际风险。比较稳妥的处理方式是:应用层使用固定配置生成外部 URL,代理层限制合法 Host,并清理不必要的转发头。测试时重点覆盖找回密码、邮箱验证、邀请注册、OAuth 回调和支付跳转等功能,通常能发现不少隐藏问题。 网络请求、日志与边界流量分析示意
5小时前5小时 这个场景里除了在应用层校验 Host,Nginx 侧建议也做一层“兜底”,否则后端一旦有绝对 URL 拼接、重定向、密码重置链接生成,还是容易被带偏。 可以重点看两个点: 1. 不要把用户传入的 Host 原样透传给后端 如果现在配置里是这样: proxy_set_header Host $http_host; # 或 proxy_set_header Host $host; 建议改成明确的后端期望域名,或者至少用白名单 server_name 约束。 例如: server { listen 80 default_server; server_name _; return 444; } server { listen 80; server_name example.com location / { proxy_pass proxy_set_header Host example.com; proxy_set_header X-Forwarded-Host example.com; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } 这样即使请求里带: Host: evil.com 也不会继续传到后端参与业务逻辑。 2. default_server 不要落到正常业务 很多 Host 头注入是因为 Nginx 没匹配到 server_name 时,默认进了第一个 server 块。建议显式加一个 default_server 拦截未知 Host: server { listen 80 default_server; listen 443 ssl default_server; server_name _; return 444; } 如果是 HTTPS,还要注意 SNI 和 Host 不一致的情况。可以用下面方式测一下: curl -k -H 'Host: evil.com' curl -k --resolve evil.com:443:你的IP curl -k --http1.1 -H 'Host: evil.com' -I 观察响应里的这些位置: Location: Set-Cookie: Refresh: 页面中的绝对链接 密码重置 / 邮件验证链接 OAuth redirect_uri 相关跳转 应用层也最好加白名单,不要只依赖 Nginx。比如后端如果是 Spring Boot,可以配置允许的 host;如果是 Django,需要确认: ALLOWED_HOSTS = ["example.com", " 修复后可以再用原来的 PoC 复测,重点确认两件事:未知 Host 是否直接被拒绝,以及后端日志里拿到的 Host 是否已经固定为可信域名。这样比单纯过滤某些特殊字符更稳。
创建帐户或登录后发表意见