WEB安全入門:如何防止 CSRF 攻擊?

華為雲開發者社群發表於2020-06-20

現在,我們絕大多數人都會在網上購物買東西。但是很多人都不清楚的是,很多電商網站會存在安全漏洞。烏雲就通報過,國內很多家公司的網站都存在 CSRF 漏洞。如果某個網站存在這種安全漏洞的話,那麼我們在購物的過程中,就很可能會被網路黑客盜刷信用卡。是不是瞬間有點「不寒而慄」 的感覺?

首先,我們需要弄清楚 CSRF 是什麼。它的全稱是 Cross-site request forgery ,翻譯成中文的意思就是「跨站請求偽造」,這是一種對網站的惡意利用。簡單而言,就是某惡意網站在我們不知情的情況下,以我們的身份在你登入的某網站上胡作非為——發訊息、買東西,甚至轉賬......

WEB安全入門:如何防止 CSRF 攻擊?

這種攻擊模式聽起來有點像跨站指令碼(XSS),但 CSRF 與 XSS 非常不同,並且攻擊方式幾乎相左。XSS 利用站點內的信任使用者,而 CSRF 則通過偽裝來自受信任使用者的請求來利用受信任的網站。與 XSS 攻擊相比,CSRF 攻擊往往很少見,因此對其進行防範的資源也相當稀少。不過,這種「受信任」的攻擊模式更加難以防範,所以被認為比 XSS 更具危險性。

這個過程到底是怎樣的呢?讓我們看個簡單而鮮活的案例。

銀行網站 A,它以 GET 請求來完成銀行轉賬的操作,如:

http://www.mybank.com/Transfer.php?toBankId=11&money=1000

危險網站 B,它裡面有一段 HTML 的程式碼如下:

<img src=http://www.mybank.com/Transfer.php?toBankId=11&money=1000>

可能會發生什麼?你登入了銀行網站 A,然後訪問危險網站 B 以後,突然發現你的銀行賬戶少了10000塊......

為什麼會這樣呢?原因是在訪問危險網站 B 之前,你已經登入了銀行網站 A,而 B 中的以 GET 的方式請求第三方資源(這裡的第三方就是指銀行網站了,原本這是一個合法的請求,但這裡被不法分子利用了),所以你的瀏覽器會帶上你的銀行網站 A 的 Cookie 發出 GET 請求,去獲取資源

「http://www.mybank.com/Transfer.php?toBankId=11&money=1000」,

結果銀行網站伺服器收到請求後,認為這是一個合理的轉賬操作,就立刻轉賬了......

其實,真實的銀行網站不會如此不加防範,但即使用 POST 替代 GET,也只是讓危險網站多花些力氣而已。危險網站 B 依然可以通過嵌入 Javascript 來嘗試盜取客戶資金,所以我們時不時會聽到客戶資金被盜的案件,其實這並不是很不稀奇。

相信,很多人瞭解到這兒,會出現一身冷汗,還讓不讓我們在「618」期間能夠愉快地享受網購的快感了?難道沒有什麼辦法防住它嘛?

當然是有的。

在業界目前防禦 CSRF 攻擊主要有三種策略:驗證 HTTP Referer 欄位;在請求地址中新增 token 並驗證;在 HTTP 頭中自定義屬性並驗證。下面就分別對這三種策略進行詳細介紹。

驗證 HTTP Referer 欄位

根據 HTTP 協議,在 HTTP 頭中有一個欄位叫 Referer,它記錄了該 HTTP 請求的來源地址。在通常情況下,訪問一個安全受限頁面的請求來自於同一個網站,比如需要訪問http://bank.example/withdraw?account=bob&amount=1000000&for=Mallory,使用者必須先登陸 bank.example,然後通過點選頁面上的按鈕來觸發轉賬事件。這時,該轉帳請求的 Referer 值就會是轉賬按鈕所在的頁面的 URL,通常是以 bank.example 域名開頭的地址。而如果黑客要對銀行網站實施 CSRF 攻擊,他只能在他自己的網站構造請求,當使用者通過黑客的網站傳送請求到銀行時,該請求的 Referer 是指向黑客自己的網站。因此,要防禦 CSRF 攻擊,銀行網站只需要對於每一個轉賬請求驗證其 Referer 值,如果是以 bank.example 開頭的域名,則說明該請求是來自銀行網站自己的請求,是合法的。如果 Referer 是其他網站的話,則有可能是黑客的 CSRF 攻擊,拒絕該請求。

