【JS 逆向百例】某空氣質量監測平臺無限 debugger 以及數據動態加密

聲明

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

逆向目標

  • 目標:某空氣質量監測平臺無限 debugger 以及請求數據、返回數據動態加密、解密
  • 主頁:aHR0cHM6Ly93d3cuYXFpc3R1ZHkuY24v
  • 接口:aHR0cHM6Ly93d3cuYXFpc3R1ZHkuY24vYXBpbmV3L2FxaXN0dWR5YXBpLnBocA==

寫在前面

這個站點更新頻率很高,在K哥之前也已經有很多博主寫了該站點的分析文章,近期有讀者問請求數據的加密和返回數據的解密,發現其加解密 JS 變成了動態的,以前的那些文章提到的解決思路不太行了,但整體上來說也不是很難,只不過處理起來比較麻煩一點,還有一些小細節需要注意。

在網站的“關于系統”里可以看到,這個站貌似是個人開發者在維護,最早在2013年就有了,在友情贊助列表里,可以看到大多數都是一些環境、測繪、公共衛生相關的大學專業、研究院人員,可以猜測到這些數據對于他們的研究是非常有幫助的,再加上反爬更新頻繁,可以看出站長飽受爬蟲之苦,K哥也不想給站長添加負擔,畢竟這種站點咱們應該支持,讓他長久維護下去,所以本期K哥只分析邏輯和少部分代碼,就不放完整代碼了,如果有相關專業人士確實需要抓取數據做研究的,可以在公眾號后臺聯系我。

繞過無限 debugger

右鍵 F12,會提示右鍵被禁用,不要緊,使用快捷鍵 Ctrl+Shift+i 或者瀏覽器右上角,更多工具,開發者工具,照樣能打開。

01.png

方法一

打開控制臺后會進入第一個無限 debugger,往上跟一個棧,可以看到一個 try-catch 語句,你下斷點會發現他會一直走 catch,調用 setTimeout() 方法,該方法用于在指定的毫秒數后調用函數或計算表達式,注意上面,是將 debugger 傳遞給了構造方法 constructor,所以這里我們有兩種方法過掉 debugger,Hook 掉 constructor 或 setTimeout 都可以。

02.png
// 兩種 Hook 任選一中
// Hook 構造方法
Function.prototype.constructor_ = Function.prototype.constructor;
Function.prototype.constructor = function (a) {
    if(a == "debugger") {
        return function (){};
    }
    return Function.prototype.constructor_(a);
};

// Hook setTimeout
var setTimeout_ = setTimeout
var setTimeout = function (func, time){
    if (func == txsdefwsw){
        return function () {};
    }
    return setTimeout_(func, time)
}

然后就來到了第二個無限 debugger,同樣跟棧,發現有個 setInterval 定時器和構造方法 constructor,類似的,我們 Hook 掉 constructor 或 setInterval 都可以。注意:定時器這里還檢測了窗口高寬,即便是你過了 constructor 或 setInterval,如果不把開發者工具單獨拿出來也是不行的,會不斷輸出“檢測到非法調試”。

03.png
// Hook setInterval
var setInterval_ = setInterval
setInterval = function (func, time){
    if (time == 2000) {
        return function () {};
    }
    return setInterval_(func, time)
}

我們觀察到,其實這兩個無限 debugger 都可以 Hook 構造方法來過掉,所以直接 Fiddler 注入該 Hook 構造方法的代碼即可:

04.png

方法二

在我們遇到第二個無限 debugger 的時候,還可以直接跟棧到一個 city_realtime.php 的頁面,里面有兩個 eval 語句,執行第一個 eval 里面的語句你就會發現正是前面我們在 VM 虛擬機里面看到的 debugger 代碼,所以這里理論上可以直接替換掉這個頁面,去掉 eval 語句,就不會有無限 debugger 了,但是K哥先告訴你,現在不行了,因為里面有加載了某個 JS,這個 JS 在后面加密解密中會用到,但是這個 JS 是動態的,每10分鐘就會改變,我們后面還要通過此頁面來獲取動態的 JS,所以是不能替換的!這里只是提一下這個思路!

05.png
06.png

方法三

當然,這里還有一種最簡單的方法,直接右鍵選擇 Never pause here,永不在此處斷下即可,同樣還需要把開發者工具窗口單獨拿出來,不然會一直輸出“檢測到非法調試”。

07.png

抓包分析

我們在實時監控頁面,順便點擊查詢一個城市,可以看到請求的 Form Data 和返回的數據都是加密的,如下圖所示:

08.png

加密入口

由于是 XHR,所以我們直接跟棧,很容易找到加密的位置:

09.png
10.png

