JavaScript逆向之七麥資料實戰

sbhg發表於2024-03-18

知識點

Promise物件

Promise物件是ES6版本中提供的,主要是為了解決死亡回撥的問題。
先看一段程式碼:

點選檢視程式碼
function fn() {
    let username = "alex";
    let password = "123456";

    // 傳送請求給伺服器要求登入
    console.log("傳送請求出去,嘗試登入");
    setTimeout(function () {
        console.log("伺服器返回了一個結果");
        let result_1 = true;
        if(result_1===true){    //登入成功
            // 載入選單資訊
            console.log("準備載入選單資訊");
            setTimeout(function () {
                console.log("顯示選單的資訊");
                // 載入使用者資訊
                console.log("準備載入使用者資訊");
                setTimeout(function () {
                    console.log("顯示使用者資訊");
                },1000)
            }, 1000);
        }
    }, 1000);
}

該程式碼是登入網站後網站一步步顯示資訊的一個demo,可以看到裡面存在很多的巢狀,如果想要解決多層巢狀的問題,就可以採用Promise物件,看如下demo:

點選檢視程式碼
function send(url) {
  // promise:確保,保證
  // reslove:解決了
  // reject:拒絕

  return new Promise(function (resolve, reject) {
      console.log("幫你傳送一個請求到", url);   // 答應你的一件事
      let result = 123;
      if (result) {
          // 這件事我辦成了
          // 接下來你要做的事應該是呼叫這個函式的那個人去寫
          resolve(i);    //這裡代表當前任務被解決
      } else {
          // 這件事沒辦成
          reject(i);     // 這裡代表當前任務沒解決
      }
  });
}

function fn() {
    let username = "";
    let password = "";

    //傳送請求到登入
    send("xxxxx").then(function (data) {
        console.log("登入的結果");
        console.log("登入返回的結果是",data);
        return send("載入選單");
    }).then(function (data) {
        // 載入選單
        console.log("載入選單得到的資訊",data);
        return send("載入個人資訊")
    }).then(function (data) {
        // 載入個人資訊
        console.log("載入個人資訊得到的資訊", data);
    })
}

send函式中會返回一個Promise物件,如果成功了就會執行resolve對應的函式,失敗了就執行reject對應的函式。在fn函式中省略了reject對應的函式,因為一般會在Promise物件的最後加一個catch,只要失敗了,就直接走catch中的函式,把整個程式碼抽象一下如下:

點選檢視程式碼
//鏈式邏輯
new Promise(function (a, b) {}).then(function () {
    return new Promise();
},function () {

}).then(function () {
    return new Promise();
}).then(function () {

}).catch(function () {
    console.log("程式出錯,請聯絡管理員....")
})

axios攔截器

axios是一個基於Promise的網路請求庫,網站如果採用的是axios方法,那麼加密和解密的邏輯大機率存在於axios攔截器中。
axios攔截器分為請求攔截器和響應攔截器,加密邏輯大機率在請求攔截器中,解密邏輯大機率在響應攔截器中,下面看axios攔截器使用的程式碼:

點選檢視程式碼
// 請求的攔截器
axios.interceptors.request.use(function (config) {
    console.log(config, "你好啊");
    // 嘗試修改請求引數
    config.data['hehe'] = "i love you";
    return config;
}, function (err) {
    console.log(err);
});

// 響應的攔截器
axios.interceptors.response.use(function (response) {
    console.log(response);
    // 這裡一般會有什麼???    解密操作

    return response.data;   // 攔截器返回的東西直接給到then中的函式
}, function (err) {
    console.log(err);
});

上面兩個知識點講完,就該進入正篇了。

七麥資料實戰

url:https://www.qimai.cn/rank
滑動頁面,抓包,老樣子還是看Fetch/XHR型別的。

有三個資料包,樣式都一樣,看下它們的請求引數和響應資料。




這樣子就知道0對應的是付費榜,1對應的是免費榜,2對應的是暢銷榜。既然三個請求引數都一樣,那就以其中一個為例即可。
請求頭中就一個analysis引數的值是加密的,那目標就是知道該引數的值如何加密的。
按照慣例,搜尋url。


總共三處地方,但這三處全是賦值操作,沒有其他的程式碼,那麼搜尋url就失效了,接下來搜尋analysis關鍵詞。

三處地方,但analysis都位於url地址中,根本不可能是給analysis引數賦值的,所以這也失效了,最後只能透過Initiator來找了。

明顯的看到了Promise物件,就可以聯想到axios攔截器了,搜尋interceptors

也是三處,第一處是個賦值,不可能是加密邏輯,看下第二處和第三處整個的邏輯。

點選檢視程式碼
l.prototype.request = function(e) {
            "string" == typeof e ? (e = arguments[1] || {}).url = arguments[0] : e = e || {},
            (e = s(this.defaults, e)).method ? e.method = e.method.toLowerCase() : this.defaults.method ? e.method = this.defaults.method.toLowerCase() : e.method = "get";
            var t = [o, void 0]
              , n = Promise.resolve(e);
            for (this.interceptors.request.forEach((function(e) {
                t.unshift(e.fulfilled, e.rejected)
            }
            )),
            this.interceptors.response.forEach((function(e) {
                t.push(e.fulfilled, e.rejected)
            }
            )); t.length; )
                n = n.then(t.shift(), t.shift());
            return n
        }

先對e進行型別判斷和值的重新賦值,然後宣告t為陣列和n為Promise物件,接著兩個for迴圈,請求攔截器中遍歷往t陣列的頭部插入元素,響應攔截器遍歷往t陣列的尾部插入元素,可以看到遍歷完成後,t陣列中總共有6個物件,最後從t陣列的頭部彈出兩個元素交給Promise物件的then函式執行。

根據Promise物件的then函式可以知道,會給其傳兩個引數,成功了執行第一個引數,失敗了執行第二個引數。所以如果這裡存在加密邏輯的話,那麼一定在t陣列的第一個引數處,定位。


從以下三個變數的值也可以看出沒找錯地方。

這段程式碼中存在非常多的花指令,得先將其還原,打斷點進行除錯,還原出來的程式碼如下。(catch中函式就不用管了)

點選檢視程式碼
function fn(t) {
    var n;
    n = i["ej"]("synct"),
        s = c["default"]["prototype"]["difftime"] = -i["ej"]("syncd") || +new z["Date"] - 1000 * n;
    var e, r = +new z["Date"] - (s || 0) - 1661224081041, a = [];
    return void 0 === t["params"] && (t["params"] = {}),
        z["Object"]["keys"](t["params"])["forEach"](function (n) {
            if (n == "analysis")
                return !1;
            t["params"]["hasOwnProperty"](n) && a["push"](t["params"][n])
        }),
        a = a["sort"]()["join"](""),
        a = i["cv"](a),
        a = (a += "@#" + t["url"]["replace"](t["baseURL"], "")) + ("@#" + r) + ("@#" + 3),
        e = i["cv"](i["oZ"](a, "xyz517cda96efgh")),
    -B == t["url"]["indexOf"]("analysis") && (t["url"] += (-B != t["url"]["indexOf"]("?") ? "&" : "?") + "analysis" + "=" + z["encodeURIComponent"](e)),
        t
}

接下來分析這段程式碼。
n = i["ej"]("synct")用於獲取cookie中synct的值。

s = c["default"]["prototype"]["difftime"] = -i["ej"]("syncd") || +new z["Date"] - 1000 * n;用於獲取cookie中syncd中的值,如果cookie中沒有syncd,則s=new Date()-1000*n

r = +new z["Date"] - (s || 0) - 1661224081041就是計算一個時間差,這個值不是固定的,所以我們可以直接把s的值固定,上面兩行程式碼就沒用了。
void 0 === t[Zt] && (t[Zt] = {})就是false。