這種方法的顯而易見的好處就是簡單易行,網站的普通開發人員不需要操心 CSRF 的漏洞,只需要在最後給所有安全敏感的請求統一增加一個攔截器來檢查 Referer 的值就可以。特別是對於當前現有的系統,不需要改變當前系統的任何已有程式碼和邏輯,沒有風險,非常便捷。

然而,這種方法並非萬無一失。Referer 的值是由瀏覽器提供的,雖然 HTTP 協議上有明確的要求,但是每個瀏覽器對於 Referer 的具體實現可能有差別,並不能保證瀏覽器自身沒有安全漏洞。使用驗證 Referer 值的方法,就是把安全性都依賴於第三方(即瀏覽器)來保障,從理論上來講,這樣並不安全。事實上,對於某些瀏覽器,比如 IE6 或 FF2,目前已經有一些方法可以篡改 Referer 值。如果 bank.example 網站支援 IE6 瀏覽器,黑客完全可以把使用者瀏覽器的 Referer 值設為以 bank.example 域名開頭的地址,這樣就可以通過驗證,從而進行 CSRF 攻擊。

即便是使用最新的瀏覽器,黑客無法篡改 Referer 值,這種方法仍然有問題。因為 Referer 值會記錄下使用者的訪問來源,有些使用者認為這樣會侵犯到他們自己的隱私權,特別是有些組織擔心 Referer 值會把組織內網中的某些資訊洩露到外網中。因此,使用者自己可以設定瀏覽器使其在傳送請求時不再提供 Referer。當他們正常訪問銀行網站時,網站會因為請求沒有 Referer 值而認為是 CSRF 攻擊,拒絕合法使用者的訪問。

在請求地址中新增 token 並驗證

CSRF 攻擊之所以能夠成功,是因為黑客可以完全偽造使用者的請求,該請求中所有的使用者驗證資訊都是存在於 cookie 中,因此黑客可以在不知道這些驗證資訊的情況下直接利用使用者自己的 cookie 來通過安全驗證。要抵禦 CSRF,關鍵在於在請求中放入黑客所不能偽造的資訊,並且該資訊不存在於 cookie 之中。可以在 HTTP 請求中以引數的形式加入一個隨機產生的 token,並在伺服器端建立一個攔截器來驗證這個 token,如果請求中沒有 token 或者 token 內容不正確,則認為可能是 CSRF 攻擊而拒絕該請求。

這種方法要比檢查 Referer 要安全一些,token 可以在使用者登陸後產生並放於 session 之中,然後在每次請求時把 token 從 session 中拿出,與請求中的 token 進行比對,但這種方法的難點在於如何把 token 以引數的形式加入請求。對於 GET 請求,token 將附在請求地址之後,這樣 URL 就變成 ?csrftoken=tokenvalue。 而對於 POST 請求來說,要在 form 的最後加上 <input type=”hidden” name=”csrftoken” value=”tokenvalue”/>,這樣就把 token 以引數的形式加入請求了。但是,在一個網站中,可以接受請求的地方非常多,要對於每一個請求都加上 token 是很麻煩的,並且很容易漏掉,通常使用的方法就是在每次頁面載入時,使用 javascript 遍歷整個 dom 樹,對於 dom 中所有的 a 和 form 標籤後加入 token。這樣可以解決大部分的請求,但是對於在頁面載入之後動態生成的 html 程式碼,這種方法就沒有作用,還需要程式設計師在編碼時手動新增 token。

