华清大学体育场地预约系统 API 逆向分析

华清大学体育场地预约系统 的北体育馆中的乒乓球场地在 2026 年马约翰杯乒乓球比赛前后被严格监控是否预约,以至于需要线下签到。对于系乒乓球队而言,这无疑是非常困难的,因为每个人在同一时段只能预约一张球台;而系队训练一般需要多张。因此,一般是收集多个同学的账号进行预约。

但是现在要求线下出示预约二维码进行签到;二维码的有效期为 30s,这造成了极大的困难:不能总是麻烦没来训练的同学登录系统转发二维码。因此,我们尝试开发一套工作流程,能够实时获取该二维码。

整体的思路分为两步。由于(众所周知)学校的各个系统网页端的鉴权几乎都是基于 Token(“词元”)的,所以第一步是逆向分析系统 API,此时 假设我们已有合法 Token。第二步再是实现自动登录获取 Token,以及免二次验证状态持久化。

我们的唯一目标是 获取预约二维码

先抓二维码请求

从页面入口看,最直接的线索是“我的预约”里的二维码按钮。打开 DevTools 的 Network 面板,点开二维码,马上能看到一个很可疑的请求:

bash
1curl 'https://www.sports.tsinghua.edu.cn/venue/site/api/reserve/user/qr?appId=1497016617475903488&timeStamp=1781318962679&nonce=Z9EMAFcZAtEGTyjRnpZNCzDSsYfbFf3x&sign=dea17cf8424a12a86713cb1091959327' \
2  -H 'token: <JWT>' \
3  ...

接口返回的 JSON 很短:

json
1{
2  "code": 0,
3  "message": "请求成功",
4  "success": true,
5  "data": "unil1SyG..."
6}

这里的 data 不是图片地址,也不是 Base64 图片,而是二维码里实际编码的字符串。前端拿到这段字符串之后,直接交给 QR Code 组件渲染将这个字符串展示为二维码。

这样一来,问题就集中到 URL 后面的四个查询参数上:

参数说明
appId固定值 "1497016617475903488"
timeStamp当前毫秒时间戳
nonce32 位随机字符串
sign请求签名

多抓几次就能看出来 appId 是固定的;timeStamp 的数量级和当前毫秒时间戳一致;nonce 每次变化,而且长度稳定为 32。剩下的 sign 最关键,也最不可能靠猜。它通常会由前面几个字段加上某个密钥算出来,所以接下来要回到前端代码里找签名逻辑。

找到签名函数

前端资源是打包后的 JS,文件名带 hash。我当时看到的是 index.47b9a9bc.js,以后重新部署后名字可能会变,所以文件名本身不重要,关键是搜索词。

我先搜了几个最直接的关键词,signappIdnoncetimeStamp

签名逻辑一般会放在统一请求封装里,因为每个接口都要加同一套公共参数。顺着 appIdsign 往附近看,可以看到类似下面的模块:

javascript
 1b764: function(e, n, t) {
 2  "use strict";
 3  t("6a54");
 4  var i = t("f5bd").default;
 5  Object.defineProperty(n, "__esModule", {
 6      value: !0
 7  }),
 8  n.API_SIGN = void 0;
 9  var a = i(t("8078"))
10    , o = t("e4ea")
11    , s = {
12      randomString: function(e) {
13          for (var n = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz0123456789", t = n.length, i = "", a = 0; a < e; a++)
14              i += n.charAt(Math.floor(Math.random() * t));
15          return i
16      },
17      getSign: function() {
18          var e = "1497016617475903488"
19            , n = (0,
20          o.getKeys)().join("")
21            , t = (new Date).getTime()
22            , i = this.randomString(32)
23            , s = "appId=" + e + "&nonce=" + i + "&timeStamp=" + t + "&key=" + n
24            , r = (0,
25          a.default)(s);
26          return {
27              appId: e,
28              timeStamp: t,
29              nonce: i,
30              sign: r
31          }
32      }
33  };
34  n.API_SIGN = s
35},

这段代码已经把签名的大体结构暴露出来了:

  • e 是固定的 appId
  • t(new Date).getTime(),也就是毫秒时间戳。
  • i 是长度为 32 的随机字符串。
  • n 来自 getKeys().join(""),看起来就是密钥。
  • r = a.default(s),其中 s 是待签名字符串。

a.default 也需要确认一下。它来自 webpack 模块 8078,继续跳过去可以看到 js-md5 的注释:

javascript
 18078: function(t, e, n) {
 2  (function(t, e, r) {
 3    var i, o = n("bdbb").default;
 4    n("80e3"),
 5    n("4db2"),
 6    n("bf0f"),
 7    n("c976"),
 8    n("4d8f"),
 9    n("7b97"),
10    n("668a"),
11    n("c5b7"),
12    n("8ff5"),
13    n("2378"),
14    n("641a"),
15    n("64e0"),
16    n("cce3"),
17    n("efba"),
18    n("d009"),
19    n("bd7d"),
20    n("7edd"),
21    n("d798"),
22    n("f547"),
23    n("5e54"),
24    n("b60a"),
25    n("8c18"),
26    n("12973"),
27    n("f991"),
28    n("198e"),
29    n("8557"),
30    n("63b1"),
31    n("1954"),
32    n("1cf1"),
33    n("295e"),
34    n("c753"),
35    n("7a76"),
36    n("c9b5"),
37    n("ab80"),
38    /**
39     * [js-md5]{@link https://github.com/emn178/js-md5}
40     *
41     * @namespace md5
42     * @version 0.8.3
43     * @author Chen, Yi-Cyuan [emn178@gmail.com]
44     * @copyright Chen, Yi-Cyuan 2014-2023
45     * @license MIT
46     */
47    ...

所以 a.default 就是 MD5 函数。剩下唯一没展开的是 getKeys()

还原密钥

继续在打包文件里搜 getKeys,可以找到这样一段:

javascript
 15532: function(e, n, t) {
 2  "use strict";
 3  t("6a54"),
 4  Object.defineProperty(n, "__esModule", {
 5      value: !0
 6  }),
 7  n.getKeys = void 0,
 8  t("aa9c"),
 9  t("dc69");
10  n.getKeys = function() {
11      for (var e = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", n = e[28] + e[56] + e[52] + e[27] + e[29] + e[60] + e[28], t = "752769392d3907", i = "", a = [], o = 0; o < t.length; o++)
12          o % 2 == 0 ? i += t[o] : a.push(t[o]);
13      var s = [7, 6, 7, 5, 3, 5]
14        , r = [7, 2, 9, 2, 2]
15        , d = "";
16      s.reverse();
17      for (var c = 0; c < s.length; c++)
18          d += s[c],
19          void 0 != r[c] && (d += r[c]);
20      a.reverse();
21      for (var u = "", l = 0; l < a.length; l++)
22          u += i[l],
23          u += a[l];
24      return [d, n, u]
25  }
26},

这段看起来绕,但本质只是把一个常量拆散再拼回去。前端真正使用的时候调用的是:

javascript
1getKeys().join("")

把它跑出来,结果就是:

text
157325972627c40bd8c77296d39293705

到这里,签名所需的所有输入就都齐了。

签名算法

整理一下生成过程:

  1. appId 固定为 "1497016617475903488"

  2. timeStamp 取当前毫秒时间戳。

  3. nonce 取 32 位随机字符串,前端使用的字符集是 ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz0123456789

  4. keygetKeys().join("") 的结果,即 "57325972627c40bd8c77296d39293705"

  5. 按下面的顺序拼接字符串:

    text
    1appId={appId}&nonce={nonce}&timeStamp={timeStamp}&key={key}
    
  6. 对拼接结果计算 MD5,得到 sign

用 Python 写出来就是:

python
 1import hashlib
 2import random
 3import time
 4
 5APP_ID = "1497016617475903488"
 6SECRET_KEY = "57325972627c40bd8c77296d39293705"
 7NONCE_CHARS = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz0123456789"
 8
 9
10def generate_sign_params():
11    timestamp = str(int(time.time() * 1000))
12    nonce = "".join(random.choices(NONCE_CHARS, k=32))
13    raw = f"appId={APP_ID}&nonce={nonce}&timeStamp={timestamp}&key={SECRET_KEY}"
14    sign = hashlib.md5(raw.encode()).hexdigest()
15    return {"appId": APP_ID, "timeStamp": timestamp, "nonce": nonce, "sign": sign}

最后拿抓包里的参数验算一次:

text
1appId=1497016617475903488
2nonce=8eNiaim2GB6KQmrfb180Aa2xPYPyeQkQ
3timeStamp=1776432736175
4key=57325972627c40bd8c77296d39293705
5
6MD5("appId=1497016617475903488&nonce=8eNiaim2GB6KQmrfb180Aa2xPYPyeQkQ&timeStamp=1776432736175&key=57325972627c40bd8c77296d39293705")
7= 3f9495413a1e30470afc7691a39ee1c9

结果和原请求中的 sign 对得上。至此,我们已经完全还原了二维码接口的签名算法。

不过,需要注意的是,二维码本身仍然是短期有效的。前端只是把后端返回的 data 渲染出来,真正的有效期判断大概率发生在扫码签到的服务端逻辑里,估计是那一端限制了二维码的有效期为 30s。