爬蟲漫游指南:無頭瀏覽器puppeteer的檢測攻防

1. 引言

許多爬蟲初學(xué)者在接觸到無頭瀏覽器的時(shí)候都會(huì)有一種如獲至寶的感覺,仿佛看到了爬蟲的終極解決方案。無論是所有爬蟲教程中都會(huì)出現(xiàn)的PhantomJS、Selenium,亦或是相對冷門的Nightmare,到后來居上的Puppeteer,都能夠作為爬蟲工程師的利刃,撕開反爬的一道道屏障。無頭瀏覽器難道就是爬蟲的終點(diǎn)了嗎?那必然不是,否則各位爬蟲工程師就只值3000塊一個(gè)月了。

首先,無論多強(qiáng)大多輕便的無頭瀏覽器,在同等配置的機(jī)器上,并發(fā)永遠(yuǎn)不可能高過python的一行request請求。在大規(guī)模數(shù)據(jù)采集中,服務(wù)器成本是必須考慮的問題,采集同樣規(guī)模的數(shù)據(jù),人家服務(wù)器成本花了1萬塊,你給霍霍了十幾萬,你猜老板會(huì)不會(huì)問候你老豆。其次,用無頭瀏覽器寫過爬蟲的人應(yīng)該都會(huì)覺得,很難靠headless browser搞出來一個(gè)復(fù)雜的、長期穩(wěn)定的、可靠的大型爬蟲,它們更適合應(yīng)用在一些小規(guī)模的數(shù)據(jù)采集場合。最后,也是最重要的,無頭瀏覽器并不是無敵的,反爬的一方不會(huì)乖乖束手就擒,你有張良計(jì),他自然就有過強(qiáng)梯,反爬一方會(huì)通過某些方法檢測出無頭瀏覽器,然后把這些請求全部處理掉,某些網(wǎng)站你使用無頭瀏覽器甚至無法打開首頁。

上段說的最后一點(diǎn),也就是針對無頭瀏覽器的反爬攻防,就是本文所要討論的內(nèi)容。PhantomJS和Selenium已經(jīng)日薄西山,本文只研究后來居上的Puppeteer。

2. 從蛛絲馬跡中認(rèn)出Puppeteer

2.1 webdriver

介紹

webdriver可以說是Puppeteer最明顯的一個(gè)特征,檢測也非常簡單,獲取navigator.webdriver這一屬性,在默認(rèn)啟動(dòng)的Puppeteer中,它的值為true,而在正常瀏覽器中,navigator里是沒有這一屬性的,是undefined。

await page.evaluateOnNewDocument(() => {
  Object.defineProperty(navigator, 'webdriver', {
    get: () => false,
  });
});

簡單解釋一下這段代碼,在新建頁面之前,將webdriver的get方法強(qiáng)制返回false。那么類似于if (navigator.webdriver)這樣的檢測就不會(huì)生效了。

var attr = window.navigator, result = [];
do {
    Object.getOwnPropertyNames(attr).forEach(function(a) {
        result.push(a)
    })
} while (attr=Object.getPrototypeOf(attr));

這段代碼中,獲取了navigator中所有屬性名,而非屬性值,也就是說,即便你把webdriver的值改為false了,這個(gè)屬性仍然是在的。但是,在正常使用的chrome中,navigator是沒有這一屬性的,一旦檢測到webdriver這個(gè)屬性名,大概率可以判定為puppeteer。

破盾

破盾就不能針對puppeteer下手了,反正我是沒有辦法在檢測前delete掉navigator.webdriver這個(gè)屬性。
在發(fā)現(xiàn)這段盾的代碼后,給它后面注入一點(diǎn):

result = result.filter(function(item) {
    return item != "webdriver"
});

嗯,從根源入手解決了問題。

2.2 UserAgent

介紹

UA在反爬界的地位,相當(dāng)于hello world在編程界的地位,入門第一課,就會(huì)教UA的檢測。所以再垃圾的爬蟲,也知道給自己偽造一個(gè)UA,puppeteer的UA也是如此。只要對puppeteer反爬稍有研究,就會(huì)知道,默認(rèn)情況下,puppeteer的UA有HeadlessChrome這一關(guān)鍵詞,非常容易檢測。

這個(gè)矛簡單的我都不想寫,一行代碼搞定。

await page.setUserAgent("隨便寫個(gè)UA");

