☰
XSS跨站脚本漏洞:原理剖析、挖掘技巧与过滤器绕过实战
2026/10/21 5:53:46 网站建设 项目流程

做Web漏洞挖掘这几年,XSS一直是我很喜欢聊的方向。原因很简单:它不像注入类问题那样需要复杂的数据库交互判断,也不像越权那样要逐个接口梳理权限模型,XSS的入口往往就在你每天都会接触的前端代码里,一个搜索框、一个评论区、一个URL参数的回显,都可能成为脚本注入的窗口。这一期就以XSS为专题,把原理、挖掘思路和几个绕过滤器的实战套路完整过一遍。

这篇文章适合两类人:一类是刚开始接触Web安全、想系统理解XSS触发机制的初学者;另一类是已经能识别基础反射型XSS、但总被各种过滤规则卡住的测试者。我会尽量用偏实战的口吻把问题讲透,也会在关键步骤给出能直接拿去测试的最小载荷样例和排查顺序。所有测试场景均基于自建的模拟项目与授权演练环境,不要把这些思路直接套到未授权系统上,这既是职业素养也是底线。

1. XSS的核心:浏览器替攻击者执行了脚本

1.1 从一段回显看触发链

XSS的根因用一句话概括:用户输入被当成了代码执行。做一个最典型的搜索模块:用户搜索hello,服务端把关键词拼进HTML模板,前端展示“你搜索的内容是:hello”。当关键词换成<script>alert(1)</script>,如果服务端不对特殊字符做任何处理,浏览器在解析HTML时就会遇到这个标签,把它当作页面的一部分加载并执行,弹出一个提示框。从“输入文本”到“执行脚本”,中间只隔了一行不安全的拼接代码。

我常用一个生活化类比:这就像你把一张写着“请帮我把这张纸上的内容打印出来”的纸条交给前台,结果前台看到纸条后半段还写着“顺便打开保险柜”,然后就去执行了。浏览器本身并没有恶意,它只是忠实地按照HTML和JavaScript的规则去解析,问题出在开发者没有对输入与输出做边界隔离。所以XSS在本质上不是浏览器漏洞,而是Web应用对“数据”和“代码”两种身份没有区分清楚。这里有一个容易忽略的点:<script>标签并不是唯一入口。实际上,现代浏览器在很多场合不会执行<script>中动态插入的脚本,但<img onerror>、<svg onload>这类配合事件属性的载荷依然能稳定触发,因为事件处理器的执行路径完全不同。这也是为什么理解XSS不能停留在“会不会弹窗”这个表面问题上。

1.2 三种类型怎么快速区分:反射型、存储型与DOM型

类型注入位置触发时机典型场景
反射型请求参数点击一次请求后立即执行搜索、URL跳转
存储型服务端数据库任意用户访问页面时执行评论区、留言墙
DOM型前端代码逻辑前端解析数据时执行单页应用、路由参数

这三类的底层触发机制没有本质区别,都是把不可信数据带入了执行上下文,但危害和检测难度差别很大。反射型需要攻击者诱导受害者点击构造好的链接,适合钓鱼场景;存储型是一次注入、持续影响,所有浏览页面的用户都会中招,危害等级最高;DOM型则完全不依赖服务端过滤,因为数据流转全在前端完成,例如location.search被解析后直接传给innerHTML,后端看到的只是一次正常请求,检测时最容易漏掉。

我实际测试时的顺序是:先判断输入是在服务端还是客户端被消费。最简单的方法是把一段特殊字符串输进去,看返回HTML里是“原样出现”,还是“被服务端转义后出现”,再配合查看网络响应和前端脚本中的数据流,基本就能定位是哪一种。这里多说一句,DOM型XSS并不要求URL里必须有明文payload,它可以是前端从接口取数据、再经过eval或document.write输出,所以排查时要特别关注前端代码里的innerHTML、outerHTML、insertAdjacentHTML、document.write这四类调用点。

1.3 “能弹窗”只是起点,真实危害在于控制浏览器

初学者经常陷入一个误区,觉得XSS验证到alert(1)就收工了。但弹窗只是证明脚本能够执行,真正的利用是在这个基础上做三件事:读取本地敏感数据、模拟用户操作、篡改页面内容。常见的payload会落到窃取Cookie并发送到攻击者控制的服务器上,或是在页面里插入一个伪造的登录框诱导用户重新输入账号密码,再或是以当前用户的身份发起接口请求完成转账、改密等敏感操作。

所以正规的漏洞报告中,alert(1)只是最小验证证明,还需要在授权范围内尽可能描述可利用位置、可触发的敏感操作以及影响面。这也是我说的“从弹出窗口到可利用”的差别:前者让开发修复问题,后者让业务方理解问题的严重等级。理解了这个,后面再看各种绕过技巧时,就不会只盯着“能不能弹”这一个指标,还会考虑载荷能不能稳定执行、能不能躲过审计、能不能配合其他漏洞形成利用链。

