☰
XSS跨站脚本攻击从原理到防御:反射型、存储型、DOM型与安全编码实践
2026/10/17 5:20:31 网站建设 项目流程

有次做代码评审,我看到某位前端同事用innerHTML拼接了一段从 URL 上取来的参数,顺口提了一句“这里有 XSS”。对方很意外:“就拼个参数而已,还能出问题?”这种反应我见得太多了——XSS 这个词几乎每个写 Web 的人都听过,但“听过”和“真正理解”之间,隔着一条很深的认知沟。

XSS,全称 Cross-Site Scripting,中文叫跨站脚本攻击。它的核心很朴素:攻击者把一段恶意脚本注入到正常网页里,让它在受害者的浏览器中执行。能干什么?偷登录态、伪造页面、诱导用户操作、篡改内容,甚至在某些场景下直接接管账号。这篇文章不是概念复述,而是我这些年做开发、做代码审计、做授权测试之后,把 XSS 从原理到防御再到排查的一次系统梳理。适合后端开发、前端开发、刚入门安全测试的同学,也适合那些项目里被安全团队提了 XSS 工单,却不知道从哪儿下手的开发者。

1. 先搞清楚:XSS 到底是什么,为什么一段输出能把人打懵

1.1 从一段“看似无害”的代码说起

先看一个典型到不能再典型的例子。某个活动页,前端拿了 URL 里的 hash 做展示:

const name = location.hash.slice(1); document.getElementById('welcome').innerHTML = '你好,' + name;

看起来平平无奇,用户访问https://example.com/page#zhangsan,页面就显示“你好,zhangsan”。但如果你把地址改成https://example.com/page#<img src=x onerror=alert(1)>呢?innerHTML不会把这段内容当纯文本,而是会解析成 HTML,于是img标签被创建,src加载失败触发onerror,脚本执行。

问题不在“展示用户输入”这个动作本身,而在“用错了渲染方式”。很多 XSS 就是这么产生的:开发者不是故意留后门,只是不知道innerHTML、document.write、v-html这类接口会把字符串当代码解释。这也解释了为什么 XSS 明明是最经典的 Web 漏洞之一,却至今还在大量出现——它不是“不会”,而是“没想到”。

1.2 本质是信任边界被打破:浏览器分不清脚本是谁写的

要真正理解 XSS,得先理解浏览器的一个“傻白甜”特性:HTML 解析器遇到<script>标签就执行,它并不关心这段脚本是开发者写的、用户提交的,还是攻击者注入的。只要脚本处在当前页面里,它就拥有这个页面的完整上下文权限,能访问document.cookie、能发请求、能改写 DOM。

同源策略约束的是“跨源读取数据”,比如 A 站脚本默认不能读取 B 站页面内容。但它阻止不了的是:在 A 站自己的页面里,执行一段恰好生成出来的恶意脚本——因为这段脚本的“家”就是 A 站。

打个比方:一家咖啡店,店员把顾客随手写在便利贴上的话,原样贴到了店里的公告栏。贴上去之后,其他顾客看着公告栏,以为这些都是店方的正式说明。攻击者干的事情就是:趁店员不注意,递上一张写着“本店今日免费”的纸条,还故意写得像店长的笔迹。浏览器就是那个店员,它没有能力区分便利贴是顾客写的还是店长写的。

1.3 XSS 能做哪些事:危害比“弹个窗”大得多

很多人对 XSS 的印象停留在“弹个 alert”,这是最致命的误解。alert 只是漏洞存在的最小证明,真实攻击中几乎没人用它。XSS 的能力边界,取决于脚本在页面里能触达什么:

  • 窃取 Cookie 与登录态:document.cookie可能直接泄露会话标识,攻击者拿到后就能伪装成受害者。
  • 伪造页面与钓鱼:脚本可以改写整个 DOM,弹出一个“登录已过期,请重新输入密码”的框,用户输入的账号密码直接发给攻击者。
  • 篡改页面内容:删掉商品价格、替换支付二维码、在首页插入虚假公告,都是同一类手法。
  • 配合 CSRF:XSS 可以在用户不知情的情况下,替用户提交表单、发起转账或修改邮箱——因为这期间浏览器会带上用户的身份凭证。
  • 内网探测:在浏览器里发请求去探测内网服务,借助 DNS 或请求时间差异判断内网机器存活,为后续攻击铺路。

