淺談動態爬蟲與去重

MickiBrinkm 7年前發布 | 31K 次閱讀 PhantomJS Ajax 網絡爬蟲

0x01 簡介

隨著Web 2.0的發展,頁面中的AJAX也越來越多。由于傳統爬蟲依靠靜態分析,不能準確的抓取到頁面中的AJAX請求以及動態更新的內容,已經越來越不能滿足需求。基于動態解析的Web 2.0爬蟲應運而生,通過瀏覽器內核解析頁面源碼,模擬用戶操作,能有效解決上述問題。本文將詳細分析利用PhantomJS + Python 編寫爬蟲并進行去重的思路。

0x02 PhantomJS

PhantomJS 是無界面的 Webkit 解析器,提供了 JavaScript API 。由于去除了可視化界面,速度比一般 Webkit 瀏覽器要快很多。同時提供了很多監控和事件接口,可以方便的操作 DOM 節點,模擬用戶操作等。

接下來我們通過一個簡單的例子,來展示下動態爬蟲和傳統爬蟲的區別。目標:加載一個頁面,并且獲取其中所有的 <a> 標簽。

// example.js
var page = require('webpage').create();
page.onAlert = function (message) {
    console.log(message);
    return true;
};
page.onCallback = function() {
    page.evaluate(function(){
        atags = document.getElementsByTagName("a");
        for(var i=0;i<atags.length;i++){
            if (atags[i].getAttribute("href")){
                alert(atags[i].getAttribute("href"));
            }
        }
    })
    phantom.exit()
};
page.open("http://named.cn/.mine", "get", "", function (status) {
    page.evaluateAsync(function(){
        if (typeof window.callPhantom === 'function') {
            window.callPhantom();
        }
    }, 10000)
});

抓取結果如下:

/.mine
/cmd2/controls/signin
/cmd2/controls/getcode
/download.html
/.blog
/.mine
/.at
/~發現推薦.findbbs
/help.html
/江南水鄉.bbs/7313348
/攝情男女.bbs/7313242
/歡樂一家親.bbs/7313356
/深夜食堂.bbs/7313168
/家有熊孩子.bbs/7313321
/樂淘親子營.bbs/7313320
.../*省略*/...
/婚禮記.bbs/7277165
/不知道的事情.bbs/7277164
/不知道的事情.bbs/7277162
/婚禮記.bbs/7277160
/不知道的事情.bbs/7277016
http://www.miitbeian.gov.cn/
/cmd2/controls/mailpost/內容舉報
download.html

靜態抓取的代碼如下:

import requests
import pyquery
res = requests.get("http://named.cn/.mine")
count = 0
pq = pyquery.PyQuery(res.content)
for i in pq("a"):
    print "[%d]: %s" % (count, pq(i).attr("href"))

抓取結果為空。

從上述的例子中,我們可以明顯看出動態分析比靜態分析抓取到了更多的結果。產生差別的原因,是頁面中的數據加載來自于AJAX請求,所有的 <a> 標簽都是動態更新到頁面中的。靜態分析對這種情況無能為力,而基于瀏覽器內核的動態分析,可以輕松的處理這些情況。

但也可以明顯看出動態分析的缺點:系統資源占用較多,且占用時間較長,還會有一些莫名其妙的坑,編寫起來也更復雜更花時間(需要對前端編程有一定的了解)。

當然除了 PhantomJS 還有一些其他的動態解析器,比如同樣基于 Webkit 內核的 PyQt(PhantomJS的最新版本也是基于pyqt來實現)、基于 PhantomJS 封裝的 CasperJS、基于的 Firefox Gecko 內核的SlimerJS等。由于并沒有一個統一的標準,各個動態解析器的API實現程度也參差不齊,也會有各種各樣的坑,并沒有一個 “最佳” 的解決方案。

0x03 觸發事件及頁面監聽