2. 挖掘前的准备:攻击面梳理与工具分工

2.1 优先排查这几类易感模块

我自己做测试时,不会漫无目的地到处点,而是先按业务功能梳理攻击面,再对高频出问题的模块集中验证。最容易出XSS的模块通常有这几类:站内搜索与列表筛选条件、用户评论与留言区、昵称与个人资料编辑、URL参数回显的页面(比如登录后跳转)、JSONP接口或前端模板渲染区、富文本编辑器内容展示区。搜索和评论这类模块之所以高频,是因为它们天然要把“用户输入”拼进页面结构中,拼接点一多,容易顾此失彼。

拿昵称举例,很多系统在注册时做了严格过滤,但用户修改昵称后,历史消息列表、操作日志、排行榜页面都可能会重新渲染这个字段,只要有一个页面忘记做输出编码,存储型XSS就能成立。所以我在梳理攻击面时,会重点看“同一个用户输入会在多少个页面被消费”,而不仅仅是当前操作所在的页面。这不是什么高深技巧,纯粹是排查习惯问题,但往往比多跑几个扫描器更管用。

2.2 自动化扫描器与手工确认怎么分工

自动化扫描器在XSS挖掘里更像“撒网工具”,它负责把大量参数和页面入口快速过一遍,输出可能有问题的候选点。但扫描器普遍存在两个问题:一是对需要登录态的页面覆盖不足,很多逻辑隐藏在登录后的操作链里;二是误报率高,尤其是遇到过滤规则时,扫描器经常把“转义后仍可执行”的复杂场景漏掉。所以我通常把扫描器结果当作线索,不直接采信,拿到候选URL后手工打开开发者工具确认上下文与过滤逻辑。

手工确认的重点不是重复扫描器的工作,而是回答三个问题:输入落在页面的哪个位置?这个位置是HTML文本、标签属性、JavaScript字符串还是URL协议?服务端做了哪些转换,是转义尖括号、过滤关键字还是统一替换为空?这三个问题问清楚了,才能真正判断候选点有没有利用价值。抓包代理工具在这里的主要作用不是篡改请求,而是观察响应原文与完整的数据流转环节,有时候我会在代理里直接对比“提交前和提交后的响应差异”,很快就能定位出过滤逻辑的规律。

2.3 测试边界与授权范围

这是老生常谈,但每次都要强调:XSS测试必须在自建靶场、CTF竞赛或者获得明确授权的范围内进行。随意向线上系统提交payload,轻则污染他人数据,重则触碰法律红线。我在讲技巧时提到“绕过”两个字,指的是在可控环境中研究过滤规则与浏览器解析机制之间的差异,而不是绕过网站运营方的授权边界。实战中的“边界感”应该体现在测试方案设计里,例如只使用无害的alert(1)或console.log做验证,不做窃取数据、持久化等后续操作。

3. 过滤对抗:绕过思路与常用载荷拆解

3.1 黑名单标签绕过:一张可解析的标签清单

很多系统第一道防线是黑名单过滤,最常见的是把script替换为空或直接拒绝包含script的请求。在授权测试环境里验证时,我会先试探过滤器是只拦了script,还是把常见的可执行标签全部考虑了一遍。事实证明,很多过滤规则只写了script一个关键词,这时候只需要换一个标签就能绕过。HTML里能触发脚本执行的标签远超想象,常用的包括<img>配合onerror事件、<svg>配合onload事件、<details>配合ontoggle事件、<math>配合onmouseover事件、<body>配合onload事件等。

下面这组载荷是我在自建靶场里做基础探测时常用的最小样例:

<img src=x onerror=alert(1)> <svg/onload=alert(1)> <details open ontoggle=alert(1)> <body onload=alert(1)>

这组载荷的价值在于:它们覆盖了不同标签、不同事件、不同触发条件。<img onerror>在图片加载失败时立即触发,适合搜索页这类即时回显场景;<details ontoggle>需要用户点击或页面自动打开属性时触发,适合评论区这类交互场景;<body onload>则适合页面加载时触发。如果黑名单把onerror也过滤了,就把onerror换成onmouseover或onfocus继续试。学习绕过的本质不是背payload,而是理解“HTML里哪些元素能绑定事件、事件在什么时机触发”,理解了这个,遇到什么样的黑名单都能现场构造候选集。

3.2 编码混用与属性解析歧义

比标签黑名单更进一级的过滤,会把尖括号、引号、javascript:这类关键字都做编码或替换。这种情况下,我会把注意力转移到“过滤层看到的字符串”和“浏览器解析后看到的字符串”之间的差异上。这个差异来自HTML规范里多个解析阶段的存在:HTML实体解码、URL解码、JavaScript字符串转义、Unicode规范化。如果应用在某一个阶段做了黑名单检查,却没有在最终输出边界做统一的编码处理,就可能出现经典的编码绕过。