從上面可以看到,修改UA一行代碼就搞定了,而且沒什么門檻,可以說人人都知道這個(gè)事情,所以這里要檢測,肯定不能檢測HeadlessChrome關(guān)鍵詞這么簡單。

我在windows和linux下的puppeteer分別獲取了一些屬性:

  • windows中的navigator.userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3477.0 Safari/537.36
  • windows中的navigator.platform: “Win32”
  • linux中的navigator.userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3477.0 Safari/537.36
  • linux中的navigator.platform: “Linux x86_64”
    由于手動(dòng)設(shè)置了UA,所以在windows和linux下的UA是一致的,都是我設(shè)的值,但在platform這一屬性漏出了馬腳。UA中明明寫著windows,platform卻說你是linux,這豈不是自相矛盾?一經(jīng)發(fā)現(xiàn),肯定是要把這個(gè)請求打入冷宮的。

我相信大多數(shù)程序員都會(huì)選擇把爬蟲部署在linux服務(wù)器上,windows服務(wù)器真是誰用誰知道。。。這里就不吐槽它了。而且根據(jù)我實(shí)踐經(jīng)驗(yàn),puppeteer在linux上運(yùn)行的遠(yuǎn)比在windows上穩(wěn)定。根據(jù)上述兩點(diǎn),得出結(jié)論,對比檢查UA和platform,能取得一些效果。

破盾

舉一隅不以三隅反,則不復(fù)也。

2.3 plugins

介紹

對plugins比較官方的描述是:返回一個(gè) PluginArray 類型的對象, 包含了當(dāng)前所使用的瀏覽器安裝的所有插件。獲取方法是navigator.plugins。這個(gè)屬性在有頭的chrome中,會(huì)返回一堆叫做PluginArray的東西,但在無頭瀏覽器中,它是空的,這個(gè)屬性的沒有值的。PluginArray是有l(wèi)ength屬性的,所以可以獲取navigator.plugins.length的值,如果是0,則基本上是無頭的。

沒有條件,就創(chuàng)造條件,沒有值,就賦值:

await page.evaluateOnNewDocument(() => {
  Object.defineProperty(navigator, 'plugins', {
    get: () => [1, 2, 3],
  });
});

不是不讓空么,那就丟個(gè)數(shù)組進(jìn)去咯。

矛中所用的賦值[1, 2, 3]的方法是網(wǎng)上比較常見的賦值plugins的方法,不是因?yàn)榇蠹彝祽?,?shí)在是plugins忒難構(gòu)造了。在介紹中說了,這是一個(gè)PluginArray對象,并非Array對象??梢源蜷_瀏覽器看一下,這個(gè)屬性的值是不是挺復(fù)雜的。復(fù)雜那對于盾來說就是好事,不是難構(gòu)造么,那就不檢查length了,仔細(xì)看看plugins里頭是些啥:

for (var result = [], n = 0; n < navigator.plugins.length; n++) !
function(n) {
    var t = navigator.plugins[n],
    r = [t.name, t.description, t.filename, t.version].join("::"),
    o = [];
    Object.keys(t).forEach(function(e) {
        o.push([t[e].type, t[e].suffixes, t[e].description].join("~"))
    }),
    o = o.join(","),
    result.push(r + "__" + o)
} (n);

通過上面的代碼獲取plugins中的具體內(nèi)容,得到了如下result:

result = [
    "Chrome PDF Plugin::Portable Document Format::internal-pdf-viewer::"
    +"__application/x-google-chrome-pdf~pdf~Portable Document Format", 
    "Chrome PDF Viewer::::mhjfbmdgcfjbbpaeojofohoefgiehjai::__application/pdf~pdf~", 
    "Native Client::::internal-nacl-plugin::__application/x-nacl~~Native Client Executable,"
    +"application/x-pnacl~~Portable Native Client Executable"
]

如果是一個(gè)類似于[1, 2, 3]的數(shù)組的話,只能得到這樣的結(jié)果:

result = [
    "::::::__", 
    "::::::__", 
    "::::::__"
]

就只剩下一些分隔符了,什么信息也沒有。

破盾

喜歡檢測的細(xì)是吧,那就大家死磕到底:

await page.evaluateOnNewDocument(() => {
  Object.defineProperty(navigator, 'plugins', {
    get: () => [
        {
            0: {type: "application/x-google-chrome-pdf", suffixes: "pdf", description: "Portable Document Format", enabledPlugin: Plugin},
            description: "Portable Document Format",
            filename: "internal-pdf-viewer",
            length: 1,
            name: "Chrome PDF Plugin"
        },
        {
            0: {type: "application/pdf", suffixes: "pdf", description: "", enabledPlugin: Plugin},
            description: "",
            filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai",
            length: 1,
            name: "Chrome PDF Viewer"
        },
        {
            0: {type: "application/x-nacl", suffixes: "", description: "Native Client Executable", enabledPlugin: Plugin},
            1: {type: "application/x-pnacl", suffixes: "", description: "Portable Native Client Executable", enabledPlugin: Plugin},
            description: "",
            filename: "internal-nacl-plugin",
            length: 2,
            name: "Native Client"
        }
    ],
  });
});

乍一看很像了,但是跑盾的代碼result里會(huì)出現(xiàn)一串小尾巴。這就涉及到PluginArray非常惡心的一個(gè)特性了,暫時(shí)按下不提。

2.4 window.chrome

從這條開始,就是寫不太重要、特征沒那么明顯的屬性了。window.chrome,在控制臺輸入chrome,敲個(gè)回車,就取到值了,有頭有值,無頭無值,這樣檢測就行了:

function hasChrome() {
    return !! window.chrome
}

繞過檢測也簡單,就這樣大差不差了,window.chrome的詳細(xì)信息也很難像plugins那樣拿來具體對比。

await page.evaluateOnNewDocument(() => {
  window.navigator.chrome = {
    runtime: {},
    loadTimes: function() {},
    csi: function() {},
    app: {}
  };
});

2.5 一些廢棄參數(shù)

puppeteer是由谷歌的Chrome團(tuán)隊(duì)在維護(hù),戰(zhàn)斗力強(qiáng)悍,版本更新也很快。隨著版本的更新,以前一些可以用來檢測puppeteer的特征現(xiàn)在已經(jīng)不存在了。但是也寫下來介紹一下,或許有助于開拓思路。

Language

這一屬性取自于navigator.language,在早期的puppeteer版本中,無頭模式下是沒有這個(gè)屬性的,所以可以通過這種方法來檢測:

function hasChrome() {
    return !navigator.language || !navigator.languages
}

這種檢測的繞過方法經(jīng)過前面的長篇累牘,相信已經(jīng)不需要贅述了。

Viewport

同樣是早期版本中,puppeteer打開的無頭瀏覽器會(huì)有一個(gè)默認(rèn)的窗口大小,800600。
大家不妨看一下800
600的窗口有多小,正常用戶是不可能用這個(gè)窗口尺寸瀏覽網(wǎng)頁的,但也不能武斷的攔截這樣的請求,林子大了什么奇葩用戶沒有。所以這一參數(shù)可以進(jìn)行收集,如果發(fā)現(xiàn)大量出現(xiàn)這個(gè)窗口尺寸的請求,就可以考慮采取反爬措施了。

Notification.permission

之前看過文章提到檢測這個(gè)參數(shù)的,我不是很認(rèn)同,所以就提一下有這么回事兒,有興趣的自己去看吧。

3. 盾上加盾

看到這里,可能會(huì)覺得似乎任何一種檢測都有辦法繞過。這么說也沒錯(cuò),因?yàn)閖avascript就是有這么個(gè)毛病,你放在瀏覽器前端執(zhí)行,用戶就一定能看到你的代碼,毫無隱私可言。就如2.1的破盾部分,直接通過js注入修改了瀏覽器的特征屬性,那么檢測方法再怎么精妙,也無法逃過矛的攻擊。

所以在瀏覽器上,無論是加密、反爬,還是puppeteer檢測,最重要的還是對js代碼的混淆,就像著名反爬服務(wù)提供商某數(shù)做的那樣,混淆到你沒法讀、沒法調(diào)試、沒法手動(dòng)運(yùn)行,那樣才能把盾鑄造的更加堅(jiān)固。當(dāng)然,也不可能牢不可破,破解某數(shù)的人也不在少數(shù)了。

4. 更高級的檢測方法

瀏覽器指紋

通過收集詳細(xì)的參數(shù),讓你可以在后臺把用戶的瀏覽器扒個(gè)干凈,非常值得探索的一個(gè)領(lǐng)域,接下來會(huì)找時(shí)間寫篇文章專門介紹瀏覽器指紋。

反爬的攻防是沒有止境的,所以希望可以和大家共同學(xué)習(xí)討論,有興趣的朋友可以發(fā)郵件交流,郵箱可以點(diǎn)我頭像看。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容