上面的例子,介紹了爬蟲中常見的一個場景:在頁面加載完成后,通過AJAX加載數據。但現實中的場景,往往會更復雜,需要與用戶進行交互后才會觸發,比如在點擊了某個按鈕后跳轉到某個頁面、滾動到頁面尾部后加載下一頁的數據等。我們需要新的解決方案,去模擬正常用戶的操作。那么,應該如何將用戶交互抽象為代碼?

用戶操作的本質,實際上是觸發了綁定在DOM節點的事件。所以模擬用戶操作的問題,可以簡化為觸發節點事件。事件執行的結果也是多種多樣的,但對于爬蟲來說,我們需要關注的結果只有兩種:1. 是否添加了新的節點( <a><iframe> 等等) 2. 是否發起了新的請求(包括AJAX請求、跳轉等)。簡化后,我們需要解決的問題有:

1. 如何獲取綁定事件?

2. 如何觸發事件?

3. 如何獲取事件觸發的結果?

最后我們的解決方案如下:

1. 如何獲取綁定事件?JavaScript中綁定事件,都會調用 addEventListener 函數。在頁面里的代碼執行前( onInitialized | PhantomJS ),hook addEventListener函數,就可以捕獲到哪些DOM節點綁定了事件。

_addEventListener = Element.prototype.addEventListener;
Element.prototype.addEventListener = function(a,b,c) {
    EVENT_LIST.push({"event": event, "element": this})
    _addEventListener.apply(this, arguments);
};

2. 如何觸發事件?JavaScript中提供了 dispatchEvent 函數,可以觸發指定DOM節點的指定事件,也就是上一個問題中,我們收集的 EVENT_LIST

for(var i in EVENT_LIST){
    var evt = document.createEvent('CustomEvent');
    evt.initCustomEvent(EVENT_LIST[i]["event"], true, true, null);
    EVENT_LIST[i]["element"].dispatchEvent(evt);
}

除了通過addEventListener綁定事件,還有一些inline-script,是無法通過hook addEventListener來獲取的。比如:

<div id="test" onclick="alert('hello')"></div>

解決方法是遍歷節點,執行所有的onxxxx屬性的值。

function trigger_inline(){
    var nodes = document.all;
    for (var i = 0; i < nodes.length; i++) {
        var attrs = nodes[i].attributes;
        for (var j = 0; j < attrs.length; j++) {
            attr_name = attrs[j].nodeName;
            attr_value = attrs[j].nodeValue;
            if (attr_name.substr(0, 2) == "on") {
                console.log(attrs[j].nodeName + ' : ' + attr_value);
                eval(attr_value);
            }
            if (attr_name in {"src": 1, "href": 1} && attrs[j].nodeValue.substr(0, 11) == "javascript:") {
                console.log(attrs[j].nodeName + ' : ' + attr_value);
                eval(attr_value.substr(11));
            }
        }
    }
}

3. 如何獲取事件觸發的結果?HTML5中的 MutationObserver 方法,可以檢查頁面中的DOM是否發生變化。但是PhantomJS并不支持(攤手 Support for Mutation Observers ),解決方案是監聽了 DOMNodeInserted 事件。AJAX請求的捕獲,解決方案有兩種: onResourceRequested 可以捕獲非主框架的請求,但需要通過正則表達式匹配篩選出有效請求;hook XMLHttpRequest.openXMLHttpRequest.send 可以準確的捕獲請求內容。

document.addEventListener('DOMNodeInserted', function(e) {
    var node = e.target;
    if(node.src || node.href){
        LINKS_RESULT.push(node.src || node.href);
    }
}, true);
_open = XMLHttpRequest.prototype.open
XMLHttpRequest.prototype.open = function (method, url) {
    if (!this._url) {
        this._url = url;
        this._method = method;
    }
    _open.apply(this, arguments);
};
_send = XMLHttpRequest.prototype.send
XMLHttpRequest.prototype.send = function (data) {
    window.$Result$.add_ajax(this._url, this._method, data);
    _send.apply(this, arguments);
};

整理一下,在頁面加載前,需要hook三個接口: addEventListenerXMLHttpRequest.openXMLHttpRequest.send 。頁面加載完之后,需要獲取所有的 <a><iframe><form> 標簽,開啟頁面DOM節點監聽,并且觸發所有的事件。最后輸出結果。

在實現了動態爬取的基本功能后,還有一些可以提升爬蟲的穩定性和效率的小tips:自動填寫表單(應對某些情況下參數為空導致表單無法提交)、禁止非必要資源的加載(jpg、png、css、mp4等)、頁面加載完成后禁止跳轉(防止因為觸發事件導致的跳轉)、hook會導致頁面阻塞的函數(alert、prompt)、觸發事件向下冒泡(解決一些不標準的前端代碼綁定的DOM節點太寬泛導致的問題,但實測非常影響效率)等。

0x04 去重

去重是爬蟲中最核心,也是最影響爬蟲效率和結果的部分。去重過于粗放,在遇到頁面比較多的網站時爬不完。過于嚴格的話,爬取的結果太少,也會影響后續掃描的效果。

去重一般分為兩個部分:任務隊列的去重、結果隊列的去重。這兩種去重的區別在于,在爬蟲運行過程中,任務隊列是一直變動的(增加 & 減少),而結果隊列是不斷的增加的。對任務隊列的去重,要在掃描過程中重復的進行的,即某個頁面爬取完成后,獲取的結果加入任務隊列進行下一次爬蟲任務之前,需要做一次去重(或者每完成一個深度的爬蟲任務,進行一次去重),而結果隊列是在所有的任務結束后進行去重,不影響爬蟲運行效率,只影響最后的結果輸出。這兩種去重可以使用相同的去重策略,也可以使用不同的策略(對任務隊列的去重,可以根據當前的任務量,進行去重力度的調整)。

我們將爬蟲的功能和需求程度逐一列出來: 

1. 基礎: 非抓取目標站點的URL 

2. 基礎: 完全重復的URL & 參數打亂但實際上仍然重復的URL 

3. 溫飽: 分析參數,去除遍歷型的,exp: page.php?id=1page.php?id=2 等 

4. 溫飽: 支持偽靜態URL去重 

5. 小康: 奇形怪狀URL的去重,exp: test.php?a=1?b=2?from=233test.php?a=1?b=3?from=test 

6. 小康: 根據當前的任務量,動態調整去重力度

前兩個基礎需求實現起來比較簡單,將域名、參數列表提取出來進行對比就可以了,一次循環解決問題。

第三個需求,需要匹配參數值,比如: int、hash、中文、URL編碼等。需要注意的是,不可以直接用匹配的方式處理英文的參數值。如:

http://test.com/index.php?m=home&c=test&a=view
http://test.com/index.php?m=home&c=test&a=add

其中m、c、a參數分別代表了不同的module、controller、action,屬于“ 功能型參數 ”,需要保留。功能性參數的值在大多數情況下是字母(有意義的單詞),有些情況下也會是數字或者數字字母的混合。那么,應該如何做策略?

這個問題目前的解決方案也比較粗暴,對全部是字母的參數值,不做處理,對數字字母混合的參數值,根據任務量的多少進行“ 彈力去重 ”(詳見需求6)。舉個實際的例子:

# 去重處理前:
http://test.com/index.php?m=home&c=test&id=3
http://test.com/index.php?m=home&c=test&type=friday
http://test.com/index.php?m=home&c=test&type=464730bbd7fb2016c880ffd597f2808f
http://test.com/index.php?m=home&c=test&type=b59c67bf196a4758191e42f76670ceba
# 處理過程:
{"m": "home", "c": "test", "id":"{{int}}"}
{"m": "home", "c": "test", "id":"{{int}}"}
{"m": "home", "c": "test", "type":"friday"}
{"m": "home", "c": "test", "type":"{{hash}}"}
{"m": "home", "c": "test", "type":"{{hash}}"}
# 去重結果:
http://test.com/index.php?m=home&c=test&id=2
http://test.com/index.php?m=home&c=test&type=friday
http://test.com/index.php?m=home&c=test&type=464730bbd7fb2016c880ffd597f2808f