举个例子,过滤规则匹配的是javascript:这个明文协议,如果输入写成&#x6a;avascript:,在源码层面过滤器看不到javascript:,但浏览器解析HTML实体时会先把它解码成javascript:,随后属性被当作协议执行。下面是一个简化的属性场景:

<a href="&#x6a;avascript:alert(1)">click</a>

这里还要注意,不同浏览器对不同协议的实体解码处理并不完全一致,所以测试时不要只在一个浏览器里验证,我会装两三款不同内核的浏览器交叉确认。编码绕过的另一个常见场景是空白字符。HTML解析器允许属性名与等号之间存在Tab、换行等字符,而很多正则只写了\sonerror\s*=,遇到onerror =就匹配不上。这类细节看起来很小,但在真实对抗中经常成为突破口,因为开发写过滤正则时很难把HTML的全部解析规则都考虑进去。

3.3 语法边界逃逸:不闭合也能执行

还有一种绕过思路特别容易被忽视:不需要“完整闭合”当前上下文,利用浏览器解析HTML时的自愈机制也能触发脚本。最典型的是在JavaScript代码块里,输入被拼进字符串中,开发者以为只要把引号转义就安全了,但</script>这个序列并不受JavaScript字符串语义控制,它是HTML解析器层面的“脚本块结束标记”。只要输入中包含</script>,浏览器就会认为当前脚本块在这里终止,后面的内容重新按HTML解析。

用一个简化的例子说明:

<script> var keyword = "</script><svg onload=alert(1)>"; </script>

当浏览器读到</script>时,脚本块被关闭,<svg onload=alert(1)>变成新的HTML标签被解析执行。这个过程中攻击者根本不需要闭合var keyword字符串的双引号,也不需要处理分号,因为脚本块已经在字符串内部被“截断”了。这给防御方带来的启示是:在<script>块内输出用户数据时,单独转义引号和尖括号往往不够,必须把<、>一并编码,或者干脆避免在前端代码里拼接任何用户可控数据。对测试者来说,识别出“输入落在脚本块内”这个上下文,就已经找到了一条比常规属性绕过更稳定的路径。

4. 靶场复盘:一个搜索回显项的完整绕过记录

4.1 发现可疑入口与上下文判断

有一次我在一套自建模拟项目里做授权测试,系统是一个带低强度过滤的站内搜索模块,主要功能是搜索内部文档标题并回显关键词。我刚开始像普通测试一样在搜索框输入<script>alert(1)</script>,提交后页面把尖括号转义成了&lt;script&gt;,看起来是做了输出编码。但我不急着放弃,而是打开开发者工具观察完整的响应结构。

响应里的关键片段是这样:

<div id="result"> <span>搜索关键词:</span> <span id="keyword">输入内容</span> </div> <script> var callback = "输入内容"; </script>

第一处keyword的输出确实做了HTML实体编码,但第二处callback变量的输出是直接拼进JavaScript字符串的。这意味着同一个参数可能在页面的多个位置被消费,每个位置的防护强度并不一致。我把注意力集中到var callback = "输入内容"这个点上,因为它属于我在3.3节说的“脚本块内字符串”上下文,绕法跟常规的HTML场景完全不同。

4.2 三次构造载荷的完整过程

第一次尝试,我把输入改为";alert(1);//,目的是闭合掉前面的双引号和JavaScript语句,然后插入新的执行语句。结果是页面返回了错误提示,说明过滤器把双引号、分号或斜杠中的某个符号拦住了。我没有直接换标签继续撞,而是先看过滤规则到底做了什么。通过对比几次不同输入的响应,发现过滤器会替换<script字符串为空,同时把"转义为\",但并没有处理</script>序列,也没有对<svg>这类标签做拦截。

于是第二次构造转向</script>闭合思路。输入值改为:

</script><svg onload=alert(1)>

提交后看响应,过滤规则把<script字符串删除时,</script>里的/script并不完全匹配<script,所以这个闭合序列被保留了。浏览器解析时,</script>正常关闭脚本块,<svg onload=alert(1)>作为新标签进入DOM并执行事件。到这里,这个点已经可以打满分——整个利用没有使用双引号、没有使用分号、没有依赖<script>标签本身,完美绕过了过滤器设置的三个检查点。

第三次是补充测试,我把单引号、反引号、实体编码版本各提交了一遍,确认这个绕过路径在不同编码形式下依然稳定,同时测试了<svg/onload=alert(document.cookie)>的自定义载荷,验证它确实能读取当前会话的Cookie。整个过程没有做任何实际窃取操作,只是在靶场环境下确认了利用链的可行性。