也正因如此,在业界公认的 Web 安全风险榜单里,XSS 常年稳居前列。几乎所有存在“用户输入 -> 页面输出”链路的系统,都可能是它的影响范围:搜索框、评论区、昵称、个人简介、富文本编辑器、URL 参数回显……覆盖面极广,这也是我把 XSS 列为所有做 Web 的人必须吃透的第一个漏洞类型的原因。

2. 反射型、存储型、DOM 型:三类 XSS 一次拆透

XSS 按触发机制分成三大类:反射型、存储型、DOM 型。很多人分不清,其实只要抓住一条线索——恶意脚本是从哪里来、在哪里被解析执行——就能理清楚。

2.1 反射型 XSS:恶意代码在请求里“逛了一圈”又回到页面

反射型 XSS 的典型场景是搜索页。你搜什么,页面就回显什么,比如后端代码可能是这样:

from flask import Flask, request app = Flask(__name__) @app.route("/search") def search(): q = request.args.get("q", "") return "<p>您搜索的是:" + q + "</p>"

攻击者把恶意脚本放在 URL 参数里,诱骗受害者点击。受害者的浏览器向服务端发请求,服务端把参数原样拼进 HTML 返回,脚本随之执行。整个过程里,恶意代码“反射”了一趟——从这里进,从这里出,不落地存储,所以是一次性的。攻击者需要让受害者点开特定链接,因此经常配合短链接、邮件、聊天消息投放。

任何“把参数回显到页面”的功能都可能存在反射型 XSS:搜索结果的搜索词、报错页的路径信息、跳转链接中的回跳地址等。它的危害范围取决于业务场景——如果页面有登录态,点击恶意链接后 Cookie 就可能被偷。

2.2 存储型 XSS:真正意义上的“定时炸弹”

存储型 XSS 是三类里危害最大的一种:恶意脚本被永久保存在服务端,比如评论区、昵称、个人资料、富文本草稿里。其他用户访问页面时,服务端把这段内容取出来渲染,脚本就在受害者的浏览器里执行,不需要诱导点击,不需要特别构造 URL,谁看谁中招。

流程拆开看很清晰:攻击者提交恶意内容 -> 服务端未做正确处理直接入库 -> 普通用户访问展示页面 -> 服务端从数据库取出内容拼进 HTML -> 浏览器执行脚本。传统论坛、评论区、Git 类代码托管平台的 issue 区域,都曾经是这类漏洞的“重灾区”。

更麻烦的是,存储型 XSS 具备“自我传播”的能力:恶意脚本在受害者的浏览器里执行后,可以自动抓取当前页面的表单、用受害者的身份继续发评论、甚至群发私信。历史上出现过借助存储型 XSS 快速扩散的蠕虫事件,几天内感染大量用户,就是这么滚起来的。

2.3 DOM 型 XSS:服务端完全不知情的前端漏洞

DOM 型 XSS 和前面两种有一个本质区别:恶意代码不需要经过服务端。它是纯前端逻辑问题,脚本通过location、document.referrer、localStorage、window.name等入口进入,又通过innerHTML、document.write、eval等“危险出口”被解析执行。

前面 1.1 的例子就是典型的 DOM 型 XSS:恶意负载全在 hash 里,永远不会发到服务端,因此服务端日志、Web 应用防火墙都看不到它。这让 DOM 型 XSS 的检测难度明显高于另外两类——传统安全设备只看 HTTP 流量,而 DOM 型 XSS 的“恶意代码”根本没进过流量。