第四個需求,支持偽靜態去重。首先要定義對路徑去重的策略,我們把路徑用/分隔開,扔到處理參數值的函數中去處理(符合規則的替換為指定字符串、不符合規則的原樣返回),然后再用替換過的URL做去重處理就可以了。當然還有一些偽靜態長這樣:

htttp://xxx.com/?index_1_test_233
htttp://xxx.com/?index_1_new_456

再按照上述的去重策略就過于粗略,應該怎么處理呢?繼續往下看。

第五個需求,奇形怪狀的URL。目前已有的去重策略都是通過分析替換參數值、路徑名來實現的,但是這種奇奇怪怪的URL根本不按套路出牌,只能采用非常的方法:在參數、路徑進行拆分處理前,替換掉一些干擾字符。舉個實例:

# 處理前
http://test.com/test.php?id=12?from=te?user=233
http://test.com/test.php?id=12?from=te?user=233_abc

# 替換后
http://test.com/test.php?id={{int}}?from=te?user={{int}}
http://test.com/test.php?id={{int}}?from=te?user={{mix_str}}

第六個需求,根據當前的任務量,自動調整去重策略。在有些情況下,上述的各種去重套路都不好用,比如:

http://test.com/user.php?name=test
http://test.com/user.php?name=今天是陰天
http://test.com/user.php?name=bbbbb
...

當用戶名為自定義,且有成千上萬個用戶的時候,上述的去重策略就都失效了。問題出在哪里?

需求三的解決方案似乎過于粗略了,直接把所有的純英文字符串放過了,但是也沒有更好的解決方案。只能針對這種特殊情況,再加一次循環,先找到出現次數過多的參數,再針對這些特定的參數進行強制去重。新的策略是這樣的:第一次循環只進行預處理,分析當前的參數列表,并計數。第二遍,根據參數列表的計數值判斷當前參數是否需要強制去重。舉個實例:

http://test.com/index.php?name=friday&m=read
http://test.com/index.php?name=test&m=read
http://test.com/index.php?name=2333&m=read 

# 第一輪遍歷結果
{
    md5(name+m):{count:3, "name":["friday","test","{{int}}"], "m": ["read"]},
}

當參數列表相同的URL數量大于某個特定值,且某個參數的值的個數大于某個特定值的時候,強制對該參數進行去重,即將全英文字符串替換為 {{str}}

上述方法實現起來稍微有點兒繞,還有個粗暴點兒的解決方案:不去檢測具體參數,只判斷當前任務隊列里的任務數是否超過某個值,一旦超過,就啟動強制去重(只要參數列表或根路徑相同,直接去掉,可能會誤殺很多偽靜態)。

在實現了上述的六個需求后,一個簡潔有效的去重腳本就完成了,流程圖如下:

0x05 對比

為了測試動態爬蟲(以下簡稱KSpider)的基本功能和效率,選取了同樣是基于動態分析的WVS掃描器的爬蟲(以下簡稱WVSSpider)來對比。

首先測試基本抓取功能。 AISec漏洞掃描器測試平臺 提供了幾個demo,爬取結果如下:

# 注: WVSSpider無法設置爬蟲深度及線程數,針對path相同的url會進行聚合處理,生成SiteFile。
WVSSpider # wvs_console.exe /Crawl http://demo.aisec.cn/demo/aisec/ /SaveLogs /SaveFolder C:\Users\xxx\Desktop /ExportXML 
Request Count: 31, SiteFile Count: 11, Time Count: 23
KSpider # python crawler.py http://demo.aisec.cn/demo/aisec/ {"depth": 5, "thread_count": 5} 
Request Count: 23, Result Count: 18, Time Cost: 33
KSpider Basic # python crawler.py http://demo.aisec.cn/demo/aisec/ {"depth": 5, "thread_count": 5, "type": "basic"} 
Request Count: 11,  Result Count: 8, Time Cost: 1

