自動化檢測CSRF(第二篇)

上一篇只是大致說明整個思路和流程。本篇就詳細說說如何檢測CSRF。為什麼不在上一篇中放出插件呢。是因為誤報率確實是比較多,而且無法檢測Referer。而本章,重點就說明「如何檢測對方是否開啟了Referer檢測機制」。

在我的認知範圍內,這是首款檢測Referer的工具(不知廉恥的笑了)。今天發現騰訊在2013年就做了類似的產品(這就尷尬了..),不過還好。而且思路和實現方法有所區別。本章說檢測Referer、優化token檢測機制。而且這些是騰訊產品所沒有的撒。

0×01 一些小的變化

之前的黑白名單列表

var placeholderFilterKeyword = [跳,搜,查,找,登陸,註冊,search]; //無用表單黑名單,用於驗證這個form表單有沒有用(針對input驗證)nvar actionFilterKeyword = [search,find,login,reg]; //無用表單黑名單,用於驗證這個form表單有沒有用(針對form表單驗證)n}n

現在的黑白名單列表:

var placeholderFilterKeyword = [跳,搜,查,找,登陸,註冊,search];nvar actionFilterKeyword = [search,find,login,reg,"baidu.com","google.com","so.com","bing.com","soso.com","sogou.com"];n

此處的代碼,決定了整體插件檢測時的誤報率大體走向。你也可以自己修改來達到自我感覺不錯的地步。

現在的初始化變數:

var actionCache,actionPath;nvar actionvParameter = "";nvar ajaxParameter = "";n

0×02:檢測token的機制優化

之前的token驗證機制是針對於type屬性為hidden的input標籤里的value的值是否大於10。代碼如下:

for(var j = 0;j < formDom.find(":hidden").length;j++){n if(formDom.find(":hidden").eq(j).val().length > 10){n continue outerFor;n }n}n

但是當我進一步測試的時候,發現這個誤報率比較大。比如我在測試Freebuf主站的時候,FB的token值不到10位,但是他是toekn。那麼就可以繞過之前的設定。當然了本章就已經解決了這個問題,使之在測試的時候,檢測token的機製成功率達到95%以上。很少的情況下才會出現誤報。OK,現在讓我們來進行修復吧:

首先我先說明一下token機制的特性:

每刷新一次頁面,token就會變化

我們可以針對此特性進行思考。優化後的token機制的思路:

使用JavaScript代碼在頁面里插入一個空白隱藏的iframe標籤,再使用ajax請求,重新獲取一下當前頁面的源代碼,至於為什麼不使用document.documentElement.outerHTML來獲取頁面的源代碼呢,很簡單的原因,document.documentElement.outerHTML是不刷新獲取,類似於我們按下Ctrl+U來查看源代碼,而ajax發送後獲取,類似於重新打開一次頁面,再按下Ctrl+U。兩者的不同就出在ajax是重新發送一次請求,就像刷新頁面一樣。

思路說完了,那我們該如何寫代碼呢,首先在第一行,outerFor:代碼之上,寫ajax獲取源代碼,因為outerFor:下面的代碼都是在for循環了,我們不需要每循環一次就ajax獲取一次。我只需要獲取一次就行了。代碼如下:

var iframe = document.createElement(iframe);n$("html").append("<iframe id =tokenCheck src=about:blank stylex=display:none;></iframe>");n$.ajax({n url: location.href,n type: get,n dataType: html,n})n.done(function(data){n $("#tokenCheck").contents().find("body").html(data);n})n

這裡的dataType必須是html。不然無法獲取標籤,然後使用$("#tokenCheck").contents().find("body").html(data);代碼,把我們使用ajax獲取到的源代碼放到iframe標籤里,如圖:

然後我們回到檢測token機制功能的代碼處。修改一下代碼為:

if(formDom.find(":hidden").length > 0){n for(var j = 0;j < formDom.find(":hidden").length;j++){n var tokenInputValue = formDom.find(":hidden").eq(j).val();n if($($("#tokenCheck").contents()[context][forms][i]).find(":hidden").eq(j).val() != tokenInputValue){n continue outerFor;n }n }n}n

這裡我新加入了if判斷,噹噹前form表單里沒有type屬性為hidden的input標籤時。則跳過此次的檢測token機制的功能代碼。for循環里,首先是賦值,把當前的input標籤的里的value值賦值給tokenInputValue變數。

