前两天有个朋友在群里发了一个链接问我这个页面明明没有放任何支付按钮为什么我登录完再点一下账号就被改绑了。我随手把链接拆开发现 query 参数里被人塞了一段很难用肉眼直接看出来的 payload页面又把这段参数当成欢迎词原样回显出来了。浏览器执行脚本的那一刻会话凭证就跟着丢了。这不是什么小众 bug这正是 XSS 攻击Cross-Site Scripting跨站脚本攻击里最常见的反射型玩法而它长期稳居前端安全威胁的前列。很多没有真正做过前端安全的人以为 XSS 就是弹个窗修起来不过把script标签过滤掉。可现实是从数据接口、管理后台、文件上传到富文本编辑器每个位置都可能成为 XSS 的入口。这篇文章我想按自己的实践路径把这套体系讲透为什么 XSS 配得上“核心威胁”这个形容三种常见形态到底差在哪里前端和后端的防御边界怎么划以及怎么用 DVWA、Pikachu、PortSwigger 这些靶场把判断力练出来。无论你是刚入门的前端还是被安全团队追着改漏洞的服务端同学都可以找到能对照的场景。1. 先弄明白 XSS 为什么能当上“前端安全头号威胁”1.1 浏览器信任模型里的底层缺口XSS 能存在这么多年根子不在某个框架的 bug 里而在浏览器的信任模型上。浏览器在面对一个页面时默认会相信从同源服务器返回的所有内容包括 HTML 标签、CSS 样式和 JavaScript 代码。当你请求一个网址服务端返回了一段字符串浏览器不会去思考“这段字符串里哪几段是程序自己的逻辑、哪几段是用户填进来的数据”它只会按既定的解析流程执行。很多开发者第一次接触 XSS 时都会问服务器返回的明明是正常页面怎么会把用户的输入当成代码执行问题就出在拼接。如果服务端把用户输入直接用字符串拼到了 HTML 里比如p欢迎回来 name /p当 name 等于img srcx onerroralert(1)时拼出来的就不再是一句欢迎语而是一个新的 HTML 标签和一个事件回调。浏览器不会为刚才那几行字符串做审查它只负责把标签当作标签渲染、把脚本当作脚本执行。这种信任关系很像一个小区的安保只要穿了制服执勤的人就放行完全不看证件。攻击者利用的就是这一点他们会想办法让自己输入的那段内容“穿上”HTML 或 JavaScript 元素的制服混进执行区。当你真正理解了这一层就不会再对“为什么删除script标签还不够”感到困惑。1.2 危害边界从盗 Cookie 到账号接管XSS 不是只能弹窗它最大的杀伤力在于继承受害者在当前站点上的权限。它能在当前页面上下文里执行任意 JavaScript所以能做的事几乎取决于业务功能有多少读取 Cookie、LocalStorage、SessionStorage 里的凭证。如果站点没有给 Cookie 加 HttpOnly攻击者直接把document.cookie回传出去就能伪装成用户身份。以用户身份调用接口。改密码、改绑定手机、下单、转账、删数据脚本在用户会话里发请求后端根本分不清是用户主动操作还是被脚本触发的操作。键盘记录与表单劫持。脚本可以监听键盘事件也可以在页面上动态插入一个假的登录框等用户自己把密码输进去再悄悄传走。钓鱼与诱导。XSS 可以直接修改页面内容把原本的页面换成一套仿冒界面用户看到的是带可信域名的页面信任感天然就高。在社区类产品里还可能扩散成蠕虫。存储型 XSS 一旦被批量灌入所有访问者都会中招访问者又可能继续写入新的 payload形成自动传播。还有一个容易被忽略的点HttpOnly 能挡住document.cookie但挡不住其他危害。XSS 脚本依然可以带着用户的身份直接发起请求、读取页面上的敏感数据、篡改页面展示也可以从 LocalStorage 和 SessionStorage 里拿走前端保存的 Token。所以 HttpOnly 只是降低损失不是解决 XSS 本身。1.3 为什么现代框架年代它依然层出不穷可能有人会说现在 React、Vue 都默认转义变量为什么 XSS 还是那么多默认转义确实挡掉了最常见的文本回显型 XSS但新问题出现在那些绕过框架默认保护的 API 上dangerouslySetInnerHTML、v-html、innerHTML、富文本组件、URL 参数与前端路由的场景。这些 API 都是开发者主动把字符串当 HTML 或代码来用的地方只要有一处没做规范处理前面默认转义的保护就全部绕过了。框架只能处理它认识的输出路径处理不了业务上自己接进来的 DOM 操作。所以技术栈再新也不能理所当然地认为“框架帮我安全了”。这也是为什么 XSS 直到今天仍然是前端安全里最值得投入精力去理解的一类漏洞。2. 反射型、存储型、DOM 型三者的攻击链路拆解2.1 反射型 XSS一次精心构造的链接点击反射型 XSS 最适合作为理解 XSS 的起点。攻击者不把 payload 长期保存在目标服务器上而是放在请求的 URL 里诱导受害者点击。服务器不做过滤就把参数拼进响应并返回浏览器又把这个响应当合法 HTML 渲染。举个例子如果一个搜索页把关键字直接回显到页面标题区构造这样的链接https://example.com/search?keyword%3Cscript%3Ealert(document.domain)%3C/script%3E用户点击后服务器把 URL 解码后的 keyword 拼到h1里浏览器解析时就会执行这段脚本。完整链路就是构造恶意链接诱导点击服务器回显参数浏览器执行脚本。攻击者不需要碰你的数据库也不需要提前在页面上留任何东西一条链接就够了。现实里的反射型 XSS诱导这一步通常靠钓鱼邮件、短链接、社交平台私信。只要你没有意识到 URL 里藏着 payload点下去之后事情就发生了。在 CTF 或靶场环境里这类题目经常要求你构造 payload 后拿一个回调平台确认脚本真的执行了比如你在自己可控的服务器上搭一个简单接收点观察目标浏览器是否带着 Cookie 访问过来。这套验证方式在防御测试里也经常用。2.2 存储型 XSS数据库里埋着最阴的定时炸弹存储型 XSS 是三种 XSS 中危害面最大的。攻击者把 payload 提交到服务端服务端存进数据库之后任何一个用户访问展示该数据的页面都会触发。它不需要受害者点击特定链接你什么都不做只要打开那个页面就中招了。典型的入口是评论区、用户昵称、个人签名、留言板、简历字段。比如一条评论里写了scriptfetch(/api/account/change-password, {method:POST, body:newpass123456})/script其他用户访问这个页面时脚本就在他们各自的浏览器里以自己的身份发起请求。更糟的是如果管理后台同样渲染这批评论管理员打开后台时也会触发攻击者可能借此以管理员身份执行高危操作。存储型 XSS 的一击往往是全站用户级别的。这类漏洞之所以容易被人忽略是因为攻击者在提交时看到的只是“评论发表成功”没有任何执行反馈但数据已经在数据库里扎下了根。防御上需要重点关注凡是“保存用户输入并在页面回显”的功能都要按不可信数据处理。2.3 DOM 型 XSS前端代码自己把危险接进页面DOM 型 XSS 在三者里最隐蔽。它的特点是服务端可能根本没有在响应里拼接你的输入漏洞只存在于前端 JavaScript 代码的执行流里。攻击者的输入通过 URL 参数、location.hash、postMessage、window.name这些来源进入页面前端脚本把它取出来又用innerHTML之类的方式写进 DOM最终造成脚本执行。看一段简单的示意// 假设这是页面上某段欢迎逻辑 const user new URLSearchParams(window.location.search).get(user); document.getElementById(welcome).innerHTML Hello, user;访问https://example.com/?userimg srcx onerroralert(document.domain)浏览器解析 URL 时会自动解码前端脚本拿到user值后直接塞进innerHTML这段输入就被当成 HTML 解析了onerror触发了脚本执行。DOM 型 XSS 为什么更难发现因为 payload 不经过服务端逻辑服务端日志里干干净净扫描器也很难从响应内容中找出异常。修复时也不能只依赖后端过滤器必须从前端代码层面排查危险函数和不可信数据源的组合。常见的危险数据源包括location.search、location.hash、document.referrer、postMessage消息、window.name常见的危险出口包括innerHTML、outerHTML、document.write、insertAdjacentHTML、eval、setTimeout、setInterval。防守方一定要对这两类函数做代码审计。2.4 三种形态的对比维度反射型存储型DOM 型payload 存放位置URL 参数服务端数据库客户端内存 / URL 片段服务端是否拼接通常是通常是往往不参与攻击者是否需要诱导需要点击链接不需要访问即触发通常需要点击带毒链接检测难度低中高典型入口搜索、跳转评论、昵称、留言前端路由、动态 DOM反射型像别人递给你一把发烫的勺子你得伸手去接才会伤到存储型像有人在门口挖了个坑只要你走那扇门左脚必中DOM 型的坑更深有时候挖坑的和埋坑的都是前端自己。3. 前端侧防御输入校验与输出编码的取舍逻辑3.1 输入校验的边界感按业务类型管别全站一刀切先厘清分工输入校验不是把全站的尖括号都删掉而是根据字段的业务类型约束它的字符集和格式。用户名可以用白名单规则限制只允许字母、数字、下划线邮箱和手机号做格式校验订单号限制为固定格式。这类结构化数据白名单校验既好用又安全。但评论、文章、签名这些自由文本你不可能禁止用户输入或因为它们在内容表达里是有意义的。对这类字段前端能做的是提示和辅助校验真正的手段要放到输出时去处理要么选择 HTML 实体编码要么用白名单标签过滤。不要因为想省事就用一条全局规则把所有用户输入的 全部替换掉。输入校验解决的是“用户是否输入了非法业务值”而不是“用户是否输入了代码”。把这两件事混在一起轻则业务数据被改坏重则过滤不彻底仍然被绕过。3.2 输出编码需要上下文感知五个位置五套规则输出编码是防御 XSS 的核心动作。关键点在于输出位置不同需要的编码方案也不同这就是安全领域常说的上下文感知编码。输出位置典型场景对应手段HTML 文本节点p{data}/pHTML 实体编码转义 HTML 属性input value...属性值编码并避免拼接危险协议URL 上下文a href...URL 编码协议白名单 http/https/mailtoCSS 上下文内联样式CSS 转义不建议允许用户控制样式JavaScript 上下文JS 字符串变量JS 字符串转义JSON 用JSON.parse而不是eval同一个输入放到不同上下文要套不同方案。比如输入quot; onmouseoverquot;alert(1)放到 HTML 文本节点时只是普通字符但放到属性值里就可能是新增属性注入。如果你只做了一种全局转义就一定会出现某个上下文漏网的情况。3.3 框架转义的盲区v-html、dangerouslySetInnerHTML 与富文本现代框架默认转义导致很多人产生安全错觉。真正平时最容易出事的三处第一模板里的{{ }}或{ expression }是安全的但只要你用了dangerouslySetInnerHTMLReact、v-htmlVue或直接操作innerHTML就绕过了默认转义。这些 API 设计出来是为了处理真正的 HTML不是给用户输入直接用的。第二富文本编辑器输出不能只做简单标签替换。很多编辑器给的是带样式的 HTML 片段直接回显时如果没做白名单过滤img onerror、a hrefjavascript:...都可能被带进来。生产环境建议用 DOMPurify 或 sanitize-html 这类成熟的净化库只允许你自己确认过的安全标签和属性比如b, strong, i, em, u, p, span, a[href], img[src, alt]并且对href和src做协议白名单检查。第三URL 属性里的javascript:协议很阴。即使你把字符串存进去了只要属性值是javascript:alert(1)用户点击时照样执行。所以对href、src、style这类属性必须单独做协议白名单不能只依赖整体转义。4. 后端兜底防线全局过滤器、HttpOnly、CSP 的组合拳4.1 Spring Boot 全局过滤器能做到什么程度如果你在一个 Java 技术栈的项目里最顺手的第一步是在 Filter 里加一层全局 XSS 兜底。它的目的不是替业务做全部转义而是把明显带标签的恶意输入挡在接口层尽量不让其进入业务逻辑和数据库。一个简化的思路是这样定义 Filter把原始请求包装成重写了参数读取方法的 RequestWrapper在读取参数时统一做一次清理或编码。Component Order(1) public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; chain.doFilter(new XssHttpServletRequestWrapper(req), response); } }包装类里重写取值方法public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { Override public String getParameter(String name) { String value super.getParameter(name); return XssUtil.stripXss(value); } Override public String getHeader(String name) { String value super.getHeader(name); return XssUtil.stripXss(value); } }如果是 JSON 请求体还需要先缓存ServletInputStream再从流里读出 body 做清理否则原生的getParameter拿不到 body 里的内容Controller 也可能因为流被消费掉而取不到参数。但这里有一个度的问题全局过滤器适合处理明显的恶意特征但不要把所有用户输入的 全部 HTML 实体化。有的团队从这里开始把用户名、文章、留言全转义了结果用户写了个1 2富文本渲染时数据全乱了。业务数据在输入层被无脑转换是一条非常危险的路。4.2 文件上传场景里 XSS 的隐藏入口PDF 与文件名都有份最近经常有人问 Spring Boot 全局过滤器怎么处理上传 PDF 时的 XSS 攻击。从实际踩坑来看文件上传流程里的 XSS 大多出现在两个地方。一是文件内容本身。用户上传一个包含 JavaScript 的 HTML 或 SVG 文件如果浏览器直接在同源路径下打开脚本就能运行PDF 文件也可以内嵌脚本。所以不要把上传目录当作应用的静态资源目录随意展示。建议把上传文件放到独立的域名或 CDN 上对用户上传的 HTML、SVG 这类文件强制返回Content-Disposition: attachment让浏览器下载而不是打开渲染。对 PDF 等文件返回时也要加上X-Content-Type-Options: nosniff减少 MIME 嗅探带来的风险。二是文件名回显。这是很容易被忽略的坑。页面上任何出现原文件名的地方都在做输出如果服务端没有转义就存在反射型 XSS 的可能。攻击者上传一个文件叫1img srcx onerroralert(document.domain).png后台把原始文件名回填到列表页时如果没有做转义或过滤这段内容就会被浏览器当成 HTML 解析。更省事的做法是服务端直接忽略原始文件名给文件另存为一个新名字用 UUID 或时间戳重命名这样文件名本身就不再是可信输入了。文件上传场景的防御重点应该是存储隔离、响应头防护、文件名重命名这三件事做对之后比在过滤器里扫描整个 PDF 字节流简单可靠得多。4.3 HttpOnly、SameSite、CSP就算漏了也让利用链条断掉防御的第二层是让已经发生的 XSS 无法转化成真正的危害。HttpOnly 是最熟悉的选项它让浏览器禁止脚本访问document.cookie于是前端 JS 就偷不走会话 Cookie。配合 Secure 和 SameSite效果更好Set-Cookie: sessionabc123; HttpOnly; Secure; SameSiteLax但要注意Token 如果存在 LocalStorage 里HttpOnly 管不着XSS 依然能通过 JS 读走。所以这层只是断链不能替代前面的修复。CSP内容安全策略是现在越来越重要的一层兜底。一个相对合理的配置长这样Content-Security-Policy: default-src self; script-src self nonce-首页随机值; object-src none; base-uri none;script-src限制脚本来源object-src把 Flash 等遗留插件的利用面关掉base-uri防止攻击者用base标签篡改页面里所有相对 URL。这种配置下即使攻击者注入了一个script标签只要它没有合法的 nonce浏览器同样拒绝执行。CSP 要作为纵深防御而不是唯一防线因为 DOM XSS 借由内联事件属性或具有 nonce 的动态脚本仍然可能打穿。建议先在Content-Security-Policy-Report-Only模式下跑一段时间把业务上有影响的脚本来源先理清楚再强制执行。老项目开启严格 CSP 时常见的问题是第三方 SDK 脚本来源太杂导致白名单越改越宽最后失去意义。所以一开始就要尽量保持script-src收窄能用 nonce 就别用域名白名单。5. 在 DVWA、Pikachu、PortSwigger 靶场里练出来的判断力5.1 靶场分级设计低、中、高级各练什么很多人学 XSS 时会搜到 DVWA、Pikachu、ctfshow 这类靶场。我赞成用靶场练习因为靶场把环境隔离在一个安全可控的容器里你可以反复尝试 payload 而不担心污染线上。我的练习顺序是这样安排的先在 DVWA 的 low 安全级别里把 Reflected、Stored、DOM 三种类型各跑一遍重点不是拿什么 payload 弹窗而是观察输入提交后到了哪里、服务端响应里它出现在什么位置。再把安全级别调成 medium 和 high看同一个漏洞在加了过滤规则后怎么变化。这个阶段你会开始理解“为什么过滤了script依然可能被绕过”。然后到 Pikachu 做场景补全。Pikachu 的中文界面更贴近实际业务像留言板、个人信息、文件上传这些场景和真实项目里的分布很像。最后用 PortSwigger 的 XSS labs 做专项训练。它的题目会把“输出上下文”明确指出来比如“在属性中输出”“在 script 块内输出”“在 href 链接中输出”练完你自然就会养成上下文敏感思维。在 CTF 题目里刷反射型 XSS 时通常需要构造一个目标 URL并用回调平台确认脚本真的执行成功。关键是先判断回显点在哪个 HTML 上下文再决定 payload 的写法而不是一上来就瞎试。靶场不是只供攻击者参考防御者同样要自己上手拉一遍才知道自己搭的过滤规则和响应头防御到底能不能挡住 payload。5.2 常见的过滤绕过思路为什么要从防御者视角看一遍很多项目上了简单过滤器之后仍然在攻防演练里被突破原因是对绕过形态没有概念。这里不是要教人去攻击线上系统而是想说明防御者如果不知道攻击者有哪些思路就不知道该在哪个环节补测试用例。常见绕过大于按大小写混淆。script被过滤后就换ScRiPt。HTML 标签名不区分大小写依然能执行。换承载标签。script被过滤后可以用img srcx onerror...、svg onload...事件属性里照样可以放脚本。换协议入口。a hrefjavascript:...、iframe srcjavascript:...只要输出点能控制 URL 属性就不需要标签本身。多层编码灌入。在最终渲染之前URL 解码、HTML 实体解码、JSON 反序列化都可能发生攻击者可以把 payload 编码一两次到最后一层才还原出真正的script。所以过滤器的存活能力不能只看“删没删掉script”而是要看“即使被删了一层浏览器在最终解析时还认不认识这段内容”。测试用例里至少要覆盖上面这四类结果才有一点参考价值。5.3 手工验证与自动化扫描的配合时机我见过不少团队买了 WAF 和扫描器就认为高枕无忧了。但在 DOM XSS 面前WAF 的作用有限因为 payload 经常不经过服务端比如location.hash根本不会发到服务器上。所以测试时必须结合手工手段。手工验证时我会打开浏览器 DevTools先看 Network 里页面真实返回的响应再在 Elements 面板观察输入插到了哪个节点最后在 Sources 面板里跟踪危险函数和不可信数据源的调用链。危险出口之外的入口比如postMessage监听器、页面间传参都是自动化覆盖率很低的地方只能靠人推代码逻辑。自动化扫描器适合做全站范围的回归测试但它的漏报率很高。像“先发表评论再跳到列表页触发”这种多阶段交互扫描器往往走不通出了报告最后还是需要人来做二次验证。我的做法是给每个关键接口写一张测试用例表包含输入点、回显位置、payload、预期输出、实际结果。上线后在 CI 里跑一轮自动化回归可以显著减少“明明修好了上线又复现”的尴尬。6. 我在真实项目里反复踩过的 4 个 XSS 防御误区6.1 以为前端过滤就算处理后端直接存原始数据很多团队在前端提交接口前把script全局替换为空后端一看数据里没有标签就放心入库了。但攻击者完全可以绕过前端界面直接给后端接口发一段完整 payload。只要后端的入库口没有限制XSS 依然成立。验证的目光必须放在服务端入站校验和输出转义两端而不是某一端。6.2 把 innerHTML 换成 textContent 就以为没风险了textContent 会把内容作为纯文本处理确实不会执行script。但只要用户数据被拼进href、src、onclick这些属性里或者通过document.createElement拼接 URL就还是会掉进javascript:这类协议陷阱。有一次我修完一个弹窗把innerHTML改成了textContent却忘了另一处imgSrc是从 URL 参数里取的攻击者构造javascript:alert(1)之后照样触发。正确做法是对属性类输出单独做协议白名单校验。6.3 在输入层把全站特殊字符转义业务数据被改坏这个坑特别常见。强行在过滤器里把所有 替换成lt;gt;用户的数学笔记、技术文档、代码示例会全部错乱存入数据库后再取出还得做一层反转义反转义又可能引入新的绕过。正确归属是输入层只做业务格式校验和恶意特征拦截真正按上下文编码放到输出渲染层去做。这样数据在存储时是原始的展示时才按场景决定怎么编码。6.4 只验证一条 payload没测编码和解码链路以前我习惯确认alert(1)不弹了就以为修好了后来安全团队用双重编码的 payload 绕过原因是中间某层做了一次解码到了最终渲染位置才把 payload 还原成标签。从那次之后我的测试要求变成所有输入参数至少用三组不同形态的样本测分别是正常文本、标签类 payload、编码类 payload并且每次都要在浏览器的 Elements 面板里看最终渲染位置是否出现了不该出现的节点。踩过这几轮坑之后我现在的经验是XSS 防御不是加一个过滤器就完事而是把“用户输入永远是不可信数据”这条原则贯穿到所有数据流转环节。前端负责交互体验和第一层约束后端负责入库边界和最终输出转义CSP、HttpOnly 这些响应头再兜一层。如果你所在的团队只有你一个人懂这块可以先从第 4 节的 Spring Boot 全局过滤器和响应头做起再逐步推动前端把危险 API 收敛掉。最后提醒一句在自己能完全控制变量的靶场里测试 payload不要在线上环境试这是底线。