前兩個掃描都抓取到了5個關鍵的請求,包括:

基礎<a>標簽: http://demo.aisec.cn/demo/aisec/html_link.php?id=2
JS自動解析: http://demo.aisec.cn/demo/aisec/js_link.php?id=2&msg=abc
JS自動解析 + FORM表單: http://demo.aisec.cn/demo/aisec/post_link.php
JS自動解析 + AJAX請求: http://demo.aisec.cn/demo/aisec/ajax_link.php?id=1&t=0.04278885293751955
事件觸發 + DOM改變: http://demo.aisec.cn/demo/aisec/click_link.php?id=2

靜態分析的掃描速度很快,但只掃出了上述5個請求中的第一個。通過表單分析抓取到了第三個POST請求,但是由于 <form> 表單中的 <input> 標簽是由JavaScript動態生成(代碼如下),所以沒有抓取到請求的具體參數。

<form method="post" name="form1" enctype="multipart/form-data" action="post_link.php">
<script>
document.write('<input type="text" name="i'+'d" size="30" value=1><br>');
document.write('<input type="text" name="m'+'sg" size="30" value="abc">');
</script>
<input type="submit" value="提交" name="B1">
</form>

接下來是爬蟲的效率測試,抓取目標是 百度貼吧 。結果如下:

WVSSpider # wvs_console.exe /Crawl https://tieba.baidu.com /SaveLogs /SaveFolder C:\Users\xxx\Desktop /ExportXML 
Request Count: 201, SiteFile Count: 101, Time Count: 220
KSpider # python crawler.py https://tieba.baidu.com {"depth": 5, "thread_count": 10} 
Request Count: 410, Result_length: 535, Time_Cost: 339

可以看到,隨著網站復雜度的上升,WVS爬蟲的請求數增長相對平穩,而KSpider在線程數為10的情況下,在6分鐘內也完成了爬取任務,表現正常。

在分析過程中,雖然 WVSSpider 速度很快,整體效率非常高,但也有一些缺點:爬取深度無法指定、無法跨平臺工作、對于偽靜態形式的URL去重效果較差(如下圖所示的SiteFile共有43個,占比42%)、爬蟲結果中有部分URL分割結果(如: https://tieba.baidu.com/home/main?un=111 會分割成兩個SiteFile, /home/home/main ,所以實際掃描到的URL數量比結果要少)等。

由于目標網站URL較多,覆蓋率比較難測算,我們用腳本簡單對比了 WVSSpider 和 KSpider 抓取的結果,除去靜態資源,KSpider 覆蓋了98%的 WVSSpider 抓取結果(即 WVSSpider 抓取結果里,有98%的結果同樣被 KSpider 抓到),而 WVSSpider 僅覆蓋了38%的 KSpider 抓取結果。

0x06 總結

除了以上提到的去重和動態解析,還有一些小tips,如fuzz常見路徑、從robots.txt中提取信息、爬取過程中進行敏感信息識別、生成網站信息畫像等,對爬蟲的覆蓋率及后續的掃描任務會有幫助。

本文詳細的介紹了在動態爬蟲的實現過程中,可能會遇到的問題以及解決方案。優秀的代碼不會一蹴而就,需要持續的優化調整,后期會考慮開源,歡迎溝通交流。

參考資料

讓人歡喜讓我憂的phantomjs 

盤點selenium phantomJS使用的坑 

SuperSpider——打造功能強大的爬蟲利器 

XSS dynamic detection using PhantomJs

 

 

 

來自:http://bobao.#/learning/detail/3391.html

 

 本文由用戶 MickiBrinkm 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!