【JS 逆向百例】網洛者反爬練習平臺第一題:JS 混淆加密,反 Hook 操作

K哥爬蟲發表於2021-12-14
關注微信公眾號:K哥爬蟲,持續分享爬蟲進階、JS/安卓逆向等技術乾貨!

宣告

本文章中所有內容僅供學習交流,抓包內容、敏感網址、資料介面均已做脫敏處理,嚴禁用於商業用途和非法用途,否則由此產生的一切後果均與作者無關,若有侵權,請聯絡我立即刪除!

寫在前面

題目本身不是很難,但是其中有很多坑,主要是反 Hook 操作和本地聯調補環境,本文會詳細介紹每一個坑,並不只是一筆帶過,寫得非常詳細!

通過本文你將學到:

  1. Hook Function 和定時器來消除無限 debugger;
  2. 解決反 Hook,通過 Hook 的方式找到加密引數 _signature;
  3. 分析瀏覽器與本地環境差異,如何尋找 navigator、document、location 等物件,如何本地補環境;
  4. 如何利用 PyCharm 進行本地聯調,定位本地和瀏覽器環境的差異,從而過掉檢測。

逆向目標

  • 目標:網洛者反反爬蟲練習平臺第一題:JS 混淆加密,反 Hook 操作
  • 連結:http://spider.wangluozhe.com/...
  • 簡介:本題要提交的答案是100頁的所有資料並加和,要求以 Hook 的方式完成此題,不要以 AST、扣程式碼等方式解決,不要使用 JS 反混淆工具進行解密。(Hook 程式碼的寫法和用法,K哥以前文章有,本文不再詳細介紹)

01.png

繞過無限 debugger

首先觀察到點選翻頁,URL 並沒有發生變化,那麼一般就是 Ajax 請求,每一次請求有些引數會改變,熟練的按下 F12 準備查詢加密引數,會發現立馬斷住,進入無限 debugger 狀態,往上跟一個棧,可以發現 debugger 字樣,如下圖所示:

02.png

這種情況在K哥以前的案例中也有,當時我們是直接重寫這個 JS,把 debugger 字樣給替換掉就行了,但是本題很顯然是希望我們以 Hook 的方法來過掉無限 debugger,除了 debugger 以外,我們注意到前面還有個 constructor 字樣,在 JavaScript 中它叫構造方法,一般在物件建立或者例項化時候被呼叫,它的基本語法是:constructor([arguments]) { ... },詳細介紹可參考 MDN 構造方法,在本案例中,很明顯 debugger 就是 constructor 的 arguments 引數,因此我們可以寫出以下 Hook 程式碼來過掉無限 debugger:

// 先保留原 constructor
Function.prototype.constructor_ = Function.prototype.constructor;
Function.prototype.constructor = function (a) {
    // 如果引數為 debugger,就返回空方法
    if(a == "debugger") {
        return function (){};
    }
    // 如果引數不為 debugger,還是返回原方法
    return Function.prototype.constructor_(a);
};

注入 Hook 程式碼的方法也有很多,比如直接在瀏覽器開發者工具控制檯輸入程式碼(重新整理網頁會失效)、Fiddler 外掛注入、油猴外掛注入、自寫瀏覽器外掛注入等,這些方法在K哥以前的文章都有介紹,今天就不再贅述。

本次我們使用 Fiddler 外掛注入,注入以上 Hook 程式碼後,會發現會再次進入無限 debugger,setInterval,很明顯的定時器,他有兩個必須的引數,第一個是要執行的方法,第二個是時間引數,即週期性呼叫方法的時間間隔,以毫秒為單位,詳細介紹可參考菜鳥教程 Window setInterval(),同樣我們也可以將其 Hook 掉:

// 先保留原定時器
var setInterval_ = setInterval
setInterval = function (func, time){
    // 如果時間引數為 0x7d0,就返回空方法
    // 當然也可以不判斷,直接返回空,有很多種寫法
    if(time == 0x7d0)
    {
        return function () {};
    }
    // 如果時間引數不為 0x7d0,還是返回原方法
    return setInterval_(func, time)
}

03.png

將兩段 Hook 程式碼貼上到瀏覽器外掛裡,開啟 Hook,重新重新整理頁面就會發現已經過掉了無限 debugger。

04.png

Hook 引數

過掉無限 debugger 後,我們隨便點選一頁,抓包可以看到是個 POST 請求,Form Data 裡,page 是頁數,count 是每一頁資料量,_signature 是我們要逆向的引數,如下圖所示:

