上一章说到通过直接调用 wasm 来实现生成 Sign,但发现只针对那一个域名接口的请求有效,换个接口就不行了,看来还是要彻底分析他的生成逻辑。
逆向小程序
在小程序打开前就对它抓包,直到页面加载完毕也没发现相关js的请求,而且api的 referer: 都是指向了 https://servicewechat.com,所以可以基本确定相关的js文件都早打包进小程序下载到了本地。
打开目录 Documents\WeChat Files\Applet
根据修改日期可以确定哪个文件夹对应着小程序包。
接下来需要对 wxapkg 文件进行解密然后解包,由于微信的更新,在5月之前的解包工具都不能正常的解带有分包的小程序,我用的是 unveilr.exe 转收费前的版本,能正常的解大部分的文件,但还是有点小毛病,相关的解包教程可以去网上找。
解包完成后可以通过文件的大小,或者是否存在 app.json
文件来确定是否成功解包,将解包后的相应文件夹导入微信开发者工具,开始动态调试。
因为解包工具比较旧,导入后会出现不少报错,只要根据 微信官方文档 稍微改改就可以正常编译,不需要的页面可以去掉,只保留需要分析的地方。
app.json
文件中的 plugins
对象内容可以删除,否则会报错未授权使用插件。其他请求登录接口报错的可以抓包拦截下来,修改为原本小程序的返回包。
如果想要第一屏设置为分包页面,那就将 pages
对象添加对应分包页面,并删除 preloadRule
对象中的分包页面相关信息,否则会报错 pages *** 不应该在分包 subPackages[*] 中
,之后重新编译即可进入设置的页面。
分析代码
全局搜索关键字 Sign 可以找到相关代码段,而且代码没有做混淆处理,分析起来非常容易。
比如这一段,一眼就可以看出是 sha1 处理函数,传入字符串,返回sha1字符串
function (e, t) {
var n = global.bc("platform", "cloud-fe-yunsdk-platform");
e.exports = function (e) {
var t, n, r = new Uint8Array(function (e) {
var t, n, r, o = [];
for (t = 0; t < e.length; t++)(n = e.charCodeAt(t)) < 128 ? o.push(n) : n < 2048 ?
o.push(192 + (n >> 6 & 31), 128 + (63 & n)) : ((r = 55296 ^ n) >> 10 == 0 ? (n =
(r << 10) + (56320 ^ e.charCodeAt(++t)) + 65536, o.push(240 + (n >> 18 &
7), 128 + (n >> 12 & 63))) : o.push(224 + (n >> 12 & 15)), o.push(
128 + (n >> 6 & 63), 128 + (63 & n)));
return o
}(e)),
o = 16 + (r.length + 8 >>> 6 << 4);
for ((e = new Uint8Array(o << 2)).set(new Uint8Array(r.buffer)), e = new Uint32Array(e.buffer),
n = new DataView(e.buffer), d = 0; d < o; d++) e[d] = n.getUint32(d << 2);
e[r.length >> 2] |= 128 << 24 - 8 * (3 & r.length), e[o - 1] = r.length << 3;
var a = [],
s = [function () {
return u[1] & u[2] | ~u[1] & u[3]
}, function () {
return u[1] ^ u[2] ^ u[3]
}, function () {
return u[1] & u[2] | u[1] & u[3] | u[2] & u[3]
}, function () {
return u[1] ^ u[2] ^ u[3]
}],
c = function (e, t) {
return e << t | e >>> 32 - t
},
i = [1518500249, 1859775393, -1894007588, -899497514],
u = [1732584193, -271733879, null, null, -1009589776];
for (u[2] = ~u[0], u[3] = ~u[1], d = 0; d < e.length; d += 16) {
var l = u.slice(0);
for (t = 0; t < 80; t++) a[t] = t < 16 ? e[d + t] : c(a[t - 3] ^ a[t - 8] ^ a[t - 14] ^ a[t -
16], 1), n = c(u[0], 5) + s[t / 20 | 0]() + u[4] + a[t] + i[t / 20 | 0] | 0, u[1] =
c(u[1], 30), u.pop(), u.unshift(n);
for (t = 0; t < 5; t++) u[t] = u[t] + l[t] | 0
}
n = new DataView(new Uint32Array(u).buffer);
for (var d = 0; d < 5; d++) u[d] = n.getUint32(d << 2);
return Array.prototype.map.call(new Uint8Array(new Uint32Array(u).buffer), (function (e) {
return (e < 16 ? "0" : "") + e.toString(16)
})).join("")
}
}
调用 sha1 传入字符串的函数是
function l(t, n) {
var r = arguments.length > 2 && void 0 !== arguments[2] && arguments[2];
return function (n) {
if (t.nonce = t.nonce || a()(), t.ts = t.ts || s.default.get(!1, !0), "object" == e(t.body))
try {
t.body = JSON.stringify(t.body)
} catch (n) {
t.body = ""
}
n = n.map((function (e) {
return t[e]
})).join(";");
var o = i()(n);
return r && console.log("[SAFE]:", o, n), {
nonce: t.nonce,
ts: t.ts,
sign: o
}
}(n)
}
有了上面的这些,也就不难分析出生成 sign的逻辑了。
生成逻辑
传入的参数t包含有nonce,ts和body属性,n通过观察前面的代码可以知道是 ["signKey", "skey", "body", "nonce", "ts"]
这样一个数组。
首先,第一个 if 会检查传入的t.nonce,t.ts 是否存在,如不存在则调用相应函数生成,接着判断body的类型,如果是 object 则将其转换为json,转换失败则置空。然后按照n数组的顺序,将数组内容对应值通过 ;
符号连接起来形成这样的字符串 signKey;skey;body;nonce;ts
signKey 的值在前面的代码也有(换个域名就不对的原因),skey 为auto-token。再把生成的字符串传给 i 即 sha1 函数,得出 sign 值。
结语
对于这种没有混淆的代码,甚至不用动态调试都可以看出生成的逻辑,便能省掉让其在微信开发者工具中运行的时间。
但是动态调试的话可以看到中间变量,减少分析过程的错误。就比如我,想当然的认为最后的L函数必定会传入body,因为post请求的body就在那里摆着,但sha1函数的输出结果总是和实际值对不起来。直到我在开发者工具里下断点调试了好几遍之后才发现,有的请求虽然有body,但并不会将body传入L函数,sha1的输出也自然没有body的事情,不知到是他代码逻辑就是这样,还是说是开发者写的bug 😓