該方法還有一個缺點是難以保證 token 本身的安全。特別是在一些論壇之類支援使用者自己發表內容的網站,黑客可以在上面釋出自己個人網站的地址。由於系統也會在這個地址後面加上 token,黑客可以在自己的網站上得到這個 token,並馬上就可以發動 CSRF 攻擊。為了避免這一點,系統可以在新增 token 的時候增加一個判斷,如果這個連結是鏈到自己本站的,就在後面新增 token,如果是通向外網則不加。不過,即使這個 csrftoken 不以引數的形式附加在請求之中,黑客的網站也同樣可以通過 Referer 來得到這個 token 值以發動 CSRF 攻擊。這也是一些使用者喜歡手動關閉瀏覽器 Referer 功能的原因。

在 HTTP 頭中自定義屬性並驗證

這種方法也是使用 token 並進行驗證,和上一種方法不同的是,這裡並不是把 token 以引數的形式置於 HTTP 請求之中,而是把它放到 HTTP 頭中自定義的屬性裡。通過 XMLHttpRequest 這個類,可以一次性給所有該類請求加上 csrftoken 這個 HTTP 頭屬性,並把 token 值放入其中。這樣解決了上種方法在請求中加入 token 的不便,同時,通過 XMLHttpRequest 請求的地址不會被記錄到瀏覽器的位址列,也不用擔心 token 會透過 Referer 洩露到其他網站中去。

然而這種方法的侷限性非常大。XMLHttpRequest 請求通常用於 Ajax 方法中對於頁面區域性的非同步重新整理,並非所有的請求都適合用這個類來發起,而且通過該類請求得到的頁面不能被瀏覽器所記錄下,從而進行前進,後退,重新整理,收藏等操作,給使用者帶來不便。另外,對於沒有進行 CSRF 防護的遺留系統來說,要採用這種方法來進行防護,要把所有請求都改為 XMLHttpRequest 請求,這樣幾乎是要重寫整個網站,這代價無疑是不能接受的。

Java 程式碼示例

下文將以 Java 為例,對上述三種方法分別用程式碼進行示例。無論使用何種方法,在伺服器端的攔截器必不可少,它將負責檢查到來的請求是否符合要求,然後視結果而決定是否繼續請求或者丟棄。在 Java 中,攔截器是由 Filter 來實現的。我們可以編寫一個 Filter,並在 web.xml 中對其進行配置,使其對於訪問所有需要 CSRF 保護的資源的請求進行攔截。

在 filter 中對請求的 Referer 驗證程式碼如下

 

清單 1. 在 Filter 中驗證 Referer

// 從 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 值,然後進行判斷,當其非空並以 bank.example 開頭時,則繼續請求,否則的話可能是 CSRF 攻擊,轉到 error.jsp 頁面。

如果要進一步驗證請求中的 token 值,程式碼如下

清單 2. 在 filter 中驗證請求中的 token

HttpServletRequest req = (HttpServletRequest)request; 
HttpSession s = req.getSession(); 
 
// 從 session 中得到 csrftoken 屬性
String sToken = (String)s.getAttribute(“csrftoken”); 
if(sToken == null){ 
 
   // 產生新的 token 放入 session 中
   sToken = generateToken(); 
   s.setAttribute(“csrftoken”,sToken); 
   chain.doFilter(request, response); 
} else{ 
 
   // 從 HTTP 頭中取得 csrftoken 
   String xhrToken = req.getHeader(“csrftoken”); 
 
   // 從請求引數中取得 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,如果沒有,則認為是第一次訪問,session 是新建立的,這時生成一個新的 token,放於 session 之中,並繼續執行請求。如果 session 中已經有 csrftoken,則說明使用者已經與伺服器之間建立了一個活躍的 session,這時要看這個請求中有沒有同時附帶這個 token,由於請求可能來自於常規的訪問或是 XMLHttpRequest 非同步訪問,我們分別嘗試從請求中獲取 csrftoken 引數以及從 HTTP 頭中獲取 csrftoken 自定義屬性並與 session 中的值進行比較,只要有一個地方帶有有效 token,就判定請求合法,可以繼續執行,否則就轉到錯誤頁面。生成 token 有很多種方法,任何的隨機演算法都可以使用,Java 的 UUID 類也是一個不錯的選擇。

除了在伺服器端利用 filter 來驗證 token 的值以外,我們還需要在客戶端給每個請求附加上這個 token,這是利用 js 來給 html 中的連結和表單請求地址附加 csrftoken 程式碼,其中已定義 token 為全域性變數,其值可以從 session 中得到。

清單 3. 在客戶端對於請求附加 token

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; 
 
       // 動態生成 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 中含有隻相當頁的錨標記
           fragment = location.substring(fragmentIndex); 
           location = location.substring(0,fragmentIndex); 
       } 
 
       var index = location.indexOf('?'); 
 
       if(index != -1) { 
           //url 中已含有其他引數
           location = location + '&csrftoken=' + token; 
       } else { 
           //url 中沒有其他引數
           location = location + '?csrftoken=' + token; 
       } 
       if(fragment != null){ 
           location += fragment; 
       } 
 
       element.setAttribute(attr, location); 
   } 
}