05.png

我們直接搜尋 _signature,只有一個結果,其中有個 window.get_sign() 方法就是設定 _signature 的函式,如下圖所示:

06.png

這裡問題來了!!!我們再看看本題的題目,JS 混淆加密,反 Hook 操作,作者也再三強調本題是考驗 Hook 能力!並且到目前為止,我們好像還沒有遇到什麼反 Hook 手段,所以,這樣直接搜尋 _signature 很顯然太簡單了,肯定是要通過 Hook 的方式來獲取 _signature,並且後續的 Hook 操作肯定不會一帆風順!

話不多說,我們直接寫一個 Hook window._signature 的程式碼,如下所示:

(function() {
    //嚴謹模式 檢查所有錯誤
    'use strict';
    //window 為要 hook 的物件,這裡是 hook 的 _signature
    var _signatureTemp = "";
    Object.defineProperty(window, '_signature', {
        //hook set 方法也就是賦值的方法 
        set: function(val) {
                console.log('Hook 捕獲到 _signature 設定->', val);
                debugger;
                _signatureTemp = val;
                return val;
        },
        //hook get 方法也就是取值的方法 
        get: function()
        {
            return _signatureTemp;
        }
    });
})();

將兩個繞過無限 debugger 的 Hook 程式碼,和這個 Hook _signature 的程式碼一起,使用 Fiddler 外掛一同注入(這裡注意要把繞過 debugger 的程式碼放在 Hook _signature 程式碼的後面,否則有可能不起作用,這可能是外掛的 BUG),重新重新整理網頁,可以發現前端的一排頁面的按鈕不見了,開啟開發者工具,可以看到右上角提示有兩個錯誤,點選可跳轉到出錯的程式碼,在控制檯也可以看到報錯資訊,如下圖所示:

07.png

整個 1.js 程式碼是經過了 sojson jsjiami v6 版本混淆了的,我們將裡面的一些混淆程式碼在控制檯輸出一下,然後手動還原一下這段程式碼,有兩個變數 i1I1i1liillllli1,看起來費勁,直接用 ab 代替,如下所示:

(function() {
    'use strict';
    var a = '';
    Object["defineProperty"](window, "_signature", {
        set: function(b) {
            a = b;
            return b;
        },
        get: function() {
            return a;
        }
    });
}());

是不是很熟悉?有 get 和 set 方法,這不就是在進行 Hook window._signature 操作嗎?整個邏輯就是當 set 方法設定 _signature 時,將其賦值給 a,get 方法獲取 _signature 時,返回 a,這麼操作一番,實際上對於 _signature 沒有任何影響,那這段程式碼存在的意義是啥?為什麼我們新增了自己的 Hook 程式碼就會報錯?

來看看報錯資訊:Uncaught TypeError: Cannot redefine property: _signature,不能重新定義 _signature?我們的 Hook 程式碼在頁面一載入就執行了 Object.defineProperty(window, '_signature', {}),等到網站的 JS 再次 defineProperty 時就會報錯,那很簡單嘛,既然不讓重新定義,而且網站自己的 JS Hook 程式碼不會影響 _signature,直接將其刪掉不就行了嘛!這個地方大概就是反 Hook 操作了。

儲存原 1.js 到本地,刪除其 Hook 程式碼,使用 Fiddler 的 AutoResponder 功能替換響應(替換方法有很多,K哥以前的文章同樣有介紹),再次重新整理發現異常解除,並且成功 Hook 到了 _signature

08.png

09.png

逆向引數

成功 Hook 之後,直接跟棧,直接把方法暴露出來了:window._signature = window.byted_acrawler(window.sign())

10.png

先來看看 window.sign(),選中它其實就可以看到是 13 位毫秒級時間戳,我們跟進 1.js 去看看他的實現程式碼:

11.png

我們將部分混淆程式碼手動還原一下:

window["sign"] = function sign() {
    try {
        div = document["createElement"];
        return Date["parse"](new Date())["toString"]();
    } catch (IIl1lI1i) {
        return "123456789abcdefghigklmnopqrstuvwxyz";
    }
}

這裡就要注意了,有個坑給我們埋下了,如果直接略過,覺得就一個時間戳沒啥好看的,那你就大錯特錯了!注意這是一個 try-catch 語句,其中有一句 div = document["createElement"];,有一個 HTML DOM Document 物件,建立了 div 標籤,這段程式碼如果放到瀏覽器執行,沒有任何問題,直接走 try 語句,返回時間戳,如果在我們本地 node 執行,就會捕獲到 document is not defined,然後走 catch 語句,返回的是那一串數字加字母,最後的結果肯定是不正確的!