单页应用、前端路由、搜索联想、数据可视化、需要在前端拼接 HTML 的场景,都是 DOM 型 XSS 的高发区。特别注意location.hash和location.search这类输入源,配合innerHTML、outerHTML、insertAdjacentHTML、eval、setTimeout('字符串')这类 sink,基本上就是漏洞的“标准配方”。

2.4 三类 XSS 速查对比

对比维度反射型 XSS存储型 XSSDOM 型 XSS
恶意代码存储位置不落地,就在 URL/请求里存储在服务端数据库不经过服务端,只存在于前端运行时
触发方式诱导用户点击恶意链接用户正常访问被污染页面用户点击带有恶意参数的链接,或触发特定前端逻辑
危害程度中高高(但需要前端存在危险 sink)
检测难度低低高,传统流量检测手段很难覆盖
典型产生环节搜索框回显、错误页评论区、昵称、富文本innerHTML 拼接路由参数、前端模板渲染

3. 在本地模拟一次 XSS 触发过程(授权环境)

说再多理论,不如亲手跑一遍。但先立个规矩:XSS 验证必须在本地环境、漏洞靶场、或者你有明确授权的测试目标上进行,不要拿线上别人的网站做实验,那既是违法的也是不道德的。我说的“本地模拟”,就是在一台自己的电脑上完成。

3.1 搭一个最小复现环境

用 Python 的一个轻量 Web 框架写一个几十行的搜索页做演示。注意,这是教学用途的“漏洞代码”,故意写得裸奔,请勿直接照搬到生产环境:

from flask import Flask, request app = Flask(__name__) @app.route("/search") def search(): q = request.args.get("q", "") # 危险写法:把用户输入直接拼进 HTML return f""" <html> <body> <p>您搜索的是:{q}</p> </body> </html> """ if __name__ == "__main__": app.run(debug=True, port=5000)

启动之后,浏览器访问http://127.0.0.1:5000/search?q=hello,页面正常显示“您搜索的是:hello”。现在试着访问http://127.0.0.1:5000/search?q=<script>alert(1)</script>,理论上应该弹窗。

不过这里有个现实问题:现代浏览器的内置缓解机制(比如某些版本 Chrome 的内容安全检查和 Firefox 的 XSS 过滤)可能会拦截这种“一看就很可疑”的反射型负载,导致 alert 不弹。这代表什么?不代表漏洞不存在,只说明浏览器帮你挡了一道。换一个不触发过滤器的验证方式,或者干脆看渲染后的 DOM 就能确认——脚本标签已经出现在页面里了。

3.2 三条攻击链的触发流程拆解

反射型的链路最直接:恶意 URL -> 浏览器向服务端请求 -> 服务端吐回含脚本的 HTML -> 浏览器解析执行。你可以打开开发者工具的 Network 面板,看那个请求的响应体里,<script>是不是原样躺在 HTML 里;再看 Elements 面板,确认脚本真的进了 DOM。

存储型的链路可以这样模拟:把/search改成一个会“记住内容”的留言板,比如用一个列表变量充当数据库:

messages = [] @app.route("/post", methods=["POST"]) def post(): msg = request.form.get("msg", "") messages.append(msg) # 危险写法:不编码直接入库 return "ok" @app.route("/list") def list_(): html = "" for m in messages: html += f"<div>{m}</div>" # 危险写法:不编码直接输出 return html

先用 POST 提交一条<script>alert(1)</script>,再打开/list,脚本就执行了。这就是存储型:恶意内容被“存起来”,任何后续访问的人都会中招。

DOM 型的模拟最简单,前面 1.1 的代码就够了,payload 放在 hash 里。注意差别:DevTools 的 Network 面板里,发出去的请求响应体是干净的,恶意负载根本不在 HTTP 报文里,只在本地被innerHTML解析了——这正是 DOM 型难排查的根源。

3.3 验证 XSS 时的常见误区与边界

