CSRF 背景與介紹
CSRF(Cross Site Request Forgery, 跨站域請求偽造)是一種網(wǎng)絡(luò)的攻擊方式,它在 2007 年曾被列為互聯(lián)網(wǎng) 20 大安全隱患之一。其他安全隱患,比如 SQL 腳本注入,跨站域腳本攻擊等在近年來已經(jīng)逐漸為眾人熟知,很多網(wǎng)站也都針對他們進(jìn)行了防御。然而,對于大多數(shù)人來說,CSRF 卻依然是一個陌生的概念。即便是大名鼎鼎的 Gmail, 在 2007 年底也存在著 CSRF 漏洞,從而被黑客攻擊而使 Gmail 的用戶造成巨大的損失。
CSRF 攻擊實例
CSRF 攻擊可以在受害者毫不知情的情況下以受害者名義偽造請求發(fā)送給受攻擊站點,從而在并未授權(quán)的情況下執(zhí)行在權(quán)限保護(hù)之下的操作。比如說,受害者 Bob 在銀行有一筆存款,通過對銀行的網(wǎng)站發(fā)送請求 http://bank.example/withdraw?account=bob&amount=1000000&for=bob2 可以使 Bob 把 1000000 的存款轉(zhuǎn)到 bob2 的賬號下。通常情況下,該請求發(fā)送到網(wǎng)站后,服務(wù)器會先驗證該請求是否來自一個合法的 session,并且該 session 的用戶 Bob 已經(jīng)成功登陸。黑客 Mallory 自己在該銀行也有賬戶,他知道上文中的 URL 可以把錢進(jìn)行轉(zhuǎn)帳操作。Mallory 可以自己發(fā)送一個請求給銀行:http://bank.example/withdraw?account=bob&amount=1000000&for=Mallory。但是這個請求來自 Mallory 而非 Bob,他不能通過安全認(rèn)證,因此該請求不會起作用。這時,Mallory 想到使用 CSRF 的攻擊方式,他先自己做一個網(wǎng)站,在網(wǎng)站中放入如下代碼: src=”http://bank.example/withdraw?account=bob&amount=1000000&for=Mallory ”,并且通過廣告等誘使 Bob 來訪問他的網(wǎng)站。當(dāng) Bob 訪問該網(wǎng)站時,上述 url 就會從 Bob 的瀏覽器發(fā)向銀行,而這個請求會附帶 Bob 瀏覽器中的 cookie 一起發(fā)向銀行服務(wù)器。大多數(shù)情況下,該請求會失敗,因為他要求 Bob 的認(rèn)證信息。但是,如果 Bob 當(dāng)時恰巧剛訪問他的銀行后不久,他的瀏覽器與銀行網(wǎng)站之間的 session 尚未過期,瀏覽器的 cookie 之中含有 Bob 的認(rèn)證信息。這時,悲劇發(fā)生了,這個 url 請求就會得到響應(yīng),錢將從 Bob 的賬號轉(zhuǎn)移到 Mallory 的賬號,而 Bob 當(dāng)時毫不知情。等以后 Bob 發(fā)現(xiàn)賬戶錢少了,即使他去銀行查詢?nèi)罩荆仓荒馨l(fā)現(xiàn)確實有一個來自于他本人的合法請求轉(zhuǎn)移了資金,沒有任何被攻擊的痕跡。而 Mallory 則可以拿到錢后逍遙法外。
CSRF 攻擊的對象
在討論如何抵御 CSRF 之前,先要明確 CSRF 攻擊的對象,也就是要保護(hù)的對象。從以上的例子可知,CSRF 攻擊是黑客借助受害者的 cookie 騙取服務(wù)器的信任,但是黑客并不能拿到 cookie,也看不到 cookie 的內(nèi)容。另外,對于服務(wù)器返回的結(jié)果,由于瀏覽器同源策略的限制,黑客也無法進(jìn)行解析。因此,黑客無法從返回的結(jié)果中得到任何東西,他所能做的就是給服務(wù)器發(fā)送請求,以執(zhí)行請求中所描述的命令,在服務(wù)器端直接改變數(shù)據(jù)的值,而非竊取服務(wù)器中的數(shù)據(jù)。所以,我們要保護(hù)的對象是那些可以直接產(chǎn)生數(shù)據(jù)改變的服務(wù),而對于讀取數(shù)據(jù)的服務(wù),則不需要進(jìn)行 CSRF 的保護(hù)。比如銀行系統(tǒng)中轉(zhuǎn)賬的請求會直接改變賬戶的金額,會遭到 CSRF 攻擊,需要保護(hù)。而查詢余額是對金額的讀取操作,不會改變數(shù)據(jù),CSRF 攻擊無法解析服務(wù)器返回的結(jié)果,無需保護(hù)。
當(dāng)前防御 CSRF 的幾種策略
在業(yè)界目前防御 CSRF 攻擊主要有三種策略:驗證 HTTP Referer 字段;在請求地址中添加 token 并驗證;在 HTTP 頭中自定義屬性并驗證。下面就分別對這三種策略進(jìn)行詳細(xì)介紹。
驗證 HTTP Referer 字段
根據(jù) HTTP 協(xié)議,在 HTTP 頭中有一個字段叫 Referer,它記錄了該 HTTP 請求的來源地址。在通常情況下,訪問一個安全受限頁面的請求來自于同一個網(wǎng)站,比如需要訪問 http://bank.example/withdraw?account=bob&amount=1000000&for=Mallory,用戶必須先登陸 bank.example,然后通過點擊頁面上的按鈕來觸發(fā)轉(zhuǎn)賬事件。這時,該轉(zhuǎn)帳請求的 Referer 值就會是轉(zhuǎn)賬按鈕所在的頁面的 URL,通常是以 bank.example 域名開頭的地址。而如果黑客要對銀行網(wǎng)站實施 CSRF 攻擊,他只能在他自己的網(wǎng)站構(gòu)造請求,當(dāng)用戶通過黑客的網(wǎng)站發(fā)送請求到銀行時,該請求的 Referer 是指向黑客自己的網(wǎng)站。因此,要防御 CSRF 攻擊,銀行網(wǎng)站只需要對于每一個轉(zhuǎn)賬請求驗證其 Referer 值,如果是以 bank.example 開頭的域名,則說明該請求是來自銀行網(wǎng)站自己的請求,是合法的。如果 Referer 是其他網(wǎng)站的話,則有可能是黑客的 CSRF 攻擊,拒絕該請求。
這種方法的顯而易見的好處就是簡單易行,網(wǎng)站的普通開發(fā)人員不需要操心 CSRF 的漏洞,只需要在最后給所有安全敏感的請求統(tǒng)一增加一個攔截器來檢查 Referer 的值就可以。特別是對于當(dāng)前現(xiàn)有的系統(tǒng),不需要改變當(dāng)前系統(tǒng)的任何已有代碼和邏輯,沒有風(fēng)險,非常便捷。
然而,這種方法并非萬無一失。Referer 的值是由瀏覽器提供的,雖然 HTTP 協(xié)議上有明確的要求,但是每個瀏覽器對于 Referer 的具體實現(xiàn)可能有差別,并不能保證瀏覽器自身沒有安全漏洞。使用驗證 Referer 值的方法,就是把安全性都依賴于第三方(即瀏覽器)來保障,從理論上來講,這樣并不安全。事實上,對于某些瀏覽器,比如 IE6 或 FF2,目前已經(jīng)有一些方法可以篡改 Referer 值。如果 bank.example 網(wǎng)站支持 IE6 瀏覽器,黑客完全可以把用戶瀏覽器的 Referer 值設(shè)為以 bank.example 域名開頭的地址,這樣就可以通過驗證,從而進(jìn)行 CSRF 攻擊。
即便是使用最新的瀏覽器,黑客無法篡改 Referer 值,這種方法仍然有問題。因為 Referer 值會記錄下用戶的訪問來源,有些用戶認(rèn)為這樣會侵犯到他們自己的隱私權(quán),特別是有些組織擔(dān)心 Referer 值會把組織內(nèi)網(wǎng)中的某些信息泄露到外網(wǎng)中。因此,用戶自己可以設(shè)置瀏覽器使其在發(fā)送請求時不再提供 Referer。當(dāng)他們正常訪問銀行網(wǎng)站時,網(wǎng)站會因為請求沒有 Referer 值而認(rèn)為是 CSRF 攻擊,拒絕合法用戶的訪問。
在請求地址中添加 token 并驗證
CSRF 攻擊之所以能夠成功,是因為黑客可以完全偽造用戶的請求,該請求中所有的用戶驗證信息都是存在于 cookie 中,因此黑客可以在不知道這些驗證信息的情況下直接利用用戶自己的 cookie 來通過安全驗證。要抵御 CSRF,關(guān)鍵在于在請求中放入黑客所不能偽造的信息,并且該信息不存在于 cookie 之中。可以在 HTTP 請求中以參數(shù)的形式加入一個隨機(jī)產(chǎn)生的 token,并在服務(wù)器端建立一個攔截器來驗證這個 token,如果請求中沒有 token 或者 token 內(nèi)容不正確,則認(rèn)為可能是 CSRF 攻擊而拒絕該請求。
這種方法要比檢查 Referer 要安全一些,token 可以在用戶登陸后產(chǎn)生并放于 session 之中,然后在每次請求時把 token 從 session 中拿出,與請求中的 token 進(jìn)行比對,但這種方法的難點在于如何把 token 以參數(shù)的形式加入請求。對于 GET 請求,token 將附在請求地址之后,這樣 URL 就變成 http://url?csrftoken=tokenvalue。 而對于 POST 請求來說,要在 form 的最后加上 <input type=”hidden” name=”csrftoken” value=”tokenvalue”/>,這樣就把 token 以參數(shù)的形式加入請求了。但是,在一個網(wǎng)站中,可以接受請求的地方非常多,要對于每一個請求都加上 token 是很麻煩的,并且很容易漏掉,通常使用的方法就是在每次頁面加載時,使用 javascript 遍歷整個 dom 樹,對于 dom 中所有的 a 和 form 標(biāo)簽后加入 token。這樣可以解決大部分的請求,但是對于在頁面加載之后動態(tài)生成的 html 代碼,這種方法就沒有作用,還需要程序員在編碼時手動添加 token。
該方法還有一個缺點是難以保證 token 本身的安全。特別是在一些論壇之類支持用戶自己發(fā)表內(nèi)容的網(wǎng)站,黑客可以在上面發(fā)布自己個人網(wǎng)站的地址。由于系統(tǒng)也會在這個地址后面加上 token,黑客可以在自己的網(wǎng)站上得到這個 token,并馬上就可以發(fā)動 CSRF 攻擊。為了避免這一點,系統(tǒng)可以在添加 token 的時候增加一個判斷,如果這個鏈接是鏈到自己本站的,就在后面添加 token,如果是通向外網(wǎng)則不加。不過,即使這個 csrftoken 不以參數(shù)的形式附加在請求之中,黑客的網(wǎng)站也同樣可以通過 Referer 來得到這個 token 值以發(fā)動 CSRF 攻擊。這也是一些用戶喜歡手動關(guān)閉瀏覽器 Referer 功能的原因。
在 HTTP 頭中自定義屬性并驗證
這種方法也是使用 token 并進(jìn)行驗證,和上一種方法不同的是,這里并不是把 token 以參數(shù)的形式置于 HTTP 請求之中,而是把它放到 HTTP 頭中自定義的屬性里。通過 XMLHttpRequest 這個類,可以一次性給所有該類請求加上 csrftoken 這個 HTTP 頭屬性,并把 token 值放入其中。這樣解決了上種方法在請求中加入 token 的不便,同時,通過 XMLHttpRequest 請求的地址不會被記錄到瀏覽器的地址欄,也不用擔(dān)心 token 會透過 Referer 泄露到其他網(wǎng)站中去。
然而這種方法的局限性非常大。XMLHttpRequest 請求通常用于 Ajax 方法中對于頁面局部的異步刷新,并非所有的請求都適合用這個類來發(fā)起,而且通過該類請求得到的頁面不能被瀏覽器所記錄下,從而進(jìn)行前進(jìn),后退,刷新,收藏等操作,給用戶帶來不便。另外,對于沒有進(jìn)行 CSRF 防護(hù)的遺留系統(tǒng)來說,要采用這種方法來進(jìn)行防護(hù),要把所有請求都改為 XMLHttpRequest 請求,這樣幾乎是要重寫整個網(wǎng)站,這代價無疑是不能接受的。
Java 代碼示例
下文將以 Java 為例,對上述三種方法分別用代碼進(jìn)行示例。無論使用何種方法,在服務(wù)器端的攔截器必不可少,它將負(fù)責(zé)檢查到來的請求是否符合要求,然后視結(jié)果而決定是否繼續(xù)請求或者丟棄。在 Java 中,攔截器是由 Filter 來實現(xiàn)的。我們可以編寫一個 Filter,并在 web.xml 中對其進(jìn)行配置,使其對于訪問所有需要 CSRF 保護(hù)的資源的請求進(jìn)行攔截。
在 filter 中對請求的 Referer 驗證代碼如下
清單 1. 在 Filter 中驗證 Referer
1
2
3
4
5
6
7
8 |
// 從 HTTP 頭中取得 Referer 值 String referer=request.getHeader( "Referer" ); // 判斷 Referer 是否以 bank.example 開頭 if ((referer!= null ) &&(referer.trim().startsWith(“bank.example”))){ chain.doFilter(request, response); } else { request.getRequestDispatcher(“error.jsp”).forward(request,response); } |
以上代碼先取得 Referer 值,然后進(jìn)行判斷,當(dāng)其非空并以 bank.example 開頭時,則繼續(xù)請求,否則的話可能是 CSRF 攻擊,轉(zhuǎn)到 error.jsp 頁面。
如果要進(jìn)一步驗證請求中的 token 值,代碼如下
1 |
<em><strong>清單 2 . 在 filter 中驗證請求中的</strong></em> token |
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
26 |
HttpServletRequest req = (HttpServletRequest)request; HttpSession s = req.getSession(); // 從 session 中得到 csrftoken 屬性 String sToken = (String)s.getAttribute(“csrftoken”); if (sToken == null ){ // 產(chǎn)生新的 token 放入 session 中 sToken = generateToken(); s.setAttribute(“csrftoken”,sToken); chain.doFilter(request, response); } else { // 從 HTTP 頭中取得 csrftoken String xhrToken = req.getHeader(“csrftoken”); // 從請求參數(shù)中取得 csrftoken String pToken = req.getParameter(“csrftoken”); if (sToken != null && xhrToken != null && sToken.equals(xhrToken)){ chain.doFilter(request, response); } else if (sToken != null && pToken != null && sToken.equals(pToken)){ chain.doFilter(request, response); } else { request.getRequestDispatcher(“error.jsp”).forward(request,response); } } |
首先判斷 session 中有沒有 csrftoken,如果沒有,則認(rèn)為是第一次訪問,session 是新建立的,這時生成一個新的 token,放于 session 之中,并繼續(xù)執(zhí)行請求。如果 session 中已經(jīng)有 csrftoken,則說明用戶已經(jīng)與服務(wù)器之間建立了一個活躍的 session,這時要看這個請求中有沒有同時附帶這個 token,由于請求可能來自于常規(guī)的訪問或是 XMLHttpRequest 異步訪問,我們分別嘗試從請求中獲取 csrftoken 參數(shù)以及從 HTTP 頭中獲取 csrftoken 自定義屬性并與 session 中的值進(jìn)行比較,只要有一個地方帶有有效 token,就判定請求合法,可以繼續(xù)執(zhí)行,否則就轉(zhuǎn)到錯誤頁面。生成 token 有很多種方法,任何的隨機(jī)算法都可以使用,Java 的 UUID 類也是一個不錯的選擇。
除了在服務(wù)器端利用 filter 來驗證 token 的值以外,我們還需要在客戶端給每個請求附加上這個 token,這是利用 js 來給 html 中的鏈接和表單請求地址附加 csrftoken 代碼,其中已定義 token 為全局變量,其值可以從 session 中得到。
1 |
<em><strong>清單 3 . 在客戶端對于請求附加</strong> </em>token |
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
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
52
53
54
55
56
57
58
59
60
61
62 |
function appendToken(){ updateForms(); updateTags(); } function updateForms() { // 得到頁面中所有的 form 元素 var forms = document.getElementsByTagName( 'form' ); for (i= 0 ; i<forms.length; i++) { var url = forms[i].action; // 如果這個 form 的 action 值為空,則不附加 csrftoken if (url == null || url == "" ) continue ; // 動態(tài)生成 input 元素,加入到 form 之后 var e = document.createElement( "input" ); e.name = "csrftoken" ; e.value = token; e.type= "hidden" ; forms[i].appendChild(e); } } function updateTags() { var all = document.getElementsByTagName( 'a' ); var len = all.length; // 遍歷所有 a 元素 for (var i= 0 ; i<len; i++) { var e = all[i]; updateTag(e, 'href' , token); } } function updateTag(element, attr, token) { var location = element.getAttribute(attr); if (location != null && location != '' '' ) { var fragmentIndex = location.indexOf( '#' ); var fragment = null ; if (fragmentIndex != - 1 ){ //url 中含有只相當(dāng)頁的錨標(biāo)記 fragment = location.substring(fragmentIndex); location = location.substring( 0 ,fragmentIndex); } var index = location.indexOf( '?' ); if (index != - 1 ) { //url 中已含有其他參數(shù) location = location + '&csrftoken=' + token; } else { //url 中沒有其他參數(shù) location = location + '?csrftoken=' + token; } if (fragment != null ){ location += fragment; } element.setAttribute(attr, location); } } |
在客戶端 html 中,主要是有兩個地方需要加上 token,一個是表單 form,另一個就是鏈接 a。這段代碼首先遍歷所有的 form,在 form 最后添加一隱藏字段,把 csrftoken 放入其中。然后,代碼遍歷所有的鏈接標(biāo)記 a,在其 href 屬性中加入 csrftoken 參數(shù)。注意對于 a.href 來說,可能該屬性已經(jīng)有參數(shù),或者有錨標(biāo)記。因此需要分情況討論,以不同的格式把 csrftoken 加入其中。
如果你的網(wǎng)站使用 XMLHttpRequest,那么還需要在 HTTP 頭中自定義 csrftoken 屬性,利用 dojo.xhr 給 XMLHttpRequest 加上自定義屬性代碼如下:
1 |
<strong><em>清單 4 . 在 HTTP 頭中自定義屬性</em></strong> |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 |
var plainXhr = dojo.xhr; // 重寫 dojo.xhr 方法 dojo.xhr = function(method,args,hasBody) { // 確保 header 對象存在 args.headers = args.header || {}; tokenValue = '<%=request.getSession(false).getAttribute("csrftoken")%>' ; var token = dojo.getObject( "tokenValue" ); // 把 csrftoken 屬性放到頭中 args.headers[ "csrftoken" ] = (token) ? token : " " ; return plainXhr(method,args,hasBody); }; |
這里改寫了 dojo.xhr 的方法,首先確保 dojo.xhr 中存在 HTTP 頭,然后在 args.headers 中添加 csrftoken 字段,并把 token 值從 session 里拿出放入字段中。
CSRF 防御方法選擇之道
通過上文討論可知,目前業(yè)界應(yīng)對 CSRF 攻擊有一些克制方法,但是每種方法都有利弊,沒有一種方法是完美的。如何選擇合適的方法非常重要。如果網(wǎng)站是一個現(xiàn)有系統(tǒng),想要在最短時間內(nèi)獲得一定程度的 CSRF 的保護(hù),那么驗證 Referer 的方法是最方便的,要想增加安全性的話,可以選擇不支持低版本瀏覽器,畢竟就目前來說,IE7+, FF3+ 這類高版本瀏覽器的 Referer 值還無法被篡改。
如果系統(tǒng)必須支持 IE6,并且仍然需要高安全性。那么就要使用 token 來進(jìn)行驗證,在大部分情況下,使用 XmlHttpRequest 并不合適,token 只能以參數(shù)的形式放于請求之中,若你的系統(tǒng)不支持用戶自己發(fā)布信息,那這種程度的防護(hù)已經(jīng)足夠,否則的話,你仍然難以防范 token 被黑客竊取并發(fā)動攻擊。在這種情況下,你需要小心規(guī)劃你網(wǎng)站提供的各種服務(wù),從中間找出那些允許用戶自己發(fā)布信息的部分,把它們與其他服務(wù)分開,使用不同的 token 進(jìn)行保護(hù),這樣可以有效抵御黑客對于你關(guān)鍵服務(wù)的攻擊,把危害降到最低。畢竟,刪除別人一個帖子比直接從別人賬號中轉(zhuǎn)走大筆存款嚴(yán)重程度要輕的多。
如果是開發(fā)一個全新的系統(tǒng),則抵御 CSRF 的選擇要大得多。筆者建議對于重要的服務(wù),可以盡量使用 XMLHttpRequest 來訪問,這樣增加 token 要容易很多。另外盡量避免在 js 代碼中使用復(fù)雜邏輯來構(gòu)造常規(guī)的同步請求來訪問需要 CSRF 保護(hù)的資源,比如 window.location 和 document.createElement(“a”) 之類,這樣也可以減少在附加 token 時產(chǎn)生的不必要的麻煩。
最后,要記住 CSRF 不是黑客唯一的攻擊手段,無論你 CSRF 防范有多么嚴(yán)密,如果你系統(tǒng)有其他安全漏洞,比如跨站域腳本攻擊 XSS,那么黑客就可以繞過你的安全防護(hù),展開包括 CSRF 在內(nèi)的各種攻擊,你的防線將如同虛設(shè)。
總結(jié)與展望
可見,CSRF 是一種危害非常大的攻擊,又很難以防范。目前幾種防御策略雖然可以很大程度上抵御 CSRF 的攻擊,但并沒有一種完美的解決方案。一些新的方案正在研究之中,比如對于每次請求都使用不同的動態(tài)口令,把 Referer 和 token 方案結(jié)合起來,甚至嘗試修改 HTTP 規(guī)范,但是這些新的方案尚不成熟,要正式投入使用并被業(yè)界廣為接受還需時日。在這之前,我們只有充分重視 CSRF,根據(jù)系統(tǒng)的實際情況選擇最合適的策略,這樣才能把 CSRF 的危害降到最低。