0
点赞
收藏
分享

微信扫一扫

XSS防御方法

天天天蓝loveyou 2022-03-14 阅读 181
xss前端

XSS的防御是复杂的。流行的浏览器都内置了一些对抗XSS的措施,比如Firefox的CSP,Noscript扩展,IE8内置的XSS Filter等。而对于网站来说,也应该寻找优秀的解决方案,保护用户不被XSS攻击。

1)HttpOnly

HttpOnly最早是由微软提出,并在IE 6中实现的,至今已成为一个标准。浏览器将禁止页面的JavaScript访问带有HttpOnly属性的Cookie。严格来说,HttpOnly并非为了对抗XSS--HttpOnly解决的是XSS后的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返回的数据
举报

相关推荐

0 条评论