我自己踩过不少坑,针对验证环节有几个经验:

  • alert(1)不是唯一验证方式,甚至不是好的方式。弹窗会阻塞页面,在自动化测试里也容易被杀软和浏览器拦。我更推荐用console.log(document.domain)这类无害验证,能证明脚本已执行且拥有当前域名的上下文,又不干扰页面。
  • 看到渲染结果里出现<script>标签就往上报,要谨慎。有些框架会把脚本标签转义成字符串,那叫“看起来有漏洞,其实没漏洞”。关键判断是:脚本到底有没有执行。
  • payload 不生效不代表漏洞不存在。可能是 WAF 拦截、浏览器内置过滤、上下文里的引号被转义等多种原因,需要通过观察服务端响应、DOM 结构来判断是哪一层拦截。
  • 测试时必须区分授权边界。本地靶场随便打,线上系统必须走正规流程——这一点没有任何商量余地。

4. XSS 防御实战:输出编码、CSP 与 HttpOnly 三板斧

防御 XSS 从来不是“加一个过滤器”那么简单。真正的防线是分层的,每一层负责阻断一类路径,漏掉一层就可能被撕开口子。

4.1 核心原则:把任何外部输入都当成不可信数据

先纠正一个最常见的错误观念:“我做了输入过滤,为什么还是被绕过?”因为过滤器的思路是黑名单——我列出一堆危险字符,把它们去掉。可黑名单永远有盲区:过滤了<script>,还有<img onerror>;过滤了script,还有svg;过滤了单引号,还有双引号、反引号、&#x60;实体编码……攻击者最擅长的事,就是研究“你没过滤什么”。

正确思维是:默认所有外部数据都是不可信的,在它进入 HTML 的每一个位置,都做对应上下文的“输出编码”——也就是把危险字符转换成无害的实体或转义序列。编码是白名单思维:不管输入是什么,在 HTML 正文里就把<变成&lt;,在属性里就把引号变成&quot;,脚本自然无从执行。

简单的判断标准:输入过滤是“拦”,输出编码是“防”。前者能降低风险、减少攻击面,但永远不能作为唯一防线;后者才是基础且必须做对的动作。

4.2 输出编码不是万能,但要分上下文做对

很多人以为“转义一次就安全了”,实际上不同的输出位置要用不同的编码方式,这是初学者最容易懵的地方:

输出位置危险字符处理示例典型手段
HTML 正文<->&lt;、>->&gt;服务端模板默认转义,或 htmlspecialchars 类函数
HTML 属性"->&quot;、'->&#39;属性值必须加引号,并对引号做实体编码
JavaScript 字符串'->\'、</script>-><\/script>使用 JSON 编码 + 不让字符串直接拼<script>
URL 地址空格 ->%20、javascript:协议头要拒绝使用 URL 编码,并进行协议白名单校验

举个实战例子:某开发者做了htmlspecialchars,很开心地说“转义了”。但它是这样用的:

<a href="<?php echo htmlspecialchars($url); ?>">点我</a>

如果$url是javascript:alert(1),htmlspecialchars 并不会处理这个协议头,浏览器点击链接照样执行脚本。这就是“上下文选错”的经典案例——在 HTML 属性里编码了,但 URL 属性还需要额外的javascript:协议拦截。

前端也一样。现代主流前端框架的插值语法默认带转义,这是一道很好的防线,但每个框架都有“逃生舱口”:有的提供了把字符串当 HTML 渲染的接口,有的允许动态模板编译。这些接口一旦被当成常规渲染方式使用,等于亲手把默认防线拆了。一个值得养成的习惯是:能用textContent就绝不碰innerHTML,能插值就绝不用动态 HTML。

4.3 CSP:给浏览器上一道“白名单保险”

输出编码做得再好,也架不住某个历史遗留接口漏了。这时候就需要纵深防御的第二层:CSP(内容安全策略)。它是一条响应头,告诉浏览器“这个页面允许哪些来源的脚本执行”。譬如下面这种配置:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-随机的nonce值'; object-src 'none'; base-uri 'none'

