DOM Clobbering:当HTML标签悄悄改写了你的JavaScript全局变量
先交代一个背景我在参与某个内部系统的安全评估时遇到过一段非常诡异的业务代码。防护规则已经拦掉了几乎所有带script、onerror之类的攻击样本但某种特定输入仍然能让页面行为偏离预期。一开始以为是 XSS 过滤器没写全后来排查到根因才发现问题根本不在脚本能不能执行而在于页面自身的 DOM 结构被输入污染了。这套技术叫DOM Clobbering。它的核心思路非常反直觉攻击者不需要往页面里注入可执行脚本只需要构造特定 id 或 name 属性的 HTML 标签就能覆盖 JavaScript 运行时依赖的全局变量、内置对象属性从而把业务逻辑静默改写。这篇文章我不打算只罗列攻击手法而是想完整拆解这条链路浏览器为什么允许这种覆盖、攻击者怎么把一次简单的标签插入变成逻辑颠覆、作为开发者和防御方应该从哪里下手以及我实测过的几种检测和修复方案。1. 一次诡异的 XSS 过滤器绕过问题根源不在脚本而在结构先还原一下我当时遇到的现场这比直接讲概念更容易理解问题的严重性。1.1 现场还原过滤器拦住了脚本却没有拦住逻辑某内部系统化名 A 系统下同的评论区允许用户提交富文本后端经过一轮白名单过滤后把内容渲染到页面。过滤规则不算差script、事件属性、javascript:协议都被处理掉了看起来该堵的口子都堵上了。但安全测试时我们提交了这样一段 HTMLa idisAdmin/a提交后回到页面刷新正常情况下页面顶部会有一段运营判断逻辑如果当前用户是管理员则展示管理按钮。结果提交完这段 HTML 之后管理按钮在普通用户账号下出现了。当时看到这个结果的第一反应是是不是 Cookie 或会话被伪造了但抓包后确认会话没有异常。真正的原因在浏览器开发者工具里一目了然在 Console 输入window.isAdmin输出结果不再是undefined而是一个HTMLAnchorElement对象。业务代码里有一段类似这样的判断if (window.isAdmin) { showAdminButton(); }在正常情况下普通用户没有isAdmin这个全局变量所以条件不成立。但a idisAdmin这个元素一出现浏览器自动把window.isAdmin指向了这个 DOM 元素真值判断直接通过管理按钮就被渲染出来了。1.2 排查过程从脚本过滤追到运行时环境顺着这个结果我做了几条排查路径确认输入没有被转义HTML 标签确实原样进入了 DOM没有变成字符串。确认不是 XSS 执行控制台没有报错也没有任何脚本被执行。确认是全局命名冲突删掉那段a标签后window.isAdmin恢复为undefined管理按钮消失。前后一对比问题的本质就浮出水面了过滤器管理的是可执行代码而这类攻击污染的是代码运行时所依赖的环境。一个 HTML 元素不执行任何脚本却改变了脚本的判断结果这让很多只关注拦截脚本标签的防护方案直接失效。1.3 为什么这类问题容易被安全团队放到最后排查我复盘了当时的排查过程发现几个容易误导人的点攻击样本看起来人畜无害一个a标签、一个img标签没有明显攻击特征。绕过的不是执行层而是逻辑层大多数 WAF 和过滤器关注的是危险代码特征对普通标签普通属性几乎不会拦截。影响面取决于业务代码写法同一个输入在 A 系统里毫无影响在 B 系统里可能就让越权分支被打开。这也是为什么我想把 DOM Clobbering 单独拿出来讲的原因它不在脚本注入这条主赛道上它污染的是更深一层的命名空间和隐式全局变量。要理解它得先把浏览器的隐藏机制翻个底朝天。2. 被忽略的隐式映射HTML 标识符如何变成 JavaScript 全局变量DOM Clobbering 之所以能成立是因为浏览器在解析 HTML 时有一套自动关联机制。这套机制对普通开发者来说几乎无感但它真实存在而且在所有主流浏览器里行为基本一致。2.1 浏览器替你做了哪些隐式全局声明在 HTML 标准里如果一个元素带有id属性那么这个 id 会成为window对象的一个属性。也就是说你往页面里放了一段img idlogo srclogo.png在 JavaScript 里就可以直接访问window.logo // 返回 img idlogo srclogo.png注意这不是某个浏览器私有的怪癖而是标准规定的行为。HTML 规范中有专门一节描述这种命名访问named access on the window object元素的 id 会被映射为window对象的可访问属性。同理带有name属性的表单控件input、select、textarea、button、form等也会被映射到window。所以下面这段代码也一样input nameusername可以直接访问window.username得到的是一个HTMLInputElement。2.2 名字冲突时的裁决顺序嵌套层级与属性优先级这里有个非常关键的细节当window上已经有了同名属性时新出现的元素不一定能覆盖它。浏览器有一套冲突裁决的规则大致顺序是显式声明的window属性优先级最高解析 HTML 过程中较早出现的命名元素有一定优先权但具体行为因浏览器而异表单、图片、链接、锚点、嵌入对象这些旧式元素在命名访问中有特殊地位。我实测过几个典型场景场景Chrome 行为Firefox 行为Safari 行为img idx后访问window.x返回元素返回元素返回元素window.name已被页面脚本赋值后再插入div namex脚本赋值优先脚本赋值优先脚本赋值优先多个元素同名 id返回 HTMLCollection返回 HTMLCollection返回 HTMLCollection嵌套form nameainput nameb/formwindow.a.b返回 inputwindow.a.b返回 inputwindow.a.b返回 input这几条规律几乎是通用的也构成了 DOM Clobbering 的地基。2.3 一层一层往下走的嵌套命名规则除了顶层window映射命名元素还支持层级访问。最常见的是form与表单控件的组合form idloginForm input namecsrfToken valueoriginal /form访问window.loginForm.csrfToken直接拿到了里面的input元素。如果攻击者在某个可注入点插入了一段恶意form结构而业务代码恰恰使用这种层级访问方式读取数据那就等于给整个命名空间都埋了雷。不仅如此form元素本身还有一个elements集合也可以通过window.formName.elements.token、window.formName.elements[token]等方式访问。层级越多可污染的面就越大。2.4 关键认知这是按标准实现的特性不是漏洞我花了很长时间才消化这个事实浏览器这种隐式创建全局变量的行为帮助了无数遗留站点在不写任何 JavaScript 的情况下也能用document.forms.formName或window.formName拿到表单数据这是老派 Web 开发的一条重要路径。也正因为如此浏览器厂商没有简单粗暴地禁用这个特性而是通过window属性冲突时的优先级规则做了一些限制。但这恰恰是攻击者可以利用的缝隙标准保证了行为的一致性攻击者也就有了确定性的武器。防御方不能只盯着这是浏览器的锅而要把这套映射规则当作威胁模型的一部分。3. 主流的 DOM Clobbering 形态从简单覆盖到深度渗透理解了基本机制之后再去看具体的攻击形态就顺理成章了。这些形态不是孤立的理论我在实际项目和漏洞测试中基本都遇到过下面按复杂程度拆开讲。3.1 基础形态任意标签覆盖全局变量最简单的用法就是利用id或name覆盖某段脚本依赖的全局变量img idconfig srcx如果业务代码里有window.config || {}这类逻辑这个 img 元素就会被当作配置对象后续读取config.xxx的地方会得到一堆undefined或者直接因为它是一个 truthy 对象而进入不该进入的分支。另一种基础形态是利用同名标签制造数组a iditems/a a iditems/a此时window.items不再是一个单一元素而是一个HTMLCollection。如果业务代码期望items是某个对象或者空值这个 HTMLCollection 会打乱后续迭代逻辑。有些第三方库会把这种元素集合误认为数组导致for...of、.length、[0]等操作行为异常。3.2 特殊标签的特殊能力不只覆盖属性还能覆盖方法普通div、img只能覆盖数据型属性但有几种标签具备更特殊的能力a标签它带有href属性且该属性本身也是可枚举的 DOM 属性可以用来覆盖某些依赖href或text字段的逻辑。form标签它可以作为命名空间的容器把子元素挂到它名下形成多级访问链。iframe标签它的name属性在旧式浏览器里可以直接对应window属性而且iframe.contentWindow还能提供独立执行环境影响面更大。object和embed标签在某些场景下它们与插件、外部资源加载联动能产生额外的加载副作用。我自己在测试时遇到过一种很隐蔽的利用攻击者在评论里插入a iddefaultAvatar hrefhttps://恶意地址/avatar.png/a而业务代码里有一段当用户没有设置头像时使用window.defaultAvatar || 默认头像地址的兜底逻辑。结果所有新用户的头像都指向了恶意地址页面加载时还会附带发起请求到该地址隐私和流量两方面的风险都不小。3.3 过滤与清洗场景下的漏网形态属性名和层级拆分很多防御方会下意识认为清洗 HTML 时把所有 id 和 name 属性移除不就解决了。实测下来情况没有这么简单原因有两个。第一属性存在多种等价编码形式。比如 HTML 实体编码、大小写混写ID、Name、属性值被截断或合并等清洗不彻底时仍能保留有效属性。第二多级元素嵌套可以把污染点藏得很深form nameadmin input nameisLogin /form即使某个标签的 name 被清洗掉攻击者仍然可以用两个或多个元素叠加把最终解释权交给浏览器的解析规则。比如一个form名字里塞了-或空格有些清洗器会过滤掉但真实浏览器对属性名的容忍度比预期高得多会生成怪异但可访问的属性。防御方要做的不是赌某种清洗规则够全面而是接受DOM 结构本身就是可被污染的输入面这个前提。3.4 影响面对比表我把常见形态的影响面整理成一张表方便开发和测试人员对照攻击形态典型标签污染目标潜在影响单元素全局覆盖img idxwindow.x条件判断失效、配置被替换同名多元素集合a idx/aa idx/awindow.x HTMLCollection迭代逻辑异常、属性读取偏差表单层级链form nameainput namebwindow.a.b深层属性覆盖、表单数据污染特殊标签副作用a idx hrefurlhref、text 等属性加载外部资源、内容替换结合 XSS 过滤器绕过任意标签合法属性过滤器的属性白名单过滤器形同虚设进入逻辑层利用这张表里没有很夸张的直接拿服务器权限之类的描述因为 DOM Clobbering 的实际效果更像组合拳的起手式它通常不会一步拿下系统而是先污染一个判断、一个配置、一个数据源给后续更深入的攻击创造条件。4. 逻辑颠覆的攻防推演几个可复现的破坏链路理论讲完我想用三个推演场景把从污染到逻辑颠覆这条链路走一遍。这些场景完全来自我在代码审计中看到的典型写法名字和细节做了脱敏替代但逻辑一摸一样。4.1 推演一权限判断被一个空锚点颠覆这是最经典也最容易被低估的场景。某管理系统的前端权限控制代码长这样if (window.currentUser window.currentUser.role admin) { initAdminPanel(); }假如攻击者在用户可编辑的自我介绍字段里注入了a idcurrentUser/awindow.currentUser变成了一个HTMLAnchorElementwindow.currentUser.role读取role属性时返回undefined但还是会返回一个 truthy 的 a 标签对象本身。问题来了if (window.currentUser window.currentUser.role admin)这段代码里window.currentUser.role是undefined所以实际上这个条件在Chrome 上不会成立——因为后半段比较会失败。但很多旧代码写的是if (window.currentUser) { initAdminPanel(); }这种只判断有没有对象的写法才是最危险的。我在测试那套老后台时用户信息接口都是登录后再异步拉取的在此之前window.currentUser一直是 undefined页面会先展示普通用户界面。但注入a idcurrentUser之后这个值变成了 truthy页面认为用户信息已经就绪直接走到了管理员分支从而暴露出一个本不该展示给普通用户的内部管理入口。这个推演想表达的核心观点是业务代码只要写过一次用全局变量是否存在来做权限判断DOM Clobbering 就有机会把 undefined 变成任意 truthy 值。修复方式是权限判断不能依赖变量存在性必须校验值的类型和内容。4.2 推演二功能开关配置被配置对象污染某个新功能上线时团队习惯用一个全局配置对象控制灰度const featureCfg window.featureConfig || {}; if (featureCfg.newFeature) { enableNewFeature(); }攻击者注入了embed idfeatureConfig srcxwindow.featureConfig变成一个HTMLEmbedElementtruthy但.newFeature是undefined。逻辑似乎还是进不了 enabled 分支不一定。有些配置读取代码会用in操作符或者遍历所有键if (newFeature in window.featureConfig) { enableNewFeature(); }DOM 元素本身有大量属性和方法比如src、type、dataset、children这些都可以被in探测到。攻击者选的标签不同暴露出来的属性列表也不同这给了防御者极大的不确定性。更直接的情况是代码里写死了if (window.featureConfig window.featureConfig.src) { enableNewFeature(); }那一个embed标签几乎就是量身定制的绕过。这里的教训是用全局对象承载业务状态本身就是一个坏味道。页面环境中充满了浏览器塞给你的不可信属性开发时觉得反正没人会去覆盖这么偏僻的名字安全测试时就会被反向利用。4.3 推演三表单序列化与隐藏字段的参数污染第三种形态更隐蔽它不污染全局变量而是污染表单的字段集合。假设一个搜索页面的 HTML 结构是form idsearchForm input namekeyword placeholder搜索关键词 input typehidden namecsrfToken valueabc123 /form攻击者如果能在页面里插入一段自定义 HTML哪怕不闭合标签都行就可以在form内追加同名控件input namecsrfToken valueattacker_value浏览器允许表单里有多个同名 input提交时会把所有同名值都发送给后端以什么顺序拼接或覆盖取决于后端框架的解析方式。不少后端框架取参数时是第一个值优先攻击者只要把自己的 input 放在更靠前的位置就能覆盖原始的 csrfToken。我甚至见过一种更刁钻的用法攻击者插入一个冒充按钮的元素button namesubmitType valueexportAll/button用户原本点击的是导出当前页但因为表单序列化时多了一个带exportAll值的同名控件后端接到了两个submitType其中一个覆盖了另一个最终实际执行的是导出全量数据造成数据泄露。这种场景下用户只是点了一个正常按钮攻击者也没有执行任何脚本一切看起来都正常但业务结果已经被篡改了。4.4 从客户端污染到逻辑颠覆的完整链路复盘综合上面三个推演我总结了一条通用的链路输入面某个表单、富文本、头像、昵称、自定义链接等用户可操控的位置允许插入有限的 HTML 标签。污染面这些标签携带的id或name属性自动进入window命名空间或表单字段集合。触发面前端脚本在某个时机读取这些被污染的全局属性、表单字段、配置对象。逻辑颠覆真值判断被绕过、配置分支被改变、表单参数被覆盖最终导致前端权限提升、安全功能关闭、数据导出范围扩大等效验级变化。这条链路最大的特点就是没有一步是执行恶意脚本因此常规的 XSS 拦截、CSP 限制脚本执行CSP 确实无法直接阻止 DOM Clobbering都很难兜住它。很多团队的安全体系能拦住执行攻击却拦不住数据污染攻击这就是 DOM Clobbering 要重点防御的核心痛点。5. 落到实处的检测与防御把隐式关联彻底暴露到明处讲完攻击面再说怎么防。这里我不打算给一个万能银弹方案因为 DOM Clobbering 的防御必须拆成编码习惯、输入清洗、运行时检测三条线同时做缺一条都有空子。5.1 代码审计时的重点检查清单先给出最实际的切入点从你的业务代码里搜出这些模式逐一排查。检查模式危险等级说明window.xxx直接做真值判断高最常见污染后直接触发分支xxx window.xxx || {}的兜底写法中默认值兜底了类型但对象来源仍不可信使用in操作符枚举全局属性高元素自带大量属性极易被利用依赖document.xxx的全局属性中某些 document 属性也有同名污染风险form.elements与隐藏字段的同名覆盖高后端解析顺序不可控时影响极大Object.keys(window)遍历全局环境低但需关注攻击元素会混入遍历结果排查时可以先用 ESLint 插件配合自定义规则禁止在业务代码里直接读写window.下未声明的属性对已有的老代码用脚本扫描window\.出现频率较高的文件优先处理被if直接包裹的那批。5.2 编码层面的防御模板从源头减少命名依赖最保险的编码风格是不在全局环境里挂任何业务状态。如果历史包袱太重至少要做到以下几点。第一用单一命名空间收口全局变量。与其四处散写window.isAdmin、window.config不如集中在一个window.AppState对象下并把这个对象初始化时用Object.create(null)创建避免它继承Object.prototype上的属性和方法window.AppState Object.create(null); window.AppState.isAdmin false; window.AppState.config { featureX: true };这样即使攻击者塞入div idAppStatewindow.AppState被覆盖成了 DOM 元素业务代码也会因为读取isAdmin得到 undefined 而走默认分支。至少不会出现因为某个隐藏属性恰好存在而误入分支的尴尬。第二权限和功能开关的判断必须做值类型校验而不是 truthy 判断// 错误示范 if (window.currentUser) { ... } // 正确示范 const user window.currentUser; if (user typeof user object user.role admin) { ... }第三不要信任表单自动序列化的字段名。对提交参数在前端做一次白名单过滤把允许提交的字段名显式列出来而非直接new FormData(form)一把梭。第四对富文本清洗时默认移除id和name属性除非有明确且安全的理由保留。很多主流的 HTML 清洗库在默认配置里就已经这样做了但要确认自己的调用没有打开保留 id/name之类的开关。5.3 运行时检测用 MutationObserver 盯住关键命名空间如果代码审计难以覆盖所有角落运行时检测就是第二道保险。我推荐两个实测有效的思路。第一个思路是在关键全局属性上做一层 getter 拦截。但直接去改window的原生属性很危险更安全的做法是在业务代码初始化阶段把关键判断统一收敛到一个受控函数里在函数内部先检查目标属性是否为 HTML 元素类型如果是立刻判定为可疑并记录日志。function safeGetWindowProp(propName) { const value window[propName]; if (value value.nodeType 1) { // 记录日志检测到疑似 DOM Clobbering console.warn(Suspicious DOM node as window property:, propName); return undefined; } return value; }第二个思路是监听 DOM 变化实时发现危险形态。用MutationObserver观察 body 下新增的子节点如果发现新增节点带有敏感命名的 id 或 name比如isAdmin、config、currentUser、csrfToken这些关键词就直接告警。这个方案对用户输入被渲染成 HTML的场景非常有效成本也不高const observer new MutationObserver((mutations) { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType ! Node.ELEMENT_NODE) continue; const el node; const id el.id || el.getAttribute(name); if (id SENSITIVE_NAMES.has(id)) { // 进入告警处理 handleSuspiciousNode(el); } } } }); observer.observe(document.body, { childList: true, subtree: true });需要注意的是MutationObserver只能观察到已经插入 DOM之后的节点如果攻击者在插入前动态修改属性可能会被遗漏。所以运行时检测更适合作为辅助手段不能替代输入清洗和编码规范。5.4 修复后的验证方法让回归测试覆盖污染场景修完代码不等于安全了必须有一个可复现的验证步骤。我的习惯是写一个最简单的污染测试页把攻击样本注入到目标容器里然后断言关键全局属性没有被异常覆盖。具体做法可以参考准备一个空白测试页面引入目标组件。在页面中插入带危险 id/name 的元素集合比如a idisAdmin、form nameconfig、input namecsrfToken valueevil等。执行一遍组件初始化逻辑。断言window.isAdmin的值类型符合预期例如必须为布尔值或 undefined禁用按钮不会因为元素存在而出现。把这组断言纳入自动化回归用例每次发布前跑一遍。我在 A 系统的修复过程中就是用这个方式验证的先在评论区提交a idisAdmin/a断言管理按钮没出现然后在自动化测试里加入相同样本保证以后不会再有开发者改出一版只看变量存在性的老代码而导致复发。另外一个验证技巧是在多人协作项目里把 DOM Clobbering 攻击样本加入安全测试用例库和 SQL 注入、XSS 样本并列。它会让新加入的同事在第一次接触项目时就意识到前端安全不只是过滤标签还包括命名空间污染。这个意识一旦建立起来整个团队的代码习惯都会更收敛。最后分享一点个人体会DOM Clobbering 最难防御的地方不是技术本身多复杂而是它正好踩中了开发者和防御者思维的一个盲区我们习惯把用户输入和页面结构当成两个互不干扰的东西但浏览器偏偏把它们缝合在了一起。一次我在给一个内部项目做安全评审时发现团队里每个人都认同不要相信外部输入但真正落实到代码里还是有人会在if (window.xxx)里写业务判断——因为这些代码在正常用户场景下跑了一两年都没出问题。直到攻击样本摆在面前大家才意识到没出问题和不可能被利用是两回事。所以我建议每个前端团队尤其是涉足复杂业务系统的团队把全局命名空间不再可信当成一条常识来看。代码评审时多问一句这个全局属性从哪来、会不会被 DOM 元素顶掉逐步养成习惯很多潜在的隐患就能在更早的阶段暴露出来。安全这件事说到底比拼的往往不是某次修复得有多漂亮而是大家能不能持续用同一种视角看待那些看似无害的输入。