初始准备

以爬取此页面中APP榜单为例:
七麦榜单首页

向下滑动发现是动态加载:
发现API

找到API,发现有一加密参数,且为动态变化
加密参数

尝试XHR重播,仍然成功获取:
XHR重播

基本断定和时间戳无关

开始逆向

查询参数名

尝试搜寻加密的参数名analysis
搜寻参数名

无果,转求其它方法

使用XHR断点

根据之前发现的请求:

https://api.qimai.cn/rank/index?analysis=ezUnUysSLA16W3ZTKQtwQSwIKhA1LT9BfF0iHi9UeBQ4DS4UIy1ZRlYkXRRjZ1xfI0dXCxtbWhgKCgVcTiFBVFNISkwFBwdSUyEaBQ==&brand=free&device=iphone&country=cn&genre=36&date=2022-11-20&page=2&is_rank_index=1&snapshot=14:50:04

可以下如下XHR断点:
XHR断点

继续滚动页面获取新榜单列表或刷新页面重新获取榜单列表来触发断点:
断点成功触发

单步跳过函数调用,直到此段代码,可以看到回调函数以及一些变量值和我们发现的API相关:
单步跳过函数直到此处

单步执行函数调用,进去函数可以发现这段函数确实与我们需要逆向的API相关,可以看到变量t的值接收了网络调用返回:
t

但我们的目标是请求参数中的加密参数analysis,因此我们可以尝试继续单步跳过函数调用,直到:
诡异处
可以看到[Kt][Xt][Ft]分别对应的是['interceptors']['response']['use'],这里补充一个知识点:

在JavaScript中,可以使用点.的形式或[]这两种操作符来访问对象属性,但是它们区别是:.操作符是静态的,右侧必须是一个对象属性名称,而[]操作符则不同,属性名通过字符串表示,这意味着在代码运行中,可以通过修改这个字符串来达到动态访问目的

我们可以大胆猜测此处是一个响应拦截器,我们需要的是请求拦截器,一般来说这两个拦截器应该放在一起,向上搜寻果然发现[Kt][Ut][Ft],可以发现Utrequest,即我们需要的请求拦截器,因此在代码段首尾下断点:
拦截器

根据断点追溯

前面通过XHR断点追溯到拦截器并在其首位下断点,现在准备使用断点来追溯,取消XHR断点后取消调试然后继续滚动获取新数据或者刷新页面,代码执行至断点:
首断点

我们恢复脚本执行一次,让代码执行到第二个断点,可以适当单步跳过下一个函数调用来观察变量值的变化来搜寻目标参数值:
尾断点

与当前正在发送的请求参数对比,可以发现断点处的e变量值有一些相似
正在发送的请求参数

我们可以大胆猜测,可能是最后一行代码对e等变量值进行了拼接处理,为了验证这个猜测,我们在控制台中手动执行一次最后一行代码(注意复制):
GET IT

可以发现已经成功获得加密参数

分析加密

前面步骤成功追溯到加密关键处,现在开始分析加密过程

我们可以方便地使用鼠标来获取到变量的值:
鼠标获取变量值
可以获知:

p+B1+z[V1](e)

便是以下代码:

'analysis' + '=' + Window.encodeURIComponent(e)

其中Window.encodeURIComponent()用于将字符串编码成URL,那么参数e便是逆向关键

向上追溯,发现与e相关代码:

e = (0, i[jt])((0,i[qt])(a, d))

此处涉及一个技巧:

对于(0,function)这种表达式,可以看作为(true && function)(0?0:function),这种间接调用的function保证了其在全局范围的执行

因此我们可以将代码拆分:

e = (0, i[jt])((0,i[qt])(a, d)); //拆分前
e = i[jt](i[qt](a, d)); //拆分后

现在,我们基本断点e便是加密处,我们仅需解决e的值来源问题即可完成逆向并获取到加密值

获取加密值

我们已经知晓了加密过程以及对应函数,现在应着手让我们自己也能生成加密值

使用“扣代码”方式获取加密值

现在我们待解决的问题是:

  • i[jt]函数
  • i[qt]函数
  • a值来源
  • d值来源

可以预见的是后面将会是混淆后的JS代码,用于加密参数,我们可以通过“扣代码” 的方式,将必要的加密函数复制下来为我们所用,如果使用Python来编写爬虫,我们仅仅需要将这些“扣”下来的代码,在需要的时候接收参数并返回加密后的analysis值供Python调用,这样便避免了完全了解加密过程和逻辑并改用Python重写加密函数,节约时间。

“扣代码”即将涉及到的加密代码复制到本地,然后将未知的变量进行替换操作,即补全代码,当然对于这些变量值,可能是动态生成,可能是写死,但是通过代码生成也可能是一个定值,以减少上面仅仅通过搜索就获取对应值的情况,对于这些本质上还是写死的值,我们大可不必深究下去,直接将其值补全至代码即可。

判断“写死值”最轻松办法即多次请求并调试(一般三次即可)关注其变化

先解决函数

我们先来解决两个函数,将ad值先分别到村到本地JS文件中,以用于函数调用