4.3 利用链验证与影响评估

对一次完整测试来说,确认能执行脚本只是中间步骤,最终还要评估影响范围。在这个搜索模块里,由于漏洞点是反射型,攻击者需要构造恶意链接诱导内部用户点击,点击后脚本以目标用户的身份执行,可造成会话标识读取、敏感接口调用、页面内容篡改等后果。如果这个模块是存储型,影响范围会扩大为所有访问该页面的用户。

我在复测验证修复的时候特别关注一个问题:开发有没有只堵住</script>闭合这条路径,却忘了处理同类场景里的其他标签。修复建议是在JavaScript输出位置统一使用JSON.stringify序列化数据,并对<、>、&做字符层面的编码,同时配合HttpOnly降低Cookie失窃风险。复测时我用原始载荷和变体载荷各测一轮,确认脚本不再执行,才算闭环。

5. 常见问题排查与防御落地清单

5.1 回显被转义但利用仍成功的几种情况

测试者经常遇到一种困惑:响应里明明看到&lt;script&gt;,感觉服务端已经做了转义,可漏洞报告仍然成立。这里的关键在于“转义是否覆盖了所有输出位置”。很多系统的输出编码只在一个公共方法里做了,但不是所有红点位置都会走这个方法。最常见的遗漏场景包括:属性值里的引号未处理、JavaScript字符串里的</script>未处理、URL协议未校验、前端拿到接口数据后又用innerHTML临时拼接。

还有一种情况是转义顺序问题。服务端把<script>替换成空,但用户输入是<scr<script>ipt>,替换后中间的<script>被删掉,剩下的部分拼成<script>,经典的重叠绕过。这类问题的根因在于“替换”而不是“编码”,替换规则天然容易被组合绕过,防御侧最稳妥的做法是禁止拼接、统一编码。

5.2 过滤器的“先转后存”与“二次解码”陷阱

我在帮开发者复盘时发现,不少系统在入库前对数据做了过滤,却在展示时不做任何处理,以为“存进去是安全的,拿出来自然安全”。这个认知有两个坑:一是数据库里存的是过滤后的内容,但如果原来的过滤规则只替换了script,展示时用户输入里残留的<img onerror>依然能执行;二是数据从数据库取出后可能还要经过前端JavaScript二次处理,比如把JSON字符串解析后再插入HTML,这时候前端的DOM操作绕过了服务端的输出编码。

换个角度说,过滤应该发生在“输出到HTML/JS/URL等不同上下文之前”,而不是“入库前”。如果希望在存储层做一道基础防线,那也应该以“编码”而非“删除敏感词”为主要手段。这道防线做好之后,展示层仍然要按上下文做第二道编码,避免出现多层解码把原本安全的输入又变回可执行代码的情况。

5.3 开发侧四段式修复清单

对于开发人员,我把XSS修复总结成一个四段式清单,覆盖最常见的四种上下文:

上下文位置推荐做法典型示例
HTML文本对< > & " '做实体编码搜索关键词回显
HTML属性属性值必须使用引号包裹并转义引号input标签value值
JavaScript字符串使用JSON序列化,不手工拼接回调变量赋值
URL协议校验协议白名单,禁止javascript:等跳转链接、iframe src

另外两条建议:第一,部署CSP时把script-src限制为'self',同时关闭unsafe-inline,能有效阻断内联脚本执行;第二,向敏感Cookie设置HttpOnly标记,即使脚本被注入也无法直接读取会话标识。这些措施单独看都不复杂,难的是在业务代码里坚持落地,尤其是老项目里到处是历史拼接代码,改造时需要按模块分批做。

5.4 挖掘效率提升的几条小习惯

最后分享几个测试习惯。第一,本地搭一个不带任何框架的最小复现页面,用来验证浏览器对某个标签、某个事件、某种编码的实际解析行为,遇到新的过滤逻辑就直接往里丢,比反复请求线上目标快得多。第二,测试时多切换几款浏览器,HTML解析差异往往在特定版本上才暴露,某款浏览器不执行不代表其他浏览器也不执行。第三,抓包看响应时顺便检查Content-Type是否为text/html,如果返回application/json,浏览器通常不会把它当HTML解析,XSS利用条件会有所不同。

我个人体会最深的一点是:XSS绕过的核心能力不是背大量的payload词典,而是训练“上下文判断力”。看到一段输入落点,能快速判断它是HTML文本、属性、JS字符串还是URL协议,对应的绕过思路就自然浮现了。这种判断力没有捷径,就是多搭建简单的过滤环境、多观察浏览器解析差异、多从响应原文里找细节,积累到一定程度,很多看起来无解的过滤规则其实都能找到一条执行路径。

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

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

立即咨询