逆向微信校园跑小程序:从解包到在宿舍完成打卡
背景
学校用了一个微信小程序来记录跑步打卡,要求每学期完成 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 脚本,实现完整的自动打卡流程:
- 验证 token 有效性
- 从
/side/fence/AllList获取围栏坐标 - 从
/side/rule/nowRule获取跑步规则 - 上传自拍照片到
/common/upload - 在围栏内生成带 GPS 抖动的仿真轨迹
- 确保时间在 07:00~22:00 内
- 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…)也只是增加了一点阅读成本,并没有真正的安全性。
免责声明:本文仅作技术研究和学习记录。