体育场馆预约小程序逆向
背景
学校的体育场馆只能通过小程序预约, 而且很难抢到, 所以想着做个脚本.
首先还是常规的 BurpSuite + Proxifier 抓包, 然而发现数据都经过了加密.

所以只好使用 Wedecode 反编译小程序的 wxapkg 包, 逆向分析源码.
小程序的源码大致结构如下:
├─@babel
│ └─...
├─common
│ └─vendor.js
├─components
│ └─...
├─pages
│ └─...
├─service
│ ├─apis.js
│ └─request.js
├─static
│ └─...
├─store
│ └─...
├─rutils
│ ├─constant.js
│ ├─encryption.js
│ └─wx-pay.js
├─app-config.json
├─app.js
├─app.json
└─app.wxss
加密方法分析
在微信开发者工具中导入该项目, 设置一下自己的AppId, 打开调试器, 编译运行, 发现小程序发起了两个相关请求, 查看一下调用栈:

从调用栈进入request.js

简单来说, 这是ES5下的生成器函数, _r 来自 _yield$t$encryptionData, 而该值实际上为上一个case的返回值, 即t.encryptionData(a), 往上翻, 查看t的定义:

因此进入encryption.js, 找到加解密函数:
var e = require("../common/vendor.js"),
t = e.CryptoJS.enc.Utf8.parse("ed79fe80db6ac20357e5f39fb42bda73c270d1258d9b81385ae0eb12fe4c6e2b");
exports.decryptData = function(r, n) {
var o = e.CryptoJS.enc.Base64.stringify(e.CryptoJS.enc.Hex.parse(r)),
s = e.CryptoJS.AES.decrypt(o, t, {
iv: e.CryptoJS.enc.Utf8.parse(n),
mode: e.CryptoJS.mode.CBC,
padding: e.CryptoJS.pad.Pkcs7
}).toString(e.CryptoJS.enc.Utf8);
return s ? JSON.parse(s) : {};
}
exports.encryptData = /*#__PURE__*/ function() {
var _ref = _asyncToGenerator2( /*#__PURE__*/ _regeneratorRuntime2().mark(function _callee(n) {
var o, s, a, _yield$Promise$all, _yield$Promise$all2, p, c;
return _regeneratorRuntime2().wrap(function _callee$(_context) {
while (1) switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return new Promise(function(t, r) {
e.index.getUserCryptoManager().getLatestUserKey({
success: function success(e) {
return t(e.iv);
},
fail: r
});
});
case 2:
o = _context.sent;
s = e.CryptoJS.enc.Utf8.parse(JSON.stringify(n));
a = e.CryptoJS.AES.encrypt(s, t, {
iv: e.CryptoJS.enc.Utf8.parse(o),
mode: e.CryptoJS.mode.CBC,
padding: e.CryptoJS.pad.Pkcs7
}).ciphertext.toString().toUpperCase();
_context.next = 7;
return Promise.all([r(), r()]);
case 7:
_yield$Promise$all = _context.sent;
_yield$Promise$all2 = _slicedToArray2(_yield$Promise$all, 2);
p = _yield$Promise$all2[0];
c = _yield$Promise$all2[1];
return _context.abrupt("return", {
iv: o,
ciphertext: "".concat(p).concat(o).concat(c, " ").concat(a)
});
case 12:
case "end":
return _context.stop();
}
}, _callee);
}));
return function(_x) {
return _ref.apply(this, arguments);
};
}();
加解密的密钥 t 是一个定值. 其中加密函数还是使用了生成器. 加密时先进入case 0, 通过微信加密通道相关接口获取用户加密的初始化向量iv, 再进入 case 2, 使用密钥t,初始化向量iv, 对数据进行AES加密得到密文a, 然后调用两次函数r(), 函数的代码如下:
function r() {
return new Promise(function(t) {
e.index.getRandomValues({
length: 12,
success: function success(r) {
return t(e.index.arrayBufferToBase64(r.randomValues).slice(0, 4));
}
});
});
}
获得两个长度为4的随机字符串, 之后进入case 7, p 与 c 是长度为4的随机字符串, o是初始化向量iv, a是密文.
调用微信api接口需要用户信息以及正确的AppID, 这使得编写相应的程序变得困难. 然而, 这一过程中存在漏洞, 初始化向量iv直接放在最后发送的密文中, 服务端直接从密文中提取出iv, 再使用密钥t与iv进行解密, 因此, iv 无需从微信平台获取, 只需随即选取即可.
注意到, 这里的加密实现方式似乎完全就是在调用crypto-js, 然而, 为了排除其对crypto-js进行修改的可能性, 这里先抠出他这里的所有加密实现代码, 将其运行结果与直接调用crypto-js进行比对.抠的代码太长, 和小程序源码一起存蓝奏云上了.