z["Object"]["keys"](t["params"])["forEach"](function (n) { if (n == "analysis") return !1; t["params"]["hasOwnProperty"](n) && a["push"](t["params"][n]) })遍歷t["params"]中的所有鍵,將對應的值全部存放到a陣列中,z是window物件,故z["Object"]["keys"]等同於Object["keys"]
a = a["sort"]()["join"]("")對a陣列進行排序,並用空字串連線。
a = i["cv"](a)需要知道i["cv"]是什麼,等下直接把原始碼複製進來即可。
a = (a += "@#" + t["url"]["replace"](t["baseURL"], "")) + ("@#" + r) + ("@#" + 3)a的值進行拼接。
e = i["cv"](i["oZ"](a, "xyz517cda96efgh"))同理,直接複製原始碼。
-B == t["url"]["indexOf"]("analysis") && (t["url"] += (-B != t["url"]["indexOf"]("?") ? "&" : "?") + "analysis" + "=" + z["encodeURIComponent"](e))判斷url背後的引數是用&還是?連線。這裡最主要的是要得到e的值,直接返回e即可。
到目前為止,化簡後的程式碼為:

點選檢視程式碼
function fn(t) {
   var e, r = new Date() + 226 - 1661224081041, a = [];
    return false,
        Object["keys"](t["params"])["forEach"](function (n) {
            if (n == "analysis")
                return !1;
            t["params"]["hasOwnProperty"](n) && a["push"](t["params"][n])
        }),
        a = a["sort"]()["join"](""),
        a = i["cv"](a),
        a = (a += "@#" + t["url"]["replace"](t["baseURL"], "")) + ("@#" + r) + ("@#" + 3),
        e = i["cv"](i["oZ"](a, "xyz517cda96efgh")),
        e;
}

下面就是要去補全i["cv"]i["oZ"]和這兩個函式中用到的其他變數,花指令該還原就還原。
補全和還原後的程式碼如下:

點選檢視程式碼
function o(n) {
    t = "",
    ['66', '72', '6f', '6d', '43', '68', '61', '72', '43', '6f', '64', '65']["forEach"](function(n) {
        t += unescape("%u00" + n)
    });
    var t, e = t;
    return String[e](n)
}

function u() {
    return unescape("861831832863830866861836861862839831831839862863839830865834861863837837830830837839836861835833"["replace"](/8/g, "%u00"))
}

var i = {
    cv:function v(t) {
        t = encodeURIComponent(t)["replace"](/%([0-9A-F]{2})/g, function(n, t) {
            return o("0x" + t)
        });
        try {
            return btoa(t)
        } catch (n) {
            return Buffer["from"](t)["toString"]("base64")
        }
    },
    oZ:function h(n, t) {
        t = t || u();
        for (var e = (n = n["split"](""))["length"], r = t["length"], a = "charCodeAt", i = 0; i < e; i++)
            n[i] = o(n[i][a](0) ^ t[(i + 10) % r][a](0));
        return n["join"]("")
    }
};


function fn(t) {
   var e, r = new Date() + 226 - 1661224081041, a = [];
    return false,
        Object["keys"](t["params"])["forEach"](function (n) {
            if (n == "analysis")
                return !1;
            t["params"]["hasOwnProperty"](n) && a["push"](t["params"][n])
        }),
        a = a["sort"]()["join"](""),
        a = i["cv"](a),
        a = (a += "@#" + t["url"]["replace"](t["baseURL"], "")) + ("@#" + r) + ("@#" + 3),
        e = i["cv"](i["oZ"](a, "xyz517cda96efgh")),
        e;
}

測試一下。

點選檢視程式碼
var t = {
    "url": "/rank/indexPlus/brand_id/1",
    "method": "get",
    "headers": {
        "common": {
            "Accept": "application/json, text/plain, */*"
        },
        "delete": {},
        "get": {},
        "head": {},
        "post": {
            "Content-Type": "application/x-www-form-urlencoded"
        },
        "put": {
            "Content-Type": "application/x-www-form-urlencoded"
        },
        "patch": {
            "Content-Type": "application/x-www-form-urlencoded"
        }
    },
    "params": {},
    "baseURL": "https://api.qimai.cn",
    "transformRequest": [
        null
    ],
    "transformResponse": [
        null
    ],
    "timeout": 15000,
    "withCredentials": true,
    "xsrfCookieName": "XSRF-TOKEN",
    "xsrfHeaderName": "X-XSRF-TOKEN",
    "maxContentLength": -1,
    "maxBodyLength": -1
}