解释一下:default-src 'self'表示所有资源默认只允许从同源加载;script-src里加了nonce,则只有带对应 nonce 属性的<script>标签才被放行——攻击者注入的脚本标签上没有这个随机值,自然被拦下。

CSP 对反射型、DOM 型 XSS 的缓解效果尤其明显,因为这两类攻击依赖“额外插入一段脚本”,CSP 直接把它们挡在最外层。反过来,需要注意的是:如果你为了省事写了script-src 'unsafe-inline',就等于对 CSP 说“页面里内联脚本都放行”,那 CSP 基本是白设了。配 CSP 不能图省事,宁可先收紧再按需放开。

4.4 辅助手段:HttpOnly、SameSite 与输入校验

HttpOnly 这个属性每个写 Web 的人都应该知道:给 Cookie 加上HttpOnly; SameSite=Lax; Secure之后,JavaScript 通过document.cookie就读不到了。它能挡住“XSS 直接偷 Cookie”这条最经典的利用路径。

注意一个边界:HttpOnly 挡的是 Cookie 被读取,但拦不住脚本顶着用户的身份发请求——用户改密码、发评论、转账这些操作,脚本在页面上下文里照样能做。所以 HttpOnly 是“降低损失”,不是“消除风险”。它配合 SameSite 属性一起设置,还能顺便缓解一部分 CSRF 问题。

输入校验属于“减小攻击面”的辅助手段:手机号字段就只允许数字和+号、昵称字段限制长度、URL 字段强制用协议白名单。这些措施不能让一段输入从“不能有脚本”变成“真的有防御”,但能明显降低各种场景下的风险等级,尤其是配合输出编码使用时,绝大部分常规攻击路径都会被堵死。

5. 怎么揪出已有代码里的 XSS:检测方法与排查实录

比写新代码更痛苦的是检查老代码。老项目结构乱、历史包袱重,但排查 XSS 是有套路可循的。

5.1 手工排查的四个步骤

第一步,梳理输入点。把所有“外部数据进入页面的路径”列出来:URL 参数、POST 表单、Cookie、location.hash、document.referrer、localStorage、window.name。没有入口就没有注入,这一步做扎实,后面能省一半时间。

第二步,找危险渲染点。前端搜innerHTML、outerHTML、insertAdjacentHTML、document.write、eval、v-html,后端搜echo、print、字符串拼接变量后输出到 HTML 模板的位置。把每个输入点和渲染点的交叉路径标记出来。

第三步,构造探测 payload。用无害的最小负载验证,比如在输入里放一个独特的标识符,看它出现在页面的什么位置、有没有被转义。如果直接显示为&lt;script&gt;说明编码生效;如果原样出现<script>,基本可以确定有问题。

第四步,修复并回归复测。修复后把同样的 payload 再跑一遍,确认输出已经被正确编码,同时检查相关功能是否正常。

5.2 工具与代码审计的关键点

自动化工具能帮不少忙:开源 DAST 扫描器可以在 CI 流程里定期扫一轮,作为回归保护;代码审计工具能按“输入源 -> 危险 sink”的路径做静态污染分析,适合定位大型项目里的 DOM 型 XSS。但工具的盲区同样明显:对需要登录态的深度页面覆盖面不足,对上下文相关的编码绕判容易误报,对纯前端的 DOM 型 XSS 覆盖率也参差不齐。

工具的价值在于“广撒网”,真正的“精确定位”还得靠人。做代码审计时,我通常按这样的优先级看:老项目优先看搜索、评论、个人资料这类历史高危模块;新项目优先看富文本编辑器、文件上传后展示、活动页营销组件;最后再看所有用了字符串拼接 HTML 的角落。拼 HTML 的代码,永远是审计的重头。

5.3 我踩过的 XSS 排查“真坑”