解決方法也很簡單,在原生程式碼裡,要麼去掉 try-catch 語句,直接 return 時間戳,要麼在開頭定義一下 document,再或者直接註釋掉建立 div 標籤的這行程式碼,但是K哥在這裡推薦直接定義一下 document,因為誰能保證在其他地方也有類似的坑呢?萬一隱藏得很深,沒發現,豈不是白費力氣了?

然後再來看看 window.byted_acrawler(),return 語句裡主要用到了 sign() 也就是 window.sign() 方法和 IIl1llI1() 方法,我們跟進 IIl1llI1() 方法可以看到同樣使用了 try-catch 語句,nav = navigator[liIIIi11('2b')]; 和前面 div 的情況如出一轍,同樣的這裡也建議直接定義一下 navigator,如下圖所示:

14.png

15.png

到這裡用到的方法基本上分析完畢,我們將 window、document、navigator 都定義一下後,本地執行一下,會提示 window[liIIIi11(...)] is not a function

16.png

我們去網頁裡看看,會發現這個方法其實就是一個定時器,沒有太大作用,直接註釋掉即可:

17.png

PyCharm 本地聯調

經過以上操作以後,再次本地執行,會提示 window.signs is not a function,出錯的地方是一個 eval 語句,我們去瀏覽器看一下這個 eval 語句,發現明明是 window.sign(),為什麼本地就變成了 window.signs(),平白無故多了個 s 呢?

18.png

19.png

造成這種情況的原因只有一個,那就是本地與瀏覽器的環境差異,混淆的程式碼裡肯定有環境檢測,如果不是瀏覽器環境的話,就會修改 eval 裡的程式碼,多加了一個 s,這裡如果你直接刪掉包含 eval 語句的整個函式和上面的 setInterval 定時器,程式碼也能正常執行,但是,K哥一向是追求細節的!多加個 s 的原因我們必須得搞清楚呀!

我們在本地使用 PyCharm 進行除錯,看看到底是哪裡給加了個 s,出錯的地方是這個 eval 語句,我們點選這一行,下個斷點,右鍵 debug 執行,進入除錯介面(PS:原始碼有無限 debugger,如果不做處理,PyCharm 裡除錯同樣也會進入無限 debugger,可以直接把前面的 Hook 程式碼加到原生程式碼前面,也可以直接刪除對應的函式或變數):

20.png

左側是呼叫棧,右側是變數值,整體上和 Chrome 裡面的開發者工具差不多,詳細用法可參考 JetBrains 官方文件,主要介紹一下圖中的 8 個按鈕:

  1. Show Execution Point (Alt + F10):如果你的游標在其它行或其它頁面,點選這個按鈕可跳轉到當前斷點所在的行;
  2. Step Over (F8):步過,一行一行地往下走,如果這一行上有方法也不會進入方法;
  3. Step Into (F7):步入,如果當前行有方法,可以進入方法內部,一般用於進入使用者編寫的自定義方法內,不會進入官方類庫的方法;
  4. Force Step Into (Alt + Shift + F7):強制步入,能進入任何方法,檢視底層原始碼的時候可以用這個進入官方類庫的方法;
  5. Step Out (Shift + F8):步出,從步入的方法內退出到方法呼叫處,此時方法已執行完畢,只是還沒有完成賦值;
  6. Restart Frame:放棄當前斷點,重新執行斷點;
  7. Run to Cursor (Alt + F9):執行到游標處,程式碼會執行至游標行,不需要打斷點;
  8. Evaluate Expression (Alt + F8):計算表示式,可以直接執行表示式,不需要在命令列輸入。

我們點選步入按鈕(Step Into),會進入到 function IIlIliii(),這裡同樣使用了 try-catch 語句,繼續下一步,會發現捕獲到了異常,提示 Cannot read property 'location' of undefined,如下圖所示:

21.png

我們輸出一下各個變數的值,手動還原一下程式碼,如下:

function IIlIliii(II1, iIIiIIi1) {
    try {
        href = window["document"]["location"]["href"];
        check_screen = screen["availHeight"];
        window["code"] = "gnature = window.byted_acrawler(window.sign())";
        return '';
    } catch (I1IiI1il) {
        window["code"] = "gnature = window.byted_acrawlers(window.signs())";
        return '';
    }
}

這麼一來,就發現了端倪,在本地我們並沒有 document、location、href、availHeight 物件,所以就會走 catch 語句,變成了 window.signs(),就會報錯,這裡解決方法也很簡單,可以直接刪掉多餘程式碼,直接定義為不帶 s 的那串語句,或者也可以選擇補一下環境,在瀏覽器裡看一下 href 和 screen 的值,定義一下即可:

var window = {
    "document": {
        "location": {
            "href": "http://spider.wangluozhe.com/challenge/1"
        }
    },
}

var screen = {
    "availHeight": 1040
}

然後再次執行,又會提示 sign is not defined,這裡的 sign() 其實就是 window.sign(),也就是下面的 window[liIIIi11('a')] 方法,任意改一種寫法即可:

22.png

再次執行,沒有錯誤了,我們可以自己寫一個方法來獲取 _signature:以下寫法二選一,都可以:

function getSign(){
    return window[liIIIi11('9')](window[liIIIi11('a')]())
}

function getSign(){
    return window.byted_acrawler(window.sign())
}

// 測試輸出
console.log(getSign())

我們執行一下,發現在 Pycharm 裡並沒有任何輸出,同樣的我們在題目頁面的控制檯輸出一下 console.log,發現被置空了,如下圖所示:

23.png

看來他還對 console.log 做了處理,其實這種情況問題不大,我們直接使用 Python 指令碼來呼叫前面我們寫的 getSign() 方法就能得到 _signature 的值了,但是,再次重申,K哥一向是追求細節的!我就得找到處理 console.log 的地方,把它變為正常!

這裡我們仍然使用 Pycharm 來除錯,進一步熟悉本地聯調,在 console.log(getSign()) 語句處下個斷點,一步一步跟進,會發現進到了語句 var IlII1li1 = function() {};,檢視此時變數值,發現 console.logconsole.warn 等方法都被置空了,如下圖所示:

24.png

再往下一步跟進,發現直接返回了,這裡有可能第一次執行 JS 時就會對 console 相關命令進行方法置空處理,所以先在疑似對 console 處理的方法裡面下幾個斷點,再重新除錯,會發現會走到 else 語句,然後直接將 IlII1li1 也就是空方法,賦值給 console 相關命令,如下圖所示:

25.png

定位到了問題所在,我們直接把 if-else 語句註釋掉,不讓它置空即可,然後再次除錯,發現就可以直接輸出結果了:

26.png

呼叫 Python 攜帶 _signature 挨個計算每一頁的資料,最終提交成功:

2.png

完整程式碼

GitHub 關注 K 哥爬蟲,持續分享爬蟲相關程式碼!歡迎 star !https://github.com/kgepachong/

以下只演示部分關鍵程式碼,不能直接執行!完整程式碼倉庫地址:https://github.com/kgepachong...

JavaScript 加密關鍵程式碼架構

var window = {
    "document": {
        "location": {
            "href": "http://spider.wangluozhe.com/challenge/1"
        }
    },
}

var screen = {
    "availHeight": 1040
}
var document = {}
var navigator = {}
var location = {}

// 先保留原 constructor
Function.prototype.constructor_ = Function.prototype.constructor;
Function.prototype.constructor = function (a) {
    // 如果引數為 debugger,就返回空方法
    if(a == "debugger") {
        return function (){};
    }
    // 如果引數不為 debugger,還是返回原方法
    return Function.prototype.constructor_(a);
};

// 先保留原定時器
var setInterval_ = setInterval
setInterval = function (func, time){
    // 如果時間引數為 0x7d0,就返回空方法
    // 當然也可以不判斷,直接返回空,有很多種寫法
    if(time == 0x7d0)
    {
        return function () {};
    }
    // 如果時間引數不為 0x7d0,還是返回原方法
    return setInterval_(func, time)
}

var iil = 'jsjiami.com.v6'
  , iiIIilii = [iil, '\x73\x65\x74\x49\x6e\x74\x65\x72\x76\x61\x6c', '\x6a\x73\x6a', ...];
