做Web安全的人,基本都会经历一个阶段:觉得XSS很简单,不就是<script>、onerror、javascript:那几招;再往后又会发现,好像随便写点什么东西都能被过滤掉,于是开始研究XSS攻击绕过方法。我当年也是这样,从一个只会背payload的小白,到能在授权项目里快速判断一个过滤规则该怎么破,花了不少时间踩坑。这篇东西,我想把绕过方法的核心逻辑、分类思路和防御对策一次性讲透,标题虽说是"100种绕过方法",但比起硬凑100个变体,我更想帮你把"为什么能绕"这件事理解清楚。
写这些内容之前先声明一下:所有讨论都限定在你有明确授权的测试项目、自建靶场、CTF题目或开源漏洞环境里进行。绕过技术本身是中性的,它最大的价值不是用来打谁,而是让你知道自己的过滤规则到底哪里会漏。下面进入正题。
1. 先搞清楚一件事:绕过研究到底在研究什么
1.1 授权边界与实验环境
很多人一听到"绕过"两个字,就自动脑补成攻击者的游戏。实际上,我在日常工作中接触到的绕过研究,绝大多数是防御侧的自我检验:上线前做代码审计、安全测试、WAF规则调优、漏洞复测,都需要模拟攻击者的思路。没有这个前提,任何绕过技巧都只是在给自己找麻烦。
所以第一步永远是确认环境。我常用的实验组合是本地搭一个带过滤逻辑的PHP或Node测试页,前端放一个输入框,后端写几行简单的黑名单正则,然后反复输入各种变形payload,观察浏览器实际执行情况。也可以用现成的开源靶场,或者在专业测试平台上做验证。重点在于:你每次触发执行,都能看到完整的输入-过滤-输出链路,这样你才知道是哪一步放行了。
1.2 两类过滤器的判断逻辑
要把绕过研究明白,得先知道你面对的是什么类型的过滤器。
黑名单过滤是最常见的,也是绕过研究的重灾区。它的逻辑是"我知道哪些东西危险,把它们拦掉"。问题在于,黑名单永远是不完整的:你拦了script,我可以拆开;你拦了onerror,我可以换onload、onfocus、onmouseover;你拦了alert(1),我可以用confirm、prompt、eval、Function构造函数。黑名单的每一项都需要人工维护,每天都在跟攻击者的创造力赛跑。
白名单过滤则是"我只允许特定字符或特定模式出现",比如只允许字母数字、只允许特定的标签和属性。这类过滤相对安全,但也不是无懈可击:如果白名单校验只作用于参数值,而忽略了对输出位置的判断,依然可能在上下文逃逸处出问题。另一类是框架内置编码,比如模板引擎自动对输出做HTML实体转义,看起来安全,但如果你用了v-html、innerHTML、dangerouslySetInnerHTML这类"跳过编码"的API,框架的保护就等于被手动关掉了。
1.3 100种方法背后的方法簇
市面上各种"XSS绕过大全"列了一百多个payload,看起来眼花缭乱,其实如果你按底层原理拆开,会发现数量并不多。我自己的分类习惯是这样:
- 编码类:利用不同编码体系之间的转换差异,让过滤器认不出、浏览器却认得。
- 上下文逃逸类:在属性、事件、脚本、样式等不同上下文里,通过闭合、截断、重组来进入可执行区域。
- 解析器怪癖类:利用HTML解析器的容错机制、大小写、空白字符、重复属性、不完整标签等特性。
- 协议与数据流类:在WAF或代理层,利用请求格式、Content-Type、字符集等差异绕过检测。
- 数据流污染类:不是从字符串层面绕过,而是利用DOM Clobbering、Mutation XSS这类"运行时才暴露"的漏洞。
我后面几节会逐个展开,并在第6节给一个速查表。你只要掌握了这些方法簇,面对任意一个具体过滤器时,都能现场组合出几十种变体,"100种"只是这个组合空间的下限。
2. 编码类绕过:让过滤器"认不出"的常见手法
2.1 HTML实体编码:数字实体与命名实体
HTML实体编码是最容易理解的绕过思路。当用户输入被插入到HTML文档中,浏览器在解析HTML标签时会先做实体解码,再判断标签结构。但很多过滤规则是在解码之前运行的,它只看到一串<script>,以为没有尖括号,就放行了。
最常见的实体写法有三种:命名实体、十进制数字实体、十六进制数字实体。比如<可以写成<、<、<。如果过滤规则只拦了<script>这种完整形态,那<script>直接就能跑起来。我遇到过一个简单的Python过滤脚本,只做了字符串替换,把<替换成<,结果又被它自己替换出来的<骗了——因为输出端没有再做一次实体编码,导致双重编码后浏览器依然能识别。
但也要说明一个前提:HTML实体编码的绕过,只在HTML上下文(标签、属性值最外层)有效。如果输入是放在<script>标签内部的JavaScript代码里,HTML实体解码不会发生在JS解析阶段,很多实体写法反而会失效。这也是为什么我总跟团队强调:别只看一个payload能不能弹窗,要看它所在的上下文。
2.2 URL编码与双重编码的条件
URL编码在绕过中的应用场景,主要是href、src、action这类带URL语义的属性。过滤规则往往会对javascript:、data:、http:等协议关键字做黑名单,但你输入javascript%3A时,WAF可能不认识这个变形,而浏览器在解析URL属性时会自动做一次URL解码。
我第一次在授权项目里遇到这个场景,是在一个图片链接参数上。服务端把javascript:关键词直接过滤为空,我改成java%73cript:,后端没拦住,浏览器解析URL时先把%73还原成s,再跳转到伪协议执行。这就是典型的"过滤器只检查原文、浏览器却会解码"的错位。
双重编码的逻辑类似:输入%253Cscript%253E,如果你的服务端对参数做了一次URL解码,然后输出到页面上,WAF看到的是%3C,可能放行;而浏览器端如果又根据页面编码解析一次,就能还原成<script>。这种方法能否成功,取决于服务端到底做几次解码、输出编码怎么处理,所以每次都要实际测试,不能凭感觉。
2.3 JavaScript上下文里的Unicode与模板字符串
在<script>上下文里,过滤器的常规姿势是拦alert、eval这类函数名。绕过思路通常不是靠HTML实体,而是靠JavaScript自身的解析特性。
一个经典手法是利用Unicode转义序列。JavaScript字符串中,\u0061lert在运行时会被解析成alert,如果过滤器只搜原始文本里的alert,它看不到这个转义形态,就能放行。再比如eval可以用Function构造函数代替,alert(1)可以改成(1, alert)(1)、[].constructor.constructor('alert(1)')()这种利用构造函数链的写法。过滤规则越复杂,这种"等价写法"的空间反而越大。
模板字符串也是个常用技巧。反引号允许你插入表达式,alert可以不作为字符串出现,而是通过拼接生成,比如top['al'+'ert'](1)。关键点在于:JavaScript的灵活性远高于任何黑名单的覆盖能力,只要执行环境允许,总有办法找到未被枚举的函数或调用方式。
2.4 编码叠加为什么会奏效
单个编码可能被拦,多组编码叠加就基本靠"解析顺序"决定成败。我曾经调试过一个案例,输入从URL参数进入,后端做了一次urllib.unquote,然后拼进HTML属性,前端又用JavaScript读取该属性值赋给location。三次解析里,只要有一层浏览器认为是合法数据,另一层过滤器认为是安全数据,绕过就成立了。
在实际测试时,我建议把这个过程拆成几步记录:输入原始形态是什么?服务端做了什么解码?输出在哪个HTML上下文?最终由哪个解析器消费?每一步都写清楚之后,你就能准确判断该用哪种编码组合。这也是为什么我鼓励团队成员把"编码叠加"当成一道解析链题目来做,而不是碰运气。
3. 上下文逃逸类:从属性、事件到协议
3.1 属性值内的闭合与注入
如果说编码绕过是"让过滤器认不出",那上下文逃逸就是"让过滤器以为它管住了,其实你跑出去了"。最常见的场景是用户输入被拼进<input value="...">这样的属性里。
此时过滤器如果只检查了script、onerror这些词,你只需要用引号闭合属性,再制造一个新属性或标签即可。比如输入"><svg/onload=alert(document.domain)>,前面部分闭合了value属性和input标签,后面就变成了一个全新的SVG标签。我见过不少新手在这里踩坑:他们以为有过滤就能防住,但过滤器根本没检查<和>的上下文关系。
这里有一个容易被忽略的细节:属性值里如果用了单引号,后端只转义了双引号,那单引号闭合照样有效;如果输入被用htmlspecialchars转义了<、>、&、引号,那直接闭合是走不通的,这时候要考虑是否还有&实体解码导致的双重编码问题,或者该属性本身的语义可以被操纵(比如style、href)。
3.2 事件处理器与伪协议
事件处理器是属性注入里的"快速通道"。onerror、onload、onfocus、onmouseover、onclick,每一个都能在特定条件下触发脚本。过滤器通常会把常见的on事件列进黑名单,但它很难穷举所有事件,像onpointerenter、onwheel、ontoggle、onanimationend这种冷门事件,就经常成为漏网之鱼。
伪协议则是另一类执行入口。javascript:可以出现在<a href>、<iframe src>、<form action>等位置;data:协议在部分浏览器中配合text/html也能执行脚本。测试策略很简单:列出所有带URL语义的标签属性,然后用最简单的alert(1)去试,看哪个位置没有被过滤覆盖。
需要特别留意的是:有些过滤规则会把javascript:中的冒号去掉,或者把script关键字去掉,但如果你用回车符、Tab、注释符、实体编码把关键字拆开,或者利用HTML解析器对空白字符的容忍,过滤规则就很容易失效。比如java\nscript:在URL解析中可能不合法,但某些浏览器对URL内部的控制字符处理比较宽松,对于一些旧的解析流程,会出现意外执行的案例。
3.3 标签拆分、关键字重组与注释干扰
当输入被拼在HTML标签名或属性名的位置时,过滤器如果直接把script替换为空,你就可以用嵌套拆字来绕过:<scr<script>ipt>alert(1)</scr</script>ipt>。第一次过滤去掉内层的script后,剩下的反而拼成了完整的<script>标签。这种过滤方式之所以可笑,是因为它用字符串替换去处理一个结构化语法,必然会产生重组机会。
注释干扰也很常见。HTML注释<!-- -->和CSS/JS里的注释块都能打断过滤器的字符串匹配,但到了浏览器解析阶段,注释会被忽略,剩下内容依然能拼成可执行结构。比如在<scr<!--x-->ipt>这种形态里,注释会打断过滤器看到的连续script字符串,但浏览器解析HTML时不会把它当作有效注释(因为注释只允许出现在特定位置),实际效果反而是标签名被拆开却不影响闭合。这种情况要逐个验证,不能光靠理论。
3.4 CSS、SVG与MathML里的脚本执行点
非主流上下文经常被人忽略,但它们是绕过过滤器的高产区域。CSS里古老的expression()虽然现代浏览器已经不执行,但在兼容模式或某些特定渲染条件下仍是历史遗留问题;@import可以加载外部样式表,配合-moz-binding这类老特性也曾在特定版本浏览器里触发脚本。
SVG是现在更实用的方向。SVG属于XML系,但内嵌在HTML中时解析规则很灵活,<svg><script>、<svg/onload>都是常见执行点。MathML同理,它把命名空间和HTML混在一起,一些过滤器只针对HTML标签做了白名单,看到<math>就直接放行,结果里面藏着事件处理器。测试时我的习惯是:除了div、span、input这类常规标签,专门把svg、math、details、marquee、video、audio这些"非典型脚本容器"过一遍。
4. 解析器的宽容与浏览器怪癖
4.1 大小写、空白字符与格式化字符
大量过滤规则用正则匹配时没加i标志,或者只匹配小写关键字。HTML标签和属性名是大小写不敏感的,所以<SCRIPT>、<ScRiPt>在执行效果上和<script>完全一样,很多过滤器却只会拦小写那一版。我第一次在别人代码里看到这种漏洞时就意识到:正则的健壮性,永远不能假设攻击者会按你期望的格式输入。
空白字符是另一个被低估的点。HTML解析器允许标签名和属性名之间有Tab、换行、回车、空格等字符,甚至在某些位置允许\0(空字节)被忽略。过滤器如果用严格的字符串匹配去搜onload=,那onload =就可能绕过。当然,现代浏览器对无效字符的处理越来越严格,但这份宽容在属性名的某些位置依然存在,尤其在拼接HTML片段时。
4.2 重复属性与参数污染
HTML解析器遇到重复属性时,浏览器普遍会取第一个或最后一个,不同浏览器之间还有差异。过滤规则如果只是简单地获取属性值然后检查,它可能取了第一个,而浏览器执行时用的却是第二个——这种错位就能用来绕过。
我曾经在某个项目里看到过滤逻辑是"先把所有on*属性值里的内容清空"。攻击者构造<img onerror="alert(1)" onerror="x">,如果浏览器取第一个,任务会被第一个拦截;但如果过滤器按顺序迭代删除了所有onerror,而浏览器恰好取的是最后一个被删掉的属性,那还是能拦下来。反过来,如果浏览器取第一个属性,过滤器却只检查了最后一个,也一样会出现绕过。这块没有万能的结论,只能实测你目标浏览器的行为。
参数污染(HPP)在URL层面也有类似效果。同一个参数名出现多次时,不同语言和框架会取第一个、最后一个,或者拼接全部。WAF可能只检查了其中一个值,而应用层实际用的是另一个值,绕过就发生在框架与WAF对参数处理规则的差异上。
4.3 不完整标签带来的注入机会
HTML解析器的容错机制很出名,这也给XSS绕过提供了特殊土壤。比如<img src=x onerror=alert(1)少写一个>,浏览器照样会正确解析并把后面的内容当作当前标签的延续。这意味着即使过滤规则要求用户输入里不能出现>(比如把>转成>),只要你能在前面构造出一个未闭合的标签,后面的载荷一样能生效。
<svg/onload=alert(1)这种不带闭合符的写法也很常用,因为SVG内部解析规则允许。再比如<body/onload=alert(1)在属性值之前用/代替空格,也是利用了解析器的宽容。做这类测试时,我建议你打开浏览器开发者工具,直接看最终的DOM树结构,别只盯着响应源码——因为源码和渲染后的DOM经常不一样。
4.4 Mutation XSS与DOM Clobbering
这两类问题不在字符串层面解决,而是在DOM操作运行时才出现。Mutation XSS指的是,你用innerHTML插入一段看似安全的HTML,但浏览器在解析和修正DOM时会把某些内容"变异"成可执行脚本。最典型的场景是把<noscript>、<template>、<textarea>、<title>包裹的内容来回移动,逃离了原本安全的上下文。
DOM Clobbering则是靠HTML元素的id或name属性污染JavaScript的全局对象。比如页面里有一个<form id="x">,然后window.x就被指向了这个表单;如果再构造<img id="x">配合x.alert这种访问链,某些较老的应用代码会被污染后的对象骗过检查,进而改变程序流程。这类问题很难靠过滤规则解决,因为它利用的是"HTML和JavaScript共享命名空间"这个基础机制。
5. WAF与框架层面的对抗:协议与数据流的角度
5.1 Content-Type与载荷传输方式
很多过滤规则把精力集中在参数值的字符串匹配上,却忘了同一份载荷可以通过不同的请求格式送进来。最典型的是把application/x-www-form-urlencoded改成multipart/form-data,或者反过来,WAF对两种格式的解析深度不一致,就会有一边漏检。
我在调WAF规则时发现,不少产品对JSON格式请求体的解析很浅,尤其是嵌套层级一深,检测引擎只检查了第一层。攻击者把payload塞进第三层或第五层,就能轻松绕过。这背后不是WAF厂商不用心,而是"协议解析的完备性"本身就是工程难题:你永远不知道后端框架会怎么处理一个畸形Content-Type。
5.2 字符集声明与编码识别差异
字符集是另一个容易造成"所见非所得"的点。HTTP响应头里的Content-Type如果没指定charset,浏览器会根据内容去猜编码。WAF是按UTF-8解析的,页面实际用的是GBK,那一些在GBK里能被解析为特殊字节序列的输入,WAF可能完全看不懂。
这种编码错位利用起来比较考验经验。一个常被提到的现象是,某些中文字符的字节序列里会包含0x3c(<)等字节,在GBK解码时它属于某个汉字的一部分,过滤器因此放行;但浏览器在某些解析路径下会把那个字节重新识别为<。这种情况我见过不止一次,每次排查都挺费劲。防御端最稳妥的解法是统一声明UTF-8,并且在后端对输入做严格的字节级校验。
5.3 数据规范化:为什么WAF漏判
WAF的检测对象是请求,而应用的执行对象是解码后的数据。如果WAF没有和应用执行完全相同的解码顺序,绕过就几乎是必然的。比如WAF看原始URL编码的payload,应用做了解码后再拼接;或者WAF做了解码检查,应用却直接把原始值拼进了某个会二次解码的位置。
这里有一个很实用的测试思路:不要只测一种编码形态,而是把"原始输入、一次解码、两次解码、实体解码、Unicode规范化"全跑一遍。每跑一次,记录过滤器和应用层各自看到的内容,你就能画出一张解析链路差异表。绕过点往往就在这张表的"不相同"那一栏里。
5.4 数据流绕过的典型形态
框架层面的绕过往往不像字符串替换那么直观。比如模板引擎默认开启了自动转义,但如果开发者用|safe、{!这种标记关闭了某段输出的转义,过滤等于被人工绕过。再比如一个API接口先返回JSON,前端拿到后用innerHTML渲染,服务端再怎么过滤,只要前端渲染时跳过了编码,XSS就还存在。
这些数据流绕过提醒我:XSS从来不只是"后端过滤"或"前端编码"单点的问题,而是整条链路的问题。你在测试时一定要跟着数据流走,从输入点、处理点、输出点一路看下去,任何一个环节用了"信任数据"的姿势,都可能成为绕过入口。
6. 方法清单的归类速查:把绕过能力装进工具箱
6.1 按上下文分类的绕过速查表
这里我把上面讨论的方法整理成一张可以反复查阅的速查表。注意,这只是一个起点,真正测试时要根据你的过滤规则、浏览器版本、输出位置做变体组合。
| 上下文 | 典型绕过思路 | 示例形式 |
|---|---|---|
| HTML标签内容 | 实体编码、标签拆分、注释干扰 | <script>、<scr<script>ipt> |
| HTML属性值 | 引号闭合、事件属性、URL伪协议 | "><svg/onload=alert(1)>、javascript:alert(1) |
| JavaScript代码 | Unicode转义、构造函数、模板拼接 | \u0061lert(1)、[].constructor.constructor('alert(1)')() |
| CSS | expression、@import、behavior | 老版本IE的expression(alert(1)) |
| 嵌套命名空间 | SVG、MathML | <svg/onload=alert(1)>、<math><mtext></mtext><script> |
| URL参数 | 二次编码、参数污染、重复属性 | %25253Cscript%25253E、同名参数多次 |
| 运行时DOM | Mutation XSS、DOM Clobbering | <noscript><p title="</noscript><img src=x onerror=alert(1)>"> |
这张表覆盖了我日常测试里90%以上的场景。真正到了实战,我会在每个上下文里再衍生出若干变体:字段拆分、大小写、换行、空字符、全角字符、Base64编码、JSFuck化等等。100种说法一点都不夸张,关键是你要知道每个变体是在变什么。
6.2 按过滤器类型分类的优先级
面对黑名单,优先试大小写、编码、拆分重组、等价函数替换;面对白名单,优先看它是否只校验了参数值而没管输出位置,以及是否有危险API被使用;面对WAF,优先看Content-Type、字符集、请求体解析深度和重复参数处理;面对框架自动转义,优先找那些被标记为"安全"的渲染API。
我把这个优先级写在测试笔记的首页,每次拿到一个测试目标,先判断它属于哪一类,再决定从哪个方向切入。盲目把payload列表从头打到尾,效率低不说,还容易漏掉真正有用的方法。
6.3 如何建立自己的绕过测试笔记
我自己的做法是维护一个Markdown笔记,每个方法记录四件事:输入样例、过滤规则原文、输出上下文、浏览器行为。刚开始记录时内容很乱,后来整理成统一的模板,查找就方便多了。比如这样一条:
场景:PHP反引号替换输入:"><scr<script>ipt>alert(1)</scr</script>ipt>结果:替换后重组为<script>,执行成功
这类笔记的价值在几个月后会显现出来。当你在另一个项目里遇到相似的过滤逻辑,翻笔记比回忆快得多,而且能帮你避免重复踩坑。我强烈建议新人从第一个测试项目开始就做这个记录,越早越好。
7. 把这些方法反转为防御手段
7.1 输出编码的上下文感知编码方案
既然绕过的根源是"过滤器/编码器与应用解析上下文不一致",那防御端最核心的动作就是做上下文感知的输出编码。HTML标签内容使用HTML实体编码,HTML属性值使用属性值编码(同样转义引号),JavaScript字符串里做JavaScript编码,URL属性里做URL编码。每个上下文用对应的一套编码规则,不要图省事用一种编码套全部。
实践中,我见过很多代码把用户输入放进<script>变量里,却只做了HTML实体编码,结果引号、反引号、</script>等字符原样进入JS上下文,直接被打穿。正确的做法是避免把用户数据直接拼进JS代码;必须拼时,至少要做JSON序列化并对<、>、&做转义以截断</script>。
7.2 CSP与SRI当作兜底
再全面的输出编码也有漏网的时候,所以CSP不能省。一个合理配置的CSP,比如default-src 'self'; script-src 'self',即使攻击者成功注入了<script>标签,浏览器也会拒绝加载内联脚本。当然,CSP本身也有绕过话题,所以建议配合nonce或hash机制管理内联脚本,不要直接用'unsafe-inline'来省事。
SRI(子资源完整性)主要针对外部脚本:给<script>加上integrity属性,能防止CDN或第三方脚本被篡改后执行。它和CSP一起,构成了"即使HTML被注入也不一定执行"的纵深防御。
7.3 过滤规则设计的常见错误
我复盘自己踩过的坑,最典型的就是用字符串替换做过滤:把script替换为空、把alert替换为空。此类规则的漏洞在于"替换后的字符串可能形成新的合法结构"。真要写过滤规则,至少要知道自己面对的解析器是什么,做语法层面的校验,而不是简单的关键词删除。
另一个常见错误是把<和>全部转义当成万能方案。它在HTML文本上下文有效,但在属性、URL、JavaScript上下文里,转义<和>根本拦不住" onerror="这类攻击。所以别再迷信"过滤尖括号就能防XSS"这句话,真正要防的是"在错误的上下文中使用可用数据"。
7.4 自我测试的闭环
防御者的自我测试,本质上是把第2到第5节的绕过方法全部变成回归用例。每写一段新的渲染代码、每上一条新的WAF规则,都用这些用例跑一遍。我通常会把常见payload做成自动化脚本,每次部署前执行一轮,至少能挡住自己已知的绕过路径。
同时要注意,自动化脚本永远不能替代人工分析。绕过方法的组合空间极大,自动化能覆盖的只是"已知的已知"。真正有价值的,是你在测试新功能时顺手做一次上下文审计,看一眼数据流经过哪些API、哪里会被浏览器二次解码。这套闭环坚持下来,你会发现过滤规则和修复方案会越来越稳。
最后说一点实际体会:做XSS绕过研究,最重要的不是记住多少payload,而是建立一套"上下文敏感"的思维方式。每看到一个输入点,先问自己三个问题——它会被哪几个解析器处理?每个解析器的处理顺序是什么?哪一个解析器和我写的过滤逻辑存在认知差异?把这三个问题想清楚,多数绕过路径你自己就能推演出来,甚至不用翻任何速查表。我每次带新人,都会逼他们亲手搭建一套含过滤的靶场环境,把常见绕过方法从"看图识字"变成"肌肉记忆",这个投入非常值得。希望这篇整理也能帮你在绕过的迷宫里找到自己的地图。