閱讀本文大概需要 20 分鐘。
”這一篇是 JavaScript 逆向爬取的第二篇。那么接下來我為大家縷順一下學(xué)習(xí)順序。
系列文章的第一篇啟于總結(jié)一些網(wǎng)站加密和混淆技術(shù),這篇文章我們介紹了網(wǎng)頁防護(hù)技術(shù),包括接口加密和 JavaScript 壓縮、加密和混淆。能夠?yàn)閷W(xué)習(xí) JavaScript 逆向爬取奠定堅(jiān)實(shí)的基礎(chǔ)。
接下來就是 JavaScript 逆向爬取的第一篇JavaScript 逆向爬取實(shí)戰(zhàn)。分為上下章發(fā)出是因?yàn)榇_實(shí)寫得太長了(手動(dòng)狗頭)。
那么話不多說,我們開始今天的學(xué)習(xí)吧~
好,那么我們觀察下上一步的輸出結(jié)果,我們把結(jié)果格式化一下,看看部分結(jié)果:
{
'count': 100,
'results': [
{
'id': 1,
'name': '霸王別姬',
'alias': 'Farewell My Concubine',
'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c',
'categories': [
'劇情',
'愛情'
],
'published_at': '1993-07-26',
'minute': 171,
'score': 9.5,
'regions': [
'中國大陸',
'中國香港'
]
},
...
]
}
這里我們看到有個(gè) id 是 1,另外還有一些其他的字段如電影名稱、封面、類別等等,那么這里面一定有什么信息是用來唯一區(qū)分某個(gè)電影的。
但是呢,這里我們點(diǎn)擊下第一個(gè)部電影的信息,可以看到它跳轉(zhuǎn)到了 URL 為 https://dynamic6.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx 的頁面,可以看到這里 URL 里面有一個(gè)加密 id 為 ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx,那么這個(gè)和電影的這些信息有什么關(guān)系呢?
這里,如果你仔細(xì)觀察規(guī)律其實(shí)是可以比較容易地找出規(guī)律來的,但是這總歸是觀察出來的,如果遇到一些觀察不出規(guī)律的那就歇菜了。所以還是需要靠技巧去找到它真正加密的位置。
這時(shí)候我們該怎么辦呢?
分析一下,這個(gè)加密 id 到底是什么生成的。
我們在點(diǎn)擊詳情頁的時(shí)候就看到它訪問的 URL 里面就帶上了 ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx 這個(gè)加密 id 了,而且不同的詳情頁的加密 id 是不同的,這說明這個(gè)加密 id 的構(gòu)造依賴于列表頁 Ajax 的返回結(jié)果,所以可以確定這個(gè)加密 id 的生成是發(fā)生在 Ajax 請求完成后或者點(diǎn)擊詳情頁的一瞬間。
那為了進(jìn)一步確定是發(fā)生在何時(shí),我們看看頁面源碼,可以看到在沒有點(diǎn)擊之前,詳情頁鏈接的 href 里面就已經(jīng)帶有加密 id 了,如圖所示。
由此我們可以確定,這個(gè)加密 id 是在 Ajax 請求完成之后生成的,而且肯定也是由 JavaScript 生成的了。
那怎么再去找 Ajax 完成之后的事件呢?是否應(yīng)該去找 Ajax 完成之后的事件呢?
可以是可以,可以試試,我們可以在 Sources 面板的右側(cè),有一個(gè) Event Listener Breakpoints,這里有一個(gè) XHR 的監(jiān)聽,包括發(fā)起時(shí)、成功后、發(fā)生錯(cuò)誤時(shí)的一些監(jiān)聽,這里我們勾選上 readystatechange 事件,代表 Ajax 得到響應(yīng)時(shí)的事件,其他的斷點(diǎn)可以都刪除了,然后刷新下頁面看下,如圖所示。
這里我們可以看到就停在了 Ajax 得到響應(yīng)時(shí)的位置了。
那這里我們怎么再去找這個(gè) id 怎么加密的呢?這里可以選擇一點(diǎn)點(diǎn)斷點(diǎn)找下去,但估計(jì)找的過程會崩潰掉,因?yàn)檫@里可能會會逐漸調(diào)用到頁面 UI 渲染的一些底層實(shí)現(xiàn),甚至可能找著找著都不知道找到哪里去了。
那怎么辦呢?這里我們再介紹一種定位的方法,那就是 Hook。
Hook 技術(shù)中文又叫做鉤子技術(shù),它就是在程序運(yùn)行的過程中,對其中的某個(gè)方法進(jìn)行重寫,在原先的方法前后加入我們自定義的代碼。相當(dāng)于在系統(tǒng)沒有調(diào)用該函數(shù)之前,鉤子程序就先捕獲該消息,鉤子函數(shù)先得到控制權(quán),這時(shí)鉤子函數(shù)既可以加工處理(改變)該函數(shù)的執(zhí)行行為。
通俗點(diǎn)來說呢,比如我要 Hook 一個(gè)方法 a,我可以先臨時(shí)用一個(gè)變量存一下,把它存成 _a,然后呢,我再重新聲明一個(gè)方法 a,里面加點(diǎn)自己的邏輯,比如加點(diǎn)調(diào)試語句、輸出語句等等,然后再調(diào)用下 _a,這里調(diào)用的 _a 就是之前的 a。那這樣就相當(dāng)于新的方法 a 里面混入了我們自己定義的邏輯,同時(shí)又把原來的方法 a 也執(zhí)行了一遍。所以這不會影響原有的執(zhí)行邏輯和運(yùn)行效果,但是我們通過這種改寫就順利在原來的 a 方法前后加上了我們自己的邏輯,這就是 Hook。
那么,我們這里怎么用 Hook 的方式來找到加密 id 的加密入口點(diǎn)呢?
想一下,這個(gè)加密 id 是一個(gè) Base64 編碼的字符串,那么生成過程中想必就調(diào)用了 JavaScript 的 Base64 編碼的方法,這個(gè)方法名叫做 btoa,這個(gè) btoa 方法可以將參數(shù)轉(zhuǎn)化成 Base64 編碼。當(dāng)然 Base64 也有其他的實(shí)現(xiàn)方式,比如利用 crypto-js 這個(gè)庫實(shí)現(xiàn)的,這個(gè)可能底層調(diào)用的就不是 btoa 方法了。
所以,我們其實(shí)現(xiàn)在并不確定是不是調(diào)用的 btoa 方法實(shí)現(xiàn)的 Base64 編碼,那就先試試吧。
要實(shí)現(xiàn) Hook,其實(shí)關(guān)鍵在于將原來的方法改寫,這里我們其實(shí)就是 Hook btoa 這個(gè)方法了,btoa 這個(gè)方法屬于 window 對象,我們將 window 對象的 btoa 方法進(jìn)行改寫即可。
改寫的邏輯如下:
(function () {
'use strict'
function hook(object, attr) {
var func = object[attr]
object[attr] = function () {
console.log('hooked', object, attr, arguments)
var ret = func.apply(object, arguments)
debugger
console.log('result', ret)
return ret
}
}
hook(window, 'btoa')
})()
我們定義了一個(gè) hook 方法,傳入 object 和 attr 參數(shù),意思就是 Hook object 對象的 attr 參數(shù)。例如我們?nèi)绻?Hook 一個(gè) alert 方法,那就把 object 設(shè)置為 window,把 attr 設(shè)置為 alert 字符串。這里我們想要 Hook Base64 的編碼方法,那么這里我們就只需要 Hook window 對象的 btoa 方法就好了。
我們來看下,首先一句 var func = object[attr]
,相當(dāng)于我們先把它賦值為一個(gè)變量,我們調(diào)用 func 方法就可以實(shí)現(xiàn)和原來相同的功能。接著,我們再直接改寫這個(gè)方法的定義,直接改寫 object[attr]
,將其改寫成一個(gè)新的方法,在新的方法中,通過 func.apply
方法又重新調(diào)用了原來的方法。這樣我們就可以保證,前后方法的執(zhí)行效果是不受什么影響的,之前這個(gè)方法該干啥就還是干啥的。但是和之前不同的是,我們自定義方法之后,現(xiàn)在可以在 func
方法執(zhí)行的前后,再加入自己的代碼,如 console.log
將信息輸出到控制臺,如 debugger
進(jìn)入斷點(diǎn)等等。這個(gè)過程中,我們先臨時(shí)保存下來了 func
方法,然后定義一個(gè)新的方法,接管程序控制權(quán),在其中自定義我們想要的實(shí)現(xiàn),同時(shí)在新的方法里面再重新調(diào)回 func
方法,保證前后結(jié)果是不受影響的。所以,我們達(dá)到了在不影響原有方法效果的前提下,可以實(shí)現(xiàn)在方法的前后實(shí)現(xiàn)自定義的功能,就是 Hook 的完整實(shí)現(xiàn)過程。
最后,我們調(diào)用 hook 方法,傳入 window 對象和 btoa 字符串即可。
那這樣,怎么去注入這個(gè)代碼呢呢?這里我們介紹三種注入方法。
·直接控制臺注入·復(fù)寫 JavaScript 代碼·Tampermonkey 注入
對于我們這個(gè)場景,控制臺注入其實(shí)就夠了,我們先來介紹這個(gè)方法。
這個(gè)其實(shí)很簡單了,就是直接在控制臺輸入這行代碼運(yùn)行,如圖所示。
執(zhí)行完這段代碼之后,相當(dāng)于我們就已經(jīng)把 window 的 btoa 方法改寫了,可以控制臺調(diào)用下 btoa 方法試試,如:
btoa('germey')
回車之后就可以看到它進(jìn)入了我們自定義的 debugger 的位置停下了,如圖所示。
我們把斷點(diǎn)向下執(zhí)行,點(diǎn)擊 Resume 按鈕,然后看看控制臺的輸出,可以看到也輸出了一些對應(yīng)的結(jié)果,如被 Hook 的對象,Hook 的屬性,調(diào)用的參數(shù),調(diào)用后的結(jié)果等,如圖所示。
那這里我們就可以看到,我們通過 Hook 的方式改寫了 btoa 方法,使其每次在調(diào)用的時(shí)候都能停到一個(gè)斷點(diǎn),同時(shí)還能輸出對應(yīng)的結(jié)果。
好,那接下來怎么用 Hook 找到對應(yīng)的加密 id 的加密入口呢?
由于此時(shí)我們是在控制臺直接輸入的 Hook 代碼,所以頁面一旦刷新就無效了,但由于我們這個(gè)網(wǎng)站是 SPA 式的頁面,所以在點(diǎn)擊詳情頁的時(shí)候頁面是不會整個(gè)刷新的,所以這段代碼依然還會生效。但是如果不是 SPA 式的頁面,即每次訪問都需要刷新頁面的網(wǎng)站,這種注入方式就不生效了。
好,那我們的目的是為了 Hook 列表頁 Ajax 加載完成后的的加密 id 的 Base64 編碼的過程,那怎么在不刷新頁面的情況下再次復(fù)現(xiàn)這個(gè)操作呢?很簡單,點(diǎn)下一頁就好了。
這時(shí)候我們可以點(diǎn)擊第 2 頁的按鈕,這時(shí)候可以看到它確實(shí)再次停到了 Hook 方法的 debugger 處,由于列表頁的 Ajax 和加密 id 都會帶有 Base64 編碼的操作,因此它每一個(gè)都能 Hook 到,通過觀察對應(yīng)的 Arguments 或當(dāng)前網(wǎng)站的行為或者觀察棧信息,我們就能大體知道現(xiàn)在走到了哪個(gè)位置了,從而進(jìn)一步通過棧的調(diào)用信息找到調(diào)用 Base64 編碼的位置。
我們可以根據(jù)調(diào)用棧的信息來觀察這些變量在哪一層發(fā)生變化的,比如最后的這一層,我們可以很明顯看到它執(zhí)行了 Base64 編碼,編碼前的結(jié)果是:
ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb1
編碼后的結(jié)果是:
ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
如圖所示。
這里很明顯。
那么核心問題就來了,編碼前的結(jié)果 ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb1
又是怎么來的呢?我們展開棧的調(diào)用信息,一層層看看這個(gè)字符串的變化情況。如果不變那就看下一層,如果變了那就停下來仔細(xì)看看。
最后我們可以在第五層找到它的變化過程,如圖所示。
那這里我們就一目了然了,看到了 _0x135c4d
是一個(gè)寫死的字符串 ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb
,然后和傳入的這個(gè) _0x565f18
拼接起來就形成了最后的字符串。
那這個(gè) _0x565f18
又是怎么來的呢?再往下追一層,那就一目了然了,其實(shí)就是 Ajax 返回結(jié)果的單個(gè)電影信息的 id。
所以,這個(gè)加密邏輯的就清楚了,其實(shí)非常非常簡單,就是 ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb1
加上電影 id,然后 Base64 編碼即可。
到此,我們就成功用 Hook 的方式找到加密的 id 生成邏輯了。
但是想想有什么不太科學(xué)的地方嗎?剛才其實(shí)也說了,我們的 Hook 代碼是在控制臺手動(dòng)輸入的,一旦刷新頁面就不生效了,這的確是個(gè)問題。而且它必須是在頁面加載完了才注入的,所以它并不能在一開始就生效。
下面我們再介紹幾種 Hook 注入方式
我們可以借助于 Chrome 瀏覽器的 Overrides 功能實(shí)現(xiàn)某些 JavaScript 文件的重寫和和保存,它會在本地生成一個(gè) JavaScript 文件副本,以后每次刷新的時(shí)候會使用副本的內(nèi)容。
這里我們需要切換到 Sources 選項(xiàng)卡的 Overrides 選項(xiàng)卡,然后選擇一個(gè)文件夾,比如這里我自定了一個(gè)文件夾名字叫做 modify,如圖所示。
然后我們隨便選一個(gè) JavaScript 腳本,后面貼上這段注入腳本,如圖所示。
保存文件。
此時(shí)可能提示頁面崩潰,但是不用擔(dān)心,重新刷新頁面就好了,這時(shí)候我們就發(fā)現(xiàn)現(xiàn)在瀏覽器加載的 JavaScript 文件就是我們修改過后的了,文件的下方會有一個(gè)標(biāo)識符,如圖所示。
同時(shí)我們還注意到這時(shí)候它就直接進(jìn)入了斷點(diǎn)模式,成功 Hook 到了 btoa 這個(gè)方法了。
其實(shí)這個(gè) Overrides 這個(gè)功能非常有用,有了它我們可以持久化保存我們?nèi)我庑薷牡?JavaScript 代碼,所以我們想在哪里改都可以了,甚至可以直接修改 JavaScript 的原始執(zhí)行邏輯也都是可以的。
如果我們不想用 Overrides 的方式改寫 JavaScript 的方式注入的話,還可以借助于瀏覽器插件來實(shí)現(xiàn)注入,這里推薦的瀏覽器插件叫做 Tampermonkey,中文叫做油猴。它是一款瀏覽器插件,支持 Chrome。利用它我們可以在瀏覽器加載頁面時(shí)自動(dòng)執(zhí)行某些 JavaScript 腳本。由于執(zhí)行的是 JavaScript,所以我們幾乎可以在網(wǎng)頁中完成任何我們想實(shí)現(xiàn)的效果,如自動(dòng)爬蟲、自動(dòng)修改頁面、自動(dòng)響應(yīng)事件等等。
首先我們需要安裝 Tampermonkey,這里我們使用的瀏覽器是 Chrome。直接在 Chrome 應(yīng)用商店或者在 Tampermonkey 的官網(wǎng) https://www.tampermonkey.net/ 下載安裝即可。
安裝完成之后,在 Chrome 瀏覽器的右上角會出現(xiàn) Tampermonkey 的圖標(biāo),這就代表安裝成功了。
我們也可以自己編寫腳本來實(shí)現(xiàn)想要的功能。編寫腳本難不難呢?其實(shí)就是寫 JavaScript 代碼,只要懂一些 JavaScript 的語法就好了。另外除了懂 JavaScript 語法,我們還需要遵循腳本的一些寫作規(guī)范,這其中就包括一些參數(shù)的設(shè)置。
下面我們就簡單實(shí)現(xiàn)一個(gè)小的腳本,實(shí)現(xiàn)某個(gè)功能。
首先我們可以點(diǎn)擊 Tampermonkey 插件圖標(biāo),點(diǎn)擊「管理面板」按鈕,打開腳本管理頁面。
界面類似顯示如下圖所示。
在這里顯示了我們已經(jīng)有的一些 Tampermonkey 腳本,包括我們自行創(chuàng)建的,也包括從第三方網(wǎng)站下載安裝的。
另外這里也提供了編輯、調(diào)試、刪除等管理功能,我們可以方便地對腳本進(jìn)行管理。
接下來我們來創(chuàng)建一個(gè)新的腳本來試試,點(diǎn)擊左側(cè)的「+」號,會顯示如圖所示的頁面。
初始化的代碼如下:
// ==UserScript==
// @name New Userscript
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://www.tampermonkey.net/documentation.php?ext=dhdg
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Your code here...
})();
這里最上面是一些注釋,但這些注釋是非常有用的,這部分內(nèi)容叫做 UserScript Header
,我們可以在里面配置一些腳本的信息,如名稱、版本、描述、生效站點(diǎn)等等。
在 UserScript Header
下方是 JavaScript 函數(shù)和調(diào)用的代碼,其中 'use strict'
標(biāo)明代碼使用 JavaScript 的嚴(yán)格模式,在嚴(yán)格模式下可以消除 Javascript 語法的一些不合理、不嚴(yán)謹(jǐn)之處,減少一些怪異行為,如不能直接使用未聲明的變量,這樣可以保證代碼的運(yùn)行安全,同時(shí)提高編譯器的效率,提高運(yùn)行速度。在下方 // Your code here...
這里我們就可以編寫自己的代碼了。
我們可以將腳本改寫為如下內(nèi)容:
// ==UserScript==
// @name HookBase64
// @namespace https://scrape.cuiqingcai.com/
// @version 0.1
// @description Hook Base64 encode function
// @author Germey
// @match https://dynamic6.scrape.cuiqingcai.com/
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict'
function hook(object, attr) {
var func = object[attr]
console.log('func', func)
object[attr] = function () {
console.log('hooked', object, attr)
var ret = func.apply(object, arguments)
debugger
return ret
}
}
hook(window, 'btoa')
})()
這時(shí)候啟動(dòng)腳本,重新刷新頁面,可以發(fā)現(xiàn)也可以成功 Hook 住 btoa 方法,如圖所示。
然后我們再順著找調(diào)用邏輯就好啦。
以上,我們就成功通過 Hook 的方式找到加密 id 的實(shí)現(xiàn)了。
現(xiàn)在我們已經(jīng)找到詳情頁的加密 id 了,但是還差一步,其 Ajax 請求也有一個(gè) token,如圖所示。
其實(shí)這個(gè) token 和詳情頁的 token 構(gòu)造邏輯是一樣的了。
這里就不再展開說了,可以運(yùn)用上文的幾種找入口的方法來找到對應(yīng)的加密邏輯。
現(xiàn)在我們已經(jīng)成功把詳情頁的加密 id 和 Ajax 請求的 token 找出來了,下一步就能使用 Python 完成爬取了,這里我就只實(shí)現(xiàn)第一頁的爬取了,代碼示例如下:
import hashlib
import time
import base64
from typing import List, Any
import requests
INDEX_URL = 'https://dynamic6.scrape.cuiqingcai.com/api/movie?limit={limit}&offset={offset}&token={token}'
DETAIL_URL = 'https://dynamic6.scrape.cuiqingcai.com/api/movie/{id}?token={token}'
LIMIT = 10
OFFSET = 0
SECRET = 'ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb'
def get_token(args: List[Any]):
timestamp = str(int(time.time()))
args.append(timestamp)
sign = hashlib.sha1(','.join(args).encode('utf-8')).hexdigest()
return base64.b64encode(','.join([sign, timestamp]).encode('utf-8')).decode('utf-8')
args = ['/api/movie']
token = get_token(args=args)
index_url = INDEX_URL.format(limit=LIMIT, offset=OFFSET, token=token)
response = requests.get(index_url)
print('response', response.json())
result = response.json()
for item in result['results']:
id = item['id']
encrypt_id = base64.b64encode((SECRET + str(id)).encode('utf-8')).decode('utf-8')
args = [f'/api/movie/{encrypt_id}']
token = get_token(args=args)
detail_url = DETAIL_URL.format(id=encrypt_id, token=token)
response = requests.get(detail_url)
print('response', response.json())
這里模擬了詳情頁的加密 id 和 token 的構(gòu)造過程,然后請求了詳情頁的 Ajax 接口,這樣我們就可以爬取到詳情頁的內(nèi)容了。
本節(jié)內(nèi)容很多,一步步介紹了整個(gè)網(wǎng)站的 JavaScript 逆向過程,其中的技巧有:
·全局搜索查找入口·代碼格式化·XHR 斷點(diǎn)·變量監(jiān)聽·斷點(diǎn)設(shè)置和跳過·棧查看·Hook 原理·Hook 注入·Overrides 功能·Tampermonkey 插件·Python 模擬實(shí)現(xiàn)
掌握了這些技巧我們就能更加得心應(yīng)手地實(shí)現(xiàn) JavaScript 逆向分析。