目录

羽毛球馆场地预约脚本的失败尝试

学校的羽毛球馆自几年前起就需要在微信小程序上进行预约。预约系统比较 naive, 而且操作手感很差,导致往往很难抢到场地。

前段时间,Persona Live 确定首次在国内举办,笔者因为没有什么抢票的经验,所以为了确保成功,在 GitHub 上找到了一套在会员购上抢票的程序,配置之后发现竟然能 work(但是最后实际抢到票还是凭运气)。观察了一下这个程序的行为,想到可以自己尝试写一个脚本来抢羽毛球馆的场地。于是就开始了这次的折腾之旅。

这次尝试最后以失败告终,但过程还是很有趣的,记录下来以备后用。

初始的思路

在所有可能的方法中,最快的莫过于直接代替客户端发送请求。

使用抓包工具 Charles 进行请求分析和重放。我们首先发现客户端的所有请求都遵循了如下的格式:

https://do.main.name/api/path1/path2?open_id=OPENID&version=VERSION&timestamp=TIMESTAMP&sign=2432421bd5e6d0522787d62000384170

其中 open_idversion 是固定的,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 脚本,用模拟鼠标点击的方式来进行预约。也算是实现了一个折中的解决方案。