绕WAF的XSS漏洞利用实战:从编码绕过到动态构造 做安全评估这些年我遇到最多的一个误解就是只要上了WAFXSS就测都不用测了。实际上WAF是一道很有价值的“闸门”但绝不是一堵密不透风的墙。我经常跟团队里新人说WAF在防护端做得越细测试端能研究的空间就越大。这篇文章想聊的就是这个绕WAF做XSS漏洞利用的完整思路从WAF为什么会拦到常见绕过手法再到一次授权测试中的完整实战记录一次性讲透。这篇文章适合三类人刚入门Web安全、想系统理解XSS与WAF对抗逻辑的初学者已经在做渗透测试或安全评估想补充绕过思路和编码技巧的工程师以及写代码时需要自查XSS风险、想搞懂自家WAF规则漏了什么的前后端开发。我会尽量把原理讲明白也会把实操中踩过的坑直接列出来方便你直接参考。1. 先说清楚为什么XSS会被WAF盯上1.1 XSS的三种基本形态与触发点XSS跨站脚本说白了就是攻击者把一段可执行的脚本“塞”进页面让浏览器在用户访问时把这段脚本当成页面自己的代码执行。分三种形态反射型、存储型和DOM型。反射型最简单脚本通常藏在URL参数里服务端不加过滤直接拼接进HTML返回存储型更危险脚本被存在服务端数据库里任何用户访问相关页面都会被“投毒”DOM型不走服务端是前端JavaScript自己把不可信数据写进了页面上下文比如用了innerHTML或者document.write。这三种形态对应不同的检测思路。反射型关注请求参数WAF主要盯URL和Header存储型关注提交内容WAF会重点检查POST请求体DOM型则让传统WAF很头疼因为服务端看不到完整的执行链路很多时候要靠浏览器端防护比如CSP来兜底。我在授权测试里最喜欢先摸清楚目标站点的XSS“触发点”集中在哪再决定后面怎么构造。比如一个有搜索框、结果直接回显的站点多半是反射型一个评论区支持富文本的站点存储型概率很大一个单页应用框架的站点就要多看前端源码找DOM型。1.2 WAF拦截的底层逻辑规则、解码与语义WAF拦截XSS核心不是“看到script就拦”那么简单。它内部是一条完整的检测流水线先拿到原始请求对数据做解码和规范化再拿处理后的内容去匹配规则库部分产品还会跑语义分析模型。规则匹配是最传统也最常见的手段。规则库里存了大量特征比如script、onerror、javascript:、alert(等等。但这些特征是静态的攻击者只要稍微换一种编码或表达方式就可能让特征匹配失效。解码是一个隐藏关键。很多WAF不会只看原始字节流它会尝试做一次或多次URL解码、HTML实体解码、Unicode解码。比如%3Cscript%3E会被还原成script再匹配。但问题来了当应用层代码也存在多层解码时WAF的解码次数和应用的解码次数不一致就会出现“WAF看到的是A应用拿到的是B”的绕过窗口。语义分析是近几年的趋势。它不再死抠关键字而是看代码结构比如检查这段内容最终是否形成了可执行的脚本上下文。这类WAF对拼接型和编码型绕过的识别能力明显更强但也带来了更高的误报率和性能开销很多中小站点未必开全。理解了这些你就能明白绕WAF不是“变个花样骗过正则”而是要找到WAF的检测盲区并且确保你构造的payload经过应用自身处理之后仍然能形成可执行的脚本。2. 从零拆解WAF的常见检测机制2.1 关键字正则匹配最基础的一道闸规则库里最常见的是正则匹配。典型规则包括检测script标签、检测on开头的事件属性、检测javascript:协议、检测alert、confirm、prompt这些敏感函数名。这类规则的好处是速度快、维护成本低坏处是太依赖“特征”。特征一旦被拆开、变形、编码规则就可能失明。比如script换成scrscriptipt有些正则没做递归匹配就漏了再比如alert(1)拆成al\ne rt(1)正则里没考虑空白符也会漏。所以在测试时我会先判断目标WAF拦截到什么程度直接拦script还是连onerror都拦然后就能反推它大概是哪一类规则引擎。这个“踩点”过程很重要能省下后面很多无效尝试。2.2 解码链与规范化容易被忽视的隐藏层WAF拿到请求后通常会做一系列“规范化”操作把所有数据还原成最原始的形态再匹配。比如URL百分号解码、HTML实体解码lt;还原成、Unicode规范化半角转全角、异体字统一、压缩重复空白字符等。但这层规范化是有“次数”和“顺序”的。某些WAF只做一次URL解码但应用代码里做了两次比如先urldecode再拼接进HTML这时WAF看到的%253Cscript%253E解一次是%3Cscript%3E不能命中规则应用解两次却还原出了script。这就是经典的“二次解码绕过”。实操中我最常做的就是反复编码测试把编码成%26lt%3B再编码成%2526lt%253B逐层观察WAF是否失明。每次增加一层编码都等于给WAF增加一次决策成本只要它少解了一层绕过就成立了。2.3 语义分析与行为检测新一代WAF的杀手锏语义分析型WAF会尝试重建请求内容的结构。比如你提交svg onloadalert(1)它会分析这是否是HTML上下文中的标签注入你提交;alert(1)//它会分析这是否是JavaScript上下文中的语句闭合。这类WAF对“拼凑型”payload的识别率极高因为它不看你有没有alert关键字而是看你构造的token序列是不是一条可执行语句。比如svg onloadalert(1)就算把alert写成al\u0065rt语义分析依然能从解码后的token里识别出函数调用结构。对抗语义分析的手段主要是“上下文拆解”和“动态计算”。比如把payload分布到多个请求参数里前端再拼起来或者用JavaScript的String.fromCharCode动态生成函数名让WAF在静态分析时看不到完整调用链。但这需要应用本身支持多参数拼接不是所有场景都能用。所以结论是不要上来就堆编码先判断目标WAF的“智力水平”。规则型WAF重编码变形语义型WAF重拆解与动态化对症下药才高效。3. 绕过思路与核心编码技巧实操重点来了3.1 大小写混写与关键字拆分大小写混写是最基础的绕过手法对付那些规则没加i修饰符的正则很有效。比如ScRiPtalert(1)/sCrIpTHTML标签名不区分大小写但正则只认小写时就被骗过了。不过现在大部分WAF的正则都加了大小写不敏感标志单独用这招基本没戏。我更常用的是“关键字拆分”把一个完整的关键字拆成两段中间插入会被浏览器忽略但能被正则“打断”的字符。比如scrscriptipt这种嵌套写法常用于绕过那些只做一层简单匹配的规则。浏览器解析HTML时会从script标签重新开始最后仍能执行脚本。还有在javascript:协议里插入注释符比如java\nscript:某些浏览器解析时换行会被当成空白忽略但正则里的.匹配不到换行就会出现空隙。这类手法的本质是“利用解析器与正则引擎的差异”。WAF是正则思维浏览器是解析器思维两者对相同字符流理解不同差异就是绕过点。注意大小写、拆分这些手法只对规则型WAF有效。遇到语义型WAF时它的tokenizer会把标签拆成token再重建嵌套和大小写基本都会被还原这时要转向3.3和3.4的思路。3.2 各类编码的双重与多重编码利用编码是绕过WAF最重要的武器库。常见编码手段包括URL百分号编码%3C%73%63%72%69%70%74%3EHTML实体编码#x3C;script#x3E;Unicode转义\u003cscript\u003eJavaScript字符串转义\x3cscript\x3ebase64编码后在运行时解码用于动态构造场景关键的技巧是“堆叠编码”也就是把上面几种编码组合使用。比如把做HTML实体编码后再对整个字符串做URL编码形成%26lt%3B。WAF如果只做一层URL解码看到的是lt;而不是规则不命中应用如果先做URL解码再做HTML实体解码最终还原成。我在测试反射型XSS时经常会在参数里构造多层编码链然后用一个本地小脚本模拟目标应用的解码顺序逐个匹配哪些组合能逃过规则。编码组合的调试本质是“排列组合”必须系统性地试不能靠灵感。要特别提醒的是编码绕过高度依赖目标应用的解码逻辑。如果应用本身只做一层解码你堆再多层也没用反而会把payload废掉。所以在编码之前务必要先确认服务端语言和框架的默认解码行为比如某个常见Java框架默认只解一次URL编码PHP则依赖配置这些都直接影响payload设计。3.3 注释符、换行符与空白字符的巧用注释符和空白字符是绕过正则的“润滑剂”。在HTML解析器眼里scr!-- --ipt中的注释会让标签名变成scr所以这个手法一般不行。但在JavaScript上下文里注释符和换行符非常有用。典型的例子是scriptalert(1)/script被过滤了alert可以写成scriptal\u0065rt(1)/script这是Unicode转义JavaScript在解析标识符时会先做转义还原或者写成scriptwindow[alert](1)/script利用字符串拼接绕开关键字匹配。在HTML标签事件里换行符也很有价值。比如svg onloadalert(1)被拦可以试svg\nonloadalert(1)。有些正则用\sonload匹配事件属性前的空白如果属性前直接换行可能不匹配但浏览器的HTML解析允许属性名前面有各种空白字符包括换行。还有零宽字符如%E2%80%8B和制表符、回车符组合也常被用来干扰正则的字符类匹配。但这些手段在语义型WAF面前效果会打折扣因为tokenizer会把这些不可见字符统一归为空白。经验当WAF把onload、onerror这些事件名全部拉黑时不要死磕事件属性可以考虑把脚本放到svg的href、xlink:href里或者用details open ontoggle这类冷门事件很多规则库对冷门标签和事件覆盖不全。3.4 动态构造与脚本替代方案动态构造的核心思路是在最终执行前把被WAF识别的关键字“拼”出来。最典型的就是alert被过滤时用window[alert]或eval(atob(...))代替。eval(atob(...))是实战中非常有用的组合。把整段恶意脚本Base64编码后放在参数里前端再用atob解码给eval执行。WAF在请求里只看到一堆Base64字符没有明显的script或alert特征很难拦。另一个好用的替代方向是放弃“弹窗函数”改用能直接表达攻击效果的API。比如不用alert(1)改用location//xxx做跳转验证反射型XSS是否执行或者玩document.cookie读取把会话信息外带。这样WAF规则库里那些专门针对alert的规则就直接失效。如果是存储型XSS还可以考虑把payload拆成多段分别藏在不同的字段或参数里利用应用自身的拼接逻辑让脚本在运行时组装。这种手法对抗语义型WAF尤其有效因为它打破了WAF单请求分析的视角。4. 实战记录一次授权测试中的完整绕过过程4.1 信息收集与点位判断在一次授权安全评估中我拿到一个某资讯类站点的测试范围。这个站点部署了商业WAF请求一提交只要参数里出现script或alert(立刻返回拦截页面响应码是典型的403页面还带着WAF厂商的标识。我先把站点整体过了一遍找到了几个疑似XSS的输入点一个站内搜索框结果页回显搜索词一个文章评论接口评论内容会展示在页面上还有一个用户昵称字段个人主页会渲染昵称。这三处都是典型的XSS候选点。搜索框的结果页回显是URL参数直接带入HTML的属于反射型文章评论和昵称是存储型的。我决定先从搜索框下手因为反射型测试反馈最快一次请求就能看到是否拦截。踩点阶段我做了三组试探请求第一组直接提交scriptalert(1)/script确认被拦第二组提交scrscriptiptalert(1)/script也被拦第三组提交%3Cscript%3Ealert(1)%3C/script%3EURL编码后依然被拦。这基本说明WAF具备URL解码能力而且规则覆盖了最基本的编码形式。于是我把主攻方向转向“多重编码 标签拆解 动态函数名”的组合。4.2 绕过尝试过程记录第一轮我尝试了纯HTML实体编码请求参数提交%26lt%3Bscript%26gt%3Balert(1)%26lt%3B/script%26gt%3B。这个payload在WAF侧先做URL解码得到lt;scriptgt;alert(1)lt;/scriptgt;规则库里没有直接匹配lt;开头的特征放行通过。但到了应用侧搜索框只做了一次HTML转义输出没有解析实体所以页面显示的是script文字本身没有形成可执行的标签这条payload等于没绕进去。第二轮考虑“标签拆解 事件属性”提交svg/onloadalert(1)被拦提交svg\nonloadalert(1)还是被拦。看来事件属性是关键匹配点规则里不只是查onload还把svg和onload的组合也纳入了特征。第三轮我把重点转到“图片标签 动态函数名”提交img srcx onerroral\u0065rt(1)。WAF在解码后的字符流里看到了al\u0065rt它不是纯alert字符串所以规则没拦而JavaScript解析器在处理事件属性值时会先把\u0065还原成e最终执行alert(1)。这一次请求通过了WAF页面弹窗成功。这条payload之所以有效核心是利用了“WAF和浏览器对Unicode转义的处理不一致”WAF的正则按字面量匹配它不解析JavaScript转义序列浏览器HTML解析器在赋值事件属性时会识别\uXXXX序列。这个差异就是绕过窗口。4.3 payload落地与验证搜索框的点位打通后我又验证了存储型评论接口。评论接口过滤更严格不只是WAF拦截应用层还做了htmlspecialchars式的标签过滤直接提交任何含的内容都会被转义。这种情况下纯靠WAF绕过不够还得应用层存在过滤缺陷才能继续。我测试后发现评论接口对和做了编码但单双引号没有完全过滤。结合前端渲染逻辑我构造了一个不依赖标签的DOM型payload提交/onmouseoveralert(1)利用应用把参数拼进某个HTML属性的机会闭合属性后注入新的事件。这条payload不包含尖括号WAF规则库里针对标签的规则全部落空应用层过滤也没有拦到最终在真实用户鼠标悬停时触发。验证阶段我不仅看弹窗还看payload是否在DOM树中“名副其实”。比如用document.cookie做个外带或者改个DOM内容确认执行上下文是可控的。弹窗只能证明有XSS执行但外带数据和DOM篡改更能反映实际危害。整个测试下来我最大的体会是绕WAF不能只依赖一个“万能payload”每个目标站的应用逻辑和WAF规则都不同必须基于实际回显不断调整。5. 常见问题与排查技巧速查5.1 WAF不拦直接弹窗先别高兴太早第一次测试时我提交scriptalert(1)/scriptWAF直接放行页面也弹窗了。当时我以为是目标没开防护后来才发现这个WAF对application/json类型请求的解析有缺陷根本没检查JSON body。这种“假绕过”很容易误导判断。正确做法是先用几个已知被拦截的恶意特征做“对照组”比如提交scriptalert(1)/script看是否被拦确认WAF确实工作之后再判断放行是漏洞还是误报。5.2 绕过后payload不生效的几类原因payload过了WAF但页面没有执行最可能的原因是应用层把关键字符转义了。比如服务端把转成了lt;你的payload在浏览器里变成了纯文本当然不执行。解决思路是寻找能被应用“放行”的上下文比如属性值、URL跳转参数、JavaScript变量赋值等。另一个常见问题是编码过度。我见过有人把payload编码了好几层WAF是绕过去了但应用只解码一次最终执行时字符串全是乱码。事前确认应用的解码层数比盲目堆编码更重要。还有一个容易被忽略的点是长度限制。某些参数有最大长度比如搜索框限制50字符长payload会被截断。这时优先用短payload比如svg onloadalert(1)或者用外链脚本