在客戶端 html 中,主要是有兩個地方需要加上 token,一個是表單 form,另一個就是連結 a。這段程式碼首先遍歷所有的 form,在 form 最後新增一隱藏欄位,把 csrftoken 放入其中。然後,程式碼遍歷所有的連結標記 a,在其 href 屬性中加入 csrftoken 引數。注意對於 a.href 來說,可能該屬性已經有引數,或者有錨標記。因此需要分情況討論,以不同的格式把 csrftoken 加入其中。

如果你的網站使用 XMLHttpRequest,那麼還需要在 HTTP 頭中自定義 csrftoken 屬性,利用 dojo.xhr 給 XMLHttpRequest 加上自定義屬性程式碼如下:

清單 4. 在 HTTP 頭中自定義屬性

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 防禦方法選擇之道

通過上文討論可知,目前業界應對 CSRF 攻擊有一些剋制方法,但是每種方法都有利弊,沒有一種方法是完美的。如何選擇合適的方法非常重要。如果網站是一個現有系統,想要在最短時間內獲得一定程度的 CSRF 的保護,那麼驗證 Referer 的方法是最方便的,要想增加安全性的話,可以選擇不支援低版本瀏覽器,畢竟就目前來說,IE7+, FF3+ 這類高版本瀏覽器的 Referer 值還無法被篡改。

如果系統必須支援 IE6,並且仍然需要高安全性。那麼就要使用 token 來進行驗證,在大部分情況下,使用 XmlHttpRequest 並不合適,token 只能以引數的形式放於請求之中,若你的系統不支援使用者自己釋出資訊,那這種程度的防護已經足夠,否則的話,你仍然難以防範 token 被黑客竊取並發動攻擊。在這種情況下,你需要小心規劃你網站提供的各種服務,從中間找出那些允許使用者自己釋出資訊的部分,把它們與其他服務分開,使用不同的 token 進行保護,這樣可以有效抵禦黑客對於你關鍵服務的攻擊,把危害降到最低。畢竟,刪除別人一個帖子比直接從別人賬號中轉走大筆存款嚴重程度要輕的多。

如果是開發一個全新的系統,則抵禦 CSRF 的選擇要大得多。筆者建議對於重要的服務,可以儘量使用 XMLHttpRequest 來訪問,這樣增加 token 要容易很多。另外儘量避免在 js 程式碼中使用複雜邏輯來構造常規的同步請求來訪問需要 CSRF 保護的資源,比如 window.location 和 document.createElement(“a”) 之類,這樣也可以減少在附加 token 時產生的不必要的麻煩。

最後,要記住 CSRF 不是黑客唯一的攻擊手段,無論你 CSRF 防範有多麼嚴密,如果你係統有其他安全漏洞,比如跨站域指令碼攻擊 XSS,那麼黑客就可以繞過你的安全防護,展開包括 CSRF 在內的各種攻擊,你的防線將如同虛設。我們只有充分重視 CSRF,根據系統的實際情況選擇最合適的策略,這樣才能把 CSRF 的危害降到最低。

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章