XSS的防禦是複雜的。流行的瀏覽器都內置了一些對抗XSS的措施,比如Firefox的CSP,Noscript擴展,IE8內置的XSS Filter等。而對於網站來說,也應該尋找優秀的解決方案,保護用戶不被XSS攻擊。
1)HttpOnly
HttpOnly最早是由微軟提出,並在IE 6中實現的,至今已成爲一個標準。瀏覽器將禁止頁面的JavaScript訪問帶有HttpOnly屬性的Cookie。嚴格來說,HttpOnly並非爲了對抗XSS--HttpOnly解決的是XSS後的Cookie劫持。
之前的文章中提到過”如何使用XSS竊取用戶的Cookie,然後登錄進該用戶的賬戶“。但如果該Cookie設置了HttpOnly,則這種攻擊會失敗,因爲JavaScript讀取不到Cookie的值。
一個Cookie的使用過程如下:
Step1:瀏覽器向服務器發起請求,這時沒有Cookie。
Step2:服務器返回時發送Set-Cookie頭,向客戶端瀏覽器寫入Cookie。
Step3:在該Cookie到期前,瀏覽器訪問該域下的所有頁面,都將發送該Cookie。
HTTPOnly是在Set-Cookie時被標記的。服務器可能會設置多個Cookie(多個key-value對),而HttpOnly可以有選擇性地加在任何一個Cookie值上。在某些時候,應用可能需要JavaScript訪問某幾項Cookie,這種Cookie可以不設置HttpOnly標記;而僅把HttpOnly標記給用於認證的關鍵Cookie。
HttpOnly的使用非常靈活,如下是一個使用HttpOnly的過程。
<?php
header("Set-Cookie: cookie1=test1;");
header("Set-Cookie: cookie2=test2;httponly", false);
?>
<script>
alert(document.cookie);
</script>
在這段代碼中,cookie1沒有HttpOnly,cookie2被標記爲HttpOnly。但是隻有cookie1倍JavaScript讀取到:
HttpOnly起到了應有的作用。
2)輸入檢查
常見的Web漏洞如XSS、SQL Injection等,都要求攻擊者構造一些特殊字符,這些特殊字符可能是正常用戶不會用到的,所以輸入檢查就有存在的必要了。
輸入檢查,在很多時候也被用於格式檢查。例如,用戶在網站註冊時填寫的用戶名,會被要求只能爲字母、數字的組合。比如 ”hello1234“ 是一個合法的用戶名,而”hello#$^"就是一個非法的用戶名。這些格式檢查,有點像白名單,也可以讓一些基於特殊字符的攻擊失效。
輸入檢查的邏輯,必須放在服務器端代碼中實現。如果只是在客戶端使用JavaScript進行輸入檢查,是很容易被攻擊者繞過的。目前Web開發的普遍做法,是同時在客戶端JavaScript中和服務器代碼中實現相同的輸入檢查。客戶端JavaScript的輸入檢查,可以阻擋大部分誤操作的正常用戶,從而節約服務器資源。
在XSS的防禦上,輸入檢查一般是檢查用戶輸入的數據中是否包含一些特殊字符,如<、>、'、“等,如果發現存在特殊字符,者將這些特殊字符過濾或者編碼。
比較只能的”輸入檢查“,可能還會匹配XSS的特徵。比如查找用戶數據中是否包含了”<script>"、"javascript"等敏感字符。這種輸入檢查的方式,可以稱爲“XSS Filter”。XSS Filter在用戶提交數據時獲取變量,並進行XSS檢查;但此時用戶數據並沒有結合渲染頁面的HTML代碼,因此XSS Filter對語境的理解並不完整。
比如下面這個XSS漏洞:
<script src="$var"></script>
其中“$var"是用戶可以控制的變量。用戶只要提交一個惡意腳本所在的URL地址,即可實施XSS攻擊。如果是一個全局的XSS Filter,則無法看到用戶的輸出語境,而只能看到用戶提交了一個URL,就很有可能漏報。因爲大多數情況下,URL是一種合法的用戶數據。
3)輸出檢查
一般來說,除了富文本的輸出外,在變量輸出到HTML頁面時,可以使用編碼或轉義的方式來防禦XSS攻擊。
安全的編碼函數
編碼分爲很多種,針對HTML代碼的編碼方式是HtmlEncode。HtmlEncode並非專用名詞,它只是一種函數實現。 它的作用是將字符轉換成HTMLEntities,對應的標準是ISO-8859-1。
爲了對抗XSS,在HTMLEncode中至少轉換以下字符:
var HtmlEncode = function(str){
var hex = new Array('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f');
var preescape = str;
var escaped = "";
for(var i = 0; i < preescape.length; i++){
var p = preescape.charAt(i);
escaped = escaped + escapeCharx(p);
}
return escaped;
function escapeCharx(original){
var found=true;
var thechar=original.charCodeAt(0);
switch(thechar) {
case 10: return "<br/>"; break; //newline
case 32: return " "; break; //space
case 34:return """; break; //"
case 38:return "&"; break; //&
case 39:return "'"; break; //'
case 47:return "/"; break; // /
case 60:return "<"; break; //<
case 62:return ">"; break; //>
case 198:return "Æ"; break;
case 193:return "Á"; break;
case 194:return "Â"; break;
case 192:return "À"; break;
case 197:return "Å"; break;
case 195:return "Ã"; break;
case 196:return "Ä"; break;
case 199:return "Ç"; break;
case 208:return "Ð"; break;
case 201:return "É"; break;
case 202:return "Ê"; break;
case 200:return "È"; break;
case 203:return "Ë"; break;
case 205:return "Í"; break;
case 206:return "Î"; break;
case 204:return "Ì"; break;
case 207:return "Ï"; break;
case 209:return "Ñ"; break;
case 211:return "Ó"; break;
case 212:return "Ô"; break;
case 210:return "Ò"; break;
case 216:return "Ø"; break;
case 213:return "Õ"; break;
case 214:return "Ö"; break;
case 222:return "Þ"; break;
case 218:return "Ú"; break;
case 219:return "Û"; break;
case 217:return "Ù"; break;
case 220:return "Ü"; break;
case 221:return "Ý"; break;
case 225:return "á"; break;
case 226:return "â"; break;
case 230:return "æ"; break;
case 224:return "à"; break;
case 229:return "å"; break;
case 227:return "ã"; break;
case 228:return "ä"; break;
case 231:return "ç"; break;
case 233:return "é"; break;
case 234:return "ê"; break;
case 232:return "è"; break;
case 240:return "ð"; break;
case 235:return "ë"; break;
case 237:return "í"; break;
case 238:return "î"; break;
case 236:return "ì"; break;
case 239:return "ï"; break;
case 241:return "ñ"; break;
case 243:return "ó"; break;
case 244:return "ô"; break;
case 242:return "ò"; break;
case 248:return "ø"; break;
case 245:return "õ"; break;
case 246:return "ö"; break;
case 223:return "ß"; break;
case 254:return "þ"; break;
case 250:return "ú"; break;
case 251:return "û"; break;
case 249:return "ù"; break;
case 252:return "ü"; break;
case 253:return "ý"; break;
case 255:return "ÿ"; break;
case 162:return "¢"; break;
case '\r': break;
default:
found=false;
break;
}
if(!found){
if(thechar>127) {
var c=thechar;
var a4=c%16;
c=Math.floor(c/16);
var a3=c%16;
c=Math.floor(c/16);
var a2=c%16;
c=Math.floor(c/16);
var a1=c%16;
return "&#x"+hex[a1]+hex[a2]+hex[a3]+hex[a4]+";";
}
else{
return original;
}
}
}
}
在PHP中,有htmlentites()和htmlspecialcahrs()兩個函數可以滿足安全要求。
相應的,JavaScript的編碼方式可以使用JavaScriptEncode。
JavaScriptEncode和HtmlEncode的編碼方式不同,它需要使用”\"對特殊字符進行轉義。在對抗XSS時,還要求輸出的變量必須在引號內部,已避免造成安全問題。比較下面兩種寫法:
var x = escapeJavasript($eval);
var y = '"'+escapeJavascript($eval)+'"';
如果escapeJavaScript()函數只轉義了幾個危險字符,比如‘、“、<、>、\、&、#等,那麼上面的兩行代碼輸出後可能會變成:
var x = 1;alert(2);
var y = "1;alert(2)";
第一個執行了額外的代碼了;第二行則是安全的。對於後者,攻擊者即使想要逃逸出引號的範圍,也會遇到困難:
var y = "\";alert(1);\/\/";
所以要求使用JavascriptEncode的變量輸出一定要在引號內。
可是很多開發者沒有這個習慣怎麼辦?這就只能使用一個更加嚴格的JavascriptEncode函數來保證安全---除了數字、字母外的所有字符,都使用十六進制 "\xHH" 的方式進行編碼。
//使用“\”對特殊字符進行轉義,除數字字母之外,小於127使用16進制“\xHH”的方式進行編碼,大於用unicode(非常嚴格模式)。
var JavaScriptEncode = function(str){
var hex=new Array('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f');
function changeTo16Hex(charCode){
return "\\x" + charCode.charCodeAt(0).toString(16);
}
function encodeCharx(original) {
var found = true;
var thecharchar = original.charAt(0);
var thechar = original.charCodeAt(0);
switch(thecharchar) {
case '\n': return "\\n"; break; //newline
case '\r': return "\\r"; break; //Carriage return
case '\'': return "\\'"; break;
case '"': return "\\\""; break;
case '\&': return "\\&"; break;
case '\\': return "\\\\"; break;
case '\t': return "\\t"; break;
case '\b': return "\\b"; break;
case '\f': return "\\f"; break;
case '/': return "\\x2F"; break;
case '<': return "\\x3C"; break;
case '>': return "\\x3E"; break;
default:
found=false;
break;
}
if(!found){
if(thechar > 47 && thechar < 58){ //數字
return original;
}
if(thechar > 64 && thechar < 91){ //大寫字母
return original;
}
if(thechar > 96 && thechar < 123){ //小寫字母
return original;
}
if(thechar>127) { //大於127用unicode
var c = thechar;
var a4 = c%16;
c = Math.floor(c/16);
var a3 = c%16;
c = Math.floor(c/16);
var a2 = c%16;
c = Math.floor(c/16);
var a1 = c%16;
return "\\u"+hex[a1]+hex[a2]+hex[a3]+hex[a4]+"";
}
else {
return changeTo16Hex(original);
}
}
}
var preescape = str;
var escaped = "";
var i=0;
for(i=0; i < preescape.length; i++){
escaped = escaped + encodeCharx(preescape.charAt(i));
}
return escaped;
}
在本例中:
var x = 1;alert(2);
變成了:
var x = 1\x3balert\x282\x29
如此代碼可以保證是安全的。
除了HtmlEncode、JavascriptEncode外,還有許多用於各種情況的編碼函數,比如XMLEncode(與HtmlEncode類似)、JSONEncode(與JavascriptEncode類似)等。
4) 正確地防禦XSS
XSS的本質還是一種“HTML注入”,用戶的數據被當成了HTML代碼一部分來執行,從而混淆了原本的語義,產生了新的語義。
如果網站使用了MVC框架,那麼XSS就發生在View層---在應用拼接變量到HTML頁面時產生。所以在用戶提交數據處進行輸入檢查的方案,其實並不是在真正發生攻擊的地方做防禦。
想要根治XSS問題,可以列出所有XSS可能發生的場景,再一一解決。
下面將用變量 “$var” 表示用戶數據,它將被填充入HTML代碼中,可能存在以下場景。
1. 在HTML標籤中輸出
<div>$var</div>
<a href=# >$var</a>
所有在標籤中輸出的變量,如果未做任何處理,都能導致直接產生XSS。
在這種場景下,XSS的利用方式一般是構造一個<script>標籤,或者是任何能夠產生腳本執行的方式。比如:
<div><script>alert(/xss/)</script></div>
或者
<a href=# ><img src=# onerror=alert(1) /></a>
防禦方法是變量用HtmlEncode。
2. 在HTML屬性中輸出
<div id="abc" name="$var" ></div>
與在HTML標籤中輸出類似,可能的攻擊方法:
<div id="abc" name=""><script>alert(/xss/)</script><"" ></div>
防禦方法也是採用HtmlEncode。
在OWASP ESAPI 中推薦了一種更嚴格的 HtmlEcode---除了字母、數字外,其他所有的特殊字符都被編碼成HTMLEntities。
String safe = ESPI.encoder().encodeForHTMLAttribute(request.getParameter("input"));
這種嚴格的編碼方式,可以保證不會出現任何安全問題。
3. 在<script>標籤中輸出
在<script>標籤中輸出時,首先應該確保輸出的變量在引號中:
<script>
var x = "$var";
</script>
攻擊者需要先閉合引號才能實施XSS攻擊:
<script>
var x = "";alert(/xss/);//";
</script>
防禦時使用JavascriptEncode。
4. 在事件中輸出
在事件中輸出和在<script>標籤中輸出類似:
<a href=# onclick="funcA('$var')" >test</a>
可能的攻擊方法:
<a href=# onclick="funcA('');alert(/xss/);//')" >test</a>
在防禦時需要使用JavascriptEncode。
5. 在CSS中輸出
在CSS和style、style attribute中形成XSS的方式非常多樣化,參考下面幾個XSS的例子。
所以,一般來說,儘可能禁止用戶可控制的變量在 ”<sytle>標籤"、”HTML 標籤的 style 屬性“ 以及 ”CSS 文件“ 中輸出。如果一定有這樣的需求,則推薦使用 OWASP ESAPI 中的 encodeForCSS()函數。
String safe = ESAPI.encoder().encodeForCSS(request.getParameter("input"));
其實現原理類似於 ESAPI.encoder().encodeForJavaScript()函數,除了字母、數字外的所有字符都被編碼成十六進制形式 “\uHH"。
6. 在地址中輸出
在地址中輸出也比較複雜。一般來說,在URL的path(路徑)或者search(參數)中輸出,使用URLEncode即可。URLEncode會將字符轉化爲 “%HH" 形式,比如空格就是 ”%20”,“<" 符號是 “%3c"。
<a href="http://www.evil.com/?test=$var" >test</a>
可能的攻擊方法:
<a href="http://www.evil.com/?test=" onclick=alert(1)"" >test</a>
經過URLEncode後,變成了:
<a href="http://www.evil.com/?test=%22%20onclick%3balert%281%29%22" >test</a>
但是還有一種情況,就是整個URL能夠被用戶完全控制。這時URL的Protocol和Host部分是不能夠使用URLEncode的,否則會改變URL的語義。
一個URL的組成如下:
[Protocol][Host][Path][Search][Hash]
例如:
https://www.evil.com/a/b/c/test?abc=123#ssss
[Protocol] = "https://"
[Host] = "www.evil.com"
[Path] = "a/b/c/test"
[Search] = "?abc=123"
[Hash] = "#ssss"
在Protocol與Host中,如果使用嚴格的URLEncode函數,則會把 ”://"、"." 等都編碼掉。
對於如下的輸出方式:
<a href="$var">test</a>
攻擊者可能會構造僞協議實施攻擊:
<a href="javascript:alert(1);">test</a>
除了“javascript"作爲僞協議可以執行代碼外,還有”vbscript“、”dataURI“等僞協議可能導致腳本執行。
”dataURI“這個僞協議是Mozilla所支持的,能夠將一段代碼寫在URL裏。如下例:
<a href="data:text/html;base64,PHNjcmldD5hbGVydCgxKTs8L3NjcmlwdD4=">test</a>
這段代碼的意思是,以text/html的格式加載編碼爲base64的數據,加載完成後實際上是:
<script>alert(1);</script>
點擊<a>標籤的鏈接,將導致執行腳本。
由此可見,如果用戶能夠完全控制URL,則可以執行腳本的方式有很多。如何解決這種情況呢?
一般來說,如果變量是整個URL,則應該先檢查變量是否以”http“開頭(如果不是則自動添加),以保證不會出現僞協議類的XSS攻擊。
<a href="$var">test</a>
在此之後,再對變量進行URLEncode,即可保證不會有此類的XSS發生了。
OWASP ESAPI中有一個URLEncode的實現(此API未解決僞協議的問題):
String safe = ESAPI.encoder().encodeForURL(request.getParameter("input"));
5)處理富文本
有些時候,網站需要允許用戶提交一些自定義的HTML代碼,稱之爲”富文本“。比如一個用戶在論壇裏發帖,帖子的內容裏要有圖片、視頻、表格等,這些”富文本“的效果都需要通過HTML代碼來實現。
如何區分安全的”富文本“和有攻擊性的XSS呢?
在處理富文本時,還是要回到”輸入檢查“的思路上來。”輸入檢查“的主要問題是,在檢查時還不知道變量的輸出語境。但用戶提交的”富文本“數據,其語義是完整的HTML代碼,在輸出時也不會拼湊到某個標籤的屬性中。因此可以特殊情況特殊處理。
HTML是一種結構化的語言,比較好分析。通過htmlparser可以解析出HTML代碼的標籤、標籤屬性和事件。
在過濾富文本時,"事件”應該被嚴格禁止,因爲“富文本”的展示需求裏不應該包括“事件”這種動態效果。而一些危險的標籤,比如<iframe>、<script>、<base>、<form>等,也是應該嚴格禁止的。在標籤的選擇上,應該使用白名單,避免使用黑名單。比如,只允許<a>、<img>、<div>等比較“安全”的標籤存在。“白名單原則”不僅僅用於標籤的選擇,同樣應該用於屬性與事件的選擇。
在富文本過濾中,處理CSS也是一件麻煩的事情。如果允許用戶自定義CSS、style,則也可能導致XSS攻擊。因此儘可能地禁止用戶自定義CSS與style。如果一定要允許用戶自定義樣式,則只能像過濾“富文本”一樣過濾“CSS”。這需要一個CSS Parser對樣式進行智能分析,檢查其中是否含危險代碼。
有一些比較成熟的開源項目,實現了對富文本的XSS檢查。Anti-Samy 是OWASP上的一個開源項目,也是目前最好的XSS Filter。最早它是基於Java的,現在已經擴展到了.Net等語言。在PHP中,可以使用另一個廣受好評的開源項目:HTMLPurify。
6)防禦DOM Based XSS
DOM Based XSS 是一種比較特別的XSS漏洞,前文提到的幾種防禦方法都不太適用,需要特別對待。
DOM Based XSS是如何形成的呢?回頭看看這個例子:
<script>
function test(){
var str = document.getElementById("text").value;
document.getElementById("t").innerHTML = "<a href='"+str+"' >testLink</a>";
}
</script>
<div id="t" ></div>
<input type="text" id="text" value="" />
<input type="button" id="s" value="write" onclick="test()" />
在button的onclick事件中,執行了test()函數,而該函數中最關鍵的一句是:
document.getElementById("t").innerHTML = "<a href='"+str+"' >testLink</a>";
在HTML代碼中寫入了DOM節點,最後導致了XSS的發生。
事實上,DOM Based XSS是從JavaScript中輸出數據到HTML頁面裏。而前文提到的方法都是針對“從服務器應用直接輸出到HTML頁面”的XSS漏洞,因此並不適用於DOM Based XSS。
看看下面這個例子:
<script>
var x=“$var";
document.write("<a href='"+x+"' >test</a>");
</script>
變量 ”$var" 輸出在<script>標籤內,可是最後又被 document.write 輸出到HTML頁面中。
假設爲了保護"$var"輸出在<script>標籤內產生XSS,服務器端對其進行了javascriptEncode。可是,$var在document.write時,仍然能夠產生XSS,如下所示:
<script>
var x="\x20\x27onclick\x3dalert\x281\x29\x3b\x2f\x2f\x27";
document.write("<a href='"+x+"' >test</a>");
</script>
頁面渲染之後的實際結果如下:
XSS攻擊成功:
其原因在於,第一次執行javascriptEscape後,只保護了:
var x = "$var";
但是當document.write 輸出數據到HTML頁面時,瀏覽器重新渲染了頁面。在<script>標籤執行時,已經對變量x進行了解碼,其後document.write再運行時,其參數就變成了:
<a href=' 'onclick=alert(1);//'' >test</a>
XSS因此而產生。
那不是因爲對“$var"用錯了編碼函數呢?如果改成HtmlEncode會怎樣?繼續看下面這個例子:
<script>
var x='1";alert(2);//"';
document.write("<a href=# onclick='alert(\""+x+"\")' >test</a>;
</script>
服務器把變量HtmlEncode後輸出到<script>後在輸出到<script>中,然後變量x作爲onclick事件的一個函數參數被document.write到了HTML頁面裏。
onclick事件執行了兩次”alert“,第二次是被XSS注入的。
那麼正確的防禦方法是什麼呢?
首先,在”$var" 輸出到<script>時,應該執行一次javascriptEncode;其次,在document.write輸出到HTML頁面時,要分具體情況看待:如果是輸出到事件或者腳本,則要再做一次javascriptEncode;如果是輸出到HTML內容或者屬性,則要做一次HtmlEncode。
也就是說,從javascript輸出到HTML頁面,也相當於一次XSS輸出的過程,需要分語境使用不同的編碼函數。
會觸發DOM Based XSS的地方有很多,以下幾個地方是JavaScript輸出到HTML頁面的必經之路。
- document.write()
- document.writeln()
- xxx.innerHTML=
- xxx.outerHTML=
- innerHTML.replace
- document.attachEvent()
- window.attachEvent()
- document.location.replace()
- document.location.assign()
....
需要重點關注這幾個地方的參數是否可以被用戶控制。
除了服務器直接輸出變量到JavaScript外,還有以下幾個地方可能會成爲DOM Based XSS的輸入點,也需要重點關注。
- 頁面中所有的inputs框
- window.location(href, hash等)
- window.name
- document.reference
- document.referrer
- document.cookie
- localstorage
- XMLHttpRequest返回的數據
....