最近接了个安全加固的活儿排查一个内部系统被第三方页面套壳点击劫持的问题。说到点击劫持就绕不开前端防嵌入的“老门神”——frame busting也就是咱们常说的“破框架”脚本。但有意思的是甲方自己写的 buster 逻辑上线没多久就被测试同学用一套 iframe 嵌套手法轻松绕过去了。顺着这条线把 frame busting 的“破坏”与“反破坏”完整梳理了一遍踩了不少坑也把加固方案彻底换成了现代 CSP 体系。这篇就把整个过程摊开讲适合正被 iframe 嵌入问题困扰的前端工程师以及刚开始接触前端安全防护的朋友。1. 理解 frame busting前端安全里的“防嵌门神”1.1 为什么要有 frame busting它到底防什么先搞清楚 frame busting 到底在防什么。它防的核心威胁叫clickjacking点击劫持。攻击者把你们的合法页面嵌进自己的恶意页面 iframe 里然后在上面覆盖一层透明的诱饵按钮诱导用户“确信自己在点一个抽奖”实际上却点到了你们页面的“授权”“确认转账”“修改密码”等真实按钮。整个过程中用户毫不知情但操作已经发生在你们系统里了。要能完成这类攻击前提之一就是“你们的页面能被塞进别人的 iframe”。所以反过来说只要让页面无法被 iframe 嵌入点击劫持就失去了重要的前置条件。frame busting 就是干这件事的页面检测到自己被嵌入了就试图挣脱——通常是把最外层窗口直接导航回自己的页面地址让用户看到真实内容而不被假的套壳界面“圈养”。我在实际项目里见过很多不安全系统的页面完全裸奔连最基本的防嵌入都没有。被安全测试一查一个准。有人可能会说点击劫持危害是不是被夸大了我的看法是在无痕浏览器、移动端 WebView 里用户很难察觉自己被套壳金融类、账号类操作一旦触发后果很直接。所以这种防护不是“优化项”而是“底线项”。1.2 从 window.top 判断到响应头frame busting 的三种实现路线frame busting 的传统思路其实很直白在页面里执行一段 JavaScript比较当前窗口 window.self 和最外层窗口 window.top 是不是同一个对象。如果不是说明自己被嵌在某个 iframe 里那就执行跳转把顶层窗口导航到自己的页面。最典型的写法是这版if (window.top ! window.self) { window.top.location window.self.location; }这段代码逻辑很清晰self 是当前页面的窗口对象top 是最外层浏览器窗口对象。正常访问时两者指向同一个被嵌入时 top 是外层页面两者不同。一旦发现不同就把 top 导航到当前页面 URL等于“强行破壳”。另一种常见实现是后端在响应头里下发X-Frame-Options值可以取 DENY完全不允许嵌入或者 SAMEORIGIN只允许同源页面嵌入。这属于浏览器层面的约束不需要页面内写脚本而且执行时机在页面渲染之前比前端 JavaScript 判断更“前置”。再往后就是 CSPContent Security Policy里的frame-ancestors指令它把防嵌入策略提升到了更精细的粒度可以声明哪些来源可以嵌套本页面。这个我们后面专门展开因为这里是形成“现代防嵌体系”的核心。注意对很多项目来说JavaScript 型 frame busting 是“最后一道人工保险”真正的第一道防线应该在服务端响应头。两者配合而不是二选一。1.3 frame busting 的边界和尴尬现状搞安全的都知道靠 JavaScript 做防护普遍有“攻击面暴露”的毛病。frame busting 脚本本身跑在浏览器里而攻击者也能控制浏览器环境这就变成了“你的脚本和攻击者的外层页面打架谁先执行、能不能执行、执行到一半会不会被环境掐断”的博弈。而且 JavaScript 型 frame busting 存在几个天然尴尬点它是事后逻辑页面渲染过程中才发现被嵌入此时用户可能已经看到了一瞬间的套壳页面。外层页面可以通过sandbox 属性制造一个类似“无父窗口导航权限”的执行环境让跳转代码直接报错。脚本执行时机如果晚于用户交互时间窗用户可能已经点下去了。正因为这样现在主流加固方案都强调“服务端响应头优先、CSP 精准控制”而不是单纯依赖一段 window.top 判断脚本。但理解 frame busting 本身仍然是基本功因为它的绕过手法几乎就是现代防嵌体系的“题库”。2. “破坏”的思路攻击者如何一步步拆掉 frame busting2.1 第一板斧sandbox 属性把脚本关进“隔离舱”要说 frame busting 最经典的绕过姿势那就是 iframe 的sandbox属性。sandbox 允许你给嵌入的 iframe 套一套权限限制没有 allow-scripts 就禁止里面跑脚本没有 allow-same-origin 就让里面变成独特源而不给 allow-top-navigation 则意味着里面的脚本没有权限去导航父级窗口。这句话直接戳中了传统 frame busting 的命门。我们看一个攻击页面的典型构造iframe srchttps://victim.example.com/page.html sandboxallow-scripts /iframe这种写法保留 allow-scripts让目标页面的 JavaScript 可以正常执行。但因为没有 allow-top-navigation目标页面里那行window.top.location window.self.location执行到一半就会因为权限问题失败甚至直接抛出 SecurityError后续逻辑全部断掉。于是 frame busting 脚本看起来“执行了”实际上没有任何作用。我实测过几种浏览器对 sandbox 行为的差异Chrome 和 Firefox 在这个问题上相对严格老版本的某些浏览器曾经还存在 sandbox 与 top-navigation 之间比较混乱的处理。这里不需要纠结具体版本号但要记住一个大原则不要认为脚本执行了就等于防护生效了。攻击者用一段属性就能把你的“门神”变成摆设这种落差感很强烈。提示如果前端脚本要兼容“被 sandbox 嵌入”的场景就不能只依赖 window.top.location 导航而要同时配合响应头里的硬约束。这也是我这次项目里最大的一个认知升级。2.2 第二板斧利用跨域限制让判断脚本“夭折”另一种破坏思路不是限制脚本权限而是制造一个让脚本“不敢继续跑”的跨域环境。很多 frame busting 实现为了增强判断会写成类似这样if (window.top.location.href ! window.self.location.href) { window.top.location.href window.self.location.href; }看起来没什么问题但注意如果顶层窗口和当前页面不同源访问 window.top.location.href 在浏览器里会直接抛跨域错误代码根本走不到跳转那一步。攻击者怎么制造这种局面呢只需要两层 iframe 嵌套iframe srchttps://middle.example.net/frame.html/iframe这个 middle 页面里再放一层iframe srchttps://victim.example.com/page.html/iframe这时 victim.example.com 上的页面是嵌在 middle.example.net 的 iframe 里的而 middle 又嵌在顶层 attacker.com 页面里。对 victim 页面来说window.self 归属于 victim.example.comwindow.top 归属于 attacker.com两者不同源。只要 frame busting 脚本里出现对跨域 window.top 的“读操作”脚本就立刻死掉后续跳转永远到不了。用一句话总结这种绕过思路让防护脚本在没有机会“读到”足够信息之前先被浏览器安全模型掐断。这挺讽刺的浏览器自身的跨域隔离反而成了 frame busting 的“敌人”。2.3 第三板斧beforeunload 拦住最后一跳如果 frame busting 脚本成功执行并且已经触发了window.top.location ...这条跳转语句攻击者就没办法了吗也不是还有一个非常流氓的拦截方式在最顶层页面监听 beforeunload 事件。beforeunload 是一个在窗口即将被卸载前触发的事件浏览器通常会弹出确认框询问用户“确定要离开吗”。攻击者只需在最外层页面挂一个监听并取消默认行为window.addEventListener(beforeunload, function (e) { e.preventDefault(); e.returnValue ; });当 frame busting 脚本尝试把顶层窗口导航到 victim 页面时浏览器会触发这个 beforeunload 弹窗。用户看到的是“系统提示离开页面”大多数用户会下意识点“留下”于是跳转被取消frame busting 再次失效。目标页面被卡在 iframe 里攻击继续。这个手法能绕开不少纯前端 frame busting而且实现成本极低。所以我现在看到很多人还在“一行 JS 防点击劫持”是比较头大的。2.4 绕过手法小结不要用“脚本必胜”的思维理解前端安全把上面三种破坏思路放一起看其实能提炼出共同点frame busting 的权力本质上来自浏览器对窗口导航的处理而攻击者也同样拥有修改这个环境的权力。sandbox 可以剥夺权限跨域可以制造读取异常beforeunload 可以拦截导航。也就是说脚本层的防护永远处于“可以被人为改造的环境”里。这也是为什么行业里公认X-Frame-Options 和 CSP 这类服务端指令才是防嵌入的主力。因为浏览器在处理这些响应头时作用点在“iframe 是否允许加载”这一层基本不给外层页面脚本“事后干扰”的机会。脚本型 frame busting 可以当作补充但绝不能当作唯一的防线。3. 实战拆解一次完整的 frame busting 攻防复盘3.1 搭建一个可复现的最小环境纸上谈兵不如上手跑一遍。我这次用 Node.js 起了两个本地服务来模拟环境一个扮演“受害者”站点跑在 8081 端口页面里包含点击劫持测试按钮和一段 frame busting 脚本另一个扮演“攻击者”站点跑在 8082 端口用来嵌入受害者页面并实施各种绕过。受害者页面大概长这样!DOCTYPE html html head meta charsetutf-8 titleVictim Page/title /head body h1敏感操作确认页/h1 button idpayBtn stylemargin-top:200px;width:200px;height:60px; 确认授权 /button script // 传统 frame busting if (window.top ! window.self) { window.top.location window.self.location; } /script /body /html攻击者页面用 iframe 加载它复杂点在于我用一个 PHP 风格的角度来理解其实前端也可以直接用静态 HTML。关键在于 iframe 标签和后续绕过手段怎么写。3.2 逐个执行三种绕过方案记录真实结果第一组实验直接嵌入不做任何处理。iframe srchttp://localhost:8081/ width800 height600/iframe打开攻击者页面可以看到受害者页面跳转到了顶层frame busting 成功生效。这说明在“标准环境”下一行简单的判断还是有用的。第二组实验给 iframe 加上 sandboxallow-scripts。iframe srchttp://localhost:8081/ width800 height600 sandboxallow-scripts /iframe刷新后受害者页面并没有把外层窗口导航走而是安静地待在了 iframe 里。原因正如前面分析sandbox 限制了 top-navigation 权限window.top.location 赋值失败脚本的作用被完全抵消。这一步实验结果和理论完全吻合所以立刻印证了只有脚本没有响应头的方案在 sandbox 面前等于裸奔。第三组实验模拟跨域双层 iframe。我在 8082 端口再提供一个 middle 页面middle 里再嵌入 8081 的受害者页面。由于顶层 attacker 页面和 victim 页面不同源攻击成功概率也因为浏览器跨域读取限制而增加。实测发现受害者脚本跑到访问 window.top.href 的那一步就抛异常了后续导航逻辑根本没机会执行。第四组实验在顶层页面挂 beforeunload。我把攻击者页面改成监听 beforeunload 的版本再嵌入受害者页面。当受害者尝试跳转顶层时浏览器弹出“离开此页面”的确认框点击“留在页面”之后一切照旧。这一组的结果也说明即使用户看到跳转请求机制上也可以被“劝退”。3.3 加固从脚本跳到响应头再用 CSP 收口实验结果都拿到了原有的单一 frame busting 脚本基本被证明防不住。这类问题的出路是什么我当时做的第一件事是关闭所有“只加一段 JS”的幻想直接上服务端加固。Nginx 或应用框架层一律加上add_header X-Frame-Options SAMEORIGIN always;这个配置表示同源页面可以嵌入跨域页面不允许嵌入。很多内部系统其实只用同源页面展示所以 SAMEORIGIN 已经够用。但 X-Frame-Options 有两个明显短板一是只能控制“是否允许嵌入”粒度太粗二是它不允许你声明“多个可信来源”。对需要产业园内部多个域名互相嵌入的场景就很吃力。于是我又配上 CSP 的 frame-ancestorsadd_header Content-Security-Policy frame-ancestors self https://trusted.example.com; always;这里self代表同源可嵌后面跟着白名单域名。CSP frame-ancestors 的语义更明确而且支持多源声明和 X-Frame-Options 同时下发时浏览器会优先参考 CSP。做完服务端配置之后攻击者再用 sandbox 或者 beforeunload 就都没用了因为浏览器在页面加载阶段就根据响应头决定是否允许 iframe 内嵌而不是等页面脚本跑起来才去“挣扎”。注意如果项目里存在一些历史页面可能需要被第三方系统嵌入比如支付回调页、报表页这种“一刀切禁止”会误伤业务。建议先用 frame-ancestors 按域名粒度配置白名单测试确认无误后再逐步收紧。4. 构建现代防嵌体系从 X-Frame-Options 到 CSP frame-ancestors4.1 X-Frame-Options 的“三板斧”和它的局限X-Frame-Options 算是最早被广泛支持的防嵌入响应头。它有三个值值含义适用场景DENY任何页面都不能嵌入高安全要求页面SAMEORIGIN只允许同源页面嵌入内部系统通用默认值ALLOW-FROM uri允许指定来源嵌入需要特定域名嵌入的页面ALLOW-FROM 看起来能解决白名单问题但很遗憾它的浏览器兼容性一直不好而且 Firefox 至今对 ALLOW-FROM 的支持都比较有限。所以如果你试图用 ALLOW-FROM 做“多域名白名单”很容易碰上不同浏览器行为不一致的深坑。实际经验是要分两条腿走老版本浏览器或某些中间件环境下用 X-Frame-Options 兜底现代浏览器环境则用 CSP frame-ancestors 做更精确的控制。两者都下发不冲突浏览器按“取更严格优先”的思路处理。4.2 CSP frame-ancestors更精细的“谁能嵌我”CSP 的frame-ancestors指令是专门管“谁可以把本页面放进 iframe”的。和 X-Frame-Options 相比它的表达能力强太多Content-Security-Policy: frame-ancestors self https://a.example.com https://b.example.com;这意味着同源页面、a.example.com、b.example.com 可以嵌入其他来源都不行。如果你对所有来源都“谢绝”就写Content-Security-Policy: frame-ancestors none;这是最严格的一种比 X-Frame-Options 的 DENY 语义更清晰。还有一个容易被忽略的点frame-ancestors 只会影响“谁可以嵌入自己”不会影响当前页面去 iframe 别人。这两个语义千万别搞混。很多人以为 CSP 里的 frame-src 和 frame-ancestors 是一回事实际上 frame-src 是限制“本页能加载哪些子帧”frame-ancestors 是限制“哪些父级能加载本页”方向正好相反。4.3 不能让 JS 彻底退休JS 补位和页面内兜底策略服务端响应头做好了脚本层就不需要了吗也不是。比如有些系统唯一入口是纯静态托管不方便配置响应头再比如一些 SPA 通过前端路由动态输出页面也要考虑“静态托管平台不支持 header 定制”的情况。这种时候脚本层的 frame busting 仍然可以作为兜底存在。但我会把脚本写得更健壮一点至少考虑三层逻辑(function () { if (window.top window.self) { return; } // 第一层直接导航尝试 try { window.top.location window.self.location; } catch (e) { // 跨域读取或导航失败时进入第二层 } // 第二层通过 location.replace 避免历史记录污染 try { window.top.location.replace(window.self.location.href); } catch (e) { // 第二层失败进入第三层 } // 第三层页面内遮罩提示避免用户误操作 var mask document.createElement(div); mask.style.position fixed; mask.style.left 0; mask.style.top 0; mask.style.width 100vw; mask.style.height 100vh; mask.style.background #fff; mask.style.zIndex 99999; mask.innerHTML p styletext-align:center;margin-top:30vh;font-size:20px;页面无法正常显示请点击右上角在浏览器中打开/p; document.body.appendChild(mask); })();这段脚本的逻辑是“能走导航就走导航导航不了至少用一层遮罩覆盖页面避免用户真的点到透明按钮”。这样即使跳转失败用户看到的是白屏提示而不是毫无防备的真实操作界面。注意遮罩方案只能作为临时兜底它会伤害正常用户体验如果你发现线上大量环境都命中遮罩那就说明响应头配置没生效要优先排查服务端设置。4.4 影响范围评估防嵌加固到底会影响哪些业务链路在实际落地时一定要评估“禁止嵌入”对现有业务的影响不能拍脑袋套策略。常见的受影响场景包括第三方系统通过 iframe 集成我们系统的报表展示页。移动端 App 内 WebView 使用 iframe 嵌入 H5 页面完成授权登录。后台管理系统中一个页面内嵌多个子系统页面。支付回调、消息通知等页面需要被外部系统以 iframe 方式唤起。我处理过的一个典型误伤某系统原本有一个“大屏展示页”设计成要被会议室控制台 iframe 嵌入结果我上线了 frame-ancestors self 之后大屏在控制台里直接白屏因为控制台域名和系统域名不同。最后只能单独为这个页面配置白名单域名而不是全局一刀切。所以落地顺序很重要先梳理页面清单按“必须可嵌、可嵌但只限同源、绝对不可嵌”分类再分批配置响应头并在灰度环境里把主要业务链路回归一遍。特别是涉及 SSO 跳转、iframe 间 postMessage 通信的页面更要多测试几种浏览器。5. 常见问题与排查技巧实录5.1 现场问题速查表直接抄作业以下这些坑都是我在项目里真实遇到并排查过的整理成一个速查表遇到类似问题可以直接对照。现象可能原因排查方向页面在自己站点 iframe 里白屏X-Frame-Options 设成了 DENY而嵌它的页面同域改为 SAMEORIGIN 或使用 frame-ancestors self配置了 CSP 但仍可被嵌入重复响应头覆盖或者配置写进了 meta 里但浏览器未生效用 curl -I 检查实际响应头CSP 尽量走响应头嵌入第三方域名时跳转又弹回顶层页面挂了 beforeunload 拦截这属于脚本层对抗必须靠响应头强制约束frame busting 脚本报跨域错误window.top 与 window.self 不同源导致读取异常改用不读取 top 属性的方式或用响应头做主防线老浏览器 CSP 不生效兼容性问题同时保留 X-Frame-Optionssandbox iframe 里防护失效sandbox 未给 allow-top-navigation别指望脚本层能解决加固必须以响应头为主页面被嵌入后立刻闪了一下才弹走脚本执行时机太晚在前置 HTML 中尽早执行脚本但彻底方案仍是响应头合法第三方 iframe 集成被拦截白名单漏配用 frame-ancestors 的域名白名单精确放开表格里最后两行尤其容易出现。页面闪一下再弹走看起来 frame busting 起作用了但“闪一下”本身就是暴露风险窗口用户眼睛快的可能已经看到了错误界面。而第三方集成被拦截则会让业务方误以为系统故障实际上是安全策略率先“动了手”。5.2 排查时最容易被忽略的三个细节第一个细节是配置了 CSP 后浏览器控制台不一定显示明显的失败原因。Frame 被阻止时有些浏览器会有提示但更多时候只表现为子页面空白。建议打开 DevTools 的 Network 面板检查页面响应头里 CSP 字段是否存在、值是否正确。用 curl 也可以curl -I https://your-domain.com/page.html重点看 HTTP 响应头里有没有 X-Frame-Options 和 Content-Security-Policy。第二个细节是如果同时存在 meta 标签 CSP 和响应头 CSP浏览器以响应头优先。但很多人只写了 meta 标签发现 no 生效就开始怀疑 CSP 没用。要记住 CSP 推荐通过 HTTP 头下发meta 方式对 frame-ancestors 的支持和兼容行为都有限。第三个细节是在 SPA单页应用里前端路由没有改变 document 的响应头。也就是说你在服务端只给 index.html 配了 CSPSPA 后续通过 ajax 加载的页面接口调用可不归 CSP 的 frame-ancestors 管。防护的对象是“HTML 文档本身能否被 iframe 嵌入”所以只要入口 HTML 受限一般就够。但如果项目里有动态生成 iframe 来加载业务页面的场景要多关注子页面的响应头是否有自己的策略。5.3 个人踩坑记录重复加响应头导致策略失效这个坑必须单独讲。在一个老项目中我用 Nginx 的 add_header 指令同时下发 X-Frame-Options 和 CSP本来想双保险结果发现页面反而被嵌入成功了。排查半天才意识到Nginx 的 add_header 在同一个 location 内如果多次使用只有最后一个会生效除非都带 always 或者把它们写在同一行。当时配置大概是这样的add_header X-Frame-Options SAMEORIGIN; add_header Content-Security-Policy frame-ancestors self;看着没问题实际上下发到浏览器的可能只有后一行。而有些老浏览器对 CSP frame-ancestors 支持不完整于是等于没防护。解决办法是把它们合并成一组指令或者确认所用 Nginx 版本对 add_header 的实际合并策略再决定怎么排版。另一个和它相关的坑是如果上游应用自己也在响应头里加了 X-Frame-Options而 Nginx 又加了一层就会变成多个同名响应头。浏览器对多个 X-Frame-Options 的处理并不一致有的取第一个有的取最后有的直接忽略。最稳妥的做法是在应用层和代理层只保留一处配置优先在应用层控制因为应用层的路由逻辑更清楚哪些页面需要放开白名单。5.4 再聊两个容易误伤的边缘案例边缘案例一**登录页。**很多登录页会集成在门户首页的 iframe 里这时候如果全局 SAMEORIGIN门户域名和认证域名不同就会被拦。而且登录页面被 iframe 嵌入本身就是一种常见的钓鱼风险所以建议认证相关页面用最严格的 frame-ancestors none如果业务需要门户集成再把门户域名加白。边缘案例二**大屏展示与数据报表页。**这种页面通常被会议室控制端或者另一套汇总系统嵌入跨域是常态。处理方式是单独把这些页面设置为 frame-ancestors 的白名单集合而不是沿用全局策略。同时建议在页面里加一层“嵌入来源校验”的脚本通过 postMessage 与父页面做一次握手确认对方在白名单内再渲染真实数据。虽然这样做会增加开发量但数据敏感时值得。我个人在实际操作里的体会是防嵌入这件事真正决定成败的往往不是技术难度而是你对“嵌入场景”有多少了解。列一下业务里所有 iframe 集成的真实来源再写策略远比死记安全响应头要重要得多。把 X-Frame-Options、CSP frame-ancestors、脚本兜底这三层串成一条链路再配合域名白名单和回归测试基本就能把 clickjacking 这类风险挡在门外。最后再分享一个小技巧上线前用浏览器的无痕模式打开攻击者页面模拟 iframe 嵌入能快速验证整套防嵌体系是否真的生效尤其适合检查 sandbox 绕过和 beforeunload 拦截这两条老路。