前言
說到前端安全問題,首先想到的無疑是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前端黑客技術揭秘》一書中有很多講述,感興趣的同學可以讀一下。
參考文獻:
