进行这个逆向的原因是学校要求的每晚的打卡,我之前也尝试对该小程序的发出的数据进行抓包并尝试请求,当然正常打卡肯定是没问题,但是 Authorization 会在三天之后过期,所以这种方法无持久性,一旦过期就要重新抓包,很显然这并不优雅,因此我进行了这次的逆向研究,并且我也没在网上找到相关的资料,所以决定写完并分享出来。

整体流程梳理

一开始尝试分析小程序包,但没有发现有价值的信息,于是转向网页端进行分析,从二维码登录流程入手。

整理后可以得到整个流程如下:

1
2
3
4
5
6
7
8
9
10
11
获取 uuid

扫码二维码

轮询获取 wx_code

换取 Token

构造加密 Payload

调用业务接口

登入流程分析

获取 uuid

首先我们可以发现一个很可疑的请求

1
https://open.weixin.qq.com/connect/qrcode/081qn7WK3XnK000R

通过刷新网页,发现后面的 qrcode 后面的路径一直在发生改变,那么我们可以猜测这个是决定二维码生成的 id,之类的内容,那么我们需要继续分析 081qn7WK3XnK000R 是从那里获取的,发现另一个可疑的请求

1
https://open.weixin.qq.com/connect/qrconnect?appid=wx4a23ae4b8f291087&scope=snsapi_login&redirect_uri=https%3A%2F%2Fi.jielong.com%2Flogin-callback&state=&login_type=jssdk&self_redirect=true

我们先把这个请求的参数进行拆分

1
2
3
4
5
appid=wx4a23ae4b8f291087
scope=snsapi_login
redirect_uri=https://i.jielong.com/login-callback
login_type=jssdk
self_redirect=true
参数 说明
appid 应用唯一标识,用于微信的登录来源验证
scope 应用授权作用域,网页应用目前仅填写snsapi_login
response_type 微信验证完成之后返回的类型,官方开发者文档写这里应该填写 code,但是这里填写了 jssdk 原因未知
self_redirect true:手机点击确认登录后可以在 iframe 内跳转到 redirect_uri,false:手机点击确认登录后可以在 top window 跳转到 redirect_uri。默认为 false

好了,我们构造参数尝试请求看看是什么,打开网页看到了一个登录二维码,打开控制台发现那个请求 uuid 的已经发起了,我们继续观察请求,但是并没有发现什么,回头查看第一个响应只有一个 html 页面,下滑观察 js 部分,看到一个有趣的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
 function m() {
var e = jQuery.Deferred(),
t = window.location.href.replace(/#.*$/, '') + '&f=xml&' + (new Date).getTime();
return jQuery.ajax({
url: t,
type: 'GET',
dataType: 'xml',
cache: !1,
success: function(t) {
$('.js_refresh_qrcode_loading').hide(),
$('.js_refresh_qrcode_mask').hide(),
G = jQuery(t).find('uuid').text(),
$('.js_qrcode_img').attr('src', '/connect/qrcode/' + G),
setTimeout(r, 2000),
e.resolve()
},
error: function(t, n, o) {
$('.js_refresh_qrcode_loading').hide(),
$('.js_refresh_qrcode_mask').hide(),
$('#quick_login_error_toast').fadeIn(
300,
function() {
var e = this;
setTimeout(function() {
$(e).fadeOut(300)
}, 1000)
}
),
console.log('qrcode img error: ', n, o),
e.resolve()
}
}),
e.promise()
}

观察登录二维码请求函数 m() 可以发现,它会在 URL 上追加 &f=xml,并设置 dataType: 'xml'
因此可以确认,这里的 f=xml 用于指定接口返回 XML 格式数据:

1
2
3
4
5
6
7
8
9
<root>
<uuid>071O1WU436rTkl2T</uuid>
<appname>接龙管家</appname>
<appdesc>
为班级群、工作群、学习群...提供接龙、打卡、填表、问卷、考试、作业、投票、报名、预约、文件签字等各式信息收集功能,支持微信、QQ和PC多端使用
</appdesc>
<redirect_uri>https://i.jielong.com/login-callback</redirect_uri>
<usenewdomain>1</usenewdomain>
</root>

这里的 uuid,就是我们请求登录二维码的参数。

生成二维码

这一步并不难,观察刚才的请求,我们可以发现请求的格式是

1
https://open.weixin.qq.com/connect/qrcode/{uuid}

直接构造链接并打开就可以看到一张 jpg 的二维码图片,可以正常扫描

获取 wx_code

我们可以在请求中看到多次 https://lp.open.weixin.qq.com/connect/l/qrconnect?uuid={uuid} 的 GET 请求,查看响应如下

1
window.wx_errcode=408;window.wx_code='';

阅读开发者文档发现并没有写这个 errcode 是什么,通过测试得到以下结果,我无法保证准确仅供参考。

errcode 说明
408 未扫码
402 二维码已过期
404 登入被取消
405 成功登录并返回 wx_code

这一步建议使用轮询观察状态,我们成功登录之后拿到 wx_code 准备下一步。

Payload 加密分析

登入成功之后抓到一个请求,在这时的 authorization 还是 undefined

1
https://i-api.jielong.com/api/User/OpenAuth?code=021UYGkl2cOqth41oBml2lUt3M3UYGkE

很明显这个链接后面的 code 就是前文的 wx_code, 观察它的请求头发现有一个 X-Api-Request-Payload 后面跟着一段 par0NCtm+oBuEkBOI+w4...,不难发现这是一个加密后的签名字段。

定位 Payload 构造入口

通过搜索 X-Api-Request-Payload 找到一个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
async function R(e, t, n) {
const a = new AbortController,
r = a.signal,
o = (0, v.L)() + (e.startsWith('/') ? '' : '/') + e,
i = {
Authorization: (0, A.gf)(),
'X-Api-Request-Payload': S.getPayload(e.startsWith('/') ? e : '/' + e, 'POST'),
'Content-Type': 'application/json',
'X-Api-Request-Mode': 'cors'
},
s = new j(n.onChunkReceived);
return fetch(
o, {
method: 'POST',
headers: i,
mode: 'cors',
body: JSON.stringify(t),
signal: r
}
).then(
(
async e => {
const t = e.body.getReader();
!async function e() {
if (r.aborted) return;
const a = await t.read();
a.done ? (s.onChunkReceived(a.value || new Uint8Array), n.onSuccess()) : (s.onChunkReceived(a.value || new Uint8Array), await e())
}()
}
)
).catch((e => {
r.aborted ||
n.onError(e)
})), {
abort: () => {
null == a ||
a.abort()
}
}
}

Payload 构造函数分析

发现 X-Api-Request-Payload 通过调用 D.getPayload() 构造了,那么我们去看一下这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
getPayload(e, t) {
const n = P;
this['prepareR' + n(401) + 'er']();
const a = r()[n(378) + n(293)].pkcs7[n(415)](
r()[n(276) + 's'].utf8[n(390) + n(267)](
JSON[n(371) + n(292) + 'y']({
Timestamp: this.timestamp(),
Host: e,
RequestId: (0, c.A)(),
Referer: this[n(379) + n(396)],
Method: t
})
)
),
o = this['getO' + n(284) + 'tion']()[n(312) + 'ypt'](a);
return (0, l.iI)(o)
}

Payload 数据结构

我们通过断点拿到以下结构

1
2
3
4
5
6
7
{
"Timestamp": 1710000000000,
"Host": "/api/User/OpenAuth",
"RequestId": "随机UUID",
"Referer": "https://i.jielong.com/",
"Method": "POST"
}

Payload 设计分析

可以看到 Host 和 Method 被纳入加密内容中,因此该 Payload 与具体接口绑定,无法直接复用于其他接口请求。
此外,Timestamp 字段也参与加密,说明该 Payload 同时具备一定的防重放能力。
结合 Host、Method 和 Timestamp,可以推测该字段主要用于防止请求被篡改或复用。

加密实现分析

首先我们先看看 c.A 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const c = function(e, t, n) {
if (r.randomUUID && !t && !e) return r.randomUUID();
const s = (e = e || {}).random ?? e.rng?.() ?? function() {
if (!o) {
if ('undefined' == typeof crypto || !crypto.getRandomValues) throw new Error(
'crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported'
);
o = crypto.getRandomValues.bind(crypto)
}
return o(i)
}();
if (s.length < 16) throw new Error('Random bytes length must be >= 16');
if (s[6] = 15 & s[6] | 64, s[8] = 63 & s[8] | 128, t) {
if ((n = n || 0) < 0 || n + 16 > t.length) throw new RangeError(`UUID byte range ${ n }:${ n + 15 } is out of buffer bounds`);
for (let e = 0; e < 16; ++e) t[n + e] = s[e];
return t
}
return a(s)
}

很明显这是一个标准的 UUID v4 生成实现。

通过组合 o 变量得到 getOperation,查找找到了这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
getOperation() {
const e = P;
if (v.O) {
const t = e(335) + e(364) + '4wvo' + e(417) + '59sA' + e(272) + e(301) + 'g1UX',
n = e(257) + e(257) + 'AAAA' + e(257);
return new(r()[e(334) + e(416) + e(299) + 'ion'][e(304)])(
r()[e(276) + 's'][e(241)][e(390) + e(267)](t),
r().utils[e(241)]['toBy' + e(267)](n)
)
} {
const t = e(250) + e(306) + e(269) + e(400) + 'PFW3' + e(338) + e(388) + 'Xo1v',
n = 'AAAA' + e(257) + e(257) + e(257);
return new(r()[e(334) + e(416) + e(299) + e(319)][e(304)])(
r()[e(276) + 's'][e(241)][e(390) + e(267)](t),
r()[e(276) + 's'].utf8[e(390) + e(267)](n)
)
}
}

通过 n 和 t 组合而成的,可以确认这里使用的是 AES-CBC 模式,填充方式为 PKCS7,需要 key 和 iv 进行加解密。
然后通过断点拿到了它们的值。

1
2
n: "AAAAAAAAAAAAAAAA"
t: "Rdb6DqK2erQtm7OZPFW3gVl5MILCXo1v"

接下来看 l.iI,跳转过去,是一个 base64 的编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
t.iI = function(e) {
for (var t, r = e.length, o = r % 3, i = [], s = 16383, a = 0, l = r - o; a < l; a += s) i.push(c(e, a, a + s > l ? l : a + s));
1 === o ? (t = e[r - 1], i.push(n[t >> 2] + n[t << 4 & 63] + '==')) : 2 === o &&
(
t = (e[r - 2] << 8) + e[r - 1],
i.push(n[t >> 10] + n[t >> 4 & 63] + n[t << 2 & 63] + '=')
);
return i.join('')
};
for (
var n = [],
r = [],
o = 'undefined' != typeof Uint8Array ? Uint8Array : Array,
i = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/',
s = 0; s < 64;
++s
) n[s] = i[s],
r[i.charCodeAt(s)] = s;

Payload 复现

至此,X-Api-Request-Payload 的完整生成流程可以总结如下:

1
2
3
4
5
6
7
JSON.stringify

UTF-8 编码

AES-CBC 加密

Base64 编码

我们简单用 Python 实现一下,看看结果是否一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import json
import base64
import time
import uuid
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

KEY = b"Rdb6DqK2erQtm7OZPFW3gVl5MILCXo1v"
IV = b"AAAAAAAAAAAAAAAA"

def get_payload(path, method):
data = {
"Timestamp": int(time.time() * 1000),
"Host": path,
"RequestId": str(uuid.uuid4()),
"Referer": "https://i.jielong.com/",
"Method": method
}

raw = json.dumps(data, separators=(',', ':')).encode()
cipher = AES.new(KEY, AES.MODE_CBC, IV)
encrypted = cipher.encrypt(pad(raw, AES.block_size))

return base64.b64encode(encrypted).decode()

经过确认,结果一致,证明我们已经完整还原了 Payload 的生成逻辑。
在获取 Authorization 后,结合该算法即可稳定构造请求并调用业务接口。

总结

到这里基本可以把整个流程串起来了:

  • 登录流程为标准微信扫码登录,核心在于获取 wx_code 并换取 Authorization
  • X-Api-Request-Payload 已经可以完整复现,包括内部数据结构和加密流程
  • Payload 会绑定接口路径和请求方法,同时带有 Timestamp,因此无法跨接口复用

实际测试中,只要自行生成 Payload 并配合 Authorization,就可以稳定请求接口,不再需要依赖抓包获取参数。

完整流程已验证可自动化实现,这里不再展开。

参考

[1] https://developers.weixin.qq.com/doc/oplatform/developers/dev/auth/h5.html
[2] https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html