可以看到傳遞的 data 鍵值對:{hXM8NDFHN: p7crXYR},鍵在這個 JS 里是寫死的,值是通過一個方法 pU14VhqrofroULds() 得到的,這個方法需要傳遞兩個參數,第一個是定值 GETDATA,第二個就是城市名稱,我們再跟進看看這個方法是啥:

11.png

一些 appId、時間戳、城市等參數,做了一些 MD5、base64 的操作,返回的 param 就是我們要的值了。看起來不難,我們再找找返回的加密數據是如何解密的,我們注意到 ajax 請求有個 success 關鍵字,我們即便是不懂 JS 邏輯,也可以猜到應該是請求成功后的處理操作吧,如下圖所示:傳進來的 dzJMI 就是返回的加密的數據,經過 db0HpCYIy97HkHS7RkhUn() 方法后,就解密成功了:

12.png

跟進 db0HpCYIy97HkHS7RkhUn() 方法,可以看到是 AES+DES+BASE64 解密,傳入的密鑰 key 和偏移量 iv 都在頭部有定義:

13.png
14.png

動態 JS

經過以上分析后,我們加密解密的邏輯都搞定了,但是你多調試一下就會發現,這一個加密解密的 JS 是動態變化的,定義的密鑰 key 和偏移量 iv 都是隔段時間就會改變的,如果你在這段代碼里下斷點,停留時間過長,突然發現斷點失效無法斷下了,那就是 JS 變了,當前代碼已經失效了。

我們隨便薅兩個不同的 JS 下來(提示:JS 每隔10分鐘會變化,后文有詳細分析),利用 PyCharm 的文件對比功能(依次選擇 View - Compare With)可以總結出以下幾個變化的地方(變量名的變化不算):

  1. 開頭的8個參數的值:兩個 aes key 和 iv,兩個 des key 和 iv;
15.png
  1. 生成加密的 param 時,appId 是變化的,最后的加密分為 AES、DES 和沒有加密,三種情況(這里是最容易忽略的地方,這里沒有注意到,請求可能會提示 appId 無效的情況):
16.png
  1. 最后發送請求時,data 鍵值對,其中的鍵也是變化的:
17.png

變化的地方我們找到了,那我們怎么獲取這個 JS 呢?因為這個 JS 的在 VM 虛擬機里,所以我們還要找到它的源頭,是從哪里來的,我們抓包可以看到一個比較特殊的 JS,類似于 encrypt_xxxxxx.js,看這取名就知道不簡單,返回的是一段 eval 包裹的代碼:

18.png

對于 eval 我們已經很熟悉了,直接去掉 eval,讓他執行一下,就可以看到正是我們需要的那段 JS:

19.png

這里有個小細節,如果你使用控制臺,會發現它一直在打印 img 標簽,影響我們的輸入,這里可以直接跟進去下斷點暫時阻止他運行就行了,不需要做其他操作浪費時間:

20.png

你以為到這里就差不多搞定了?錯了,同樣的這個 encrypt_xxxxxx.js 也藏有玄機:

  1. encrypt_xxxxxx.js 的名稱是動態的,后面的 v 值是秒級時間戳,隔600秒,也就是十分鐘就會改變,這個 JS 可以在 city_realtime.php 頁面找到,還記得我們前面說過的繞過無限 debugger 不能替換此頁面嗎?我們要通過此頁面來獲取動態的 JS,所以是不能替換的!
21.png
22.png
  1. encrypt_xxxxxx.js 返回的 JS,并不是所有的執行一遍 eval 就能得到明文代碼了,它是 eval 和 base64 相結合的,第一遍都是 eval,但是后面就說不定了,有可能直接出結果,有可能需要 base64,有可能 base64 兩遍,有可能兩遍 base64 之后還要再 eval,總之,除了第一遍是 eval 以外,后面是否需要 base64 和 eval,以及需要的次數和先后順序,都是不確定的!舉幾個例子:
23.png
24.png
25.png

這里可能有人會問,你怎么看出來那是 base64 呢?很簡單,直接在網站頁面的控制臺里輸入 dswejwehxt,點擊去看這個函數,就是 base64:

26.png

