
前阵子我帮一个内部项目做前端安全评估项目组后端同学很自信地拍胸脯说他们的接口全都做了输出编码和过滤XSS这种老问题肯定绝迹了。结果我打开项目的一个活动页在地址栏 URL 参数里塞了一小段畸形标签页面的消息横幅立刻弹出了当前站点的域名。问题压根不在后端而在前端几行看起来完全正常的字符串拼接上。这就是 DOM 型 XSS——攻击载荷在客户端本地被解析并执行服务端全程没有参与也不会在后端日志里留下任何可疑痕迹。它不像反射型、存储型那样在服务端有明确的输入输出镜像更像是页面自己的 JavaScript 把用户可控数据当成代码喂给了浏览器。接下来的内容我从漏洞原理、真实利用链复现、自动化扫描器的盲区一直写到客户端防御体系的落地方法覆盖前端、安全和测试三类读者的需求。就算你之前只接触过服务端 XSS也可以把这篇文章当成一次完整的认知补全。1. 为什么说DOM型XSS是“前端自己的漏洞”1.1 三种XSS的分类逻辑触发位置与数据流向大部分人刚接触 XSS 时都会按“反射型、存储型、DOM型”这个分类来记但很少有人想清楚分类标准到底是什么。我个人的习惯是看一句话用户输入到底在哪个环节变成了可执行代码。反射型 XSS是攻击者把 Payload 放在请求 URL 里服务端拿到参数后把这个参数原样拼进响应 HTML。用户点开链接浏览器解析服务端返回的页面脚本在解析 HTML 时执行。整个过程有一个非常明显的特征你去查服务端响应报文能在 HTML 源码里直接看到攻击代码。存储型 XSS是用户输入被服务端保存到了数据库或文件里之后任何用户访问该页面服务端都把这份“被污染的数据”输出到 HTML 中。它的杀伤力通常比反射型大因为一次植入长期生效所有访问者都可能中招。DOM 型 XSS 就不一样了。服务端返回的 HTML 完全正常攻击代码不在响应报文中。真正的漏洞藏在页面自带的 JavaScript 逻辑里——脚本从location、document.referrer、window.name、postMessage等客户端数据源读取内容经过一番拼接后直接赋值给了innerHTML、document.write、eval这类危险 sink。浏览器执行到这段脚本时恶意代码才被创建并执行。用一个类比来区分反射型和存储型相当于快递公司没有检查包裹把炸弹直接送到了你手上而 DOM 型更像是寄件人把炸药配方写在信封表面收件人自己看完配方之后在屋里现配现炸——“运输环节”完全合法爆炸发生在客户端内部。1.2 为什么DOM型XSS最难被发现这几年我做过不少代码评审和渗透测试说实话三类 XSS 里DOM 型的漏报率最高。原因其实不复杂就几点静态源码里看不到攻击载荷。反射型和存储型至少能在服务端响应里找到恶意字符串的“镜像”而 DOM 型的恶意代码在请求和响应中都不存在只有执行到特定脚本逻辑的瞬间才会生出来。漏洞触发依赖运行时状态。一段有问题的前端代码如果必须等用户点击某个按钮、或者等某个异步接口返回之后才走到危险分支那么普通爬虫和简单扫描根本触发不了。服务端防护设备看不到恶意信号。攻击者构造的链接往往就是一个看起来极其普通的 URL例如page.html#contentsvg/onloadalert(1)请求本身完全合法服务端日志里查不出异常。前端框架让数据流更难追踪。现在很多项目用数据驱动视图URL 参数先进全局状态管理再经过若干层组件属性传递最后才落到某个v-html或dangerouslySetInnerHTML上人工审计时很容易在半路丢失线索。我还遇到过一个更隐蔽的情况某项目对innerHTML做了输入过滤把script标签和javascript:协议都拦了看起来挺安全。但因为没有限制img的事件属性我换成img srcx onerroralert(document.domain)就直接弹窗了。这类“半吊子过滤”比完全不过滤更危险因为它给了开发者虚假的安全感。2. 高危DOM操作盘点攻击者的主要入口做 DOM 型 XSS 评估之前我一般会在代码仓库里先跑一轮关键词搜索把“危险 sink”全部捞出来。不用贪多常见的就三类HTML 渲染类、脚本执行类、URL 跳转与消息通道类。下面逐个说清楚方便你对号入座。2.1 HTML渲染类API最常见的一类入口前端把用户可控字符串直接塞进 HTML 渲染这是 DOM 型 XSS 最经典的形态。相关 API 主要是这几个innerHTML/outerHTMLinsertAdjacentHTMLdocument.write()框架层的v-html、dangerouslySetInnerHTML下面这段代码是我在某个内部活动页面里真实见过的写法简化版function renderNotice() { const raw decodeURIComponent(location.search.split(msg)[1] || ); const box document.getElementById(notice-bar); box.innerHTML div classnotice raw /div; }开发者的原意是让运营可以通过 URL 参数临时指定消息内容但问题是raw里的内容完全由用户控制。如果 URL 里是msgimg srcx onerroralert(document.domain)那么当页面执行到box.innerHTML ...时浏览器会把这段字符串当成 HTML 解析onerror事件随即触发脚本执行。document.write更特殊。如果是在页面解析期调用脚本可以通过/script提前闭合当前标签再注入新标签如果页面已经加载完成调用document.write会导致整个文档被重写。两类行为都可能被攻击者利用属于风险最高的 sink 之一。2.2 脚本执行与URL注入类API绕过渲染限制的捷径有些场景下开发者不会用innerHTML但会用下面的方式处理用户输入同样危险eval()/Function()构造函数setTimeout()和setInterval()的第一个参数传字符串location.href、location.assign()、location.replace()拼接用户输入a标签的href属性直接来自用户输入尤其是 URL 跳转注入很多人会忽略javascript:伪协议。举个最简单的例子const url new URLSearchParams(location.search).get(redirect); if (url) { location.href url; }攻击者构造?redirectjavascript:alert(document.cookie)页面就会把这段伪协议当作跳转链接执行。更隐蔽的手法是在url前加一些控制字符或换行前端如果用正则^https://开头校验可能被javascript:\nhttps://...这类手法绕过。在这个 sink 家族里eval反而是最容易引起警觉的团队基本会禁止真正容易被漏掉的是setTimeout字符串形式和location.href的直接赋值。建议把这两条也写进前端的危险函数禁用清单。2.3 消息通道类API来自其他窗口的注入单页应用里经常用postMessage做跨窗口通信比如嵌在 iframe 里的子应用向父页面传递数据。这块的漏洞模式比较统一window.addEventListener(message, (event) { const data event.data; document.getElementById(preview).innerHTML data.html; });问题在于监听器没有校验event.origin任何来源的窗口都能向当前页面发消息消息数据直接进入innerHTML没有经过任何净化。攻击者只要在自己的页面里内嵌目标站点 iframe然后向它postMessage一段恶意 HTML目标页面就会在毫无感知的情况下执行注入代码。此类漏洞在后台管理端、在线文档编辑器、低代码搭建平台这类“多窗口协作”场景里尤其常见。我整理了一张小表方便做代码审计时快速对照数据类型常见危险 API风险特征字符串 → HTMLinnerHTML、insertAdjacentHTML、document.write直接解析标签与事件Payload 形态最丰富字符串 → 脚本eval、Function、setTimeout字符串代码执行路径最直接但易被代码规范拦截字符串 → URLlocation.href、a.href、window.open可注入javascript:伪协议消息数据 → 页面addEventListener(message)跨窗口来源不可信容易成为供应链攻击入口3. 从一行可疑拼接开始完整复现一个DOM型XSS光讲理论没用我把一次真实的评估过程拆开给你看。项目背景是某个模拟的内部运营活动系统前端是一个静态页面所有配置通过 URL 参数下发不涉及登录态。这类“低风险前端页面”往往是 DOM 型 XSS 的重灾区因为团队默认它不重要反而放过了最危险的入口。3.1 漏洞定位先找数据源再追拼接点第一步不是翻逻辑而是搜索代码里的危险函数。我直接在项目里 grep 了innerHTML一共命中 30 多处。逐条看过去大部分是静态模板数据流不可控但有一条引起了注意const rawMsg new URLSearchParams(window.location.search).get(notice); const el document.querySelector(.announcement); if (rawMsg) { el.innerHTML b管理员通知/b rawMsg; }这里的数据源是location.search里的notice参数sink 是innerHTML中间只隔了一层模板字符串拼接。看起来平平无奇但rawMsg直接拼进了 HTML 上下文具备了注入条件。3.2 利用链构造与Payload设计第二步就是构造 Payload 验证。我先用最简单的一个/activity/index.html?noticeimg srcx onerroralert(document.domain)打开页面后alert弹出说明 HTML 注入成立。但我没有立刻下结论因为还要看有没有过滤或编码逻辑。接着测试了以下几种形态确保不存在“只是看着危险但实际被拦截”的情况如果rawMsg被某种方式过滤了script那么换成svg/onloadalert(1)还能不能触发如果过滤了尖括号试试lt;img srcx onerroralert(1)gt;看浏览器是否会二次解码后解析如果用replace只替换小写关键字那么用ImG sRcx Onerroralert(1)的大小写混合形式能不能绕过。实测结果这个页面没有任何过滤img起手就弹窗。整个利用链只需要三步攻击者构造恶意 URL → 诱导用户点击 → 页面执行到innerHTML赋值时注入触发。这里有一个值得强调的点DOM 型 XSS 的利用链通常不需要用户输入任何表单用户只是“打开了一个链接”。所以钓鱼场景极其好用这也意味着风险评估的优先级不能因为“页面不重要”而降低。3.3 修复与回归验证为什么不能只靠转义修复方案很简单优先推荐直接改 sinkconst rawMsg new URLSearchParams(window.location.search).get(notice); const el document.querySelector(.announcement); if (rawMsg) { el.textContent rawMsg; }textContent会把字符串当作纯文本渲染img标签只会原样显示不会触发解析。这是最省事也最可靠的方案。但如果产品要求支持部分富文本你就不能只用textContent了需要走“白名单净化 结构化渲染”的路线。我当时的处理是把允许的标签和属性列成白名单比如只允许b、i、a href其他一律剥掉然后在后台统一对富文本做校验。前端再配合 CSP 和下面要讲的 Trusted Types把风险兜底。修复之后一定要回归验证两个方向一是把原来的 Payload 原样贴回去确认不再触发二是构造一些边界情况比如超长字符串、带引号的字符串、Unicode 编码字符串看是否会出现绕过或页面崩溃。4. 自动化扫描器为什么常年漏掉它不少团队问过我一个问题我们每周都跑自动化扫描怎么还是会在前端项目里发现 XSS答案很残酷常规扫描器对 DOM 型 XSS 的检出率天然偏低这不是工具不努力而是漏洞形态决定的。4.1 扫描器在DOM型XSS上的结构性盲区先理解传统扫描器的工作方式。它通常做三件事抓取页面、解析 HTML、构造恶意请求并分析响应。这套流程对反射型和存储型是有效的因为攻击代码会在响应中“现形”。但 DOM 型 XSS 的恶意代码不出现在响应 HTML 里而是运行时由 JavaScript 动态生成。扫描器如果要发现它必须做到完整执行页面里的 JavaScript包括异步事件、定时器、路由切换跟踪每个变量在不同分支下的数据流判断用户可控数据是否最终进入了危险 sink模拟足够多的用户交互把页面推入存在漏洞的状态和分支。第一点不少工具能勉强做到第二点就涉及引擎级的污点分析能力第三点更是难上加难。很多前端页面有登录门槛、权限控制、状态流转扫描器要么进不去要么进去了但走不到那个特定逻辑分支。我在几个项目上做过对比测试同一批前端代码手动审计能发现 7 处 DOM 型 XSS自动化扫描只报出 2 处而且这 2 处还是因为页面里有明显的innerHTML拼接、能被启发式规则命中。凡是需要“先点击某个按钮、再等到接口返回、最后弹出一段数据”的链路扫描器基本全部漏掉。4.2 更可靠的手工排查思路五步数据流追溯法既然工具靠不住手工方法就得顶上。我的习惯是一份“五步走”清单配合浏览器自带的调试工具来用第一步列数据源。找出页面里所有用户可控的输入点location.search、location.hash、document.referrer、window.name、postMessage接收的数据、localStorage/sessionStorage里被其他页面写入的键值。第二步列危险 sink。搜索innerHTML、outerHTML、insertAdjacentHTML、document.write、eval、Function、setTimeout、location.href等。第三步连线。从数据源出发沿着变量赋值、函数调用、框架组件传递的方向看能不能走到某个 sink。这一步用调试器在可疑函数入口打断点单步跟踪最直接。第四步构造 Payload。不需要复杂先试最简单的img srcx onerroralert(1)再根据上下文调整比如javascript:协议、svg/onload、编码绕过。第五步验证和记录。确认触发后记录完整的 URL、触发条件、受影响的页面功能再写修复建议。这个过程通常比用扫描器跑一遍更慢但胜在准确而且能帮你积累对业务代码的深入理解。5. 客户端加固的防御体系从代码规范、CSP到Trusted Types修复单个漏洞容易真正难的是让整个前端团队不再制造新的 DOM 型 XSS。我建议按三层来做从代码编写层一直打到浏览器策略层。5.1 第一道防线危险API的替代与白名单净化最理想的状态是从源头消灭危险 sink。新代码里禁用innerHTML拼用户数据改用以下方式纯文本用textContent或createTextNode构建复杂 DOM 用createElement 逐属性赋值不要用字符串模板拼接框架场景里能不用v-html/dangerouslySetInnerHTML就不用必须用时优先考虑组件内部的结构化渲染。如果某个业务场景实在绕不开富文本渲染比如富文本编辑器、运营公告、邮件预览那必须引入成熟的白名单净化库对 HTML 做标签和属性级别的过滤再在服务端做同策略校验避免有人绕过前端直接提交脏数据。这里我得说一句自写净化函数是最不推荐的方案。你以为过滤了script、onerror、javascript:就结束了但 HTML 的解析规则里有大量历史遗留特性比如svg、math、noscript上下文切换、属性值里的字符实体解码没有完整的浏览器解析器知识很难覆盖全。用经过大量测试的成熟方案比自己造轮子安全得多。5.2 第二道防线CSP与Trusted Types的组合拳代码规范防得住自觉的团队但人在高强度迭代下一定会犯错。防御纵深的意义就在于即使某个开发者不小心写了一句危险代码浏览器也能把风险拦下来。CSP是最基础的策略。给页面加上响应头Content-Security-Policy: script-src self nonce-特定随机值; object-src none; base-uri none这个配置的意思是页面只能执行本站加载的脚本以及带有特定 nonce 的内联脚本。攻击者通过innerHTML注入的script标签没有 nonce浏览器会直接拒绝执行。配合report-uri或report-to还能把违规行为上报到监控平台等于给前端装了一个“越界报警器”。Trusted Types是另一个被低估的利器。它把innerHTML这类 sink 的入参强制收编为“受信任的 HTML 对象”普通字符串直接赋值会被浏览器拦截。启用方式是在 CSP 里加一条Content-Security-Policy: require-trusted-types-for script然后在代码里定义策略if (window.trustedTypes trustedTypes.createPolicy) { const policy trustedTypes.createPolicy(default, { createHTML: (input) { // 这里必须经过严格的白名单净化再返回净化后的 HTML 字符串 return sanitizeHTML(input); } }); const raw userControlledString; el.innerHTML policy.createHTML(raw); }一旦启用任何没走策略的innerHTML赋值都会在控制台报错相当于把“写错代码”变成了一件无法静默发生的事。我在推荐方案时通常把 Trusted Types 放在 CSP 之后两者配合效果最好。5.3 前端安全评审清单可以直接抄作业下面这份清单是我在前端代码评审时固定过一遍的你可以直接拿去作为团队的检查项列出页面全部数据源URL 参数、hash、postMessage、localStorage等搜索所有危险 sink逐个确认入参是否可能来自用户控制富文本输入一律走白名单净化前端和服务端双端校验所有 URL 跳转目标强制使用协议白名单拒绝javascript:与自定义协议postMessage监听必须校验event.origin和消息格式CSP 头在生产环境生效并配置违规上报有条件就启用 Trusted Types把危险 API 的入参类型强制收编。每一条看着都不难难的是每次迭代都坚持执行。我见过不少团队上线前才想起来过一遍安全评审发现问题一大堆最后只能加班返工。把这些检查项固化到流水线里把“新代码是否引入危险 sink”做成自动检查比事后补救稳妥得多。6. 边角战场DOM Clobbering、间接入口与前端信任边界讲到这里DOM 型 XSS 的主干脉络已经清楚了但还有两个边角战场容易被人忽略顺手也聊一下。6.1 从属性覆盖到函数劫持DOM Clobbering 的攻击思路DOM Clobbering 不是一种独立的 XSS 类型但它经常和 DOM 型 XSS 绑定出现。核心原理是浏览器会把带有id或name属性的元素变成全局对象属性攻击者可以利用页面上可控的 HTML 片段覆盖或创建原本不存在的全局变量。举个例子如果页面向innerHTML注入了攻击者可控的标签那么a idconfiga idconfig namesafe/a/a这段 HTML 会在浏览器解析后让window.config变成一个 HTML 元素集合。如果脚本里有类似if (window.config window.config.safe expected)的判断攻击者可以构造出满足条件的内存结构把整个判断逻辑架空让后续安全校验直接短路。我评估过一个内部系统页面的鉴权逻辑依赖一个全局对象const state window.appState || {}; if (state.isAdmin true) { showAdminPanel(); }当时代码里没有直接拼接用户输入到innerHTML但页面引用的一个小插件会把 URL 里的某个参数渲染成 HTML。攻击者注入一段带特定id的标签就能创建window.appState里被信赖的字段触发了“本不该出现的功能”。这类攻击的验证点不在于看 Payload 是否弹窗而在于看它是否改变了页面逻辑的判断结果。6.2 间接入口第三方SDK与后端JSON的前端信任危机还有一种情况更隐蔽后端返回的 JSON 本身没有直接拼入 HTML但前端拿到数据后为了渲染方便直接把它塞进了富文本容器。比如运营配置的公告内容存在后端管理端编辑时做了过滤但用户端读取后被前端模板当成 HTML 渲染。如果管理端过滤不严就相当于攻击者把脏数据存进了数据库用户端在某个时间点批量触发性质接近存储型 XSS但执行点依然在前端。这不是说后端不用做校验而是强调一个信任边界任何离开后端、最终进入前端 DOM 的字符串都必须被视为不可信数据。前端在渲染层做白名单净化、后端在输入层做校验两层校验面对的数据可能不一样原因也不一样不能互相替代。我在多个项目里推行过一个原则前端代码里凡是“要从外部数据中读取并展示的内容”都要先在数据层标注数据是否可信不可信数据通往innerHTML的路径必须显式经过净化函数。听起来有点笨但它让数据流变得可见后来代码评审时几乎不再需要靠人肉翻变量追踪了。写在最后几个亲测有效的落地建议回头看一下DOM 型 XSS 的攻防核心其实就一句话用户可控数据到底能不能变成可执行上下文。理解了这句话很多看似神秘的绕过手法都能推导出来。我个人的体会是防御做到位需要三层配合代码规范管住手CSP 和 Trusted Types 兜住底代码评审和回归测试守住每一次迭代。不要迷信任何一个单点方案也不要迷信任何一台扫描器——它可能帮你发现几十个反射型 XSS却对 DOM 型漏洞视而不见。最后分享一个小技巧前端团队可以在测试环境强制开启最严格的 CSP 和 Trusted Types 策略让所有“漏写净化”的代码在开发阶段就报错而不是等上线后再到生产环境排查。把安全左移到编码阶段成本最低效果也最明显。