羽毛球馆场地预约脚本的失败尝试
学校的羽毛球馆自几年前起就需要在微信小程序上进行预约。预约系统比较 naive, 而且操作手感很差,导致往往很难抢到场地。
前段时间,Persona Live 确定首次在国内举办,笔者因为没有什么抢票的经验,所以为了确保成功,在 GitHub 上找到了一套在会员购上抢票的程序,配置之后发现竟然能 work(但是最后实际抢到票还是凭运气)。观察了一下这个程序的行为,想到可以自己尝试写一个脚本来抢羽毛球馆的场地。于是就开始了这次的折腾之旅。
这次尝试最后以失败告终,但过程还是很有趣的,记录下来以备后用。
初始的思路
在所有可能的方法中,最快的莫过于直接代替客户端发送请求。
使用抓包工具 Charles 进行请求分析和重放。我们首先发现客户端的所有请求都遵循了如下的格式:
https://do.main.name/api/path1/path2?open_id=OPENID&version=VERSION×tamp=TIMESTAMP&sign=2432421bd5e6d0522787d62000384170
其中 open_id
和 version
是固定的,timestamp
是当前时间戳,sign
是一个 md5 签名。
很显然,服务端会根据请求的参数进行签名校验,以确保请求的合法性。我们只有找到签名的算法,才可以伪造请求,达到抢票的目的。
逆向分析
微信小程序本质上就是网页,也就是一堆明文的 HTML、CSS 和 JavaScript 代码。但因为其运行在特定的环境中,我们不能像通常处理网页那样直接查看源代码和调试。
搜索了其他人处理这一问题的方法后,发现有现成的工具可以解包小程序。
解包之后,很快就在唯一的 js 文件 app-service.js
中找到了签名的函数。该函数的实现如下:
var n = exports.mx_request = function(e) {
var n, r, l = getApp(),
u = a(a({}, e.data), {}, {
open_id: l.globalData.open_id,
version: l.globalData.accountInfo.miniProgram.version || "1.0.0",
timestamp: (new Date).getTime()
});
u.sign = (n = u, r = [], Object.keys(n).sort().map((function(e) {
if (void 0 !== n[e] && null !== n[e]) {
var a = n[e];
"" !== (a = "object" === t(a) ? JSON.stringify(a) : a.toString().trim()) && r.push("".concat(e, "=").concat(a))
}
})), (0, s.default)(r.join("&") + "SALTSALTSALT")), "POST" === e.method && (u = i.Base64.encode(JSON.stringify({
all_params: u
}))), wx.request(a(a({}, e), {}, {
data: u,
url: o.base_url + e.url,
header: a(a({}, e.header || {}), {}, {
Platform: "MiniProgram",
DeviceFingerprint: l.globalData.deviceFingerprint || "",
"Wx-Env": l.globalData.environment || "",
Authorization: "Bearer " + l.globalData.token
}),
success: function(a) {
if (200 !== a.statusCode || 0 !== a.data.code) return 401 === a.statusCode ? (l.removeLogin(), void wx.navigateTo({
url: "/pages/login/silent"
})) : void(200 === a.statusCode ? e.fail ? e.fail(a.data) : wx.showModal({
title: "提示",
content: a.data.message || "系统错误",
showCancel: !1,
confirmText: "我知道了"
}) : wx.showModal({
title: "提示",
content: a.data.message || "服务器错误:" + a.statusCode,
showCancel: !1,
confirmText: "我知道了",
success: function(e) {
401 === a.statusCode && wx.switchTab({
url: "/pages/index/index"
})
}
}));
e.success && e.success(a.data.data)
},
fail: function(a) {
console.log("wx.request.fail", a, e.fail), e.fail && e.fail(a.data)
}
}))
};
阅读源码,我们可以看到签名的计算过程:
- 将请求参数按字典序排序
- 将所有参数拼接成
key1=value1&key2=value2&...
的格式 - 在拼接后的字符串后加上一个盐值,然后进行 md5 签名
- 将签名结果作为
sign
参数添加到请求中
有了签名的算法,这样我们就能伪造请求了。
碰壁
有了源码之后,一切看起来都变得简单了,然而很快就遇到了无法解决的问题。
首先,我们定位了发送预约请求的函数:
onFinish: function() {
var t = this,
e = arguments.length > 0 && void 0 !== arguments[0] && arguments[0],
a = {
day: this.data.day_tab,
kind_id: this.data.venue_info.id,
share_open: this.data.share_open
};
if (a.selected_block = this.data.selected_block, !this.data.is_look_on && this.data.venue_info.need_partner && !e) {
var n = this.data.partner_list.filter((function(t) {
return t.selected
}));
if (0 === n.length) return this.data.venue_info.must_partner ? void(0, o.default)("最少需要邀请一名同伴") : void r.default.confirm({
title: "提示",
message: "您未邀请同伴,是否继续提交"
}).then((function() {
t.onFinish(!0)
})).catch((function() {}));
a.selected_partner = n
}
wx.showLoading({
title: "正在提交...",
mask: !0
}), (0, i.mx_request_noise)({
url: "/venue/order",
method: "POST",
data: a,
success: function(t) {
wx.redirectTo({
url: "/pages/venue/success?order_no=".concat(t.result)
})
},
fail: function(e) {
wx.showModal({
title: "提示",
content: e.message || "系统错误",
showCancel: !1,
confirmText: 20003 === e.code ? "立即刷新" : "我知道了",
success: function() {
20003 === e.code && t.queryVenuePage()
}
})
},
complete: function() {
wx.hideLoading()
}
})
}
发现它会通过 mx_request_noise
调用微信的云函数进行签名校验
exports.mx_request_noise = function(e) {
wx.cloud.init(), wx.cloud.callFunction({
name: "noise",
data: e.data
}).then((function(t) {
e.data = a(a({}, e.data), {}, {
noise: t.result
}), n(e)
}))
};
输入是预约场地相关的全部参数,输出是一个 noise
参数。因为预约场地的参数中,包含了日期,所以我们无法在不调用云函数的情况下,伪造请求。
要在外部调用云函数,就有点过于复杂了,已经超出了我们愿意尝试的范围。至此,本次尝试宣告失败。
后话
最后发现,实际上微信小程序是可以在 PC 上运行的。于是笔者花了点时间,写了个 AHK 脚本,用模拟鼠标点击的方式来进行预约。也算是实现了一个折中的解决方案。