那么針對 encrypt_xxxxxx.js 內容不確定的情況,我們可以寫一個方法,獲取到 encrypt_xxxxxx.js 后,需要執行 eval 就執行 eval,需要執行 base64 就執行 base64,直到沒有 eval 和 base64 即可,可以分別用字符串 eval(functiondswejwehxt( 來判斷是否需要 eval 和 base64(當然也有其他方式,比如 () 的個數等),示例代碼如下所示:

def get_decrypted_js(encrypted_js_url):
    """
    :param encrypted_js_url: encrypt_xxxxxx.js 的地址
    :return: 解密后的 JS
    """
    decrypted_js = requests.get(url=encrypted_js_url, headers=headers).text
    flag = True
    while flag:
        if "eval(function" in decrypted_js:
            # 需要執行 eval
            print("需要執行 eval!")
            replace_js = decrypted_js.replace("eval(function", "(function")
            decrypted_js = execjs.eval(replace_js)
        elif "dswejwehxt(" in decrypted_js:
            # 需要 base64 解碼
            base64_num = decrypted_js.count("dswejwehxt(")
            print("需要 %s 次 base64 解碼!" % base64_num)
            decrypted_js = re.findall(r"\('(.*?)'\)", decrypted_js)[0]
            num = 0
            while base64_num > num:
                decrypted_js = base64.b64decode(decrypted_js).decode()
                num += 1
        else:
            # 得到明文
            flag = False
    # print(decrypted_js)
    return decrypted_js

本地改寫

通過以上函數我們就拿到了動態的 JS 了,那么我們可以直接執行拿回來的 JS 嗎?當然是不可以的,你可以自己本地執行一下,可以發現里面的 CryptoJS、Base64、hex_md5 都需要補齊才行,所以到這里我們就有兩種做法:

  1. 拿到解密后的動態 JS 后,動態 JS 和我們自己寫的 Base64、hex_md5 等方法組成新的 JS 代碼,執行新的 JS 代碼拿到參數,這里還需要注意因為里面的其他方法名都是動態的,所以你還得想辦法匹配到正確的方法名來調用才行,所以這種方法個人感覺還是稍微有點兒麻煩的;
  2. 我們本地自己寫一個 JS,拿到解密后的動態 JS 后,把里面的 key、iv、appId、data 鍵名、param 是否需要 AES 或 DES 加密,這些信息都匹配出來,然后傳給我們自己寫的 JS,調用我們自己的方法拿到加密結果。

雖然兩種方法都很麻煩,但K哥暫時也想不到更好的解決方法了,有比較好的想法的朋友可以留言說一說。

以第二種方法為例,我們本地的 JS 示例(main.js):

var CryptoJS = require("crypto-js");

var BASE64 = {
    encrypt: function (text) {
        return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(text))
    },
    decrypt: function (text) {
        return CryptoJS.enc.Base64.parse(text).toString(CryptoJS.enc.Utf8)
    }
};

var DES = {
    encrypt: function (text, key, iv) {
        var secretkey = (CryptoJS.MD5(key).toString()).substr(0, 16);
        var secretiv = (CryptoJS.MD5(iv).toString()).substr(24, 8);
        secretkey = CryptoJS.enc.Utf8.parse(secretkey);
        secretiv = CryptoJS.enc.Utf8.parse(secretiv);
        var result = CryptoJS.DES.encrypt(text, secretkey, {
            iv: secretiv,
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        });
        return result.toString();
    },
    decrypt: function (text, key, iv) {
        var secretkey = (CryptoJS.MD5(key).toString()).substr(0, 16);
        var secretiv = (CryptoJS.MD5(iv).toString()).substr(24, 8);
        secretkey = CryptoJS.enc.Utf8.parse(secretkey);
        secretiv = CryptoJS.enc.Utf8.parse(secretiv);
        var result = CryptoJS.DES.decrypt(text, secretkey, {
            iv: secretiv,
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        });
        return result.toString(CryptoJS.enc.Utf8);
    }
};

var AES = {
    encrypt: function (text, key, iv) {
        var secretkey = (CryptoJS.MD5(key).toString()).substr(16, 16);
        var secretiv = (CryptoJS.MD5(iv).toString()).substr(0, 16);
        secretkey = CryptoJS.enc.Utf8.parse(secretkey);
        secretiv = CryptoJS.enc.Utf8.parse(secretiv);
        var result = CryptoJS.AES.encrypt(text, secretkey, {
            iv: secretiv,
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        });
        return result.toString();
    },
    decrypt: function (text, key, iv) {
        var secretkey = (CryptoJS.MD5(key).toString()).substr(16, 16);
        var secretiv = (CryptoJS.MD5(iv).toString()).substr(0, 16);
        secretkey = CryptoJS.enc.Utf8.parse(secretkey);
        secretiv = CryptoJS.enc.Utf8.parse(secretiv);
        var result = CryptoJS.AES.decrypt(text, secretkey, {
            iv: secretiv,
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        });
        return result.toString(CryptoJS.enc.Utf8);
    }
};

function getDecryptedData(data, AES_KEY_1, AES_IV_1, DES_KEY_1, DES_IV_1) {
    data = AES.decrypt(data, AES_KEY_1, AES_IV_1);
    data = DES.decrypt(data, DES_KEY_1, DES_IV_1);
    data = BASE64.decrypt(data);
    return data;
}

function ObjectSort(obj) {
    var newObject = {};
    Object.keys(obj).sort().map(function (key) {
        newObject[key] = obj[key];
    });
    return newObject;
}

function getRequestParam(method, obj, appId) {
    var clienttype = 'WEB';
    var timestamp = new Date().getTime()
    var param = {
        appId: appId,
        method: method,
        timestamp: timestamp,
        clienttype: clienttype,
        object: obj,
        secret: CryptoJS.MD5(appId + method + timestamp + clienttype + JSON.stringify(ObjectSort(obj))).toString()
    };
    param = BASE64.encrypt(JSON.stringify(param));
    return param;
}

function getRequestAESParam(requestMethod, requestCity, appId, AES_KEY_2, AES_IV_2){
    var param = getRequestParam(requestMethod, requestCity, appId);
    return AES.encrypt(param, AES_KEY_2, AES_IV_2);
}

function getRequestDESParam(requestMethod, requestCity, appId, DES_KEY_2, DES_IV_2){
    var param = getRequestParam(requestMethod, requestCity, appId);
    return DES.encrypt(param, DES_KEY_2, DES_IV_2);
}

我們匹配 JS 里面的各項參數的 Python 代碼示例(匹配8個 key、iv 值、appId 和 param 的加密方式):

def get_key_iv_appid(decrypted_js):
    """
    :param decrypted_js: 解密后的 encrypt_xxxxxx.js
    :return: 請求必須的一些參數
    """
    key_iv = re.findall(r'const.*?"(.*?)";', decrypted_js)
    app_id = re.findall(r"var appId.*?'(.*?)';", decrypted_js)
    request_data_name = re.findall(r"aqistudyapi.php.*?data.*?{(.*?):", decrypted_js, re.DOTALL)

    # 判斷 param 是 AES 加密還是 DES 加密還是沒有加密
    if "AES.encrypt(param" in decrypted_js:
        request_param_encrypt = "AES"
    elif "DES.encrypt(param" in decrypted_js:
        request_param_encrypt = "DES"
    else:
        request_param_encrypt = "NO"

    key_iv_appid = {
        # key 和 iv 的位置和原來 js 里的是一樣的
        "aes_key_1": key_iv[0],
        "aes_iv_1": key_iv[1],
        "aes_key_2": key_iv[2],
        "aes_iv_2": key_iv[3],
        "des_key_1": key_iv[4],
        "des_iv_1": key_iv[5],
        "des_key_2": key_iv[6],
        "des_iv_2": key_iv[7],
        "app_id": app_id[0],
        # 發送請求的 data 的鍵名
        "request_data_name": request_data_name[0].strip(),
        # 發送請求的 data 值需要哪種加密
        "request_param_encrypt": request_param_encrypt
    }
    # print(key_iv_appid)
    return key_iv_appid

我們發送請求以及解密返回值的 Python 代碼示例(以北京為例):

def get_data(key_iv_appid):
    """
    :param key_iv_appid: get_key_iv_appid() 方法返回的值
    """
    request_method = "GETDATA"
    request_city = {"city": "北京"}
    with open('main.js', 'r', encoding='utf-8') as f:
        execjs_ = execjs.compile(f.read())

    # 根據不同加密方式調用不同方法獲取請求加密的 param 參數
    request_param_encrypt = key_iv_appid["request_param_encrypt"]
    if request_param_encrypt == "AES":
        param = execjs_.call(
            'getRequestAESParam', request_method, request_city,
            key_iv_appid["app_id"], key_iv_appid["aes_key_2"], key_iv_appid["aes_iv_2"]
        )
    elif request_param_encrypt == "DES":
        param = execjs_.call(
            'getRequestDESParam', request_method, request_city,
            key_iv_appid["app_id"], key_iv_appid["des_key_2"], key_iv_appid["des_iv_2"]
        )
    else:
        param = execjs_.call('getRequestParam', request_method, request_city, key_iv_appid["app_id"])
    data = {
        key_iv_appid["request_data_name"]: param
    }
    response = requests.post(url=aqistudy_api, headers=headers, data=data).text
    # print(response)

    # 對獲取的加密數據解密
    decrypted_data = execjs_.call(
        'getDecryptedData', response,
        key_iv_appid["aes_key_1"], key_iv_appid["aes_iv_1"],
        key_iv_appid["des_key_1"], key_iv_appid["des_iv_1"]
    )
    print(json.loads(decrypted_data))

運行結果,成功請求并解密返回值:

27.png
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,885評論 6 541
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,312評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,993評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,667評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,410評論 6 411
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,778評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,775評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,955評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,521評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,266評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,468評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,998評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,696評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,095評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,385評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,193評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,431評論 2 378

推薦閱讀更多精彩內容