漏掃動態爬蟲實踐

漏掃動態爬蟲實踐

這篇文章在前段時間首發於安全客,從正式工作以來,自己沒有堅持住經常更新博客的習慣,大部分積累都保存在了個人的筆記,很少有時間整理髮出來,果然懶纔是原罪。

0x00 簡介

動態爬蟲作爲漏洞掃描的前提,對於web漏洞發現有至關重要的作用,先於攻擊者發現脆弱業務的接口將讓安全人員佔領先機。即使你有再好的payload,如果連入口都發現不了,後續的一切都無法進行。這部分內容是我對之前開發動態爬蟲經驗的一個總結,在本文將詳細介紹實踐動態爬蟲的過程中需要注意的問題以及解決辦法。

在Chrome的Headless模式剛出現不久,我們當時就調研過用作漏洞掃描器爬蟲的需求,但由於當時功能不夠完善,以及無法達到穩定可靠的要求。舉個例子,對於網絡請求,無法區分導航請求和其它請求,而本身又不提供navigation lock的功能,所以很難確保頁面的處理不被意外跳轉中斷。同時,不太穩定的CDP經常意外中斷和產生Chrome殭屍進程,所以我們之前一直在使用PhantomJS。

但隨着前端的框架使用越來越多,網頁內容對爬蟲越來越不友好,在不考慮進行服務端渲染的情況下,Vue等框架讓靜態爬蟲徹底失效。同時,由於JS的ES6語法的廣泛使用,缺乏維護(創始人宣佈歸檔項目暫停開發)的PhantomJS開始變的力不從心。

在去年,puppeteer和Chromium項目在經歷了不斷迭代後,新增了一些關鍵功能,Headless模式現在已經能大致勝任掃描器爬蟲的任務。所以我們在去年果斷更新了掃描器的動態爬蟲,採用Chromium的Headless模式作爲網頁內容解析引擎,以下示例代碼都是使用pyppeteer 項目(採用python實現的puppeteer非官方版本),且爲相關部分的關鍵代碼段,如需運行請根據情況補全其餘必要代碼。

0x01 初始化設置

因爲Chrome自帶XSS Auditor,所以啓動瀏覽器時我們需要進行一些設置,關閉掉這些影響頁面內容正常渲染的選項。我們的目的是儘可能的去兼容更多的網頁內容,同時在不影響頁面渲染的情況下加快速度,所以常見的瀏覽器啓動設置如下:

browser = await launch({
	"executablePath": chrome_executable_path,
	"args": [
		"--disable-gpu",
		"--disable-web-security",
		"--disable-xss-auditor",# 關閉 XSS Auditor
		"--no-sandbox",
		"--disable-setuid-sandbox",
		"--allow-running-insecure-content",# 允許不安全內容
		"--disable-webgl",
		"--disable-popup-blocking"
	],
	"ignoreHTTPSErrors": True # 忽略證書錯誤
})

接下來,創建隱身模式上下文,打開一個標籤頁開始請求網頁,同樣,也需要進行一些定製化設置。比如設置一個常見的正常瀏覽器UA、開啓請求攔截並注入初始的HOOK代碼等等:

context = browser.createIncognitoBrowserContext()
page = await context.newPage()
tasks = [
    # 設置UA
    asyncio.ensure_future(page.setUserAgent("...")),
    # 注入初始 hook 代碼,具體內容之後介紹
    asyncio.ensure_future(page.evaluateOnNewDocument("...")),
    # 開啓請求攔截
    asyncio.ensure_future(page.setRequestInterception(True)),
    # 啓用JS,不開的話無法執行JS
    asyncio.ensure_future(page.setJavaScriptEnabled(True)),
    # 關閉緩存
    asyncio.ensure_future(page.setCacheEnabled(False)),
    # 設置窗口大小
    asyncio.ensure_future(page.setViewport({"width": 1920, "height": 1080}))
]
await asyncio.wait(tasks)

這樣,我們就創建了一個適合於動態爬蟲的瀏覽器環境。

0x02 注入代碼

這裏指的是在網頁文檔創建且頁面加載前注入JS代碼,這部分內容是運行一個動態爬蟲的基礎,主要是Hook關鍵的函數和事件,畢竟誰先執行代碼誰就能控制JS的運行環境。

包含新url的函數

hook History API,許多前端框架都採用此API進行頁面路由,記錄url並取消操作:

window.history.pushState = function(a, b, url) { console.log(url);}
window.history.replaceState = function(a, b, url) { console.log(url);}
Object.defineProperty(window.history,"pushState",{"writable": false, "configurable": false});
Object.defineProperty(window.history,"replaceState",{"writable": false, "configurable": false});

監聽hash變化,Vue等框架默認使用hash部分進行前端頁面路由:

window.addEventListener("hashchange", function()  {console.log(document.location.href);});

監聽窗口的打開和關閉,記錄新窗口打開的url,並取消實際操作:

window.open = function (url) { console.log(url);}
Object.defineProperty(window,"open",{"writable": false, "configurable": false});

window.close = function() {console.log("trying to close page.");};
Object.defineProperty(window,"close",{"writable": false, "configurable": false});

同時,還需要hook window.WebSocketwindow.EventSourcewindow.fetch 等函數,具體操作差不多,就不再重複貼代碼了。

定時函數

setTimeoutsetInterval兩個定時函數,在其它文章裏都是建議改小時間間隔來加速事件執行,但我在實際使用中發現,如果將時間改的過小,如將 setInterval 全部設置爲不到1秒甚至0秒,會導致回調函數執行過快,極大的消耗資源並阻塞整個頁面內javascript的正常執行,導致頁面的正常邏輯無法執行,最後超時拋錯退出。

所以在減小時間間隔的同時,也要考慮穩定性的問題,個人不建議將值設置過小,最好不小於1秒。因爲這些回調函數一般都是相同的操作邏輯,只要保證在爬取時能觸發一次即可覆蓋大部分情況。就算是設置爲1秒,部分複雜的網頁也會消耗大量資源並顯著降低爬取時間,如果你發現有一些頁面遲遲不能結束甚至超時,說不定就是這兩個定時函數惹的禍。

收集事件註冊

我們爲了儘可能獲取更多的url,最好能將頁面內註冊過的函數全部觸發一遍,當然也有意見是觸發常見的事件,但不管什麼思路,我們都需要收集頁面內全部註冊的事件。

除了內聯事件,事件註冊又分DOM0級DOM2級事件,兩種方式都可以註冊事件,使用的方式卻完全不相同,Hook點也不同。許多文章都提到了Hook addEventListener的原型,但其實是有遺漏的,因爲 addEventListener 只能Hook DOM2級事件的註冊,無法Hook DOM0 級事件。總之就是,DOM0級事件與DOM2級事件之間需要不同的方式處理。

測試如下:

在這裏插入圖片描述
可以看到,在註冊事件時並沒有打印出 name 的值。

DOM0 級事件

這是JavaScript指定事件處理程序的傳統方式,將一個函數賦值給一個事件處理程序屬性。這種方式目前所有瀏覽器都支持,使用簡單且廣泛。下面的代碼就是一個常見的DOM0級事件註冊:

let btn = document.getElementById("test");
btn.onclick = function() {
    console.log("test");
}

那如何Hook DOM0級事件監聽呢?答案就是修改所有節點的相關屬性原型,設置訪問器屬性。將以下JS代碼提前注入到頁面中:

function dom0_listener_hook(that, event_name) {
    console.log(that.tagName);
    console.log(event_name);
}

Object.defineProperties(HTMLElement.prototype, {
    onclick: {set: function(newValue){onclick = newValue;dom0_listener_hook(this, "click");}},
    onchange: {set: function(newValue){onchange = newValue;dom0_listener_hook(this, "change");}},
    onblur: {set: function(newValue){onblur = newValue;dom0_listener_hook(this, "blur");}},
    ondblclick: {set: function(newValue){ondblclick = newValue;dom0_listener_hook(this, "dblclick");}},
    onfocus: {set: function(newValue){onfocus = newValue;dom0_listener_hook(this, "focus");}},
    ... ... // 略 繼續自定義你的事件
})
// 禁止重定義訪問器屬性
Object.defineProperty(HTMLElement.prototype,"onclick",{"configurable": false});

這樣我們就完成了對DOM0級事件的Hook收集。效果如下:

在這裏插入圖片描述

DOM2 級事件

DOM2級事件定義了兩個方法,用於處理指定和刪除事件處理函數的操作:addEventListener()removeEventListener(),所有的DOM節點中都包含了這兩個方法。下面是一個簡單的示例:

let btn = document.getElementById("test");
btn.addEventListener("click", function() {
    console.log("test");
}, true)

其中第三個參數,true表示在捕獲階段調用事件處理函數,false表示在冒泡階段調用。

Hook DOM2 級事件這部分比較簡單,大多數文章也都有提到,通過Hook addEventListener的原型即可解決:

let old_event_handle = Element.prototype.addEventListener;
Element.prototype.addEventListener = function(event_name, event_func, useCapture) {
    let name = "<" + this.tagName + "> " + this.id + this.name + this.getAttribute("class") + "|" + event_name;
    console.log(name);
    old_event_handle.apply(this, arguments);
};

鎖定表單重置

爬蟲在處理網頁時,會先填充表單,接着觸發事件去提交表單,但有時會意外點擊到表單的重置按鈕,造成內容清空,表單提交失敗。所以爲了防止這種情況的發生,我們需要Hook表單的重置並鎖定不能修改。

HTMLFormElement.prototype.reset = function() {console.log("cancel reset form")};
Object.defineProperty(HTMLFormElement.prototype,"reset",{"writable": false, "configurable": false});

0x03 導航鎖定

爬蟲在處理一個頁面時,可能會被期間意外的導航請求中斷,造成漏抓。所以除了和本頁面相同url的導航請求外,其餘所有的導航請求都應該取消。面對重定向需要分多種情況對待:

  • 前端重定向全部取消,並記錄下目標鏈接放入任務隊列
  • 後端重定向響應的body中不包含內容,則跟隨跳轉
  • 後端重定向響應的body中含有內容,無視重定向,渲染body內容,記錄下location的值放入任務隊列

雖然有請求攔截的相關API(setRequestInterception),但導航請求其實已經進入了網絡層,直接調用 request.abort 會使當前頁面拋出異常(aborted: An operation was aborted (due to user action)),從而中斷爬蟲對當前頁面的處理。所以下面會介紹相關的解決辦法。

Hook前端導航

前端導航指由前端頁面JS發起的導航請求,如執行 location.href 的賦值、點擊某個a標籤等,最後的變化都是location的值發生改變。如何優雅的hook前端導航請求之前一直是個難題,因爲location是不可重定義的:

在這裏插入圖片描述

意味着你無法通過Object.defineProperty 方法去重定義訪問器屬性,也就無法hook window.location的相關賦值操作。PhantomJS中有個navigationLocked選項可以很容易的鎖定當前導航,但很遺憾這個特性在Chromium中並沒有。一旦導航請求進入網絡層,整個頁面進入阻塞狀態。

在說我的做法之前,先介紹一下目前已知的兩種解決方案。

修改Chromium源碼

這是fate0師傅提出的方案,既然Chromium默認location屬性的configurable選項是false,那直接修改源碼將它設置爲true就解決了,具體操作見其博客文章。優點是直接從底層修改源碼支持,但維護成本較高,每次都得自己編譯Chromium。

加載自定義插件

這是由豬豬俠在去年的先知白帽大會上提出的,通過hook網絡層的API來解決。但問題是,Chromium的headless模式是無法加載插件的,官方也明確表示目前沒有對headless模式加載插件功能的開發計劃,也就是說, 只要你開啓了headless模式,那麼就無法使用插件

這是個很關鍵的問題,因爲我們的爬蟲幾乎都是在服務器上運行,不可能去使用圖形化的桌面版本,更不可能使用windows server,這會極大降低速度和穩定性。這是一個非常好的思路,但很遺憾不能在實際環境中大規模運用。

不穩定的onbeforeunload

在之前,我想通過設置onbeforeunload訪問,當觸發確認彈窗時自動執行dialog.dimiss()來取消當前的前端導航請求,如在頁面中注入以下代碼:

window.onbeforeunload = function(e){
	console.log("onbeforeunload trigger.")
};

設置自動dimiss彈窗:

import asyncio
from pyppeteer import dialog

async def close_dialog(dialog_handler: dialog):
    await dialog_handler.dismiss()

page.on("dialog", lambda dialog_handle: asyncio.ensure_future(close_dialog(dialog_handle)))

按照理想中的情況,每一次離開當前頁面的導航都會彈窗詢問(是否離開當前頁面),如果點擊取消,那麼此次導航請求就會被取消,同時當前頁面不會刷新。

但這個方法有個嚴重的問題,無法獲取即將跳轉的url,即onbeforeunload回調函數中無法拿到相關的值。並且經過一段時間的測試,這個方法並不可靠,它的觸發有一些前置條件,官方說需要用戶在當前頁面存在有效的交互操作,纔會觸發此回調函數。即使我已經嘗試用各種API去模擬用戶點擊等操作,但最後依舊不是百分百觸發。

To combat unwanted pop-ups, some browsers don't display prompts created in beforeunload event handlers unless the page has been interacted with. Moreover, some don't display them at all.

所以這個方法最後也被我否決了。

204狀態碼

這是我目前找到的最優雅的解決方案,不用修改源碼,不用加載插件,在攔截請求的同時返回狀態碼爲204的響應,可以讓瀏覽器對該請求不做出反應,即不刷新頁面,繼續顯示原來的文檔。

RFC7231中我們可以看到如下說明:

The 204 response allows a server to indicate that the action has been successfully applied to the target resource, while implying that the user agent does not need to traverse away from its current "document view" (if any)

意思是,服務端說明操作已經執行成功,同時告訴瀏覽器不需要離開當前的文檔內容。

以下示例代碼是攔截當前頁面top frame的導航請求並返回204狀態碼:

import asyncio
from pyppeteer.network_manager import Request

async def intercept_request(request: Request):
    if request.isNavigationRequest() and not request.frame.parentFram:
        await request.respond({
            "status": 204
        })
        # 保存 request 到任務隊列

page.on('request', lambda request: asyncio.ensure_future(
            intercept_request(request)))

這樣,我們成功的Hook住了前端導航,並將目標請求保存到了任務隊列。

處理後端重定向

許多時候,後端會根據當前用戶是否登錄來決定重定向,但其實響應的body中依舊包含了內容。最常見的情況就是未登錄的情況下訪問某些後臺管理頁面,雖然body中不包含任何用戶的信息,但多數情況都會有許多接口,甚至我們能找到一些未授權訪問的接口,所以對於重定向的body內容一定不能忽略。

在解決了前端的導航請求問題之後,處理後端重定向響應就很簡單了。當後端重定向響應的body中不包含內容,則跟隨跳轉或者返回location的值然後退出。如果後端重定向響應的body中含有內容,則無視重定向,渲染body內容,並返回location的值。

在這裏插入圖片描述

目前puppeteer並沒有攔截修改響應的接口,所以這需要我們思考如何手動完成這個操作。具體方法不再贅述,思路是用requests等庫請求該url,並用 request.respond手動設置響應狀態碼爲200。

0x04 表單處理

過去靜態爬蟲通過解析form節點手動構造POST請求,放到現在已經顯得過時。越來越複雜的前端處理邏輯,從填寫表單到發出POST請求,期間會經過複雜的JS邏輯處理,最後得到的請求內容格式和靜態構造的往往差別較大,可以說靜態爬蟲現在幾乎無法正確處理表單的提交問題。

所以我們必須模擬正常的表單填寫以及點擊提交操作,從而讓JS發送出正確格式的請求。

表單填充

填充數據雖然很簡單,但需要考慮各種不同的輸入類型,常見的輸入類型有:textemailpasswordradiofiletextareahidden等等。我們分爲幾種類型來單獨說明需要注意的問題。

文本

這部分包括textmailpassword等只需要輸入文本的類型,處理較爲簡單,綜合判斷 id 、name 、class 等屬性中的關鍵字和輸入類型來選擇對應的填充文本。如包含mail關鍵字或者輸入類型爲email,則填充郵箱地址。如果包含phone、tel等關鍵字或輸入類型爲tel,則填充手機號碼。具體不再贅述。

選擇

這部分包括radiocheckboxselect,前面兩個比較簡單,找到節點後調用 elementHandle.click() 方法即可,或者直接爲其設置屬性值checked=true

對於select 稍微複雜一些,首先找到select節點,獲取所有的option子節點的值,然後再選擇其中一個執行page.select(selector, ...values) 方法。示例代碼如下:

def get_all_options_values_js():
    return """
        function get_all_options_values_sec_auto (node) {
            let result = [];
            for (let option of node.children) {
                let value = option.getAttribute("value");
                if (value)
                    result.push(value) 
            }
            return result;
        }
    """

async def fill_multi_select():
    select_elements = await page_handler.querySelectorAll("select")
    for each in select_elements:
        random_str = get_random_str()
        # 添加自定義屬性 方便後面定位
        await page_handler.evaluate("(ele, value) => ele.setAttribute('sec_auto_select', value)", each, random_str)
        attr_str = "sec_auto_select=\"%s\"" % random_str
        attr_selector = "select[%s]" % attr_str
        value_list = await page_handler.querySelectorEval(attr_selector, get_all_options_values_js())
        if len(value_list) > 0:
            # 默認選擇第一個
            await page_handler.select(attr_selector, value_list[0])

文件

表單中常見必須要求文件上傳文件,有時JS還限制了上傳的文件後綴和文件類型。我們無法覆蓋所有的文件類型情況,但可以準備幾種常見的文件類型,如:pngdocxlsxzip等。當然,對於一些簡單的限制,我們還是可以去掉的,比如找到文件上傳的dom節點並刪除 acceptrequired 屬性:

input_node.removeAttribute('accept');
input_node.removeAttribute('required');

這樣可以儘可能的讓我們的文件上傳成功。

這裏有個問題需要注意一下,在過去版本的Chromium headles模式下上傳文件時,request intercept 抓取到的postData內容將爲空,這是個Chromium的BUG,官方在新版本已經修復了這個問題,請在開發時避開相應的版本。

表單提交

提交表單也有一些需要注意的問題,直接點擊form表單的提交按鈕會導致頁面重載,我們並不希望當前頁面刷新,所以除了Hook住前端導航請求之外,我們還可以爲form節點設置target屬性,指向一個隱藏的iframe。具體操作的話就是新建隱藏iframe然後將form表單的target指向它即可,我在這裏就不贅述了。

要成功的提交表單,就得正確觸發表單的submit操作。不是所有的前端內容都有規範的表單格式,或許有一些form連個button都沒有,所以這裏有三種思路可供嘗試,保險起見建議全部都運行一次:

  • 在form節點的子節點內尋找type=submit的節點,執行elementHandle.click()方法。
  • 直接對form節點執行JS語句:form.submit(),注意,如果form內有包含屬性值name=submit的節點,將會拋出異常,所以注意捕獲異常。
  • 在form節點的子節點內尋找所有button節點,全部執行一次elementHandle.click()方法。因爲我們之前已經重定義並鎖定了表單重置函數,所以不用擔心會清空表單。

這樣,絕大部分表單我們都能觸發了。

0x05 事件觸發

關於事件觸發這部分其實有多種看法,我在這裏的觀點還是覺得應該去觸發所有已註冊的事件,並且,除了允許自身的冒泡之外,還應該手動進行事件傳遞,即對觸發事件節點的子節點繼續觸發事件。當然,爲了性能考慮,你可以將層數控制到三層,且對兄弟節點隨機選擇一個觸發。簡單畫圖說明:

在這裏插入圖片描述

ID爲parent的節點存在onclick的內聯事件,對其子節點,同一層隨機選擇一個觸發。上圖中彩色爲觸發的節點。

事件冒泡是指向父節點傳遞,事件傳遞指向子節點傳遞,遺憾的是我在CustomEvent中沒有找到傳遞方式指定爲事件傳遞的參數選項,所以簡單手動實現。

內聯事件

對於內聯事件,因爲無法通過Hook去收集註冊事件,所以需要查詢整個DOM樹,找出包含關鍵字屬性的節點,常見的內聯事件屬性如下:

inline_events = ["onabort", "onblur", "onchange", "onclick", "ondblclick", "onerror","onfocus", "onkeydown","onkeypress", "onkeyup", "onload", "onmousedown","onmousemove", "onmouseout", "onmouseover","onmouseup", "onreset", "onresize", "onselect", "onsubmit", "onunload"]

然後遍歷每個事件名,找出節點並自定義觸發事件:

def get_trigger_inline_event_js():
    return """
        async function trigger_all_inline_event(nodes, event_name) {
            for (let node of nodes) {
                let evt = document.createEvent('CustomEvent');
                evt.initCustomEvent(event_name, false, true, null);
                try {
                    node.dispatchEvent(evt);
                }
                catch {}
            }
        }
    """

for event_name in ChromeConfig.inline_events:
    await self.page_handler.querySelectorAllEval("[%s]" % event_name, get_trigger_inline_event_js(), event_name.replace("on", ""))

至於DOM事件,將收集到的事件依次觸發即可,不再贅述。

0x06 鏈接收集

除了常見的屬性 srchref, 還要收集一些如 data-urllongDesclowsrc等屬性,以及一些多媒體資源URI。以收集src屬性值舉例:

def get_src_or_href_js():
    return """
        function get_src_or_href_sec_auto(nodes) {
            let result = [];
            for (let node of nodes) {
                let src = node.getAttribute("src");
                if (src) {
                    result.push(src)
                }
            }
            return result;
        }
    """

links = await page_handler.querySelectorAllEval("[src]", get_src_or_href_js())

當然這裏你也可以使用 TreeWalker。

同時在拼接相對URL時應該注意base標籤的值。

<HTML>
 <HEAD>
   <TITLE>test</TITLE>
   <BASE href="http://www.test.com/products/intro.html">
 </HEAD>

 <BODY>
   <P>Have you seen our <A href="../cages/birds.gif">Bird Cages</A>?
 </BODY>
</HTML>

相對url "../cages/birds.gif" 將解析爲http://www.test.com/cages/birds.gif

註釋中的鏈接

註釋中的鏈接一定不能忽略,我們發現很多次暴露出存在漏洞的接口都是在註釋當中。這部分鏈接可以用靜態解析的方式去覆蓋,也可以採用下面的代碼獲取註釋內容並用正則匹配:

comment_elements = await page_handler.xpath("//comment()")

for each in comment_elements:
    if self.page_handler.isClosed():
        break
    # 註釋節點獲取內容 只能用 textContent
    comment_content = await self.page_handler.evaluate("node => node.textContent", each)
    # 自定義正則內容 regex_comment_url
    matches = regex_comment_url(comment_content)          
    for url in matches:
        print(url)

0x07 去重

說實話這部分是很複雜的一個環節,從參數名的構成,到參數值的類型、長度、出現頻次等,需要綜合很多情況去對URL進行去重,甚至還要考慮RESTful API設計風格的URL,以及現在越來越多的僞靜態。雖然我們在實踐過程中經過一些積累完成了一套規則來進行去重,但由於內容繁瑣實在不好展開討論,且沒有太多的參考價值,這方面各家都有各自的處理辦法。但歸結起來,單靠URL是很難做到完美的去重,好在漏洞掃描時即使多一些重複URL也不會有太大影響,最多就是掃描稍微慢了一點,其實完全可以接受。所以在這部分不必太過糾結完美,實在無法去重,設定一個閾值兜底,避免任務數量過大。

但如果你對URL的去重要求較高,同時願意耗費一些時間並有充足的存儲資源,那麼你可以結合響應內容,利用網頁的結構相似度去重。

結構相似度

一個網頁主要包含兩大部分:網頁結構和網頁內容。一些僞靜態網頁的內容可能會由不同的信息填充,但每個網頁都有自己獨一無二的結構,結構極其相似的網頁,多半都屬於僞靜態頁面。每一個節點它的節點名、屬性值、和父節點的關係、和子節點的關係、和兄弟的關係都存在特異性。節點的層級越深,對整個DOM結構的影響越小,反之則越大。同級的兄弟節點越多,對DOM結構的特異性影響也越小。可以根據這些維度,對整個DOM結構進行一個特徵提取,設定不同的權值,同時轉化爲特徵向量,然後再對兩個不同的網頁之間的特徵向量進行相似度比較(如僞距離公式),即可準確判斷兩個網頁的結構相似度。

這方面早已有人做過研究,百度10年前李景陽的專利《網頁結構相似性確定方法及裝置》 就已經很清楚的講述瞭如何確定網頁結構相似性。全文通俗易懂,完全可以自動手動實現一個簡單的程序去判斷網頁結構相似度。整體不算複雜,希望大家自己動手實現。

大量網頁快速相似匹配

這裏我想講一下,在已經完成特徵向量提取之後,面對龐大的網頁文檔,如何做到在大量存儲文檔中快速搜索和當前網頁相似的文檔。這部分是基於我自己的摸索,利用ElasticSearch的搜索特性而得出的簡單方法

首先,我們在通過一系列處理之後,將網頁結構轉化爲了特徵向量,比如請求 https://www.360.cn/的網頁內容經過轉化後,得到了維數爲鍵,權值爲值的鍵值對,即特徵向量:

{
    5650: 1.0, 
    5774: 0.196608, 
    5506: 0.36, 
    2727: 0.157286, 
    1511: 0.262144, 
    540: 0.4096, 
    1897: 0.4096, 
    972: 0.262144, 
    ... ...
}

一般稍微複雜點的網頁全部特徵向量會有數百上千個,在大量的文檔中進行遍歷比較幾乎不可能,需要進行壓縮,這裏使用最簡單的維數取餘方式,將維數壓縮到100維,之後再對值進行離散化變成整數:

{ 50: 13, 75: 8, 92: 18, 33: 12, 2: 15, 86: 10, 9: 9, 95: 10, 55: 14, 42: 12, 35: 15, 82: 10, 17: 7, 54: 14, 22: 11, 10: 16, 77: 11, 44: 17, 60: 9, 26: 19, ... ... }

現在,我們得到了一個代表360主站網頁結構的100維模糊特徵向量,由0-99爲鍵的整數鍵值對組成,接下來,我們按照鍵的大小順序排列,組成一個空格分割的字符串:

0:2 1:10 2:15 3:9 4:4 5:7 6:10 7:15 8:11 9:9 10:16 11:4 12:12 ... ...

最後我們將其和網頁相關內容本身一起存入ElasticSearch中,同時對該向量設置分詞爲whitespace

"fuzz_vector": {
    "type": "text",
    "analyzer": "whitespace"
}

這樣,我們將模糊特徵向量保存了下來。當新發現一個網頁文檔時,如何查找?

首先我們需要明白,這個100維特徵向量就代表這個網頁文檔的結構,相似的網頁,在相同維數上的權值是趨於相同的(因爲我們進行了離散化),所以,如果我們能計算兩個向量在相同維數上權值相同的個數,就能大致確定這兩個網頁是否相似!

舉個例子,對於安全客的兩篇文章,https://www.anquanke.com/post/id/178047https://www.anquanke.com/post/id/178105 ,我們分別進行以上操作,可以得到以下的兩組向量:

0:6 1:5 2:3 3:7 4:5 5:1 6:9 7:2 8:4 9:6 10:4 11:4 12:6 13:2 14:10 15:10 16:8 ...

0:6 1:6 2:3 3:7 4:5 5:1 6:9 7:2 8:3 9:6 10:4 11:4 12:6 13:2 14:10 15:8 16:8 ...

相同的鍵值對佔到了70個,說明大部分維度的DOM結構都是相似的。通過確定一個閾值(如30或者50),找出相同鍵值對大於這個數的文檔即可。一般會得到個位數的文檔,再對它們進行完整向量的相似度計算,即可準確找出和當前文檔相似的歷史文檔。

那麼如何去計算兩個字符串中相同詞的個數呢?或者說,如果根據某個閾值篩選出符合要求的文檔呢?答案是利用ElasticSearch的match分詞匹配。