那就可以放心地直接调用AES加密了. 然而在利用Python实现加解密过程时, 碰到了一个问题, Cryptojs并不检验密钥的长度, 而这个小程序使用的密钥长度恰好不符合规范, 因此难以直接调用Python的Crypto库. 这里选择用Python的实现并进行一定的修改, crypto-js找到了密钥长度与轮数的关系, 即 密钥长度 / 4 + 6 = 轮数, 因此, 这里主要需要修改的地方是对密钥长度的检验以及轮数的选择. 这里给出部分代码:
class AES:
def __init__(self, master_key):
self.n_rounds = len(master_key) // 4 + 6
self._key_matrices = self._expand_key(master_key)
def _expand_key(self, master_key):
key_columns = bytes2matrix(master_key)
iteration_size = len(master_key) // 4
i = 1
while len(key_columns) < (self.n_rounds + 1) * 4:
word = list(key_columns[-1])
if len(key_columns) % iteration_size == 0:
word.append(word.pop(0))
word = [s_box[b] for b in word]
word[0] ^= r_con[i]
i += 1
elif iteration_size > 6 and len(key_columns) % iteration_size == 4:
word = [s_box[b] for b in word]
word = xor_bytes(word, key_columns[-iteration_size])
key_columns.append(word)
return [key_columns[4 * i: 4 * (i + 1)] for i in range(len(key_columns) // 4)]
有了加密的实现, 解密也就很简单了, 这里略去.
好了, 现在解决了加解密的问题. 接着考虑分析小程序相关的接口, 在一开始, 我们提到小程序编译运行后自动发起了两个请求, 一个是/notice/list, 从字面意思不难猜出与公告有关; 一个是/user/login, 显然是登录接口, 那接着来看看这个接口.
登录接口分析
还是老方法, 查看调用堆栈:

这里选择查看apis.js. 从调用栈跳转到如下位置:

这里可以看到, 首先调用了 e.index.login 方法, 直接将返回的code 作为 r 发送给对应接口. 进一步分析可知, 这里实际上调用的是微信小程序的login接口, 通过 BurpSuite 抓包可知, 这里主要使用到了newticket与appid参数, 向/wxa-dev-logic/jslogin接口发起请求.

使用自己的AppID将无法登录该小程序, 伪造AppID将无法获取相应code. 所以这里选择换个思路. 直接对真实环境进行抓包.
所以下面利用 BurpSuite + Proxifier 对真实环境进行抓包.

通过分析抓包结果可知, 后端主要依赖 Authorization 的值进行身份认证. 所以脚本实现思路是, 抓包获取Authorization, 将该值作为输入, 运行脚本, 预约场馆.
预约场馆的相关api接口及参数也可通过抓包获取, 并结合之前的解密函数进行分析, 没什么花样, 这里由于篇幅原因就略去了.
这里给出主要的Python代码:
# -*- coding: utf-8 -*-
import json
import random
import requests
import aes
HEADER = {
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) "
"Version/15.0 Mobile/15E148 Safari/604.1 wechatdevtools/1.06.2409140 MicroMessenger/8.0.5 "
"Language/zh_CN webview/",
"Authorization": ""}
HOST = "https://mini.ecnu-api2.ziyun188.com"
KEY = "ed79fe80db6ac20357e5f39fb42bda73c270d1258d9b81385ae0eb12fe4c6e2b"
MY_AES = aes.AES(KEY.encode('utf-8'))
def random_str(n: int) -> str:
return ''.join(random.sample('abcdefghijklmnopqrstuvwxyz0123456789', n))
def get_ciphertext(data: dict, iv: str) -> str:
data_json = json.dumps(data)
return f"{random_str(4)}{iv}{random_str(4)}%20{MY_AES.encrypt_cbc(data_json.encode('utf-8'), iv.encode('utf-8')).hex().upper()}"
def decrypt(ciphertext: str, iv: str) -> str:
return MY_AES.decrypt_cbc(bytes.fromhex(ciphertext), iv.encode('utf-8')).decode('utf-8')
if __name__ == '__main__':
book_time = {"date": "2024-1-1", "hours": "2", "earliestTime": "18:00", "latestTime": "20:00",
"time": [{"start": "18:00", "end": "19:00"}, {"start": "19:00", "end": "20:00"}]}
stu_num = ""
name = ""
phone = ""
HEADER["Authorization"] = ""
order_info = {"id": "", "bookAttr": "1", "bookOpenId": "", "bookUserName": "", "bookTime": book_time, "isAll": "",
"code": stu_num, "device": "", "contact": name, "phone": phone, "dept": "", "idCard": "", "unit": "",
"siteId": "4132b8f240704556a6fe71e93eabe900", "siteName": "中北校区大活多功能厅羽毛球",
"siteType": "羽毛球", "siteArea": "中北", "siteUnit": "3", "equipment": "", "publicity": "",
"car": "", "memo": "", "status": "7", "amount": "10", "cancelReason": "", "presentPeopleNum": "1",
"siteCode": "2"}
iv = random_str(16)
url = f"{HOST}/order/edit"
response = requests.post(url, headers=HEADER, data=json.dumps({"ciphertext": get_ciphertext(order_info, iv)}),
verify=False)
cipher = response.json().get('ciphertext', '')
print(decrypt(cipher, iv))
BTW, 工位附近有对情侣, 天天乱我道心, 最近这工位是待不下去了

浙公网安备 33010602011771号