var liIIIi11 = function(_0x11145e, _0x3cbe90) {
    _0x11145e = ~~'0x'['concat'](_0x11145e);
    var _0x636e4d = iiIIilii[_0x11145e];
    return _0x636e4d;
};
(function(_0x52284d, _0xfd26eb) {
    var _0x1bba22 = 0x0;
    for (_0xfd26eb = _0x52284d['shift'](_0x1bba22 >> 0x2); _0xfd26eb && _0xfd26eb !== (_0x52284d['pop'](_0x1bba22 >> 0x3) + '')['replace'](/[fnwRwdGKbwKrRFCtSC=]/g, ''); _0x1bba22++) {
        _0x1bba22 = _0x1bba22 ^ 0x661c2;
    }
}(iiIIilii, liIIIi11));
// window[liIIIi11('0')](function() {
//     var l111IlII = liIIIi11('1') + liIIIi11('2');
//     if (typeof iil == liIIIi11('3') + liIIIi11('4') || iil != l111IlII + liIIIi11('5') + l111IlII[liIIIi11('6')]) {
//         var Ilil11iI = [];
//         while (Ilil11iI[liIIIi11('6')] > -0x1) {
//             Ilil11iI[liIIIi11('7')](Ilil11iI[liIIIi11('6')] ^ 0x2);
//         }
//     }
//     iliI1lli();
// }, 0x7d0);
(function() {
    var iiIIiil = function() {}();
    var l1liii11 = function() {}();
    window[liIIIi11('9')] = function byted_acrawler() {};
    window[liIIIi11('a')] = function sign() {};
    (function() {}());
    // (function() {
    //     'use strict';
    //     var i1I1i1li = '';
    //     Object[liIIIi11('1f')](window, liIIIi11('21'), {
    //         '\x73\x65\x74': function(illllli1) {
    //             i1I1i1li = illllli1;
    //             return illllli1;
    //         },
    //         '\x67\x65\x74': function() {
    //             return i1I1i1li;
    //         }
    //     });
    // }());
    var iiil1 = 0x0;
    var l11il1l1 = '';
    var ii1Ii = 0x8;
    function i1Il11i(iiIll1i) {}
    function I1lIIlil(l11l1iIi) {}
    function lllIIiI(IIi1lIil) {}

    // 此處省略 N 個函式
    
    window[liIIIi11('37')]();
}());

function iliI1lli(lil1I1) {
    function lili11I(l11I11l1) {
        if (typeof l11I11l1 === liIIIi11('38')) {
            return function(lllI11i) {}
            [liIIIi11('39')](liIIIi11('3a'))[liIIIi11('8')](liIIIi11('3b'));
        } else {
            if (('' + l11I11l1 / l11I11l1)[liIIIi11('6')] !== 0x1 || l11I11l1 % 0x14 === 0x0) {
                (function() {
                    return !![];
                }
                [liIIIi11('39')](liIIIi11('3c') + liIIIi11('3d'))[liIIIi11('3e')](liIIIi11('3f')));
            } else {
                (function() {
                    return ![];
                }
                [liIIIi11('39')](liIIIi11('3c') + liIIIi11('3d'))[liIIIi11('8')](liIIIi11('40')));
            }
        }
        lili11I(++l11I11l1);
    }
    try {
        if (lil1I1) {
            return lili11I;
        } else {
            lili11I(0x0);
        }
    } catch (liIlI1il) {}
}
;iil = 'jsjiami.com.v6';

// function getSign(){
//     return window[liIIIi11('9')](window[liIIIi11('a')]())
// }

function getSign(){
    return window.byted_acrawler(window.sign())
}

console.log(getSign())

Python 計算關鍵程式碼

# ==================================
# --*-- coding: utf-8 --*--
# @Time    : 2021-12-01
# @Author  : 微信公眾號:K哥爬蟲
# @FileName: challenge_1.py
# @Software: PyCharm
# ==================================


import execjs
import requests

challenge_api = "http://spider.wangluozhe.com/challenge/api/1"
headers = {
    "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
    "Cookie": "將 cookie 值改為你自己的!",
    "Host": "spider.wangluozhe.com",
    "Origin": "http://spider.wangluozhe.com",
    "Referer": "http://spider.wangluozhe.com/challenge/1",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36",
    "X-Requested-With": "XMLHttpRequest"
}


def get_signature():
    with open('challenge_1.js', 'r', encoding='utf-8') as f:
        ppdai_js = execjs.compile(f.read())
    signature = ppdai_js.call("getSign")
    print("signature: ", signature)
    return signature


def main():
    result = 0
    for page in range(1, 101):
        data = {
            "page": page,
            "count": 10,
            "_signature": get_signature()
        }
        response = requests.post(url=challenge_api, headers=headers, data=data).json()
        for d in response["data"]:
            result += d["value"]
    print("結果為: ", result)


if __name__ == '__main__':
    main()

相關文章