"query": {
    "match": {
        "fuzz_vector": {
            "query": "0:6 1:5 2:3 3:7 4:5 5:1 6:9 7:2 8:4 ... ...",
            "operator": "or",
            "minimum_should_match": 30
        }
    }
}

以上查詢能快速篩選出相同鍵值對個數爲30及以上的文檔,這種分詞查詢對於億級文檔都是毫秒返回。

0x08 任務調度

我這裏談論的任務調度並不是指鏈接的去重以及優先級排列,而是具體到單個browser如何去管理對應的tab,因爲Chromium的啓動和關閉代價非常大,遠大於標籤頁Tab的開關,並且如果想要將Chromium雲服務化,那麼必須讓browser長時間駐留,所以我們在實際運行的時候,應當是在單個browser上開啓多個Tab,任務的處理都在Tab上進行。

那麼這裏肯定會涉及到browser對Tab的管理,如何動態增減?我使用的是pyppeteer,因爲CDP相關操作均是異步,那麼對Tab的動態增減其實就等價於協程任務的動態增減。

首先,得確定單個browser允許同時處理的最大Tab數,因爲單個browser其實就是一個進程,而當Tab數過多時,維持了過多的websocket連接,當你的處理邏輯較複雜,單個進程的CPU佔用就會達到極限,相關任務會阻塞,效率下降,某些Tab頁面會超時退出。所以單個的browser能同時處理的Tab頁面必須控制到一定的閾值,這個值可以根據觀察CPU佔用來確定。

實現起來思路很簡單,創建一個事件循環,判斷當前事件循環中的任務數與最大閾值的差值,往其中新增任務即可。同時,因爲開啓事件循環後主進程阻塞,我們監控事件循環的操作也必須是異步的,辦法就是創建一個任務去往自身所在的事件循環添加任務。

在這裏插入圖片描述

當然,真實的事件循環並不是一個圖中那樣的順序循環,不同的任務有不同佔用時間以及調用順序。

示例代碼如下:

import asyncio


class Scheduler(object):
    def __init__(self, task_queue):
        self.loop = asyncio.get_event_loop()
        self.max_task_count = 10
        self.finish_count = 0
        self.task_queue = task_queue
        self.task_count = len(task_queue)

    def run(self):
        self.loop.run_until_complete(self.manager_task())

    async def tab_task(self, num):
        print("task {num} start run ... ".format(num=num))
        await asyncio.sleep(1)
        print("task {num} finish ... ".format(num=num))
        self.finish_count += 1

    async def manager_task(self):
        # 任務隊列不爲空 或 存在未完成任務
        while len(self.task_queue) != 0 or self.finish_count != self.task_count:
            if len(asyncio.Task.all_tasks(self.loop)) - 1 < self.max_task_count and len(self.task_queue) != 0:
                param = self.task_queue.pop(0)
                self.loop.create_task(self.tab_task(param))
            await asyncio.sleep(0.5)


if __name__ == '__main__':
    Scheduler([1, 2, 3, 4, 5]).run()

運行結果如下:

在這裏插入圖片描述

Chromium 的相關操作必須在主線程完成,意味着你無法通過多線程去開啓多個Tab和browser。

0x09 結語

關於爬蟲的內容上面講了這麼多依舊沒有概括完,調度關係到你的效率,而本文內容中的細節能夠決定你的爬蟲是否比別人發現更多鏈接。特別是掃描器爬蟲,業務有太多的case讓你想不到,需要經歷多次的漏抓覆盤才能發現更多的情況並改善處理邏輯,這也是一個經驗積累的過程。如果你有好的點子或思路,非常歡迎和我交流!

微博:@9ian1i

0x10 參考文檔

@fate0: 爬蟲基礎篇[Web 漏洞掃描器]爬蟲 JavaScript 篇[Web 漏洞掃描器]爬蟲調度篇[Web 漏洞掃描器]
@Fr1day: 淺談動態爬蟲與去重淺談動態爬蟲與去重(續)
@豬豬俠: 《WEB2.0啓發式爬蟲實戰》
https://peter.sh/experiments/chromium-command-line-switches/
https://miyakogi.github.io/pyppeteer/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章