console.log(fn(t));

執行結果如下:

得到了跟analysis引數值相似的字串,說明我們找到了加密的邏輯,接下來就可以寫python程式碼爬取資料了,完整的python程式碼和JavaScript程式碼如下:
JavaScript程式碼:

點選檢視程式碼
function o(n) {
    t = "",
    ['66', '72', '6f', '6d', '43', '68', '61', '72', '43', '6f', '64', '65']["forEach"](function(n) {
        t += unescape("%u00" + n)
    });
    var t, e = t;
    return String[e](n)
}

function u() {
    return unescape("861831832863830866861836861862839831831839862863839830865834861863837837830830837839836861835833"["replace"](/8/g, "%u00"))
}

var i = {
    cv:function v(t) {
        t = encodeURIComponent(t)["replace"](/%([0-9A-F]{2})/g, function(n, t) {
            return o("0x" + t)
        });
        try {
            return btoa(t)
        } catch (n) {
            return Buffer["from"](t)["toString"]("base64")
        }
    },
    oZ:function h(n, t) {
        t = t || u();
        for (var e = (n = n["split"](""))["length"], r = t["length"], a = "charCodeAt", i = 0; i < e; i++)
            n[i] = o(n[i][a](0) ^ t[(i + 10) % r][a](0));
        return n["join"]("")
    }
};


function fn(t) {
   var e, r = new Date() + 226 - 1661224081041, a = [];
    return false,
        Object["keys"](t["params"])["forEach"](function (n) {
            if (n == "analysis")
                return !1;
            t["params"]["hasOwnProperty"](n) && a["push"](t["params"][n])
        }),
        a = a["sort"]()["join"](""),
        a = i["cv"](a),
        a = (a += "@#" + t["url"]["replace"](t["baseURL"], "")) + ("@#" + r) + ("@#" + 3),
        e = i["cv"](i["oZ"](a, "xyz517cda96efgh")),
        e;
}

function final(url, pm) {
    var params = {
        "url": url,
        "baseURL": "https://api.qimai.cn",
        "params":pm,
    };
    return fn(params);
}

python程式碼:

點選檢視程式碼
import subprocess
from functools import partial

subprocess.Popen = partial(subprocess.Popen, encoding="utf-8")

import execjs
import json
import requests

f = open("攔截器邏輯二.js", mode="r", encoding="utf-8")
js = execjs.compile(f.read())
f.close()

data = {
    "brand": "all",
    "country": "cn",
    "date": "2024-03-18",
    "device": "iphone",
    "genre": "36",
    "page": 2,
}

host = "https://api.qimai.cn"
url = "/rank/indexPlus/brand_id/1"
analysis = js.call("final", url, data)
final_url = host+url+"?analysis=" + analysis
# print(final_url)

session = requests.session()
session.headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 "
    "Safari/537.36",
}
# 載入最開始的cookie
session.get("https://www.qimai.cn/rank")

# 經過測試,這玩意沒什麼用
session.cookies["qm_check"] = "A1sdRUIQChtxen8pI0dAMRcOUFseEHBeQF0JTjVBWCwycRd1QlhAXFEGFUdeS0laHQdKAAkABAsgXyVBWD0TR1JRRAp0BQlFEBQ3TSZKFUdBbwxvBBRFIlQsSUhTFxsQU1FVV1NHXEVYVElWBRsCHAkSSQ%3D%3D"

# 開幹
resp = session.get(final_url)
decoded_text = bytes(resp.text, 'utf-8').decode('unicode_escape')
print(decoded_text)

執行python程式碼結果如下:

成功拿到資料。

相關文章