下面的代碼就很重要了,獲取iframe標籤里相同的form標籤及相同的input標籤里的值。首先我們使用$("#tokenCheck").contents()來獲取iframe標籤里的內容。再使用[context]來獲取html的DOM樹,再使用[forms]來獲取iframe里的form標籤。然後使用最外層的i變數,使之iframe獲得的form表單和我們正在處理的form時同一個。然後在最外層寫上$(),使JavaScript對象變成jQuery變數,我們就可以使用jquery的API了。下面的find(":hidden").eq(j).val()就是獲取相同form標籤里的相同input標籤里的值。再使用if判斷,兩個token的值是否一樣:

if($($("#tokenCheck").contents()[context][forms][i]).find(":hidden").eq(j).val() != tokenInputValue){n continue outerFor;n}n

如果一樣則不是token,如果不一樣則是token。

0×03:插件的整體框架

因為Maxthon瀏覽器的API實在是太少,沒有這些API我無法進行Referer檢測,於是,檢測CSRF插件,就不寫Maxthon的插件了,下面是Chrome插件的框架:

icons 是存放插件圖標的地方,我比較懶,直接使用AutoFindXSS插件的圖標。

background.html 是為了讓我們修改插件的作用域,讓我們可控,可以在Chrome的API中使用jquery插件

background.js 這裡我們把它理解為後端程序,類似於服務端的存在。用於處理base.js文件的數據

base.js 會在網站載入完成後調用。在檢測Referer的時候,把數據傳給background.js文件

manifest.json Chrome插件的核心文件,用於配置插件參數。

這裡我先給大家看一下manifest.json文件的內容:

{n "background": {n "page": "background.html",n "persistent": truen },n "name": "AutoFindCSRF",n "version": "1.0.0",n "manifest_version": 2,n "description": "CSRF[by:Black-Hole&158099591@qq.com]", n "content_security_policy": "script-src self unsafe-eval; object-src self",n "permissions": [ n "<all_urls>","tabs"n ],n "icons":{"16": "icons/icon_16.png","48": "icons/icon_48.png","128": "icons/icon_128.png"},n "content_scripts": [{n "matches": ["*://*/*"],n "js": ["jquery.js","base.js"],n "run_at": "document_end"n }]n}n

content_security_policy 簡稱CSP,用戶限制插件的安全性

permissions 是插件向Chrome申請的許可權。

content_scripts 意思是,在任何協議下,當網站載入完成後,都會運行jquery.js和base.js文件。JavaScript this指向的是當前網頁

background JavaScript this指向的是插件,用戶處理base.js和background.js通信的存在

而上一篇文章的JavaScript代碼,都存放在base.js里,待會說「檢測Referer機制」時,也是寫在這個文件里。

0×04:檢測對方是否開啟了Referer檢測機制

首先為了下面程序的簡潔,先把當前表單的action地址賦值給一個變數:actionCache = formDom.attr("action");

然後匹配action地址。為什麼要匹配action地址呢,因為action分為以下幾種情況:

#test

./test.php && ./test(處理方式一樣)

/test.php?a=11

test.php

baidu.com/?

這裡我們使用switch來實現匹配,代碼如下:

switch(actionCache[0]){n case "#":n actionPath = location.href + actionCache;n break;n case "/":n actionPath = location.origin + actionCache;n break;n case ".":n if(actionCache.indexOf("?") != "-1"){n actionvParameter = "?" + actionCache.split("?")[1];n actionCache = actionCache.slice(0,actionCache.indexOf("?"));n }n if(location.href.split("/").pop().split(".").length == 1){n actionPath = location.href + actionCache.substr(1,actionCache.length-1) + actionvParameter;n }else{n actionPath = location.href.substr(location.href,location.href.lastIndexOf(location.href.split("/").pop())) + actionCache.substring(1,actionCache.length) + actionvParameter;n }n break;n default:n if(location.protocol == "http:" || location.protocol == "https:"){n actionPath = location.href;n break;n }n if(location.href.split("/").pop().split(".").length == 1){n actionPath = location.href + "/" + actionCache;n }else{n actionPath = location.href.substr(location.href,location.href.lastIndexOf(location.href.split("/").pop())) + actionCache;n }n break;n}n

當action地址的第一個值是#時,直接使用location.href + actionCache;拼接。

當action地址的第一個值是/時,使用location.origin + actionCache;來進行拼接

當action地址的第一個值是.時:先使用indexOf函數來把參數賦值給一個變數並去除,

if(actionCache.indexOf("?") != "-1"){n actionvParameter = "?" + actionCache.split("?")[1];n actionCache = actionCache.slice(0,actionCache.indexOf("?"));n}n

詳細的情況如下:

然後根據有無後綴進行匹配:

if(location.href.split("/").pop().split(".").length == 1){n actionPath = location.href + actionCache.substr(1,actionCache.length-1) + actionvParameter;n}else{n actionPath = location.href.substr(location.href,location.href.lastIndexOf(location.href.split("/").pop())) + actionCache.substring(1,actionCache.length) + actionvParameter;n}n

location.href.split("/").pop().split(".").length是檢測當前url有無後綴,如果有那麼長度是為2.如果沒有後綴長度是1。如果沒有參數,將不會加任何字元串,因為在初始變數的時候就已經設為空了。詳情如下:

除去這些之外,還有直接是文件名或者直接是url,這裡呢,我直接寫到switch的default分之上去了,因為無法使用actionCache[0]來匹配,代碼如下:

default:n if(location.protocol == "http:" || location.protocol == "https:"){n actionPath = location.href;n break;n }n if(location.href.split("/").pop().split(".").length == 1){n actionPath = location.href + "/" + actionCache;n }else{n actionPath = location.href.substr(location.href,location.href.lastIndexOf(location.href.split("/").pop())) + actionCache;n }n break;n

首先是判斷location.protocol是否為http或https協議。如果是的話,直接使用location.href;。當不為http://或者https://的時候,跳過此if判斷。接下來就是判斷url的後綴存在。如果存在將運行:actionPath = location.href + "/" + actionCache;,反饋如圖:

當存在後綴時,運行:actionPath = location.href.substr(location.href,location.href.lastIndexOf(location.href.split("/").pop())) + actionCache;。反饋如圖:

0×05:模擬form的參數

代碼如下:

for(var v = 0;v < formDom.find(":text").length;v++){n var input = formDom.find(":text").eq(v);n if(input.attr("name") != ""){n if(input.val() == ""){n ajaxParameter += input.attr("name") + "=" + "15874583485&";n }else{n ajaxParameter += input.attr("name") + "=" + input.val() + "&";n }n }else{n continue;n }n}najaxParameter = ajaxParameter.substring(0,ajaxParameter.length-1);n

使用for循環對當前form表單下屬性為text的input標籤,然後使用var input = formDom.find(":text").eq(v);來進行賦值,把當前的input賦值給input變數。

再使用if判斷,當前的input標籤是否存在name屬性,如果沒有,則使用continue;跳出初始化表達式變數為v的本次循環。如果存在,再判斷當前的input的value屬性里是否有值,如果有值則直接賦值給ajaxParameter。代碼:ajaxParameter += input.attr("name") + "=" + input.val() + "&";,如果不存在則把15874583485賦值給ajaxParameter變數,為什麼要使用類似於手機號碼的呢,因為容錯率挺高的。可以看到我在每次賦值的時候,都會在後面加上&字元。因為方便下面發送ajax。當然需要去掉最後一個&。於是乎,有了下面的代碼:ajaxParameter = ajaxParameter.substring(0,ajaxParameter.length-1);。

0×04:與插件的background.js進行通信

這裡呢,我先說說「檢測Referer的思路」,在當前網站發送一次ajax請求,Referer的地址肯定是當前的URL,是正常的,和普通提交form表單是一樣的,這裡呢,把action地址和method值及參數傳給插件,在插件里再發送一次AJAX請求,chrome插件發送AJAX時,Refere是為空的。兩次提交,如果存在Referer檢測,那麼返回的結果長度肯定是不一樣的,如果不存在Referer檢測,長度是一樣的(當然可能存在個別的差異,因為可能要顯示時間等,結果長度不一樣,但是是不存在「Referer檢測」的,下面會增加容錯率)

Chrome對插件通信提供了發送chrome.runtime.sendMessage和接受chrome.runtime.onMessage.addListener的API。首先讓我們來看看base.js文件里的發送chrome.runtime.sendMessageAPI代碼:

$.ajax({n url: actionPath,n type: (formDom.attr("method") == undefined) || (formDom.attr("method") == get)?get:post,n dataType: html,n data: (formDom.attr("method") == undefined) || (formDom.attr("method") == get)?:ajaxParameter,n async: false,n})n.done(function(data){n var firstAjax = data.length;n var formCache = formDom;n chrome.runtime.sendMessage({action: actionPath, parameter: (formDom.attr("method") == undefined) || (formDom.attr("method") == get)?:ajaxParameter},function (response) {n if(Math.abs(firstAjax - response.status) < 10){n formCache.attr("style","border: 1px red solid;")n }n });n})n

因為form的method屬性的值是不確定的。所以就需要對ajax的參數type進行設置:(formDom.attr("method") == undefined) || (formDom.attr("method") == get)?get:post,這裡使用了三目運算符。當method的值不存在、為get的時候,type為get。當存在的時候,則為post。

下面的data參數同理。只不過沒有了get、post選項。改為:ajaxParameter。因為method值為get時,參數是附在actionPath變數里的。當為post的時候,將把之前拼接的參數傳給data參數。這裡計算一下返回頁面的長度var firstAjax = data.length;,至於下面的為什麼要給變數再賦值一次呢,我也不知道,可能下面的Chrome API的作用域不同,導致在下面使用API的時候,使用formDom變數,結果不對。只能重新賦值給formCache變數,這個時候API才算正常。

下面就是Chrome的API了:

chrome.runtime.sendMessage({action: actionPath, parameter: (formDom.attr("method") == undefined) || (formDom.attr("method") == get)?:ajaxParameter},function (response) {n if(Math.abs(firstAjax - response.status) < 10){n formCache.attr("style","border: 1px red solid;")n }n });n

這裡的action和parameter是發送的參數及值。至於代碼(formDom.attr("method") == undefined) || (formDom.attr("method") == get)?:ajaxParameter和上面同理,當為get的時候,不給parameter值,當為post的時候,值為ajaxParameter。response為回調函數,類似ajax的done函數,返回background.js的處理結果。

那background.js是如何處理的呢:

chrome.runtime.onMessage.addListener(function(message,sender,sendResponse){n $.ajax({n url: message.action,n type: (message.parameter == "")?get:post,n dataType: html,n data: (message.parameter == "")?:message.parameter,n async: false,n })n .done(function(data) {n sendResponse({status: data.length})n })n})n

chrome.runtime.onMessage.addListener是接受函數,然後就是AJAX了,在done函數里,有一個API是sendResponse({status: data.length})返回插件發送AJAX時的長度。這個時候前端base.js將會受到background.js文件的返回結果。代碼就返回上面的處理方式了:

if(Math.abs(firstAjax - response.status) < 10){n formCache.attr("style","border: 1px red solid;")n}n

這裡的Math.abs是求絕對值的,當兩次ajax返回的長度差值小於10的時候,說明不存在「Referer檢測」,當大於10時,就說明存在「檢測Referer的機制」了。這裡的10就是容錯值。

當存在CSRF漏洞的時候,會在form表單的外部包含一個紅色的框,如圖:

整個代碼如下:base.js

