前言
说到前端安全问题,首先想到的无疑是XSS(Cross Site Scripting,即跨站脚本),其主要发生在目标网站中目标用户的浏览器层面上,当用户浏览器渲染整个HTML文档的过程中出现了不被预期的脚本指令并执行时,XSS就会发生。XSS有三类:
- 反射型XSS:发出请求时,XSS代码出现在URL中,作为输入提交到服务端,服务端解析后响应,在响应内容中出现这段XSS代码,最后浏览器解析执行,此过程就像一次反射;
- 存储型XSS:它与反射型XSS的差别仅在于--提交的XSS代码会存储在服务端,下次请求目标页面时不用再提交XSS代码。典型的例子就是留言板XSS,用户提交一条包含XSS代码的留言存储到数据库,再次查看留言时会显示出来,进而触发XSS攻击。
- DOM XSS:它与以上两种XSS不同之处在于--DOM XSS不需要服务器解析响应的直接参与,触发XSS靠的就是浏览器端的DOM解析,完全在客户端发生。
XSS诱发原因有很多,很多网站做了各种针对性工作防御XSS,浏览器厂商也做了很大努力。为了防御XSS,很多可能触发XSS的敏感字符会被过滤或转义,而这些转义规则也是各不相同的。不了解这些不同的编码规则,会给我们日常编程造成很大的困惑,本文是针对各种编码规则写的一篇总结,希望给大家一些帮助。
1.字符编码
字节:一字节由8位二进制数组成。
字符:肉眼看到的一个文字或者符号单元就是一个字符,一个字符可能对应1~n个字节。
字符集:一些字符组成的合集,如ASCII字符集就是由128个字符组成,基本上就是键盘上的英文字符(包括控制符)。
字符集编码:一种字符集往往都对应于一种字符编码方式。一个字符对应1~n字节是由字符集与编码决定的,说白了字符集编码就是一种字符与编码值的映射关系。
常见的编码方式有ASCII,GB2312,GBK,Big5,UTF-8,UTF-7等。不同的编码方式,会产生不同的编码结果,比如以GBK编码的文件用UTF-8打开就会出现乱码问题。如果文件是英文的,并不会出现乱码。因为,在GBK中ASCII字符编码是一个字节,继承自ASCII码,而汉字编码是两个字节;在UTF-8中ASCII字符依然是一个字节,和ASCII码一样,而汉字编码是三或四个字节;所以,关于ASCII字符并不存在转码问题,其表示方式一致,而汉字需要重新转码。
其他编码方式都是兼容ASCII的,ASCII字符编码方式相同。
注:有些安全问题是由字符集使用不当造成的,所以在实际开发中需要选择合适的编码规则。
2.URL编码
URL编码是一种多功能技术,可以通过它来战胜多种类型的输入过滤器。URL编码的最基本表示方式是使用问题字符的十六进制ASCII编码来替换它们,并在ASCII编码前加%。例如,单引号字符的ASCII码为0x27,其URL编码的表示方式为%27。
URL的一种常见的组成模式如下:
<scheme>://<netloc>/<path>?<query>#<fragment>
RFC3986文档规定,Url中只允许包含英文字母(a-zA-Z)、数字(0-9)、-_.~4个特殊字符以及所有保留字符。
保留字符:Url可以划分成若干个组件,协议、主机、路径等,RFC3986中指定了以下字符为保留字符:! * ' ( ) ; : @ & = + $ , / ? # [ ]。
不安全字符:还有一些字符,当他们直接放在Url中的时候,可能会引起解析程序的歧义。这些字符被视为不安全字符,原因有很多。
- 空格:Url在传输的过程,或者用户在排版的过程,或者文本处理程序在处理Url的过程,都有可能引入无关紧要的空格,或者将那些有意义的空格给去掉;
- 引号以及<>:引号和尖括号通常用于在普通文本中起到分隔Url的作用;
- #:通常用于表示书签或者锚点;
- %:百分号本身用作对不安全字符进行编码时使用的特殊字符,因此本身需要编码;
- {}|\^[]`~:某一些网关或者传输代理会篡改这些字符。
需要注意的是,对于Url中的合法字符,编码和不编码是等价的,但是对于上面提到的这些字符,如果不经过编码,那么它们有可能会造成Url语义的不同。因此对于Url而言,只有普通英文字符和数字,特殊字符$-_.+!*'()还有保留字符,才能出现在未经编码的Url之中。其他字符均需要经过编码之后才能出现在Url中。
如何进行URL编码?
Url编码通常也被称为百分号编码(Url Encoding,also known as percent-encoding),是因为它的编码方式非常简单,使用%百分号加上两位的字符——0123456789ABCDEF——代表一个字节的十六进制形式。Url编码默认使用的字符集是US-ASCII。例如a在US-ASCII码中对应的字节是0x61,那么Url编码之后得到的就是%61,我们在地址栏上输入http://g.cn/search?q=%61%62%63,实际上就等同于在google上搜索abc了。又如@符号在ASCII字符集中对应的字节为0x40,经过Url编码之后得到的是%40。
对于非ASCII字符,需要使用ASCII字符集的超集进行编码得到相应的字节,然后对每个字节执行百分号编码。对于Unicode字符,RFC文档建议使用utf-8对其进行编码得到相应的字节,然后对每个字节执行百分号编码。如"中文"使用UTF-8字符集得到的字节为0xE4 0xB8 0xAD 0xE6 0x96 0x87,经过Url编码之后得到"%E4%B8%AD%E6%96%87"。
如果某个字节对应着ASCII字符集中的某个非保留字符,则此字节无需使用百分号表示。例如"Url编码",使用UTF-8编码得到的字节是0x55 0x72 0x6C 0xE7 0xBC 0x96 0xE7 0xA0 0x81,由于前三个字节对应着ASCII中的非保留字符"Url",因此这三个字节可以用非保留字符"Url"表示。最终的Url编码可以简化成"Url%E7%BC%96%E7%A0%81" ,当然,如果你用"%55%72%6C%E7%BC%96%E7%A0%81"也是可以的。
注:不同的浏览器及不同的浏览器版本可能采用不同的URLEncode编码规则,其编码的敏感字符可能不完全相同。
3.HTML编码
HtmlEncode:是将html源文件中不容许出现的字符进行编码,通常是编码以下字符:"<"、">"、"&"、"""、"'"等;
HtmlDecode:跟HtmlEncode恰好相反,解码出原来的字符。
为了防止XSS攻击,有的浏览器本身就会对某些HTML标签内的内容进行处理,这样我们就可以利用某些浏览器对这些标签包含内容的转义完成HTML编解码。并不是所有的浏览器都会为标签内置这样的功能,但绝大多数浏览器都会支持JS,那么使用JS就是完成HTML编解码就有更好的适用性。
下面是一些需要编码的字符对应关系举例:
- &--&
- <--<
- >-->
- 空格--
- “--"
(还有一些其他的特殊字符,其转义对应关系,请参考:HTML转义字符)
具体实现代码如下:
1 var HtmlUtil = {
2 /*1.用浏览器内部转换器实现html转码*/
3 htmlEncode: function(html) {
4 //1.首先动态创建一个容器标签元素,如DIV
5 var temp = document.createElement("div");
6 //2.然后将要转换的字符串设置为这个元素的innerText(ie支持)或者textContent(火狐,google支持)
7 (temp.textContent != undefined) ? (temp.textContent = html) : (temp.innerText = html);
8 //3.最后返回这个元素的innerHTML,即得到经过HTML编码转换的字符串了
9 var output = temp.innerHTML;
10 temp = null;
11 return output;
12 },
13 /*2.用浏览器内部转换器实现html解码*/
14 htmlDecode: function(text) {
15 //1.首先动态创建一个容器标签元素,如DIV
16 var temp = document.createElement("div");
17 //2.然后将要转换的字符串设置为这个元素的innerHTML(ie,火狐,google都支持)
18 temp.innerHTML = text;
19 //3.最后返回这个元素的innerText(ie支持)或者textContent(火狐,google支持),即得到经过HTML解码的字符串了。
20 var output = temp.innerText || temp.textContent;
21 temp = null;
22 return output;
23 },
24 /*3.用正则表达式实现html转码*/
25 htmlEncodeByRegExp: function(str) {
26 var s = "";
27 if (str.length == 0) return "";
28 s = str.replace(/&/g, "&");
29 s = s.replace(/</g, "<");
30 s = s.replace(/>/g, ">");
31 s = s.replace(/ /g, " ");
32 s = s.replace(/\'/g, "'");
33 s = s.replace(/\"/g, """);
34 return s;
35 },
36 /*4.用正则表达式实现html解码*/
37 htmlDecodeByRegExp: function(str) {
38 var s = "";
39 if (str.length == 0) return "";
40 s = str.replace(/&/g, "&");
41 s = s.replace(/</g, "<");
42 s = s.replace(/>/g, ">");
43 s = s.replace(/ /g, " ");
44 s = s.replace(/'/g, "\'");
45 s = s.replace(/"/g, "\"");
46 return s;
47 }
48 };
注:会自动对其包含的敏感字符进行编码,具备HTMLEncode功能的标签有:
- <title></title>;
- <textarea></textarea>;
- <xmp></xmp>;
- <iframe></iframe>;
- <noscript></noscript>;
- <noframes></noframes>;
- <plaintext></plaintext>等。
4.JavaScript编码
上边讲述了HTML编解码的知识,一个网站并不仅仅包含HTML,还会带有JS代码,JS也有一些敏感的字符需要进行处理,当HTML和JS混在一起时,它们会采用什么样的规则进行编解码呢?下面有四个实例,可以了解一下其运作机理。
样例1
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8"/>
5 <title>样例1</title>
6 </head>
7 <body>
8 <input type="button" id="XSS" value="XSS" onclick="document.write('<img src=@ onerror=alert(1234) />')"/>
9 </body>
10 </html>
运行结果:弹出-1234。
样例2
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8"/>
5 <title>样例2</title>
6 <script type = "text/javascript" >
7 function HtmlEncode(str) {
8 var s = "";
9 if (str.length == 0) return "";
10 s = str.replace(/&/g, "&");
11 s = s.replace(/</g, "<");
12 s = s.replace(/>/g, ">");
13 s = s.replace(/ /g, " ");
14 s = s.replace(/\'/g, "'");
15 s = s.replace(/\"/g, """);
16 return s;
17 }
18 </script>
19 </head>
20 <body>
21 <input type="button" id="XSS" value="XSS" onclick="document.write(HtmlEncode('<img src=@ onerror=alert(1234) />'))" />
22 </body>
23 </html>
运行结果:页面输出字符串--<img src=@ onerror=alert(1234) />。(chorme下没有>输出,应该进行过滤了)
样例3
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8"/>
5 <title>样例3</title>
6 </head>
7 <body>
8 <input type="button" id="XSS" value="XSS" onclick="document.write('<img src=@ onerror=alert(1234) />')" />
9 </body>
10 </html>
运行结果:弹出-1234。
样例4
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8"/>
5 <title>样例4</title>
6 </head>
7 <body>
8 <input type="button" id="XSS" value="XSS"/>
9 <script type = "text/javascript" >
10 var btn = document.getElementById('XSS');
11 btn.onclick = function() {
12 document.write('<img src=@ onerror=alert(1234) />');
13 // document.write('<img src=@ onerror=alert(1234) />');
14 }
15 </script>
16 </body>
17 </html>
运行结果:弹出-1234。
执行注释代码:页面输出字符串--<img src=@ onerror=alert(1234) />。(chorme下没有>输出,应该进行过滤了)
结果分析
对比样例1和样例2可以看出,当HTML代码段不被编码时,页面写入的是一个IMG标签,点击后会触发弹出框;而被编码后再写入页面时,展现的是标签的字符串形式,并没有被当成img DOM渲染。
那对比样例2和样例3 的执行结果,从二者document.write写入页面的字符串('<img src=@ onerror=alert(1234) />')来说是相同的,但为什么会有不同的执行结果呢?两个实例唯一的区别就是样例3的写入代码是完全的<input>标签内部,而样例2的写入代码先由<script>内的HtmlEncode编码后再写入。样例3中onclick里的这段JavaScript代码出现在HTML中,在浏览器载入后,浏览器会对其自动解码,所以在JavaScript执行前所要写入的字符串已经是‘<img src=@ onerror=alert(1234) />’,所以点击后会有弹出框。所以,样例1和样例3执行结果相同。
再看样例4,直接执行和执行注释部分二者有不同的结果,执行注释部分代码,里面的'<img src=@ onerror=alert(1234) />'会在JS执行前自动解码吗?根据其不同的执行结果,很明显是不会自动解码的,当用户输入的字符上下文环境是JavaScript,不是HTML(可以认为<script>标签里的内容和HTML环境毫无关系)时,这段内容需要遵循JavaScript规则。
为了防止XSS攻击,对于需要在JavaScript处理的字符,JavaScript也会其进行编码,有以下几种形式:
- Unicode形式:\uH(十六进制);
- 普通十六进制:\xH。
- 纯转义:\',\",\<,\>这样在特殊字符前加上\进行转义。
如果在样例4中写入的字符串按照JavaScript编码规则转义为--'\<img src\=@ onerror=alert\(1234\) \/\>',执行代码结果依然是弹出“1234”,并不是输出字符串,这是因为在JS代码中的代码会在执行之前进行自动解码,自动去掉转义。即使进行Unicode和十六进制编码,在执行前仍然会自动解码。
如何进行编码?
在JavaScript中有三套编码/解码函数,分别为:
- escape/unescape;
- encodeURL/decodeURL;
- encodeURLComponent/decodeURLComponent;
它们都是将不安全不合法的Url字符转换为合法的Url字符表示,其中一个很大的区别就是它们编码的敏感字符集不同,对于下面的字符不会进行编码:
- escape:*/@+-._0-9a-zA-Z (69个),对0-255以外的unicode值进行编码输出格式为:%u**** (已经被W3C废弃);
- encodeURL:!#$&'()*+,/:;=?@-._~0-9a-zA-Z (82个),使用UTF-8对非ASCII字符进行编码,然后再进行百分号编码;
- encodeURLComponent:!'()*-._~0-9a-zA-Z (71个),使用UTF-8对非ASCII字符进行编码,然后再进行百分号编码。
为了更好的理解,写了一个函数来实现escape功能,代码如下:
1 var escape = function(str) {
2 var _a, _b;
3 var _c = "";
4 for (var i = 0; i < str.length; i++) {
5 _a = str.charCodeAt(i);
6 _b = _a < 255 ? "%" : "%u"; // u不可大写
7 _b = _a < 16 ? "%0" : _b;
8 _c += _b + _a.toString(16).toUpperCase();
9 }
10 return _c;
11 }
escape函数是从Javascript 1.0的时候就存在了,其他两个函数是在Javascript 1.5才引入的。但是由于Javascript 1.5已经非常普及了,所以实际上使用encodeURI和encodeURIComponent并不会有什么兼容性问题。
5.Base64编码
Base64编码可用于在HTTP环境下传递较长的标识信息。例如,在Java Persistence系统Hibernate中,就采用了Base64来将一个较长的唯一标识符(一般为128-bit的UUID)编码为一个字符串,用作HTTP表单和HTTP GET URL中的参数。在其他应用程序中,也常常需要把二进制数据编码为适合放在URL(包括隐藏表单域)中的形式。此时,采用Base64编码不仅比较简短,同时也具有不可读性,即所编码的数据不会被人用肉眼所直接看到。
Base64编码要求把3个8位字节(3*8=24)转化为4个6位的字节(4*6=24),之后在6位的前面补两个0,形成8位一个字节的形式。 如果剩下的字符不足3个字节,则用0填充,输出字符使用'=',因此编码后输出的文本末尾可能会出现1或2个'='。
为了保证所输出的编码位可读字符,Base64制定了一个编码表,以便进行统一转换。编码表的大小为2^6=64,这也是Base64名称的由来。
Base64编码过程
以下是一个Base64编码过程举例:
- 初始字符:s 1 3;
- ascii表示:115 49 51;
- 2进制(8个一组,3组):01110011 00110001 00110011;
- 重新分组(6个一组,4组): 011100 110011 000100 110011;
- 由于计算机是按照byte存储的,也就是8位8位的存数,6位不够,两个高位自动补0;
- 二进制转换为: 00011100 00110011 00000100 00110011;
- 转换为十六进制:28 51 4 51;
- 根据Base64编码表可得: c z E z。
由上例可知,初始字符“s13”就被转换为了“czEz”,使需要传输的字符变得不可读,一定程度上增加了安全性。
从网上找了一段JavaScript实现Base64的代码,如下所示:
var base64EncodeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var base64DecodeChars = new Array(-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63,
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1);
function base64encode(str) {
var returnVal, i, len;
var c1, c2, c3;
len = str.length;
i = 0;
returnVal = "";
while (i < len) {
c1 = str.charCodeAt(i++) & 0xff;
if (i == len) {
returnVal += base64EncodeChars.charAt(c1 >> 2);
returnVal += base64EncodeChars.charAt((c1 & 0x3) << 4);
returnVal += "==";
break;
}
c2 = str.charCodeAt(i++);
if (i == len) {
returnVal += base64EncodeChars.charAt(c1 >> 2);
returnVal += base64EncodeChars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4));
returnVal += base64EncodeChars.charAt((c2 & 0xF) << 2);
returnVal += "=";
break;
}
c3 = str.charCodeAt(i++);
returnVal += base64EncodeChars.charAt(c1 >> 2);
returnVal += base64EncodeChars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4));
returnVal += base64EncodeChars.charAt(((c2 & 0xF) << 2) | ((c3 & 0xC0) >> 6));
returnVal += base64EncodeChars.charAt(c3 & 0x3F);
}
return returnVal;
}
function base64decode(str) {
varc1, c2, c3, c4;
vari, len, returnVal;
len = str.length;
i = 0;
returnVal = "";
while (i < len) {
/*c1*/
do {
c1 = base64DecodeChars[str.charCodeAt(i++) & 0xff];
} while (i < len && c1 == -1);
if (c1 == -1) {
break;
}
/*c2*/
do {
c2 = base64DecodeChars[str.charCodeAt(i++) & 0xff];
} while (i < len && c2 == -1);
if (c2 == -1) {
break;
}
returnVal += String.fromCharCode((c1 << 2) | ((c2 & 0x30) >> 4));
/*c3*/
do {
c3 = str.charCodeAt(i++) & 0xff;
if (c3 == 61) {
return returnVal;
}
c3 = base64DecodeChars[c3];
} while (i < len && c3 == -1);
if (c3 == -1) {
break;
}
returnVal += String.fromCharCode(((c2 & 0XF) << 4) | ((c3 & 0x3C) >> 2));
/*c4*/
do {
c4 = str.charCodeAt(i++) & 0xff;
if (c4 == 61) {
return returnVal;
}
c4 = base64DecodeChars[c4];
} while (i < len && c4 == -1);
if (c4 == -1) {
break;
}
returnVal += String.fromCharCode(((c3 & 0x03) << 6) | c4);
}
return returnVal;
}
结束语
由编码规则产生的安全漏洞有很多,作为开发者要详细了解不同编码规则,对潜在的安全问题有所防御。有很多黑客会根据不同浏览器编码特性及采用的编码规则,利用特定的编码方式可绕过安全防御,实现对网站的攻击。在《Web前端黑客技术揭秘》一书中有很多讲述,感兴趣的同学可以读一下。
参考文献: