逆向微信校园跑小程序:从解包到在宿舍完成打卡

背景

学校用了一个微信小程序来记录跑步打卡,要求每学期完成 40 次有效跑步。必须在指定操场围栏内、规定时段跑够距离,还要拍开始/结束自拍。

我想搞清楚它的计算逻辑和上传接口,看能不能用脚本代替实际跑步。

第一步:解包小程序

微信小程序本质上是一个 .wxapkg 压缩包,用 wxappUnpacker 或类似工具解包后,得到的目录结构大致如下:

wx**************91/
  ├── app-service.js
  ├── appservice.app.js      # 主业务逻辑(webpack 打包)
  ├── chunk_21.appservice.js  # 跑步页面
  ├── page-frame.html
  └── ...

核心逻辑全在 appservice.app.js 里,单行压缩,十几万字符。这是个 uni-app 框架、webpack 打包的项目,所有模块用数字 ID 标识(如 "54e7"、"5826"、"e5fe")。

第二步:分析跑步计算逻辑

跑步页面在 chunk_21.appservice.js,格式化后约 1600 行。核心逻辑:

GPS 距离计算

用标准 Haversine 公式,地球半径取 6371km:

function haversine(lat1, lon1, lat2, lon2) {
  var R = 6371000;
  var dLat = (lat2 - lat1) * Math.PI / 180;
  var dLon = (lon2 - lon1) * Math.PI / 180;
  var a = Math.sin(dLat/2)**2 +
    Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) *
    Math.sin(dLon/2)**2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}

GPS 信号会做两层过滤:

  • 精度过滤:accuracy > 65m 的点直接丢弃
  • 速度过滤:瞬时速度 > 30m/s 的点丢弃(防止 GPS 漂移)

电子围栏

围栏是一个多边形,用经典的射线法(Ray Casting)做点在多边形内判断。不是简单的进出判断,而是带迟滞的状态机:

  • 连续 3 次检测到“围栏外” → 标记为无效区域
  • 连续 2 次检测到“围栏内/缓冲区” → 恢复有效
  • 缓冲区 = 围栏边界外扩 8 米

在围栏内跑的距离累加到 validDistance,围栏外的累加到 invalidDistance。

配速计算

var secondsPerKm = movingDuration / (totalDistance / 1000);
var paceMinutes = Math.floor(secondsPerKm / 60);
var paceSeconds = Math.round(secondsPerKm % 60);

时间就是一个 setInterval(() => movingDuration++, 1000) 的秒级计数器,暂停时停止计时。

轨迹简化

上传前用 Douglas-Peucker 算法简化轨迹,容差 4e-5 度(约 4 米),减少上传数据量。

第三步:逆向上传接口

接口定义

在 webpack 模块 "54e7" 中找到:

t.uploadRunRecord = function(e) {
  return request({
    url: "/side/runRecord/uploadRunRecord",
    method: "post",
    encrypt: true,  // 关键:需要加密
    data: e
  })
}

Base URL 在模块 "eff3" 中:

{ baseUrl: "https://runa.*****.edu.cn" }

上传字段

模块 "5826" 中的 runObjectConversion 函数把内部数据转成上传格式,然后做了一层字段混淆:

混淆字段 实际含义 说明
aa distance 总距离(有效+无效),米
bb effectiveDistance 有效距离,米
cc movingDuration 运动时长,秒
dd startRunTime 开始时间,YYYY-MM-DD HH:mm:ss
ee endRunTime 结束时间
ff startRunPhoto 开始自拍 URL
gg endRunPhoto 结束自拍 URL
hh runRunningCoordinatesList 轨迹坐标数组

坐标数组每个元素:

{
  "segmentOrder": 1,
  "pointOrder": 1,
  "latitude": "29.338136",
  "longitude": "106.254275",
  "index": 1
}

AES/RSA 混合加密

这是整个逆向中最有趣的部分。上传接口设置了 encrypt: true,请求经过统一拦截器时会执行加密:

1. 生成随机 16 位 AES 密钥和 IV
2. 用 AES-CBC-PKCS7 加密 JSON 数据
3. 用 RSA 公钥加密 AES 密钥
4. 发送 { data: AES密文, key: RSA加密的AES密钥, iv: 明文IV }
5. 请求头添加 X-Encrypt-Mode: AES/RSA

RSA 公钥直接硬编码在前端代码中。更有意思的是,用于解密服务端响应的 RSA 私钥也在前端代码里——这意味着响应加密形同虚设。

Python 实现加密:

from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Util.Padding import pad

def encrypt_data(obj):
    key = random_string(16).encode()
    iv = random_string(16).encode()
    plaintext = json.dumps(obj).encode('utf-8')
    cipher = AES.new(key, AES.MODE_CBC, iv)
    ct = cipher.encrypt(pad(plaintext, 16))
    rsa_key = RSA.import_key(RSA_PUBLIC_KEY)
    enc_key = PKCS1_v1_5.new(rsa_key).encrypt(key)
    return {
        "data": base64.b64encode(ct).decode(),
        "key": base64.b64encode(enc_key).decode(),
        "iv": iv.decode(),
    }

服务端校验规则

通过 buildEndRunValidation 函数和 /side/rule/nowRule 接口返回的规则,总结出服务端校验:

  • 总距离 >= 20 米
  • 有效距离 >= 最低要求(男/女不同)
  • 运动时长在 [minTime, maxTime] 范围内
  • 配速在 [maxSpeed, minSpeed] 范围内(单位:秒/公里)
  • 开始时间在允许时段内(如 07:00 ~ 22:00)
  • 日期在本学期范围内
  • 需要开始和结束自拍照片

WAF 绕过

直接用 Python requests 请求会被 WAF 拦截,返回 HTML 拦截页面。解决方法是模拟微信请求头:

headers = {
    "User-Agent": "...MicroMessenger/8.0.42...",
    "xweb_xhr": "1",
    "Referer": "https://servicewechat.com/wx**************91/...",
}

加上 MicroMessenger UA 和 xweb_xhr 头后,WAF 放行。

第四步:获取认证 Token

小程序使用微信登录,流程是 wx.login() 获取 code → 发送到 /weChatLogin → 返回 JWT token,存储在 App-Token 中。

问题是 wx.login() 只能在微信客户端内调用,且该小程序禁止在 PC 端运行。

绕过 PC 端检测

小程序的 PC 检测代码在 onShow 生命周期中:

onShow: function() {
  var e = t.getDeviceInfo();
  "windows" !== e.platform && "mac" !== e.platform ||
    t.showModal({
      title: "温馨提示",
      content: "请使用手机端访问小程序",
      showCancel: false,
      success: function() { t.exitMiniProgram() }
    });
}

使用 WMPFDebugger 连接 PC 微信小程序的 Chrome DevTools,在 appContext 上下文中注入:

// 用 Proxy 拦截 getDeviceInfo,伪造 platform
wx.getDeviceInfo = new Proxy(wx.getDeviceInfo, {
  apply(target, thisArg, args) {
    let result = Reflect.apply(target, thisArg, args);
    result.platform = 'android';
    return result;
  }
});

// 兜底:让 exitMiniProgram 变成空操作
wx.exitMiniProgram = function(){};

Proxy 拦截了 getDeviceInfo 的每次调用,在返回结果前把 platform 从 "windows" 篡改为 "android"。小程序的判断条件不成立,弹窗不触发。第二行是保险——即使弹窗逻辑走到了退出,也什么都不会发生。

注入后执行 wx.reLaunch({url:'/pages/index'}) 重新进入,小程序正常运行。登录后通过 wx.getStorageSync('App-Token') 即可取得 token。

操作流程

1. 启动 WMPFDebugger (npx ts-node src/index.ts)
2. PC 微信打开小程序(会弹拦截提示)
3. Chrome 打开 devtools://devtools/bundled/inspector.html?ws=127.0.0.1:62000
4. Console 切换到 appContext 上下文
5. 注入 Proxy hook 代码
6. wx.reLaunch 重新进入
7. 正常使用,从 Storage 取 token

第五步:自动上传跑步记录

最终写了一个 Python 脚本,实现完整的自动打卡流程:

  1. 验证 token 有效性
  2. 从 /side/fence/AllList 获取围栏坐标
  3. 从 /side/rule/nowRule 获取跑步规则
  4. 上传自拍照片到 /common/upload
  5. 在围栏内生成带 GPS 抖动的仿真轨迹
  6. 确保时间在 07:00~22:00 内
  7. AES/RSA 加密后上传到 /side/runRecord/uploadRunRecord

路线生成的策略是:把围栏多边形向质心收缩 20%,沿收缩后的边界跑圈,每个点加高斯分布的 GPS 抖动(约 2 米标准差),模拟真实 GPS 信号。

总结

技术点 方案
小程序解包 wxappUnpacker
代码分析 webpack 模块 ID 追踪 + 格式化
加密破解 AES-CBC + RSA 公钥均在前端,Python 复现
WAF 绕过 模拟微信 User-Agent + xweb_xhr 头
PC 检测绕过 WMPFDebugger + Proxy 拦截 getDeviceInfo
Token 获取 DevTools appContext 读 Storage
轨迹伪造 围栏内缩 + 边界跑圈 + 高斯抖动

整个过程中最关键的发现:RSA 私钥和公钥都硬编码在前端代码里,加密层完全可以在客户端侧还原。字段混淆(aa/bb/cc…)也只是增加了一点阅读成本,并没有真正的安全性。

免责声明:本文仅作技术研究和学习记录。

← 返回文章列表