聊两个实际案例,细节做了脱敏。

模拟项目 X 的评论区,开发者在用户昵称处做了输出编码,但在富文本编辑器的内容区,他用了一个“过滤危险标签”的正则。表面上看起来做了防御,测试时<script>也的确被清了。但绕过很轻松:用<img src=x onerror=...>就穿过去了,因为正则只过滤了 script,没覆盖事件属性。最后的修复方案是把“正则黑名单”换成基于白名单的富文本解析——只允许指定的标签和属性,其余一律剥离。这是存储型 XSS 排查里最典型的坑:过滤手段选错了。

另一个项目是前端 SPA 应用,路由参数直接通过innerHTML渲染到页面,服务端响应完全干净。扫描器没报,流量侧也看不见,最后是人工审计代码时发现location.search参数一路传到了innerHTML。这类 DOM 型 XSS 特别隐蔽,实践中一定要把“前端输入源 + 危险渲染接口”当成重点排查组合,不能只看服务端代码。

6. XSS 常见问题速查:开发者最容易迷糊的几个点

6.1 高频疑问与参考答案

疑问参考答案
我做了输入过滤,为什么还是被打?过滤只是辅助,输出编码才是基础防线,黑名单永远有空子可钻
<script>被过滤了是不是就安全了?不一定。事件属性、svg、javascript:链接等替代路径仍然能形成 XSS
前端做了校验,后端是不是不用管了?当然要管,攻击者完全可以绕过前端直接抓包发请求
框架插值默认转义,还需要做输出编码吗?大部分场景框架兜底了,但动态 HTML 接口(v-html 等)一旦使用就要自己做安全审查
开了 HttpOnly 就万事大吉了?没有。HttpOnly 只防 Cookie 被读取,XSS 还能伪造操作、发请求、改页面
有 WAF 就能挡住 XSS 吗?WAF 能提高攻击门槛,但 DOM 型 XSS 恶意负载根本不经过流量,WAF 看不见
测试时为什么不弹 alert 就是没漏洞?可能是浏览器内置拦截或 WAF 干扰,需结合响应体和 DOM 确认

6.2 给刚接触安全的开发者的几条实操建议

把这几条变成肌肉记忆,XSS 相关的问题能少掉八成:

第一,默认用安全的渲染方式。前端能用textContent就不用innerHTML,后端能用模板引擎的自动转义就不要手拼字符串。手拼一次 HTML,就是给 XSS 递一次刀。

第二,所有动态内容进页面时都要问一句“我输出的位置是哪里”。同一个变量,放在 HTML 正文、放在属性值、放在 JavaScript 字符串里,编码方式完全不同。搞不清上下文就写不出正确的转义。

第三,把 CSP 和安全响应头做成“开箱即用”的默认配置。新项目一上来就带好 CSP、HttpOnly、SameSite,而不是等安全测试发现问题再来补。补丁式的安全加固,永远比从设计阶段就植入的防线贵。

第四,上线前做一次快速自查。搜一遍代码里的危险渲染接口,看是否有输入源能触达;扫描器跑一轮,人工再复核高危告警。几分钟的事,能拦住绝大多数低级漏洞。

第五,别迷信“这个接口只有内部用”。内部系统的 XSS 一样能造成严重损失,钓鱼、横向攻击场景下,内网系统往往是优先级最高的目标。

做代码审计这几年,一个很深的感受是:XSS 不是那种需要多高深技巧才能防住的问题,它输给的是“图省事”和“我以为”。图省事就用了 innerHTML,以为前端框架转义了全局都安全,以为做了过滤就高枕无忧——等线上被人打了,才意识到每一个“以为”都是学费。我现在给自己定的规矩很朴素:字符串拼 HTML 一律不用,能 textContent 就不 innerHTML,富文本必须走白名单解析,上线前扫一遍危险接口。这套习惯养成之后,想被 XSS 坑一次都难。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询