var iframe = document.createElement(iframe);n$("html").append("<iframe id =tokenCheck src=about:blank stylex=display:none;></iframe>");n$.ajax({n url: location.href,n type: get,n dataType: html,n})n.done(function(data){n $("#tokenCheck").contents().find("body").html(data);n})nouterFor:nfor(var i = 0;i < $("form").length;i++){n var formDom = $("form").eq(i);n var imageFileSuffix = [.jpg,.png,.jpge,.ico,.gif,.bmp];n var placeholderFilterKeyword = [跳,搜,查,找,登陸,註冊,search];n var actionFilterKeyword = [search,find,login,reg,"baidu.com","google.com","so.com","bing.com","soso.com","sogou.com"];n var actionCache,actionPath;n var actionvParameter = "";n var ajaxParameter = "";n //去除類似搜索、頁面跳轉等無用的form表單n if(formDom.attr("action") != undefined){n var actionCheck = actionFilterKeyword.some(function(item,index){n return (formDom.attr("action").toLowerCase().indexOf(item) != "-1");n })n if(actionCheck){n continue;n }n }else{n continue;n }n for(var x = 0;x < formDom.find(":text").length;x++){n var inputTextCheck;n var inputText = formDom.find(":text").eq(x);n if(inputText.attr("placeholder") == undefined){n continue;n }n inputTextCheck = placeholderFilterKeyword.some(function(item,index){n return (inputText.attr("placeholder").toLowerCase().indexOf(item) != "-1");n })n if(inputTextCheck){n continue outerFor;n }n }n //去除沒有提交按鈕的form表單n if(formDom.find(":submit").length < 1){n continue outerFor;n }n //去除具有token的form表單n if(formDom.find(":hidden").length > 0){n for(var j = 0;j < formDom.find(":hidden").length;j++){n var tokenInputValue = formDom.find(":hidden").eq(j).val();n console.log($($("#tokenCheck").contents()[context][forms][i]).find(":hidden").eq(j).val(),tokenInputValue)n if($("#tokenCheck").contents().find("form").eq(i).find(":hidden").eq(j).val() != tokenInputValue){n continue outerFor;n }n }n }n //去除帶有驗證碼的form表單n if(formDom.find("img").length > 0){n var imageCheck;n for(var z = 0;z < formDom.find("img").length;z++){n var img = formDom.find("img").eq(z);n var imgSrc = img.attr("src")n if(!!imgSrc){n if(imgSrc.indexOf("?") != "-1"){n imgSrc = imgSrc.slice(0,imgSrc.indexOf("?"));n }n imgSrc = imgSrc.substr(imgSrc.lastIndexOf("."),imgSrc.length);n imageCheck = imageFileSuffix.some(function(item,index){n return (imgSrc == item);n })n if(!imageCheck){n continue outerFor;n }n }n }n }n //去除「檢測對方開啟了Referer檢測機制」的form表單n actionCache = formDom.attr("action");n switch(actionCache[0]){n case "#":n actionPath = location.href + actionCache;n break;n case "/":n actionPath = location.origin + actionCache;n break;n case ".":n if(actionCache.indexOf("?") != "-1"){n actionvParameter = "?" + actionCache.split("?")[1];n actionCache = actionCache.slice(0,actionCache.indexOf("?"));n }n if(location.href.split("/").pop().split(".").length == 1){n actionPath = location.href + actionCache.substr(1,actionCache.length-1) + actionvParameter;n }else{n actionPath = location.href.substr(location.href,location.href.lastIndexOf(location.href.split("/").pop())) + actionCache.substring(1,actionCache.length) + actionvParameter;n }n break;n default:n if(location.protocol == "http:" || location.protocol == "https:"){n actionPath = location.href;n break;n }n if(location.href.split("/").pop().split(".").length == 1){n actionPath = location.href + "/" + actionCache;n }else{n actionPath = location.href.substr(location.href,location.href.lastIndexOf(location.href.split("/").pop())) + actionCache;n }n break;n }n for(var v = 0;v < formDom.find(":text").length;v++){n var input = formDom.find(":text").eq(v);n if(input.attr("name") != ""){n if(input.val() == ""){n ajaxParameter += input.attr("name") + "=" + "15874583485&";n }else{n ajaxParameter += input.attr("name") + "=" + input.val() + "&";n }n }else{n continue;n }n }n ajaxParameter = ajaxParameter.substring(0,ajaxParameter.length-1)n $.ajax({n url: actionPath,n type: (formDom.attr("method") == undefined)?get:post,n dataType: html,n data: (formDom.attr("method") == undefined) || (formDom.attr("method") == get)?:ajaxParameter,n async: false,n })n .done(function(data){n var firstAjax = data.length;n var formCache = formDom;n chrome.runtime.sendMessage({action: actionPath, parameter: (formDom.attr("method") == undefined) || (formDom.attr("method") == get)?:ajaxParameter},function (response) {n if(Math.abs(firstAjax - response.status) < 10){n formCache.attr("style","border: 1px red solid;")n }n });n })n}n

background.js:

chrome.runtime.onMessage.addListener(function(message,sender,sendResponse){n $.ajax({n url: message.action,n type: (message.parameter == "")?get:post,n dataType: html,n data: (message.parameter == "")?:message.parameter,n async: false,n })n .done(function(data) {n sendResponse({status: data.length})n })n})n

0×06:結尾

Author:Black-Hole

Blog:Black-Holes Blog

github:BlackHole1 (Black-Hole) · GitHub

Twitter:twitter.com/Free_BlackH

Email:158blackhole@gmail.com

推薦閱讀:

安全客季刊 | 資深讀者閱讀分享
處理器Meltdown與Spectre漏洞修復簡要指南
三個混淆過狗一句話分析
用兩分鐘告訴你 我是如何用搞定隔壁老王的 WiFi 密碼

TAG:CSRF | 网络安全 | 信息安全 |