前言
说到前端安全问题,首先想到的无疑是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=@ οnerrοr=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=@ οnerrοr=alert(1234) />'))" /> 22 </body> 23 </html>
运行结果:页面输出字符串--<img src=@ οnerrοr=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=@ οnerrοr=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=@ οnerrοr=alert(1234) />'); 13 // document.write('<img src=@ οnerrοr=alert(1234) />'); 14 } 15 </script> 16 </body> 17 </html>
运行结果:弹出-1234。
执行注释代码:页面输出字符串--<img src=@ οnerrοr=alert(1234) />。(chorme下没有>输出,应该进行过滤了)
结果分析
对比样例1和样例2可以看出,当HTML代码段不被编码时,页面写入的是一个IMG标签,点击后会触发弹出框;而被编码后再写入页面时,展现的是标签的字符串形式,并没有被当成img DOM渲染。
那对比样例2和样例3 的执行结果,从二者document.write写入页面的字符串('<img src=@ οnerrοr=alert(1234) />')来说是相同的,但为什么会有不同的执行结果呢?两个实例唯一的区别就是样例3的写入代码是完全的<input>标签内部,而样例2的写入代码先由<script>内的HtmlEncode编码后再写入。样例3中onclick里的这段JavaScript代码出现在HTML中,在浏览器载入后,浏览器会对其自动解码,所以在JavaScript执行前所要写入的字符串已经是‘<img src=@ οnerrοr=alert(1234) />’,所以点击后会有弹出框。所以,样例1和样例3执行结果相同。
再看样例4,直接执行和执行注释部分二者有不同的结果,执行注释部分代码,里面的'<img src=@ οnerrοr=alert(1234) />'会在JS执行前自动解码吗?根据其不同的执行结果,很明显是不会自动解码的,当用户输入的字符上下文环境是JavaScript,不是HTML(可以认为<script>标签里的内容和HTML环境毫无关系)时,这段内容需要遵循JavaScript规则。
为了防止XSS攻击,对于需要在JavaScript处理的字符,JavaScript也会其进行编码,有以下几种形式:
- Unicode形式:\uH(十六进制);
- 普通十六进制:\xH。
- 纯转义:\',\",\<,\>这样在特殊字符前加上\进行转义。
如果在样例4中写入的字符串按照JavaScript编码规则转义为--'\<img src\=@ οnerrοr=alert \/\>',执行代码结果依然是弹出“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前端黑客技术揭秘》一书中有很多讲述,感兴趣的同学可以读一下。
参考文献: