本文僅用作學(xué)習(xí)交流,不得用于任何商業(yè)用途;
4 月的北京,這天氣像是 80 歲的老奶奶 ??,捂得慌。
實(shí)驗(yàn)室/自習(xí)室的角落,清脆的打字聲,夾雜著幾聲嘆氣,一個(gè)套著格子襯衫,頭發(fā)不多的大叔直視著屏幕。
眼神堅(jiān)定的他,快速的敲下了一行命令。‘這次一定要成了’。
而屏幕的那頭,終于有了些反應(yīng),一個(gè)個(gè)字符鮮活的蹦了出來,像極了[黑鏡:潘達(dá)斯奈基]
中男主被操控的感覺。
可他原本充滿期待的臉突然開始扭曲,放佛在屏幕上看到了什么恐怖的東西
只見屏幕上欣然出現(xiàn)這么一句話
image
上次發(fā)完你已經(jīng)是一個(gè)成熟的爬蟲了,應(yīng)該學(xué)會(huì)自己去對(duì)抗反爬碼農(nóng)了 ??-『爬蟲進(jìn)階指南』就不斷有小伙伴向我我請(qǐng)教如何解決一些 js 逆向工程的問題
其實(shí)這個(gè)問題說小了涉及 js、py 基礎(chǔ)語(yǔ)法,說大了涉及網(wǎng)絡(luò)攻防,涉及對(duì)方公司架構(gòu),甚至涉密。
而且做這種逆向工程還特別費(fèi)時(shí)間,(其實(shí)反爬工程師做加密方案也特別累, 所以一般做這種混淆加密
的都是該公司的核心業(yè)務(wù)),所以這方便的資料其實(shí)特別少。
還是再想提醒下大家,爬蟲是一個(gè)獲取信息的好工具,但還請(qǐng)相互體諒,本文也僅用作學(xué)習(xí)使用。
本文分析兩個(gè)案例,一個(gè)是去年被爬怕了的馬蜂窩
,一個(gè)是攜程
, 像這種公司體量很大,業(yè)務(wù)繁多,也不可能全部分析,具體來說:
亂入總結(jié):
- 攜程 996
- 馬蜂窩看不出來 是不是 996
打個(gè)小廣告, 求 star自帶高可用 Proxy 庫(kù)的 spider 代碼 hhh
馬蜂窩
我們要爬的是所有景點(diǎn)信息,這個(gè)信息是請(qǐng)求http://www.mafengwo.cn/ajax/router.php
獲取的
其中需要一坨參數(shù),除了一眼能看出語(yǔ)義的參數(shù)之外,也就_sn
, sAct
(可惜它不變)可能是被加密過的。
google 了一下沒發(fā)現(xiàn)有提供解決方案的(快速解決問題才是王道),大概推測(cè)了一下馬蜂窩在去年年底做了這次加密方案。
第一反應(yīng),不是先做逆向,而是猜測(cè)可能是通過前置請(qǐng)求從后端拿到的,然后翻了一下發(fā)現(xiàn)并沒有,對(duì)比了一下前后幾個(gè)請(qǐng)求,發(fā)現(xiàn)這個(gè)參數(shù)出現(xiàn)次數(shù)還挺多的,而且值還不一樣,行了 js 加密石錘了
然后去篩哪些 js 對(duì)這個(gè)參數(shù)的生成有所影響(用 chrome 的開發(fā)者工具暴力 block)
餓 他們這 js 有點(diǎn)少,閉著眼睛 都能猜出來是哪個(gè),jQuery 一般會(huì)放自己構(gòu)造 cookie 構(gòu)造 Header 頭的邏輯(當(dāng)然這里也有可能同時(shí)使用 Cookie & _sn
一起做效驗(yàn))
這個(gè)時(shí)候我們做一個(gè)實(shí)驗(yàn),來看一下 Header 里面的內(nèi)容有沒有作用在 encoder 和 decoder 中。
打開 chrome 的無(wú)痕模式(有些時(shí)候需要 clear 一下 History, 這跟 ServiceWork 機(jī)制有關(guān),有興趣的同學(xué)可以查下相關(guān)資料), 先打開開發(fā)者模式,然后鍵入我們爬取的 URL。
(現(xiàn)在我們模擬的是首次進(jìn)入該網(wǎng)站的用戶,通常為了做到首次加載網(wǎng)頁(yè)在幾百 ms 內(nèi),都會(huì)對(duì)一些不必要的功能做 delay 加載操作,這個(gè)時(shí)候的條件能獲得到信息,則之后也能)-這也是一個(gè)小技巧吧 ??
然后我們看一下,沒有 Cookie(這也可以理解,JQuery.js 和混淆所需要的/js/hotel/sign/index.js
兩個(gè)文件是異步獲取的,為了保證用戶的用戶體驗(yàn),前端在首次加載做了妥協(xié)。
然后我們工作的重點(diǎn),就是研究http://js.mafengwo.net/js/hotel/sign/index.js?1552035728
這個(gè) js 做了哪些混淆
首先,一看這個(gè)安全做的就不是特別好,這個(gè) js 是通過靜態(tài)的 url 來獲取的,獲取的過程沒有任何加密,也就是說我解密出來一次,只要你不發(fā)版,我基本上都能用(所以大家看看就行了,別用做商業(yè)用途)
(然后,從時(shí)間戳上看,這個(gè)版本是2019-03-08 17:02:08
發(fā)的,所以起碼這一個(gè)多月他們沒有做什么改動(dòng),3 月 8 日星期五,hhh 看不出來是不是 996
我們來看一下代碼,首先,這種混淆也做的比較套路,就是最外層堆一層 Unicode 來讓你不能直接在 Preview 中直接看出語(yǔ)義信息,然后丟一個(gè)數(shù)組來存使用的字符變量
所以我們一開始做的事情是,做 js 代碼解混淆,起碼先變成能看的代碼(PS: 看 Vscode 中右邊 ?? 預(yù)覽中,那一大坨很規(guī)律的代碼塊,這一定是做的是類似 DES 的多層加密計(jì)算
- 解 Unicode
\\x75
->\x75
->codecs.unicode_escape_decode(origin_js)[0]
- 從第五行的數(shù)組
__Ox2133f
中,替換常量字符 - 然后這種無(wú)意義的變量名稱看的難受,而且太長(zhǎng),就做一個(gè)替換
- 然后用 Vscode,
Toggle Format
插件做一下自動(dòng)格式化
def decode_js_test():
''' decode js for test '''
with open(decoder_js_path, 'r') as f:
decoder_js = [codecs.unicode_escape_decode(ii.strip())[0] for ii in f.readlines()]
__Ox2133f = [ii.strip() for ii in decoder_js[4][17:-2].replace('\"', '\'').split(',')]
decoder_str = '|||'.join(decoder_js)
params = re.findall(r'(\_0x\w{6,8}?)=|,|\)', decoder_str)
params = sorted(list(set([ii for ii in params if len(ii) > 6])), key=lambda ii: len(ii), reverse=True)
for ii, jj in enumerate(__Ox2133f):
decoder_str = decoder_str.replace('__Ox2133f[{}]'.format(ii), jj)
for ii, jj in enumerate(params):
decoder_str = decoder_str.replace(jj, 'a{}'.format(ii))
decoder_js = decoder_str.split('|||')
with open(origin_js_path, 'w') as f:
f.write('\n'.join(decoder_js))
return decoder_js
經(jīng)過初步的解耦之后,我們得到了上面的代碼。可以看到在第 389 行出現(xiàn)了我們需要的 _sn
變量 ??
a40["ajaxPrefilter"](function(a410, a411) {
var a23 = a40["extend"](true, {}, a411["data"] || {}); // 拿data
if (a23["_sn"]) {
delete a23["_sn"]; // 刪去已有的`_sn`
}
a23["_ts"] = new Date()["getTime"](); // `_ts` = 13位時(shí)間戳
var a13 = a425(a40["extend"](true, {}, a23)); // 加密了
if ("data" in a410) {
a410["data"] += "&_ts=" + a23["_ts"] + "&_sn=" + a13; // 拼接字符串
} else {
a410["data"] = "_ts=" + a23["_ts"] + "&_sn=" + a13;
}
});
上面這坨代碼實(shí)際上就是實(shí)現(xiàn)_sn
的入口函數(shù),邏輯比較簡(jiǎn)單,就是把拿到的拿到的 data 加上當(dāng)前時(shí)間戳,丟給 a425 這個(gè)函數(shù)
a425
就是上面那張圖第 348 行定義的,a425 也就 40 多行代碼,邏輯也很簡(jiǎn)單
他是調(diào)用了a36
這個(gè)函數(shù)的hash
方法,傳進(jìn)去了(一個(gè)把 Object 做了 JSON 序列化的字符串,在加上了一個(gè)字符常量),最后對(duì)結(jié)果截取了 2,12 位
然后a18
就是上面那行調(diào)用了一下a427
的返回值,大概掃一眼,a427
做了一件按 dict 的keys
排序的工作(因?yàn)楹竺嬉?JSON 序列化,順序很重要)
然后hash
函數(shù)是在第 283 行定義的
a36["hash"] = function(a11, a36a) {
if (/[?-?]/["test"](a11)) {
a11 = unescape(encodeURIComponent(a11));
}
var a26 = a38(a11); // 實(shí)際上a36a就是告訴你做幾層加密
return !!a36a ? a26 : a9(a26);
};
我想你聽到這里已經(jīng)懵逼了,然后我告訴你,其實(shí)這個(gè)的逆向已經(jīng)做完了,只要找到入口函數(shù),(只要接下來的所有函數(shù)都在本文件范圍內(nèi))問題一切都解決了
爬蟲 本質(zhì)上來講 就是做一個(gè)模擬瀏覽器的工作。從最開始的模擬瀏覽器發(fā) HTTP 請(qǐng)求,發(fā) WebSocket 請(qǐng)求,到后面的模擬瀏覽器編譯 js,其實(shí)做的都是一件事情。
在這里做逆向,也是模擬瀏覽器做 js 的編譯,那為什么不讓 node 或者 chrome v8 幫我們做這些事情呢。
很大程度上是因?yàn)楹芏?js 編譯需要一個(gè) DOM,當(dāng)然我們有了 jsdom 之后,這就不是問題了
所以我們按著剛才的思路解析構(gòu)造 data,添加_ts
時(shí)間戳,做排序,加鹽,然后丟給上面的 a36 函數(shù)是不是就可以了
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
function analysis_js(html, salt, prepare_map) {
const dom = new JSDOM(html);
window = dom.window;
document = window.document;
window.decodeURIComponent = decodeURIComponent;
const script_element = document.querySelector("script");
console.log(script_element);
const script = script_element.innerHTML;
eval(script);
return window["SparkMD5"]
["hash"](JSON["stringify"](prepare_map) + salt)
["slice"](2, 12);
}
在實(shí)際操作過程中需要把最后一段入口函數(shù)刪了,因?yàn)樗麜?huì)掉 JQuery 中定義的一個(gè)變量, 當(dāng)然你也可以在前面申明一下這個(gè)變量。
拿到大概 95w 個(gè)景點(diǎn)信息,至于為什么花了 6 個(gè)小時(shí),因?yàn)槊總€(gè)頁(yè)面都需要去調(diào) node 去編譯,這個(gè)并發(fā)數(shù)就不能開太多,試了一下超過 300 我的 MBP 就不聽話了,最后開了 150 個(gè)(這個(gè)后面有空可以調(diào)優(yōu)一下
看著黃色的_sn
彈出來的時(shí)候,滿臉的喜悅 ??
攜程酒店詳情頁(yè)
和攜程比起來,前面的馬蜂窩就是個(gè)弟弟(太真實(shí)了,當(dāng)然這部分比較涉及攜程他們核心業(yè)務(wù)了,做的好也是可以理解的
我們的目標(biāo)是爬取上面網(wǎng)頁(yè)中各種房型,及其實(shí)時(shí)價(jià)格(話說我在做這個(gè)項(xiàng)目的過程中,見證了這個(gè)酒店從最低的 2k 一晚漲到現(xiàn)在最低 2.5k 一晚,有錢真好 ??
直接切入正題,要的信息是通過https://hotels.ctrip.com/Domestic/tool/AjaxHote1RoomListForDetai1.aspx?psid=&MasterHotelID=4889292&hotel=4889292&EDM=F&roomId=&IncludeRoom=&city=2&showspothotel=T&supplier=&IsDecoupleSpotHotelAndGroup=F&contrast=0&brand=776&startDate=2019-04-22&depDate=2019-04-23&IsFlash=F&RequestTravelMoney=F&hsids=&IsJustConfirm=&contyped=0&priceInfo=-1&equip=&filter=&productcode=&couponList=&abForHuaZhu=&defaultLoad=T&esfiltertag=&estagid=&Currency=&Exchange=&minRoomId=0&maskDiscount=0&TmFromList=F&th=119&RoomGuestCount=1,1,0&eleven=573c4df76400696c0f50224f3feea79e894d093dbe66ed9a281dc762444f01a0&callback=CASHLJQCOMFKWXCzzML&_=1555901561986
這個(gè) URL 獲得的,可以看到這次的參數(shù)有點(diǎn)多,剔除有語(yǔ)義含義的,不會(huì)變的,只剩下最后三個(gè)參數(shù)
同樣經(jīng)過測(cè)試,發(fā)現(xiàn) Cookie 對(duì)請(qǐng)求沒有影響
然后還是篩 js,嗚嗚嗚,真 TM 的多,block 之前大概 59 個(gè) js,最后主要是兩個(gè) js 在起作用
https://webresource.c-ctrip.com/ResHotelOnline/R8/search/js.merge/showhotelinformation.js?releaseno=2019-04-20-10-34-30
https://hotels.ctrip.com/domestic/cas/oceanball?callback=CASkcfYhfhvxJljvZR&_=1555901561840
根據(jù)時(shí)間戳,20 號(hào)也就是前天,周六,攜程 996 石錘了 ??(showhotelinformation
這個(gè)文件實(shí)際上是前端和反爬工程師共用的文件,目測(cè)應(yīng)該是前端做了修改)
也正是這個(gè)原因,showhotelinformation
文件長(zhǎng)達(dá) 8k 多行,我的 Vscode 都卡死了(VScode 垃圾,還是 Sublime 香), 當(dāng)然這樣也以為這這個(gè) js 不會(huì)有太多混淆(明明就沒有)
為了節(jié)省大家時(shí)間,直接搜getDetailHotel
,然后往上看就可以看到 Url 拼接的入口函數(shù)getRoomHtml
我們發(fā)現(xiàn)他通過讀 html 一些標(biāo)簽來構(gòu)造參數(shù),也可以理解,畢竟那么多個(gè)酒店詳情頁(yè)呢,得寫的通用一點(diǎn)
然后調(diào)用了getHotelDetail
傳了拼接好的 URL 和兩個(gè)回調(diào)函數(shù),繼續(xù)看
getHotelDetails: a,
function a(e, t) {
$.BizMod.Promise(n).any(function (i) {
console.log(i);
i && (e += "&eleven=" + encodeURIComponent(i)),
o(e, t)
})
可以看到這邊通過一個(gè) Promise 的回調(diào)函數(shù)來獲得了 eleven 參數(shù),那么關(guān)鍵就在這個(gè) n 的函數(shù)中
function n(t) {
var o, i = e(15), n = !1; // callback隨機(jī)15位字符
for (; i in window;) // 去window里面占坑,用于后面的回調(diào)函數(shù)
i = e(15);
o = hotelDomesticConfig.cas.OceanBallUrl + "?callback=" + i + "&_=" + (new Date).getTime(),
window[i] = function (e) {
var o = "";
try {o = e()
} catch (i) {
} finally {
t.resolve(o)
}
},
$.loader.js(o, { // 調(diào)用封裝的cQuery的loader js
onload: function () {
setTimeout(function () {n || t.reject("")}, 5e3)
},
onerror: function (e) {e && (window[i] = void 0),t.reject("")}
})
}
function e(e) { // 就是隨機(jī)生成指定長(zhǎng)度字符串, 關(guān)鍵應(yīng)該是字符長(zhǎng)度
for (var t = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"], o = "CAS", i = 0; i < e; i++) {
var n = Math.ceil(51 * Math.random());
o += t[n]
}
return o
}
也就是 通過隨機(jī)生成 callback,請(qǐng)求 oceanball 這個(gè) URL 來獲得 eleven
lz 當(dāng)時(shí)也是這么天真的,直到我看了一下返回的 js
你可以從右邊的預(yù)覽條感受到這個(gè)的恐怖
其實(shí)邏輯超級(jí)簡(jiǎn)單,用 String.formCharCode 把那么長(zhǎng)的數(shù)組轉(zhuǎn)成 js 代碼,然后再用 eval 編譯這個(gè) js 代碼
那我們看一下,Unicode2Char 之后的代碼長(zhǎng)什么樣子
同樣的做一次解混淆,把過長(zhǎng)的不含語(yǔ)義的變量做一個(gè)替換,得到
可以看到最后一行,出現(xiàn)了我們剛才的那個(gè)隨機(jī)生成的callback
變量,果然他在這里,作為一個(gè)回調(diào)函數(shù),需要 window 有聲明 window[callback]
這個(gè)變量
所以,我們要的 eleven 就是上面的 a52,也就是第 408 行做的一個(gè) String.fromCode 操作
好做到這里,其實(shí)我已經(jīng)大概明白了,想也用之前的辦法用 jsdom 加 node 編譯做一個(gè),
經(jīng)過一系列調(diào)試,結(jié)果我竟然拿到了一句具有語(yǔ)義的字符,竟然還是中文,這就跟做壞事被人發(fā)現(xiàn)一樣,惶恐惶恐
不得不說,攜程在這上面還是做了挺多工作的,首先三層混淆,拿到這個(gè) js 的本來就已經(jīng)比較難了,還做了 node this 的欺騙,防止你直接運(yùn)行這段代碼來獲取 eleven
其次除了這點(diǎn),他在代碼里還做了很多效驗(yàn),你 User-Agent 不同拿到的 elven 也是不同的,還會(huì)效驗(yàn)location.url
,navigation.href
,還會(huì)去看你能不能 createElement,有多少個(gè) Element
其次,這個(gè) OceanBall 是動(dòng)態(tài)請(qǐng)求獲得的,一個(gè)是針對(duì) referer 做的效驗(yàn),還有一個(gè)是其實(shí)他這個(gè) js 有好幾個(gè)版本,防止你只分析一個(gè),
具體代碼詳見 對(duì)攜程我就只爬了一個(gè)頁(yè)面,因?yàn)樽罱鼘?duì)這部分?jǐn)?shù)據(jù)沒什么需求,也不打算使勁懟了,成年人要相互體諒 哈哈哈哈哈哈哈哈哈
得到的結(jié)果
其實(shí)你再用心一點(diǎn)還能發(fā)現(xiàn)一些很奇妙的東西,比如說攜程好像還和華住有廣告合作什么的 hhh
好哩,這部分內(nèi)容就講完了,歡迎給我評(píng)論交流,謝謝 ??