i[qt]开始,进入该函数,该函数为h(n,t)
函数h
当然,也可以在控制台输出该函数然后进入:
函数h

我们将该函数代码复制到本地JS文件中,并开始尝试补足缺少变量对应值。
本地代码如下:

var a = 'Mjg=@#/index/banner@#7707053466@#3'
var d = 'xyz517cda96abcd'

function h(n, t) {
    t = t || u();
    for (var e = (n = n[$1](_))[R], r = t[R], a = q1, i = H; i < e; i++)
        n[i] = o(n[i][a](H) ^ t[(i + 10) % r][a](H));
    return n[I1](_)
}

console.log(h(a,d))

通过控制台(获取鼠标来获取值),我们可以很方便地先将字符串类型的值进行替换,例如$1,通过控制台可以发现其值为split
替换值
我们可以猜测这可能为字符串split()方法,但是我们并不需要关心这么多,因为只需要关心成功运行并去的结果

依次类推,逐步替换未知变量,直到o()方法,同样进入该函数并复制到本地。注意,因为代码混淆过,因此o不一定是所需要的代码,若想获取o()的代码,应该通过调试h()处代码,让其执行到o()后停止,再进入,此时代码才是在h()中调用的代码。
此时本地代码为:

var a = 'Mjg=@#/index/banner@#7707053466@#3'
var d = 'xyz517cda96abcd'

function o(n) {
    t = _,
    [f2, s2, d2, m2, l2, v2, p2, s2, l2, d2, h2, y2][M](function(n) {
        t += z[g2](w2 + n)
    });
    var t, e = t;
    return z[b2][e](n)
}

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['(^| )']('')
}

console.log(h(a,d))

继续补足代码,直到z,前面已获知其为Window对象,而g2'unescape',即调用Window.unescape(),在本地代码中我们可以直接使用该函数(可以在浏览器中运行,如果成功运行,即可在本地运行),同理后面的z[b2]为直接调用String()
最终代码:

var a = 'Mjg=@#/index/banner@#7707053466@#3'
var d = 'xyz517cda96abcd'

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.fromCharCode(n)
}

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']('')
}

console.log(h(a,d))

同理,现在处理i[jt],进入函数,该函数为v(t)
进入函数1

函数1

将其复制到本地JS文件,补足缺少的变量对应值:

function v(t) {
    t = z[V1](t)[T](/%([0-9A-F]{2})/g, function(n, t) {
        return o(Y1 + t)
    });
    try {
        return z[Q1](t)
    } catch (n) {
        return z[W1][K1](t)[U1](Z1)
    }
}

最终代码:

var a = 'Mjg=@#/index/banner@#7707053466@#3'
var d = 'xyz517cda96abcd'

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.fromCharCode(n)
}

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 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')     //这里为异常处理,其实可以不用管,不用补足替换
    }
}

result = v(h(a,d))

console.log(result)

运行代码:
获得加密参数

成功获得加密参数

现在验证一下:
再次请求一次,获得a,d参数分别为

a = "MzZjbmZyZWVpcGhvbmU=@#/rank/index@#7714163242@#3"
d = "xyz517cda96abcd"

用以本地运行,对比结果:
FINAL
结果一致,至此两函数破解完毕

解决a,d值

再次观察拦截器处代码:
for a
涉及到a代码:

var e, r = +new z[W] - (s || H) - 1661224081041, a = [];
return void 0 === t[Zt] && (t[Zt] = {}),
z[Z][i7](t[Zt])[M](function(n) {
    if (n == p)
        return !B;
    t[Zt][N2](n) && a[b](t[Zt][n])
}),
a = a[Ot]()[I1](_),
a = (0,
i[jt])(a),
a = (a += v + t[Jt][T](t[Mt], _)) + (v + r) + (v + 3),

基本可以断定a就诞生于这段代码,
我们可以先在此处下断点

return void 0 === t[Zt] && (t[Zt] = {}),
z[Z][i7](t[Zt])[M](function(n) {
    if (n == p)
        return !B;
    t[Zt][N2](n) && a[b](t[Zt][n])
}),

观察到a值为: ['free', 'iphone', 'cn', '36', '2022-11-20', 4, 1, '17:46:04'],与下面tparams类似
我们试着把

z[Z][i7](t[Zt])[M](function(n) {
    if (n == p)
        return !B;
    t[Zt][N2](n) && a[b](t[Zt][n])
}),

补全

var a = []
Object.keys(t['params'])['forEach'](function(n) {
    if (n == 'analysis')
        return !1;
    t['params']['hasOwnProperty'](n) && a['push'](t['params'][n])
})
console.log(a)

运行结果:

[ 'free', 'iphone', 'cn', '36', '2022-11-20', 4, 1, '17:46:04' ]

继续将代码复制到本地并补全,并封装成一个函数:

function a_make(){
    var a = []
    Object.keys(t['params'])['forEach'](function(n) {
        if (n == 'analysis')
            return !1;
        t['params']['hasOwnProperty'](n) && a['push'](t['params'][n])
    })
    a = a[Ot]()[I1](_)
    a = (0,i[jt])(a)
    a = (a += v + t[Jt][T](t[Mt], _)) + (v + r) + (v + 3),

其中rnew z[W] - (s || H) - 1661224081041
补足为new Date() - (-208||0) - 1661224081041

发现t为对象:

t = {
    "url": "/rank/index",
    "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": {
        "brand": "free",
        "device": "iphone",
        "country": "cn",
        "genre": "36",
        "date": "2022-11-20",
        "page": 3,
        "is_rank_index": 1,
        "snapshot": "17:46:04"
    },
    "baseURL": "https://api.qimai.cn",
    "transformRequest": [
        null
    ],
    "transformResponse": [
        null
    ],
    "timeout": 15000,
    "withCredentials": true,
    "xsrfCookieName": "XSRF-TOKEN",
    "xsrfHeaderName": "X-XSRF-TOKEN",
    "maxContentLength": -1,
    "maxBodyLength": -1
}

这个对象基本可以作为一个定值使用(基本是API信息,因此基本不会改动),甚至可以将加密中对应调用处直接改写成对应值,例如:
t[Jt]补全后为 t['url'],对应值为/rank/index,可以直接将此处改写成/rank/index
此处暂时将其作为参数传入,最终代码为:

var t = {
    "url": "/rank/index",
    "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": {
        "brand": "free",
        "device": "iphone",
        "country": "cn",
        "genre": "36",
        "date": "2022-11-20",
        "page": 3,
        "is_rank_index": 1,
        "snapshot": "17:46:04"
    },
    "baseURL": "https://api.qimai.cn",
    "transformRequest": [
        null
    ],
    "transformResponse": [
        null
    ],
    "timeout": 15000,
    "withCredentials": true,
    "xsrfCookieName": "XSRF-TOKEN",
    "xsrfHeaderName": "X-XSRF-TOKEN",
    "maxContentLength": -1,
    "maxBodyLength": -1
}

function a_make(t){
    var r = new Date() - (-208||0) - 1661224081041
    var a = []
    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 = v(a)
    a = (a += '@#' + t['url']['replace'](t['baseURL'], '')) + ('@#' + r) + ('@#' + 3)
    return a
}

至此a值破解完毕,现在关注d值。

在拦截器代码中,并未发现d值的赋值操作,转而寻求拦截器函数上层函数代码,找到如下代码:
d

d = (0, i[zt])(Rt, B)
d = i[zt](Rt, B)

初步补全:

d = i[zt]('qimai@2022&Technology',1)

现在关注函数i[zt],进入该函数,函数为y(n,t,e)
y

复制到本地并补全,最终涉及到d的代码为:

function y(n, t, e) {
    for (var r = void 0 === e ? 2166136261 : e, a = 0, i = n['length']; a < i; a++)
        r = (r ^= n['charCodeAt'](a)) + ((r << 1) + (r << 4) + (r << 7) + (r << 8) + (r << 24));
    return t ? ('xyz' + (r >>> 0)['toString'](16) + 'abcd')['substr'](-16) : r >>> 0
}

function d_make(){
    return y('qimai@2022&Technology',1)
}

至此为止,先前的两函数和两变量都已逆向完毕,进行包装后本地代码如下:

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.fromCharCode(n)
}

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 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 z[W1][K1](t)[U1](Z1)
        return Buffer['from'](t)['toString']('base64')
    }
}

function y(n, t, e) {
    for (var r = void 0 === e ? 2166136261 : e, a = 0, i = n['length']; a < i; a++)
        r = (r ^= n['charCodeAt'](a)) + ((r << 1) + (r << 4) + (r << 7) + (r << 8) + (r << 24));
    return t ? ('xyz' + (r >>> 0)['toString'](16) + 'abcd')['substr'](-16) : r >>> 0
}


function a_make(t){
    var r = new Date() - (-208||0) - 1661224081041
    a = []
    a = a['sort']()['join']('')
    a = v(a)
    a = (a += '@#' + t['url']['replace'](t['baseURL'], '')) + ('@#' + r) + ('@#' + 3)
    return a
}

function d_make(){
    return y('qimai@2022&Technology',1)
}

function get_analysis(t){
    return v(h(a_make(t),d_make()))
}

var t = {
    "url": "/rank/index",
    "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": {
        "brand": "free",
        "device": "iphone",
        "country": "cn",
        "genre": "36",
        "date": "2022-11-20",
        "page": 3,
        "is_rank_index": 1,
        "snapshot": "17:46:04"
    },
    "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(get_analysis(t))

收尾

在逆向a值时,我们其实已经直到a的值与tparams有关

"params": {
        "brand": "free",
        "device": "iphone",
        "country": "cn",
        "genre": "36",
        "date": "2022-11-20",
        "page": 3,
        "is_rank_index": 1,
        "snapshot": "17:46:04"
}

其值与榜单页的选项有很大关系:
榜单页选项
而其中page则很容易推测出,是动态翻页(例如上面值为3,则表示请求第三页)

因此在实际应用中重点传递params,通过传递params即可生成加密值

通过配置相同的参数生成加密值,加密值一致
RESULT

Python脚本调用示例