
1. 项目概述与场景还原1.1 这个实验是怎么来的做Web开发的人应该都用过cURL命令行下调试接口、模拟请求、验证重定向顺手就敲一行curl -I https://example.com。但有一类参数平时用得不多真正用的时候就容易翻车比如-H Host: xxx。这个参数允许你手动覆盖HTTP请求里的Host头。正常浏览器发请求Host头是自动填的你无法修改。但cURL作为一个开发者工具把这个开关留给了用户。问题是一旦你可以自定义Host头它带来的连锁反应远不止改了个请求头那么简单。我这个项目就是从一次线上事故开始的。某个内部系统在测试环境复现用户反馈时前端页面一直登录态异常后来排查发现是测试脚本里写死了自定义Host头把带Cookie的请求打到了错误的虚拟主机上结果Set-Cookie写错了作用域整个会话被污染。后来我把这个场景单独拎出来复现、分析越查越觉得这里面的坑值得专门写一篇。如果你平时会在命令行里用cURL调试多域名或虚拟主机环境或者你在写自动化脚本时习惯加Host头来访问测试环境这篇文章值得看完。它能帮你搞明白自定义Host头到底会影响什么Cookie是怎么在这种场景下被跨源读写的以及攻击者如何利用这个特性做注入。1.2 自定义Host头的正常用途先说说大家平时为什么加 Host 头。最常见的场景是本地联调。后端服务部署在开发机上Nginx配置了多个虚拟主机你本地的hosts文件没有同步开发机的域名映射于是就直接用IP访问再通过-H Host: dev.example.com告诉Nginx你要访问的是哪个站点。这个做法非常普遍不少老工程师的习惯就是curl -H Host: dev.example.com http://192.168.1.10/。还有一类场景是测试WAF规则、验证CDN回源策略、或者模拟特定域名的访问行为。比如你想确认生产域名在某种特定请求头组合下是否会被拦截直接改Host头模拟一遍是最快的。但这里有个容易被忽略的细节cURL自定义Host头只改了请求行里的Host字段并没有改Cookie、Referer、Origin等任何其他字段。这就是问题开始的地方。一个Web应用判断我是谁通常依赖几个信息Host头、请求协议、访问路径。而Cookie的归属则由Domain和Path属性决定。当Host头和Cookie携带的域信息不一致时服务端和浏览器都会陷入一种身份混乱状态。你以为是访问example.com实际上请求被路由到了另一个虚拟主机上但Cookie还是example.com的——数据就是这么流出去的。2. 跨源Cookie泄漏的底层逻辑2.1 Cookie的Domain属性决定是谁的Cookie要理解Cookie为什么会泄漏先要弄清楚Cookie的本质。Cookie是服务器扔给浏览器的一段键值对数据但它不是无主的数据。服务器在返回Set-Cookie响应头时会指定Domain和Path浏览器收到后会做一个判断这个Cookie应该挂在哪个域下面。比如服务器返回Set-Cookie: session_idabc123; Domain.example.com; Path/那么浏览器就会把session_id这个键值对挂在.example.com下面访问任何以example.com结尾的域都会带上这个Cookie。但如果Domain是shop.example.com那么它只会在访问shop域的时候被携带。这里有个关键点Cookie的判断依据是浏览器的当前访问地址而不是响应里Host头或者业务逻辑。浏览器不会关心你HTTP请求里Host头到底填了什么它只关心地址栏里的域名是什么。那问题就来了如果你的服务端代码里生成Cookie时用了Host头作为Domain属性的值而Host头是用户可控的会发生什么我写了个最小复现服务端逻辑假设是这样的Java伪代码String host request.getHeader(Host); Cookie cookie new Cookie(token, randomToken()); cookie.setDomain(host); // 直接用Host头当Domain cookie.setPath(/); response.addCookie(cookie);你正常访问https://a.comHost头是a.com生成的Cookie Domain也是a.com一切正常。但攻击者可以构造一个请求curl -H Host: attacker.com https://a.com/login如果后端没有对Host头做白名单校验那么服务端就会返回一个Domain为attacker.com的Cookie。虽然这个Cookie是a.com的应用生成的但浏览器会把它当作attacker.com的Cookie存起来。这就完成了第一次注入——你在a.com的应用里给attacker.com种了一个Cookie。2.2 Host头改写如何绕过同源策略紧接着第二层问题跨源读取。同源策略是浏览器的安全基石要求协议、域名、端口完全一致才允许相互访问Cookie和DOM。但Host头改写这种发生在HTTP协议层面的操作完全不经过浏览器的同源策略判断。因为cURL不是浏览器它不会强制执行Same-Origin Policy它只会忠实地把请求发出去。举个例子你本机跑着两个服务服务A监听80端口虚拟主机绑定api.internal.com服务B监听80端口虚拟主机绑定public.web.com你直接用公网IP访问默认情况下Nginx匹配不到任何虚拟主机会走默认server块。但如果你加上-H Host: api.internal.com请求就会被路由到服务A。此时如果你在这个请求里携带了原属于public.web.com的Cookie服务A照样能收到。Cookie本身不会自检我是不是应该发给这个Host它只会按照浏览器或客户端的规则决定要不要附带。用cURL模拟这个过程非常简单curl -v -H Host: api.internal.com \ -H Cookie: session_idsecret-token-from-public-web \ http://127.0.0.1/服务A的后端日志会完整记录到session_idsecret-token-from-public-web。这就是典型的跨源Cookie泄漏——Cookie原本属于公网Web域却因为一个自定义Host头被送到了另一个内部服务的怀里。2.3 代理与转发过程中的Cookie路径真实生产环境里客户端很少直连业务服务器中间一般还有Nginx、HAProxy、云负载均衡等代理层。代理层在处理Host头时行为差异很大这会让Cookie泄漏问题更隐蔽。常见的代理配置有这么几种透传Host头代理层不修改Host头原样转发给后端。如果代理层根据其他字段比如IP端口做路由后端拿到的是客户端定义的Host头。重写Host头代理层强制把Host头改成后端真实域名比如proxy_set_header Host $host;改成proxy_set_header Host api.internal.com;。自定义header透传很多微服务网关规定了一个约定客户端不能直接改Host但可以通过X-Forwarded-Host等自定义头传递原始Host。透传模式的问题最严重。假设负载均衡器根据URL前缀真实IP路由请求后端有两套应用分别绑定不同域名。攻击者只要找到负载均衡能访问到的一个内网地址配合自定义Host头就可以把带Cookie的请求导向任意后端。整个过程中代理层不会去校验Host头是否与后端域名匹配它只做了转发。更隐蔽的是路径型泄漏当反向代理配置了基于路径的转发比如/api/转发到服务A/转发到服务B而服务A用Host头决定生成Cookie的Domain时攻击者构造一个Host: evil.com的请求访问/api/anything服务A生成的Cookie Set-Cookie就带有evil.com域。如果这个响应通过了CDN缓存还会影响其他用户。这个链路我后面用具体实验演示。3. Cookie注入的攻击面与复现过程3.1 从Host头到Set-Cookie的注入点服务端Set-Cookie的Domain字段是一个容易被忽略的反射型注入点。现在很多框架已经默认用配置项而不是Host头来设置Domain但历史代码里用request.getServerName()、$_SERVER[HTTP_HOST]拼Cookie Domain的比比皆是。用Go语言标准库写个例子很多人初学时会这样写func loginHandler(w http.ResponseWriter, r *http.Request) { token : generateToken() http.SetCookie(w, http.Cookie{ Name: auth, Value: token, Domain: r.Host, // 问题行 Path: /, }) w.Write([]byte(ok)) }r.Host直接取了请求中的Host字段。此时我用一条cURL命令就能给任意域种Cookiecurl -i -H Host: evil.example.net http://target.com/login返回的响应头里会出现HTTP/1.1 200 OK Set-Cookie: authtoken_value; Domainevil.example.net; Path/; HttpOnly虽然这个Cookie是target.com的应用生成的但它的Domain被设置成了evil.example.net。浏览器只要收到过这次响应就会把这个Cookie保存到evil.example.net名下。后续攻击者在自己的evil.example.net站点上可以通过JavaScript读取这个Cookie如果没标记HttpOnly更是如此或者配合子域攻击使用。这就是注入的本质不是往SQL里注入而是往Cookie的归属空间里注入了一枚不属于你的凭证。3.2 会话固定攻击路径Cookie注入最常见的危害是会话固定Session Fixation。正常流程下用户在a.com完成登录服务端创建一个会话返回的Session ID被写入浏览器。但攻击者可以提前通过自定义Host头让a.com生成一个攻击者已知的Session ID并让它落在victim.com域下。受害者访问victim.com时因为攻击者种下的Cookie Domain匹配浏览器就会自动带上这个已知的Session ID。完整的攻击路径我整理成了一张表步骤操作者动作结果1攻击者curl -H Host: victim.com http://a.com/logina.com返回Domainvictim.com的Cookie攻击者知道其值2攻击者诱导受害者访问a.com的一个URL受害者浏览器收到Set-Cookie将Cookie保存在victim.com域下3受害者登录victim.com服务端复用已有Session ID攻击者用已知Session ID直接进入受害者会话4攻击者使用之前的Session ID请求victim.com接口完成会话劫持这个攻击链最关键的点是攻击者在没有任何跨域脚本能力的情况下通过一个HTTP请求就完成了对另一个域名的Cookie预置。而且由于Cookie是一个服务端响应Set-Cookie设置的浏览器完全信任它不会发出任何跨域告警。3.3 实际复现一个最小模拟环境为了验证这个攻击链我搭了一个非常简单的模拟环境。两台服务挂在同一个Nginx下分别绑定a.test和v.test核心逻辑是后端用Host头设置Cookie。Nginx虚拟主机配置server { listen 80; server_name a.test; location / { proxy_pass http://127.0.0.1:8081; } } server { listen 80; server_name v.test; location / { proxy_pass http://127.0.0.1:8082; } }后端A代码如下Node.jsconst http require(http); http.createServer((req, res) { const host req.headers.host; res.setHeader(Set-Cookie, sessionabc123; Domain${host}; Path/); res.end(login ok); }).listen(8081);然后我执行攻击请求curl -i http://127.0.0.1/login -H Host: v.test注意这里指IP访问Nginx仍然根据Host头路由到了a.test的server块但后端代码读取到的Host是v.test于是返回Set-Cookie: sessionabc123; Domainv.test; Path/这个Cookie落在v.test域下。我再正常访问v.test的登录接口就能看到浏览器把之前种下的Cookie带过去了。整个过程没有跨域脚本没有浏览器漏洞纯粹是服务端信任了用户可控的Host头。v.test上的后端B如果是这套逻辑const http require(http); http.createServer((req, res) { console.log(cookie:, req.headers.cookie); res.end(v site); }).listen(8082);那么攻击者构造一条带上sessionabc123的请求就能直接被v.test后端当成合法会话处理。4. 实操记录与问题排查技巧实录4.1 复现环境的完整搭建步骤上面那个实验完整过程我记录一下方便你自己在本地复刻。环境准备就一个Linux或macOS不需要联网装好Docker或者本地有Node/Python都可以。以下用Docker方式演示依赖最少。第一步写一个Dockerfile装Node环境和Nginx。说实话直接拉镜像更省事docker run -d --name curl-host-lab -p 8080:80 nginx:alpine echo 模拟完成第二步进入容器改Nginx配置添加两个server块。容器里没有vim用sed或者直接挂载配置文件都行。我习惯本地写配置再挂载方便反复修改。目录结构是这样的lab/ ├── nginx.conf └── app/ ├── a.js └── v.js第三步分别启动两个Node后端再启动Nginx完整的启动脚本我放在一个run.sh里#!/bin/bash node app/a.js NODE_PID_A$! node app/v.js NODE_PID_B$! nginx -c $(pwd)/nginx.conf echo A和B服务已启动整个过程踩坑点有几个本地hosts不用改因为全走IP容器内Nginx如果是在本机跑listen 80不会和Node冲突Node监听不同端口重要的是一定要让Nginx按Host头分流确认后端收到的是自定义Host而不是Nginx自己生成的Host。4.2 验证Cookie注入的完整命令清单环境起来之后我用的验证命令和输出记录在此。场景一证明Set-Cookie Domain可控curl -i http://127.0.0.1/login -H Host: evil.test输出节选HTTP/1.1 200 OK Server: nginx/1.21.6 Content-Type: text/plain Set-Cookie: sessionabc123; Domainevil.test; Path/这里有三个关键点要注意如果后端在Nginx后面proxy_pass http://127.0.0.1:8081;不加任何改写Host头不会被Nginx篡改此时后端能完整读到evil.test。如果Nginx写了proxy_set_header Host $host;那么$host是Nginx请求行里的值也就是IP后端收到的Host就变成IP了注入会失败。这一点可以用来做防御验证。如果Nginx写了proxy_set_header Host $http_host;则会透传完整Host头包括端口注入依然成功。场景二验证Cookie被跨源携带curl -i http://127.0.0.1/v -H Host: v.test -H Cookie: sessionabc123后端V的日志里会显示出收到了来自evil.test种的Cookie。此时注意访问v.test时你主动携带了Cookie但在真实浏览器场景里应该模拟浏览器自动携带。这个更准确的方法是用一个脚本先拿到Set-Cookie再把它存到Cookie Jar里然后访问v.test看是否自动带上。cURL的Cookie Jar用法curl -c cookies.txt -i http://127.0.0.1/login -H Host: evil.test curl -b cookies.txt -i http://127.0.0.1/v -H Host: v.test如果cookies.txt里记录的Domain是evil.test而第二条命令访问的Host是v.testcURL会做域名匹配理论上不会自动带。但我们在后端代码写的是Node原生解析它不做域名匹配请求头里有什么Cookie它就打印什么。这个细节容易让实验结果失真所以我在脚本里手动把Cookie加进了第二条请求的Header里——这种方式模拟的是攻击者在后续请求中直接注入已知Cookie。场景三用真实浏览器验证命令行工具验证到这一步只能证明HTTP层面数据流要证明浏览器的行为还得借助真实浏览器。我做了个简单的HTML在a.test下发起fetch请求到v.test接口这里注意CORS配置观察Network面板里的Cookie变化。实测下来比较麻烦因为浏览器的Secure、SameSite策略会干预。实验环境如果是http而不是httpsSameSiteLax默认会在跨站场景下拦掉Cookie。所以在模拟真实攻击链时最好在同一主域的子域之间做实验比如evil.test和v.test无法共享Cookie的话换成a.example.com和v.example.com即可。4.3 实战中常见的报错与坑位排查这个实验的过程中我遇到了几个非常典型的报错写下来给后来人避坑。cURL 35错误curl: (35) schannel: next initialize security context failed: SEC_E_INVALID_TOKEN这是在Windows上用cURL访问https站点时最常见的错误原因是本地系统的证书信任库或者schannel与后端TLS配置不兼容。遇到这个错误先别怀疑代码先确认后端是不是TLS握手有问题。临时绕开可以用-k参数跳过证书校验但只能用于本地实验。cURL 56错误curl: (56) recv failure: connection reset by peer这个一般是服务端主动断连最常见的原因就是后端的虚拟主机配置不匹配Nginx找不到对应的server块直接返回400或者关闭连接。特别是添加了自定义Host头之后如果Nginx里没有配置对应的server_name默认会走第一个server块有时行为会让人迷惑。有一个最小的诊断技巧curl -v -H Host: no-such-host.test http://127.0.0.1/如果返回400或者connection reset多半是Nginx的默认server没配置好。加一个默认server块返回明确错误码后续排查会快很多。Cookie带不上模拟浏览器自动携带Cookie的场景时最容易踩坑的是cURL的-b cookies.txt和-H Cookie: xxx混用。cURL会优先用-H指定的Cookie如果同时指定了cookie jar行为不一定符合预期。我建议一次只用一个方式别混用否则排查时不知道Cookie到底来自哪里。Node后端读不到HostDocker环境里Node监听的是127.0.0.1而Nginx也在容器里proxy_pass是127.0.0.1没问题。如果你把Node跑在宿主机Nginx跑在容器里就要用宿主机IP去配proxy_pass否则就超时。这个和Host头本身无关但实验时很容易卡在这里。SameSite干扰最后说一个最容易被忽略的点现代浏览器默认的Cookie SameSite策略是Lax跨站请求默认不携带Cookie。这个会直接影响你对跨源的验证效果。如果你要复现的是跨站场景需要把Set-Cookie里加SameSiteNone; Secure但Secure又要求https。实验环境如果不是https这个配置会让Cookie直接被浏览器丢弃。最稳妥的做法是做跨子域实验比如Cookie Domain设置为.example.com访问sub1.example.com和sub2.example.com。做跨源实验用两个完全不同的顶级域名这时Cookie不会被浏览器自动带上但攻击者可以通过脚本或代理场景强制携带。5. 防御建议与安全编码实践5.1 服务端如何正确校验Host头这个问题的根源是信任了不该信任的输入。服务端对Host头的信任就像在登录表单里信任了用户输入的手机号——你以为那是用户自己的号码其实是可以随便填的。安全的做法包括第一设置Host白名单。现代主流框架都有allowedHosts之类的配置。以Spring Boot为例配置项server: forward-headers-strategy: framework然后用ServerProperties里的host校验。Node的Express社区有host-validation中间件Nginx层面可以通过默认server返回403来兜底。第二不要直接使用request.getServerName()、r.Host这类原始Host值作为业务逻辑输入。要么从代理层注入的X-Forwarded-Host取前提是代理层已经清理过要么从配置中心读取自己的域名。生成Cookie时Domain固定写死不要动态拼。第三代理层要显式重写Host头。Nginx配置里server { listen 80 default_server; server_name _; return 403; } server { listen 80; server_name api.example.com; proxy_set_header Host api.example.com; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://backend; }第一段default_server直接拦截所有非我域名的请求从入口处杜绝了自定义Host头的注入。第二段将Host头强制写死后端无论怎么读都不会读到客户端传来的值。5.2 开发与测试中的cURL使用安全清单作为开发者日常用cURL调试时也要养成好习惯。我在踩过坑之后给自己总结了一份清单分享出来本地调试虚拟主机时优先改本地hosts文件而不是用-H Host。比如echo 127.0.0.1 dev.example.com /etc/hosts然后直接curl http://dev.example.com/。这样Cookie域、Host头、TLS证书全部一致最接近线上真实状态。如果必须用-H Host记得同时加上--resolve参数。比如curl --resolve dev.example.com:80:127.0.0.1 http://dev.example.com/这种方式既能保证Host正确又能保证请求确实发到目标IP比手动指定IP加Host头更安全。涉及Cookie的测试用cookie jar别手动拼Cookie。curl -c cookies.txt -b cookies.txt http://example.com/login这样能看到浏览器视角下的Cookie存取行为而不是凭直觉乱带。凡是发出去的请求带了Cookie务必确认Host头是真实业务域名。我在自动化脚本里写过一个检查函数发现Host头包含ip地址或者非白名单域名就直接抛错避免无意识的密钥泄露。5.3 额外补充关于Cookie的安全属性最后补充一下Cookie本身的安全属性。即便你修复了Host头注入问题Cookie的安全配置依然要到位。只用Domain和Path控制可见范围是不够的还需要关注属性作用建议Secure只在HTTPS请求中携带生产环境一律开启HttpOnly禁止JavaScript读取需要防XSS的Cookie必须开启SameSite控制跨站携带策略默认Lax业务上无跨站需求就直接StrictDomainCookie作用域尽量精确到当前域名不要随便用上级域PathCookie作用路径能窄就别宽/是最后的选择这几项配合Host白名单才能形成完整的纵深防御。单一修复某一层在实际攻击链里可能还是会留下缺口。从实验的角度看这个项目最有价值的收获不是发现了某个工具的bug而是让我们重新审视了HTTP协议中一个看起来无辜的字段到底承载了多少信任。cURL的自定义Host头功能本身没有错错的是服务端把Host当成了可信标识。换个角度看任何能控制HTTP请求的应用层工具都具备类似的能力不只是cURL也包括Postman、Burp Suite等。理解了这个机制你在写服务端代码的时候就会多想一层这个Header是谁在控制它能被我信任吗我自己的习惯是写完一段读取Host、处理Cookie的代码之后先用一条cURL命令做负向测试——故意传一个不存在的Host看服务端是否会生成一个带有攻击者可控域的Cookie。这个习惯帮我拦下了好几次潜在隐患你可以试试。