XSS的防禦

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 "&nbsp;"; break; //space
            case 34:return "&quot;"; break; //"
            case 38:return "&amp;"; break; //&
            case 39:return "&#x27;"; break; //'
            case 47:return "&#x2F;"; break; // /
            case 60:return "&lt;"; break; //<
            case 62:return "&gt;"; break; //>
            case 198:return "&AElig;"; break;
            case 193:return "&Aacute;"; break;
            case 194:return "&Acirc;"; break; 
            case 192:return "&Agrave;"; break; 
            case 197:return "&Aring;"; break; 
            case 195:return "&Atilde;"; break; 
            case 196:return "&Auml;"; break; 
            case 199:return "&Ccedil;"; break; 
            case 208:return "&ETH;"; break;
            case 201:return "&Eacute;"; break; 
            case 202:return "&Ecirc;"; break; 
            case 200:return "&Egrave;"; break; 
            case 203:return "&Euml;"; break;
            case 205:return "&Iacute;"; break;
            case 206:return "&Icirc;"; break; 
            case 204:return "&Igrave;"; break; 
            case 207:return "&Iuml;"; break;
            case 209:return "&Ntilde;"; break; 
            case 211:return "&Oacute;"; break;
            case 212:return "&Ocirc;"; break; 
            case 210:return "&Ograve;"; break; 
            case 216:return "&Oslash;"; break; 
            case 213:return "&Otilde;"; break; 
            case 214:return "&Ouml;"; break;
            case 222:return "&THORN;"; break; 
            case 218:return "&Uacute;"; break; 
            case 219:return "&Ucirc;"; break; 
            case 217:return "&Ugrave;"; break; 
            case 220:return "&Uuml;"; break; 
            case 221:return "&Yacute;"; break;
            case 225:return "&aacute;"; break; 
            case 226:return "&acirc;"; break; 
            case 230:return "&aelig;"; break; 
            case 224:return "&agrave;"; break; 
            case 229:return "&aring;"; break; 
            case 227:return "&atilde;"; break; 
            case 228:return "&auml;"; break; 
            case 231:return "&ccedil;"; break; 
            case 233:return "&eacute;"; break;
            case 234:return "&ecirc;"; break; 
            case 232:return "&egrave;"; break; 
            case 240:return "&eth;"; break; 
            case 235:return "&euml;"; break; 
            case 237:return "&iacute;"; break; 
            case 238:return "&icirc;"; break; 
            case 236:return "&igrave;"; break; 
            case 239:return "&iuml;"; break; 
            case 241:return "&ntilde;"; break; 
            case 243:return "&oacute;"; break;
            case 244:return "&ocirc;"; break; 
            case 242:return "&ograve;"; break; 
            case 248:return "&oslash;"; break; 
            case 245:return "&otilde;"; break;
            case 246:return "&ouml;"; break; 
            case 223:return "&szlig;"; break; 
            case 254:return "&thorn;"; break; 
            case 250:return "&uacute;"; break; 
            case 251:return "&ucirc;"; break; 
            case 249:return "&ugrave;"; break; 
            case 252:return "&uuml;"; break; 
            case 253:return "&yacute;"; break; 
            case 255:return "&yuml;"; break;
            case 162:return "&cent;"; 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&#x22;&#x3b;alert&#x28;2&#x29;&#x3b;&#x2f;&#x2f;&#x22;';
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返回的數據

....

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章