目录
- 摘要
- 一、一个"正确"的过滤为什么失效了?
- 二、核心模型:浏览器的三层解析架构
- [三、逐条详解 15 种绕过案例](#三、逐条详解 15 种绕过案例)
- [四、一张表总结全部 15 个案例](#四、一张表总结全部 15 个案例)
- 五、防御建议
- 六、总结
拆解浏览器三层解析器:从 HTML 到 URL 到 JavaScript
一、一个"正确"的过滤为什么失效了?
先看一个场景:
你写了这样一段代码:
php
$input = $_GET['url'];
// 过滤尖括号,防止 HTML 注入
$safe = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
echo '<a href="' . $safe . '">点击</a>';
攻击者输入:
javascript:alert(1)
htmlspecialchars 把 <> 转义了,但尖括号在这里本来就没用------攻击者根本不需要 <>。他把 payload 改成这样:
html
javascript:alert(1)
弹窗了。
你的第一反应是:"我明明转义了 <>,为什么还能执行 JavaScript?"
答案是:浏览器不是"一次性"解析 HTML 的。它内部有三层解析器依次接力,每层有自己的编码规则和解码时机。攻击者可以利用层间编码差异,绕过你的单层防护。
这篇文章将用 15 个精心构造的案例,逐层拆解浏览器解析机制,让你彻底搞懂 XSS 编码绕过的底层原理。
二、核心模型:浏览器的三层解析架构
浏览器在渲染一个 HTML 页面时,会依次经过三个独立的解析器:
原始 HTML 字符串
│
▼
┌────────────────────────────────────────┐
│ 第一层:HTML 解析器
│
│ · 解码 HTML 实体(j → j)
│ · 区分标签、属性、RCDATA、纯文本
│ · 构建 DOM 树
│ · 遇到 </style> 或 </textarea> 切换状态
└────────────────────────────────────────┘
│
▼ (输出 DOM 树,属性值进入下一层)
│
┌────────────────────────────────────────┐
│ 第二层:URL 解析器
│
│ · 解析 href / src / action 等属性值
│ · 识别协议(http: / https: / javascript:)
│ · 解码百分号编码(%6a → j)
└────────────────────────────────────────┘
│
▼ (识别为 javascript: 协议后)
│
┌────────────────────────────────────────┐
│ 第三层:JavaScript 解析器
│
│ · 解析 onclick / onerror 等事件属性
│ · 解码 Unicode 转义(a → a)
│ · 执行 JavaScript 代码
└────────────────────────────────────────┘
│
▼
alert(1) ------ 弹窗!
三个核心原则(请反复读三遍):
- 解码是逐层进行的------上一层解码后的结果传给下一层,每层只能看到上一层处理完的输出
- 每层只认自己的编码 ------HTML 解析器不认识
\u,JS 解析器不认识&#x;,URL 解析器不认识 HTML 实体 - 上下文决定解析路径 ------同一段输入放在
hrefvsonclickvs<script>中,走完全不同的解析器组合
下面,我们用 15 个案例来验证这个模型。按照解析层分为 5 组讲解。
三、逐条详解 15 种绕过案例
🅰 组:HTML 实体编码的误解与真相
核心:HTML 实体编码在"数据状态"下只是字符,不会创建新标签
案例④ ------ <div> 中的 HTML 实体
html
<div><img src=x onerror=alert(4)></div>
结果:❌ 不弹窗
解析过程:
- HTML 解析器进入 标签开始状态 ,看到
<div> - 解析器进入 数据状态(Data State) ,开始处理
<div>和</div>之间的文本 - 遇到
<------这是 HTML 字符引用(Character Reference),解析器把它解码为< - 但是!此时解析器处于数据状态,不是标签开始状态(Tag Open State) 。解码后的
<只是一个普通文本字符,不会触发"创建新标签"的逻辑 - 最终渲染为纯文本
<img src=x onerror=alert(4)>
💡 关键理解 :HTML 实体解码发生在任何状态 下,但"创建标签"只发生在标签开始状态 。要进入标签开始状态,必须有一个未被转义的
<触发状态切换。一旦<被编码为<,解析器在文本状态解码它,看到的是字符<而不是状态机指令。
案例⑤ ------ <textarea> 中的 HTML 实体
html
<textarea><script>alert(5)</script></textarea>
结果:❌ 不弹窗
解析过程:
- HTML 解析器遇到
<textarea>,进入 RCDATA 状态 - RCDATA 状态的特点:允许 HTML 实体解码,但不允许解析新的标签
<被解码为<,>被解码为>,但解析器把它们都当作文本处理- 在 RCDATA 状态下,只有
</textarea>和</title>会被识别为标签结束标记
💡 RCDATA 是什么? HTML 规范定义了四种解析状态:普通元素(Normal) 、RCDATA 元素 (textarea、title)、原始文本元素(Raw Text) (script、style)、可替换元素(Void)。RCDATA 介于普通和原始之间:解码实体但不认标签。
案例⑥ ------ <textarea> 中的纯文本标签
html
<textarea><script>alert(6)</script></textarea>
结果:❌ 不弹窗
解析过程:
- 同样是 RCDATA 状态
<script>不会被识别为标签开始------解析器只关心</textarea>- 整个
<script>alert(6)</script>都是 textarea 的文本内容
💡 案例⑤和⑥的对比 :案例⑤至少进行了实体解码(
<→<),案例⑥连实体解码都不需要------<script>本身就不是一个合法的"状态切换指令"。这才是 RCDATA 的完整含义:仅解码实体,不解析标签。
案例⑨ ------ <script> 中的 HTML 实体
html
<script>alert(9);</script>
结果:❌ 不弹窗
解析过程:
- HTML 解析器遇到
<script>,进入 原始文本状态(Raw Text State) - 在原始文本状态下,HTML 解析器不对内容做任何实体解码
a就是一串普通字符& # 9 7 ;,不会被解析成a- JS 解析器拿到的是
alert(9);------这不是有效的 JS 语法,直接报错
💡 陷阱 :很多人以为"在 script 标签里编码就能绕过",但 HTML 规范明确规定:script 是原始文本元素,解析器在里面不解码任何实体。要执行 JS,必须使用 JS 层面的编码(下一组会讲)。
🅱 组:URL 协议识别的边界
核心:URL 解析器在判断协议类型时,看的是解码后的字符串开头是否匹配已知协议名。
案例① ------ URL 编码了整个协议前缀
html
<a href="%6a%61%76%61%73%63%72%69%70%74:%61%6c%65%72%74%28%31%29">aaa</a>
结果:❌ 不执行
解析过程:
- HTML 解析器看到
href="%6a%61%76..."------这是一个普通的属性值字符串 - URL 解析器接手,检查字符串开头
- URL 解析器的协议识别规则:看第一个
:之前的部分 - 第一个字符是
%,解码后是j,但 URL 解析器是先识别协议名 再解码余下内容的 - 它从左到右扫描,看到
%6a%61%76%61%73%63%72%69%70%74------这是一个没有注册的协议名(%6a%61...不是javascript的字面形式) - 最终认为这是一个无效协议,不执行 JS
💡 关键 :URL 解析器的"协议识别"发生在 URL 解码之前 。它只看原始字符串有没有以
javascript:开头,而不是解码后有没有。所以%6a%61%76%61%73%63%72%69%70%74不等于javascript。
案例③ ------ URL 编码了冒号
html
<a href="javascript%3aalert(3)">aaa</a>
结果:❌ 不执行
解析过程:
- URL 解析器扫描字符串,寻找
: - 看到
javascript%3aalert(3)------在整个字符串中没有找到:字面字符 - 实际上
%3a就是:的 URL 编码,但 URL 解析器的协议检测阶段不做 URL 解码 - 因为没有找到
:,URL 解析器认为这是一个无协议的相对路径 ,相当于./javascript%3aalert(3) - 浏览器尝试把它当作相对 URL 处理,不会执行 JS
💡 对比 :案例①是编码了协议字母,案例③是编码了冒号------两条路都走不通。协议检测只看未解码的字符串。
案例② ------ HTML 实体 + URL 编码的双重绕过(✅ 成功的经典案例)
html
<a href="javascript:%61%6c%65%72%74%28%32%29">aaa</a>
结果:✅ 弹窗!
这是最经典的多层解析绕过案例,我们来逐层拆解:
原始输入:javascript:%61%6c%65%72%74(2)
│
│ 第1层:HTML 解析器
│ href 属性值中的 HTML 实体被解码
│ j → j, a → a, ... t → t
▼
javascript:%61%6c%65%72%74(2)
│
│ 第2层:URL 解析器
│ 扫描字符串开头 → 找到 "javascript:" 协议
│ 协议识别成功!对剩下的部分做 URL 解码
│ %61 → a, %6c → l, %65 → e, %72 → r, %74 → t
▼
javascript:alert(2)
│
│ 第3层:JavaScript 解析器
│ 执行 alert(2)
▼
弹窗!
为什么这个能成功而案例①和③不能?
| 案例 | 输入 | HTML 解码后 | URL 协议识别 | 结果 |
|---|---|---|---|---|
| ① | %6a%61%76...(协议被 URL 编码) |
不变 | 不识别 %6a%61... 协议 |
❌ |
| ③ | javascript%3aalert(3)(冒号被 URL 编码) |
不变 | 找不到 : |
❌ |
| ② | ja...t:%61%6c... |
javascript:%61%6c... |
HTML 解码后 露出了 javascript: |
✅ |
案例②的成功密码在于 :攻击者把协议字母用 HTML 实体 编码,URL 协议部分用 URL 编码 。HTML 解析器先解码实体,露出了 javascript: 协议头,然后 URL 解析器正常识别协议并解码剩余部分。两层编码分别被两个解析器消化,互不冲突。
🅲 组:JavaScript 层的 Unicode 转义规则
核心:JS 支持 \uXXXX Unicode 转义,但只允许用于标识符字符(字母、数字、$、_),不能用于符号。
HTML 规范规定 script 是原始文本元素,不解码 HTML 实体。所以在
<script>标签内,必须使用 JS 自己的转义机制------Unicode 转义。
案例⑩ ------ 字母的 Unicode 转义(✅ 成功)
html
<script>alert(10);</script>
结果:✅ 弹窗!
解析过程:
- HTML 解析器在原始文本状态,不做任何解码,直接把字符串传给 JS 引擎
- JS 解析器开始词法分析
a被识别为 Unicode 转义序列,解析为al→l,e→e,r→r,t→talert→alert(10)是正常的函数调用- 执行
alert(10)✅
💡 为什么? JS 规范允许在标识符(Identifier)中使用 Unicode 转义序列。
alert是一个标识符,所以a→a是合法的。
案例⑪ ------ 符号也被 Unicode 转义(❌ 失败)
html
<script>alert(11)</script>
结果:❌ 不执行
解析过程:
- JS 解析器尝试解析
( (解码为(- 但 JS 规范规定:Unicode 转义不能用于界定符(delimiter)和运算符(operator)
- 标识符中的 Unicode 转义和 字符串/正则中的 Unicode 转义 是两套不同规则
((左括号)不是一个合法的标识符字符,JS 词法分析器在遇到它时报错
💡 JS 中的性别歧视 :
\u转义在 JS 中"重男轻女"------它只允许出现在标识符名称 (字母、数字、$、_)中,不允许出现在符号 (括号、引号、运算符、分号等)中。所以((左括号)和)(右括号)都会导致语法错误。
案例⑫ ------ 数字被 Unicode 转义
html
<script>alert(12)</script>
结果:❌ 不执行
解析过程:
a...rt正常解析为alert- 左括号
(是普通字符,正常 1解码为1,2解码为2- 但问题是:JS 解析器在解析标识符时,
1→1作为标识符是合法的(数字可以出现在标识符中) - 然而这里的
12是作为数值字面量出现的,不是标识符 - 在数值字面量中,JS 不支持 Unicode 转义
- 加上引号写成
'12'没问题------因为在字符串中\u转义始终有效
💡 知识点 :JS 中有三种地方支持
\u转义,每种规则不同:
- 标识符中:仅字母、数字、$、_ 可转义
- 字符串/模板字面量中:任意字符都可转义
- 正则表达式 中:任意字符都可转义(在两个
/之间)
案例⑭ ------ Unicode 换行符
html
<script>alert('14
')</script>
结果:✅ 弹窗!
解析过程:
是换行符(Line Feed)的 Unicode 编码- 在 JS 字符串中,
\u转义可以用于任意字符,包括控制字符 alert('14 ')等价于alert('14\n')- 弹窗内容为
14后跟一个换行(可能看不见效果,但字符串内确实包含了一个换行符)
💡 与案例⑪的对比 :同样是
\u+ 符号类字符,((括号)不行,但(换行)可以。区别在于上下文------标识符 中只能转义标识符字符,字符串 中几乎可以转义任何字符。出现在字符串字面量的内容中,所以合法。
案例⑧ ------ onclick 属性中的 Unicode 转义
html
<button onclick="confirm('8');">Button</button>
结果:❌ 不弹窗
解析过程:
- HTML 解析器看到
onclick属性值为confirm('8'); - HTML 解析器不做特殊处理,直接将属性值传给 JS 解析器
- JS 解析器开始解析,看到
confirm('8'); - 单引号字符串
'8''------'解码为'(单引号) - 但是! 这个
'是在字符串内部的 Unicode 转义 - 在 JS 字符串中,
'被解析为单引号字符',但它只是字符串内容的一部分,不会闭合外层字符串 - 也就是:
'8''→ 字符串8',字符串已经结束,后面的)变成语法错误 - 等一下------不对,
'8''这个字符串到底是什么?
这里要仔细分析 JS 的词法:
confirm('8');
- 开头的
'开始一个单引号字符串 8是字符串内容'是 Unicode 转义序列,解析为'字符- 然后遇到
)------字符串什么时候结束的?字符串没有结束! - 因为
'解析为字符',但它不会被当作字符串结束的定界符 - 所以 JS 解析器继续搜索匹配的
',直到字符串结束都没找到,报错
💡 关键理解 :
\u转义在字符串中只是"生成"一个字符,不具备语法功能。'生成一个'字符,但这个'是数据 ,不是语法标记 。就像\'在字符串中是一个转义的单引号字符一样,'也是。它不会闭合外部字符串。
如果想用 Unicode 绕过 onclick 的引号过滤,出路在哪里?
答案是用 \u 转义函数名,而不是引号:
html
<button onclick="alert(1)">Button</button>
这会执行 alert(1)------因为 alert 作为标识符可以被 Unicode 转义,而且不在字符串内部。
🅳 组:三重编码的终极套娃(案例 15🌟)
html
<a href="javascript:%5c%75%30%30%36%31%5c%75%30%30%36%63%5c%75%30%30%36%35%5c%75%30%30%37%32%5c%75%30%30%37%34(15)">aaa</a>
结果:✅ 弹窗!
这是全文中最复杂也最精彩的一个案例------它同时用到了三种编码,由三层解析器依次消化。
逐层拆解:
原始输入:jav...t:%35%63%75%33%30%30%36%31...
│
│ 第1层:HTML 解析器
│ 在 href 属性值中,HTML 实体被解码
│ j → j, a → a, ..., t → t, : → :
│ 特别注意 : 就是冒号 :
▼
javascript:%5c%75%33%30%30%36%31%5c%75%33%30%30%36%33...
│
│ 第2层:URL 解析器
│ 识别到 "javascript:" 协议 ✅
│ 对协议后的内容做 URL 解码
│ %35 → 5, %63 → c, 等等
│ %5c → \ (反斜杠!)
│ %75 → u
│ 组合起来就是 1, c, ...
▼
acert(15)
│
│ 第3层:JavaScript 解析器
│ 执行 Unicode 转义
│ a → a, c → c, ..., t → t
▼
alert(15) → 弹窗!
编码嵌套关系:
%5c ← HTML 实体编码(第1层消化)
↓ 解码后
%5c ← URL 编码(对应 \)
↓ URL解码后
\ ← 反斜杠字符
↓ 和后面的 u 组合
a ← JS Unicode 转义(第3层消化)
↓
a
攻击者的编码流程(反向):
原始目标:alert(15)
↓ 第3层编码(JS Unocode 转义)
acert(15)
↓ 第2层编码(URL 百分号编码)
%5c%75%30%30%36%31%5c%75...
↓ 第1层编码(HTML 实体编码)
%5c%75...
↓
最终 payload
💡 这个案例揭示了浏览器解析的本质 :三层解析器是串联 的,每层只处理自己"认识"的编码。攻击者可以给每层"喂"它认识的食物,搭建一条从 HTML → URL → JS 的编码通道。单层的
htmlspecialchars只能防第一层(HTML),防不住第二层和第三层。
🅴 组:属性值中的实体解码 + JS 执行(案例 7)
html
<button onclick="confirm('7')">Button</button>
结果:⚠️ 取决于后续处理,存在风险
解析过程:
- HTML 解析器看到
onclick属性值为confirm('7') - 在 HTML 属性值中,字符引用会被解码
'→'(单引号)- 属性值解码后变为:
confirm('7') - 传给 JS 解析器后:
confirm('7')------字符串'7'被'闭合,语法成立
这个案例的核心意义在于:HTML 实体在属性值中会被解码,解码后的结果可能改变 JS 的语法结构。
攻击思路拓展:
<button onclick="confirm('7')+alert(1)//')">Button</button>
htmlspecialchars 不会转义单引号 '(默认只转义双引号),但如果用了 ENT_QUOTES,它会转义 ' 为 '。看起来安全了?
攻击者可以反过来用 HTML 实体解码 这个 ',让它变回 ',然后闭合 JS 字符串。
💡 启示 :属性值中的 HTML 实体解码发生在 JS 解析之前。这意味着攻击者可以在属性值中用 HTML 实体"隐藏"JS 语法符号(引号、括号等),等 HTML 解码后再进入 JS 上下文执行。对属性值做
htmlspecialchars只能防止 HTML 标签注入,不能保证 JS 上下文的安全。
四、一张表总结全部 15 个案例
| 编号 | 涉及解析层 | Payload 位置 | 编码类型 | 是否成功 | 一句话原因 |
|---|---|---|---|---|---|
| ① | URL | href 属性值 | URL 编码协议字母 | ❌ | URL 解析器先查协议名,不解码 |
| ② | HTML→URL | href 属性值 | HTML 实体 + URL 编码 | ✅ | HTML 解码露出 javascript:,URL 解码执行 |
| ③ | URL | href 属性值 | URL 编码冒号 | ❌ | URL 解析器找不到 : 字面字符 |
| ④ | HTML | div 文本 | HTML 实体 | ❌ | 数据状态下实体解码不创建标签 |
| ⑤ | HTML | textarea | HTML 实体 | ❌ | RCDATA 状态只解码不解析标签 |
| ⑥ | HTML | textarea | 无编码 | ❌ | RCDATA 状态下只认 </textarea> |
| ⑦ | HTML→JS | onclick 属性值 | HTML 实体 | ⚠️ 有风险 | 属性值中实体先解码,改变 JS 语法 |
| ⑧ | JS | onclick 属性值 | Unicode | ❌ | ' 是字符串内字符,不闭合引号 |
| ⑨ | HTML | script 标签内 | HTML 实体 | ❌ | 原始文本元素不解码实体 |
| ⑩ | JS | script 标签内 | Unicode 字母 | ✅ | JS 允许标识符使用 Unicode 转义 |
| ⑪ | JS | script 标签内 | Unicode 全编码 | ❌ | 符号不能使用 Unicode 转义 |
| ⑫ | JS | script 标签内 | Unicode 数字 | ❌ | 数字字面量不支持 Unicode 转义 |
| ⑭ | JS | script 标签内 | Unicode 控制字符 | ✅ | 字符串内任意字符可转义 |
| ⑮ | HTML→URL→JS | href 属性值 | 三重嵌套 | ✅ | 三层解码各管各的,完美串联 |
五、防御建议
理解了这三层解析机制,就能针对性地进行防御。以下是逐层的防护方案:
1. 为每个上下文选择合适的编码
| 上下文类型 | 编码方式 | PHP 函数 | 说明 |
|---|---|---|---|
HTML 文本(<div>...</div>) |
HTML 实体编码 | htmlspecialchars($input, ENT_QUOTES) |
转义 < > & " ' |
HTML 属性(<div class="...">) |
HTML 实体编码 | htmlspecialchars($input, ENT_QUOTES) |
同上,还要注意属性值引号 |
URL(<a href="...">) |
URL 编码 | urlencode($input) |
但最好不要把用户输入放到协议位置 |
| JS 字符串 | JS 字符串转义 | CUSTOM | \ → \\, ' → \', \n → \\n 等 |
| CSS | CSS 转义 | 不建议直接拼接 | 尽量避免用户输入进入 CSS |
2. 对关键属性做白名单校验
php
// href / src / action 等属性:只允许 http / https
$allowed_schemes = ['http', 'https'];
$url = parse_url($input, PHP_URL_SCHEME);
if (!in_array($url, $allowed_schemes)) {
die('不支持的协议类型');
}
// 或者更简单:
if (preg_match('/^(https?:\/\/)/i', $input)) {
// 安全
}
这样可以彻底终结 javascript:、data:、vbscript: 等协议的绕过。
3. 设置 Content-Security-Policy (CSP)
这是最底层的防御手段,即使代码层面有漏洞,CSP 也可以作为最后一道防线:
nginx
# 禁止内联脚本和事件处理器
Content-Security-Policy: default-src 'self'; script-src 'self';
# 更严格:只允许从指定域名加载 JS
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com;
4. 避免在危险位置直接拼接用户输入
危险位置清单:
❌ <a href="用户输入"> → URL 上下文,可执行 javascript:
❌ <img src="用户输入"> → URL 上下文
❌ <div onclick="用户输入"> → JS 上下文
❌ <script>用户输入</script> → JS 上下文(原始文本)
❀ <div>用户输入</div> → HTML 文本上下文(相对安全,仍有风险)
安全做法:
php
// 安全:用户输入作为文本内容
echo '<div>' . htmlspecialchars($input) . '</div>';
// 安全:用户输入作为 URL 参数值(对 URL 做整体校验)
$safe_url = filter_var($input, FILTER_VALIDATE_URL);
if ($safe_url && preg_match('/^https?:\/\//', $safe_url)) {
echo '<a href="' . htmlspecialchars($safe_url) . '">链接</a>';
}
// 不安全:用户输入作为 JS 事件监听值
// ❌ echo '<div onclick="' . $input . '">...</div>';
// ✅ 改用 addEventListener 从 JS 中动态绑定
5. 使用专业的上下文编码库
不要自己手写编码函数。OWASP 提供了成熟的库:
- OWASP Java Encoder(Java)
- OWASP ESAPI(多语言)
- Twig / Blade / Smarty 等模板引擎内置了上下文感知的自动转义
六、总结
回到最初的问题:为什么 htmlspecialchars 防不住这个 payload?
html
<a href="javascript:alert(1)">
因为浏览器解析 HTML 经历了三层解析器:HTML → URL → JavaScript 。htmlspecialchars 只作用于第一层(转义 < 和 >),而攻击者根本没有使用能被 htmlspecialchars 处理的字符------他用的都是 &、#、x、; 等合法字符,在 HTML 层面被解码后露出了 javascript: 协议头,然后在 URL 解析层和 JS 解析层一路畅通。
记住三个核心结论:
- 解析是分层的,每层只处理自己的编码 ------HTML 处理
&#x;,URL 处理%xx,JS 处理\uXXXX - 单层防御是不够的------必须在每个上下文(HTML 文本、属性值、URL、JS、CSS)使用对应的编码方案
- 白名单胜于黑名单 ------对关键属性做协议白名单校验(只允许
http/https),比尝试穷举所有编码绕过方式更可靠
这 15 个案例是理解浏览器解析机制的起点,不是终点。实际攻防中,攻击者可以组合更多编码方式。关键是掌握一个思维模型------"当前输入会经过哪些解析器?每个解析器会做什么处理?"------有了这个模型,遇到任何新绕过手法,你都能第一时间分析出它的路径。