前言
作为 Web 漏洞家族常见的一员 —— XSS 跨站脚本攻击的身影依旧活跃在各类网站,而特殊字符转义作为 XSS 漏洞的主要修复和防御手段,被开发人员广泛应用。本文目的在于通过具体的代码实例,从浏览器的解析规则出发,分析 HTML 转义、JS 转义防御 XSS 攻击背后的原理,从而深入理解 XSS 漏洞。
浏览器解析
解析规则
浏览器在解析 HTML 文档时无论按照什么顺序,主要有三个过程:HTML 解析、JS 解析 和 URL 解析,每个解析器负责HTML文档中各自对应部分的解析工作。下面以一篇HTML文档解析来简单的讨论下解析器如何协同工作的。
- 首先浏览器接收到一个 HTML 文档时,会触发 HTML 解析器对 HTML 文档进行词法解析,这一过程完成 HTML解码 并创建 DOM 树;
- 接下来 JavaScript 解析器会介入对内联脚本进行解析,这一过程完成 JS 的解码工作;
- 如果浏览器遇到需要 URL 的上下文环境,这时 URL 解析器也会介入完成 URL 的解码工作。
URL 解析器的解码顺序会根据 URL 所在位置不同,导致在 JavaScript 解析器之前或之后解析。每个解析过程中也有许多细节,下面再做具体讨论。
字符编码
- HTML字符实体:
在呈现 HTML 页面时,针对某些特殊字符如“<”
或”>”
直接使用,浏览器会误以为它们标签的开始或结束,若想正确的在 HTML 页面呈现特殊字符就需要用到其对应的字符实体。HTML 字符实体以&
开头 + 预先定义的实体名称,以分号结束,如“<
”的实体名称为<
或以 & 开头+#
符号以及字符的十进制数字(或者 十六 进制,都能解析),如”<
”的实体编号为<
。 - JavaScript 编码:最常用的如 “
\uXXXX
” 这种写法为 Unicode 转义序列,表示一个字符,其中 xxxx 表示一个 16 进制数字,如”<
” Unicode 编码为“\u003c
”。 - URL编码:
%
加字符的 ASCII 编码对于的 2 位 16 进制数字,如”/”
对应的 URL 编码为%2f
。
HTML 转义字符表 可以参考:
HTML在线编码转换 可以参考:
解析结果
简而言之,&#**;
格式的字符串是 HTML 的转义字符,\
是JS的转义符,转义的目的就是告诉解析器该符号为字符,而不是代码,防止代码出现歧义。
HTML 源代码 DOM 结构和浏览器解析后的 DOM 结构是不一样的:
- 在浏览器中我们右键查看网页源码(或者按住快捷键
ctrl+u
),看到的是服务器接受我们的请求后返回的 HTML 源码。 - 而按F12进入开发者工具面板,开发者工具分析出的 DOM 结构,就是浏览器的解析结果。
转义实例
JS 转义
来看一段测试代码 test1.php:
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>一个XSS</title>
</head>
<body>
<script type="text/javascript">
var input_str = "<?php echo $_POST['str']?>";
if(input_str.length>0){
document.write("Your input:"+input_str);
}
</script>
<form action="" method="post">
<input type="text" name="str" />
<input type="submit" value="提交">
</form>
</body>
将其部署在 phpstudy 的 WWW 文件夹下:
启动 phpstudy,访问测试页面:
当我们提交<script>alert(1)</script>
,前端并没有出现我们期待的弹窗,而是输出了以下字符串:
而当我们提交<script>alert(1)<\/script>
,却则可以正常弹框:
如何解释这两种情况,我们来思考一下?
原理分析
当我们输入第一个 payload :<script>alert(1)</script>
提交后,得到的 html 源码如下:
当我们的 HTM L解析器解析到 <script>
标签时,它会快速去查找离它最近的闭合标签</script>
。
- 这时它查找到是8行中的
</script>
,而不是12行的</script>
。 - 这时
<script>
标签内的var input_str = "<script>alert(1);
被交给 js 引擎去解析。而8行</script>
和12行的</script>
之间的代码被当成字符串输出到前端页面。 - 而由于6行
</script>
标签没有配对成功,故不会被浏览器解析为一个合法标签。 所以最终的解析结果是第8行的<script>
被解析为字符串,</script>
被解析为html标签。
故最终浏览器解析后的 DOM 结构如下:
当我们输入第二个 payload:<script>alert(1)<\/script>
提交后,得到的 html 源码如下:
与上面代码类似,只是差异只在第8行(多了一个/
)。
- 但还是同样的解析原则,html 解析引擎解析到7行的
<script>
时,它会快速去查找离它最近的闭合标签</script>
。在到第8行时发现<\/script>
标签,而不是</script>
,故继续往下,直到找寻到12行的标签,才完成了配对。 - 这时8行和11行的代码交给了 js 引擎去解析。 而这时又因为
\
为 js 语法中的转义字符,故在 js 解析引擎解析时,又能正常解析input_str
变量的值为<script>alert(1)</script>
字符串,所以最总成功弹窗,很巧妙!
故最终浏览器解析后的 DOM 结构如下:
从上面实例我们可以具体了解到服务端返回的源码的 DOM 结构与浏览器解析后的 DOM 结构的差异,以及 JS 转义的巧妙作用!
Html转义
来看看另一段测试代码 test2.php:
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>XSS与html编码</title>
</head>
<body>
<?php
if(isset($_POST['submit'])){
echo "<a href='".$_POST['str1']."'>str1</a>";
echo "<br/>";
echo "str2:".$_POST['str2'];
}
?>
<form action="" method="post">
str1:<input type="text" name="str1" />
<br/>
str2:<input type="text" name="str2" />
<br/>
<input type="submit" name="submit" value="提交">
</form>
</body>
</html>
同样放在 phpstudy 网站目录下,浏览器访问:
我们将javascript:alert(1);
进行 html 转义(字符的十六进制)得到如下字符串,并填写到 str1 输入框:
javascript:alert(1);
同时将<script>alert(1);</script>
进行 html 转义后得到如下字符,并填写到str2输入框:
<script>alert(1)</script>
提交后发现点击 str1 链接可以弹框,说明前者被当代码来执行了,而后者被当字符串输出了:
如何解释以上结果?
原理分析
提交 payload 之后,服务器返回的 html 源码代码如下:
而浏览器 html 解析器解析后的结果如下:
从上面可以看到,两个 payload 其实在浏览器的 HTML 解析器解析之后都被当成了字符输出了。
- 只是当用户再次点击 str1 的链接时,前者被解码之后的字符会被浏览器的 JS 解析器当成代码执行了。
- 而后者
<script>alert(1);</script>
被浏览器 html 解析器解码后已经被当作一个普通字符串而非 js 代码(请重点注意<script>alert(1)</script>
此时被浏览器解析后的结果是跟str2:
一起被包围在双引号里当作一个字符串显示),所以已无法被执行。
可以进一步来看看 str2 直接输入<script>alert(1)</script>
的话是什么效果,答案是会触发弹框:
进一步看看浏览器的解析结果:
此时,<script>alert(1)</script>
已经不是跟“str2:"
一样被当作字符串输出了。
由上面的实例分析,可以清晰地看到 HTML 转义字符对于 XSS 防御的意义,它会告诉浏览器将一些危险字符当作普通字符串输出,而不是当作 js 代码执行。
自动转义
PHP 语言里,我们在服务端可以使用htmlspecialchars($str);
函数对用户传输过来的客户端输入值进行 HTML 转义。
htmlspecialchars($str);
函数只转换5个字符,即:和号(&),双引号(“),单引号(‘),小于(<),大于(>),将它们转换为 HTML 实体形式,输出时浏览器会自动还原并将其当成普通字符输出的。
利用htmlspecialchars($str);
我们对前面 JS 转义的测试代码 test1.php 进行变更:
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>一个XSS</title>
</head>
<body>
<script type="text/javascript">
var input_str = "<?php echo htmlspecialchars(($_POST['str']))?>";
if(input_str.length>0){
document.write("Your input:"+input_str);
}
</script>
<form action="" method="post">
<input type="text" name="str" />
<input type="submit" value="提交">
</form>
</body>
</html>
此时再在浏览器输入 payload:<script>alert(1)<\/script>
,已经无法触发弹框了,而是被当作普通字符输出:
看看网页源码就更加清晰了:
最后来看看某次测试时发现 XSS 漏洞,客户修复后的结果:
可以看到,<script>alert(1)<\/script>
可以成功插入并正常显示,但是已无法触发弹框,而根本原因,点击数据查询按钮,查看服务器的响应数据就可以发现已经进行 HTML 转义了:
进阶示例
先来继续看一个实例代码 123.html:
<a href="javascript:alert(1)">test</a>
<a href="%6A%61%76%61%73%63%72%69%70%74:%61%6C%65%72%74%28%31%29">test1</a>
<a href="javascript:%61%6C%65%72%74%28%32%29">test2</a>
<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(3)">test3</a>
来看看实际页面:
其中各个链接的编码过程依次是:
链接 | 编码 |
---|---|
test1 | URL 编码 "javascript:alert(1)” |
test2 | HTML字符实体编码 “javascript” 、URL 编码 “alert(2)” |
test3 | 对 "javascript:alert(1)” 先进行 JS编码、然后URL编码、最后HTML编码 |
实际运行效果是,test2、test3 可以触发弹窗,而 test1 则不能。
分析下原因:
-
test1 为何不能弹窗?
URL 解析器解析过程中有一个细节了,不能对协议类型进行任何的编码操作,否则URL解析器会认为它无类型,这就导致 test1 中被 URL 编码的“javascript”
没能成功解码,当然不会被URL解析器识别了。 -
test2 为何能弹窗?
"javascript"
是做的HTML实体编码,HTML解析器工作时,href 里的 HTML 实体会被解码,接下来 URL 解析器工作解析 href 属性里的链接时,"javascript"
协议在第一步被 HTML 解码了,这样 URL 解析器是可以识别的,然后继续解析后面的”%61%6C%65%72%74%28%32%29”
,最后 JavaScript 解析器完成解析操作,脚本执行。 -
test3为何能弹窗?
test3 实现了 3 层复合编码,每一层编码都能正常执行。
以上我们直观地通过具体的实例代码了解了浏览器解析规则、字符转义与 XSS 漏洞之间的关系,下面还将从底层进一步分析它们的关系。
解析器原理
浏览器无论什么情况都会遵守一个这样的解码规则:
- HTML 解析器对 HTML 文档进行解析,完成 HTML 解码并且创建 DOM树;
- JavaScript 或者 CSS 解析器对内联脚本进行解析,完成 JS、CSS 解码;
- URL 解码会根据 URL 所在的顺序不同而在 JS 解码前或者解码后执行。
Html解析器
HTML中有五类元素:
- 空元素(Void elements),有area、base、br、col、command、embed、hr、img、input、keygen、link、meta、param、source、track、wbr;
- 原始文本元素(Raw text elements),有
<script>和<style>
; - RCDATA 元素(RCDATA elements),有
<textarea>和<title>
; - 外部元素(Foreign elements),例如 MathML 命名空间或者 SVG 命名空间的元素;
- 基本元素(Normal elements),即除了以上4种元素以外的元素。
五类元素的区别如下:
- 空元素,不能容纳任何内容(因为它们没有闭合标签,没有内容能够放在开始标签和闭合标签中间);
- 原始文本元素,可以容纳文本;
- RCDATA元素,可以容纳文本和字符引用;
- 外部元素,可以容纳文本、字符引用、CDATA段、其他元素和注释;
- 基本元素,可以容纳文本、字符引用、其他元素和注释。
HTML 解析器以状态机的方式运行,它从文档输入流中消耗字符并根据其转换规则转换到不同的状态。
示例:
<html>
<body>
Hello world
</body>
</html>
以上 HTML 文档,浏览器 HTML 解析器的状态机运行过程为:
- 初始状态为"Data State",当遇到 “<” 字符,状态变为 “Tag open state”,读取一个 a-z 的字符将产生一个开始标签符号,状态相应变为 “Tag name state”,一直保持这个状态直到读取到 “>”,每个字符都附加到这个符号名上,例子中创建的是一个 html符号。
- 当读取到 “>”,当前的符号就完成了,此时状态回到"Datastate",
"<body>"
重复这一处理过程。到这里,html 和 body 标签都识别出来了。现在,回到 “Data state”,读取 “Helloworld” 中的字符 “H” 将创建并识别出一个字符符号,这里会为 “Hello world” 中的每个字符生成一个字符符号。 - 这样直到遇到
"</body>"
中的 “<”。现在又回到了 “Tag open state”,读取下一个字符 “/” 将创建一个闭合标签符号,并且状态转移到 “Tag name state”,还是保持这一状态,直到遇到 “>”。然后产生一个新的标签符号并回到 “Data state”。后面的"</html>"
将和"</body>"
一样处理。
【注意】 HTML 解析器处于 数据状态(Data State)、RCDATA 状态(RCDATA State)、属性值状态(Attribute ValueState)时,字符实体会被解码为对应的字符。
示例:
<div><img src=x οnerrοr=alert(4)></div>
<和>被编码为字符实体<和>。
当HTML解析器解析完<div>时,会进入数据状态(Data State)并发布标签令牌。
接着解析到实体<时因为处在数据状态(Data State)就会对实体进行解码为<,
后面的>同样道理被解码为>。
这里会有个问题,被解码后,此处的 img 是否会被解析为 HTM L标签而导致 JS 执行呢?答案是否定的。
因为解析器在使用字符引用后不会转换到标签打开状态(Tag Open State),不进入标签打开状态就不会被发布为 HTML 标签。因此,不会创建新 HTML 标签,只会将其作为数据来处理。这也是为什么我们可以使用字符实体来避免用户不安全输入导致XSS的原因。
1、原始文本元素(Raw text elements)
在HTML中,属于 Raw text elements 的标签有两个:script、style。在 Raw text elements 类型标签下的所有内容块都属于该标签。
存在一条特性:Raw textelements类型标签下的所有字符实体编码都不会被HTML解码。HTML解析器解析到script、style标签的内容块(数据)部分时,状态会进入Script Data State,该状态并不在我们前面说的会解码字符实体的三条状态之中。
因此,<script>alert(9);</script>
这样字符实体并不会被解码,也就不会执行JS。
2、RCDATA
在HTML中,属于 RCDATA Elements 的标签有两个:textarea、title。RCDATA Elements 类型的标签可以包含文本内容和字符实体。解析器解析到 textarea、title 标签的数据部分时,状态会进入RCDATA State。前面我们提到,处于RCDATA State状态时,字符实体是会被解析器解码的。
示例:
<textarea><script>alert(5)</script></textarea>
<和>被编码为实体<和>。
解析器解析到它们时会进行解码,最终得到<textarea><script>alert(5)</script></textarea>。
但是里面的JS同样还是不会被执行,原因还是因为解码字符实体状态机不会进入标签打开状态(Tag Open State),
因此里面的<script>并不会被解析为HTML标签
3、外部元素(Foreignelements)
Foreign elemnts 来源于 MathML 和 SVG 命名空间,<svg>
遵循XML和SVG 的定义。
示例:
<script>alert(1)</script>
不能弹窗,Raw text elements类型标签下的所有字符实体编码都不会被HTML解码
<svg><script>alert(1)</script>
能弹窗,在XML中,(会被解析成(,在XML中实体会自动转义,除了<[CDATA[和]]>包含的实体
JS 解析器
形如 \uXXXX
这样的 Unicode 字符转义序列或 Hex 编码是否能被解码需要看情况。
首先,JavaScript 中有三个地方可以出现 Unicode 字符转义序列:
- 字符串中
Unicode 转义序列出现在字符串中时,它只会被解释为普通字符,而不会破坏字符串的上下文。例如,<script>alert("\u0031\u0030");</script>
被编码转义的部分为10,是字符串,会被正常解码,JS代码也会被执行。 - 标识符中
若 Unicode 转义序列存在于标识符中,即变量名(如函数名等…),它会被进行解码。例如:<script>\u0061\u006c\u0065\u0072\u0074(10);</script>
,被编码转义的部分为 alert 字符,是函数名,属于在标识符中的情况,因此会被正常解码,JS代码也会被执行。 - 控制字符中
若 Unicode 转义序列存在于控制字符中,那么它会被解码但不会被解释为控制字符,而会被解释为标识符或字符串字符的一部分。控制字符即'、"、()
等。例如,<script>alert\u0028"xss"); </script>
,其中(
进行了Unicode编码,那么解码后它不再是作为控制字符,而是作为标识符的一部分alert(
。因此函数的括号之类的控制字符进行 Unicode 转义后是不能被正常解释的。
总结,Unicode序列不能出现在控制字符中,否则不能被解释。
示例1:
<script>\u0061\u006c\u0065\u0072\u0074\u0028\u0031\u0031\u0029</script>
被编码部分为alert(11)。
该例子中的JS不会被执行,因为控制字符被编码了。
示例2:
<script>\u0061\u006c\u0065\u0072\u0074(\u0031\u0032)</script>
被编码部分为alert及括号内为12。
该例子中JS不会被执行,原因在于括号内被编码的部分不能被正常解释,要么使用ASCII数字,要么加""或''使其变为字符串,作为字符串也只能作为普通字符。
示例3:
<script>alert('13\u0027)</script>
被编码处为'。
该例的JS不会执行,因为控制字符被编码了,解码后的'将变为字符串的一部分,而不再解释为控制字符。
因此该例中字符串是不完整的,因为没有'来结束字符串。
示例4:
<script>alert('14\u000a')</script>
该例的JS会被执行,因为被编码的部分处于字符串内,只会被解释为普通字符,
不会突破字符串上下文。
示例5:
<img src="1" onerror=\u0061\u006c\u0065\u0072\u0074\u0028\u0031\u0029>
无法执行
我们以浏览器的视角来看:首先读到<开始读取标签,然后读到onerror调用JS解析器。在JS中,单引号,双引号和圆括号等属于控制字符,
编码后将无法识别。所以对于防御来说,应该编码这些控制字符。
下面这种方式可以解析:
<img src="1" οnerrοr=\u0061\u006c\u0065\u0072\u0074('\u0031')>
可以结合上面的HTML编码
按照解析顺序反过去,先JS编码然后HTML解码
<img src="1" onerror=\u0061\u006c\u0065\u0072\u0074('\u0031')>
浏览器读到了<标签开始构造语法树,然后HTML解码,解码之后发现onerror于是进行一个JS解码,成功弹窗
延伸:
开发人员单纯的设置HTML实体编码为防御xss的手段,但是用户输入点在alert中
<img src = "https://text.com" onclick = 'alert("输入点")'>
如果用户正常输入的话凡是存在< ," 等都能被转码
攻击者可以通过语句 ");alert("test,在服务端被转码:
<img src = "https://gss1.bdstatic.com" onclick = 'alert("FIRST XSS");alert("test")'>
弹窗两次,是因为浏览器进行HTML解码发现存在两个alert()
所以对于这种情况,正确防御XSS的方法:
应该是先JavaScript编码然后再进行HTML编码
用户输入 ");alert("test 后在服务端先JavaScript编码然后再进行HTML编码
在
在浏览器端:
首先经过第一步HTML解码后变为\u0022\u0029\u003B\u0061\u006C\u0065\u0072\u0074\u0028\u0022\u0074\u0065\u0073\u0074
JavaScript解析器工作,变为 ");alert("test ,刚才已经讲过JavaScript解析时只有标识符名称不会被当做字符串,
控制字符仅会被解析为标示符名称或者字符串,因此\u0022被解释成双引号文本,\u0028和\u0029被解释成为圆括号文本,不会变为控制字符被解析执行。
在这里采用的先JS编码后HTML编码中只弹窗了一次。
URL解析器
通用URI的格式如下:
[协议名]://[用户名]:[密码]@[主机名]:[端口]/[路径]?[查询参数]#[片段ID]
URL 解析器也被建模为状态机,文档输入流中的字符可以将其导向不同的状态。首先,要注意的是 URL 的 Scheme 部分(协议部分)必须为 ASCII 字符,即不能被任何编码,否则URL解析器的状态机将进入No Scheme状态。
示例:
<a href="%6a%61%76%61%73%63%72%69%70%74:%61%6c%65%72%74%28%31%29"></a>
URL编码部分的是javascript:alert(1)。
JS不会被执行,因为作为Scheme部分的"javascript"这个字符串被编码,导致URL解析器状态机进入No Scheme状态。
URL中的:
也不能被以任何方式编码,否则URL解析器的状态机也将进入 No Scheme 状态。
<a href="javascript%3aalert(3)"></a>
由于:被URL编码为%3a,导致URL状态机进入No Scheme状态,JS代码不能执行。
示例:
<a href="javascript:%61%6c%65%72%74%28%32%29">
"javascript"这个字符串被实体化编码,:没有被编码,alert(2)被URL编码。
成功执行
首先,在HTML解析器中我们谈到过,HTML状态机处于属性值状态(Attribute Value State)时,字符实体时会被解码的,此处在href属性中,所以被实体化编码的"javascript"字符串会被解码。其次,HTML解析是在URL解析之前的,所以在进行URL解析之前,Scheme部分的"javascript"字符串已被解码,而并不再是被实体编码的状态。
解析顺序
- 首先浏览器接收到一个 HTML 文档时,会触发 HTML 解析器对 HTML 文档进行词法解析,这一过程完成 HTML 解码并创建 DOM 树。
- 接下来 JavaScript 解析器会介入对内联脚本进行解析,这一过程完成J S的解码工作。
- 如果浏览器遇到需要 URL 的上下文环境,这时 URL 解析器也会介入完成 URL 的解码工作,URL 解析器的解码顺序会根据 URL 所在位置不同,可能在 JavaScript 解析器之前或之后解析。但HTML解析总是第一步。
URL 解析和 JavaScript 解析,它们的解析顺序要根据情况而定。
示例1:
<a href="UserInput"></a>
该例子中,首先由HTML解析器对UserInput部分进行字符实体解码;
接着URL解析器对UserInput进行URL decode;
如果URL的Scheme部分为javascript的话,JavaScript解析器会再对UserInput进行解码。
所以解析顺序是:HTML解析->URL解析->JavaScript解析。
示例2:
<a href=# onclick="window.open('UserInput')"></a>
该例子中,首先由HTML解析器对UserInput部分进行字符实体解码;
接着由JavaScript解析器会再对onclick部分的JS进行解析并执行JS;
执行JS后window.open('UserInput')函数的参数会传入URL,所以再由URL解析器对UserInput部分进行解码。
因此解析顺序为:HTML解析->JavaScript解析->URL解析。
示例3:
<a href="javascript:window.open('UserInput')">
该例子中,首先还是由HTML解析器对UserInput部分进行字符实体解码;
接着由URL解析器解析href的属性值;
然后由于Scheme为javascript,所以由JavaScript解析;
解析执行JS后window.open('UserInput')函数传入URL,所以再由URL解析器解析。
所以解析顺序为:HTML解析->URL解析->JavaScript解析->URL解析。
综合实例:
<a href="javascrip
t:%5c%75%3
0%30%36%31
%5c%75%30%
30%36%63%5
c%75%30%30
%36%35%5c%
75%30%30%3
7%32%5c%75
%30%30%37%
34(15)"></a>
首先 HTML 解析器进行解析,解析到 href 属性的值时,状态机进入属性值状态(Attribute Value State),该状态会解码字符实体。接着由URL解析器进行解析并解码,再接着由于 Scheme 为 javascript,因此由 JavaScript 解析器解析并解码,加上编码部分是函数名,属于标识符,因此可以正常解码解释。经过三轮解析解码后得到结果:<a href="javascript:alert(15)"></a>
。
总结
本文参考文章: