某高校跑步打卡 APP 协议逆向实现赛博跑步

 

仅供安卓逆向交流学习, 相关数据已脱敏.

最近某高校推出了要求学生跑步打卡的操作, 虽然配速要求其实并不严格, 但一学期要打卡 24 次, 每次至少 2400 m… 对我来说还是有点困难了, 所以来研究一下相关的软件.

0x00 前期观察&信息收集

通过分析相关 Activity 的包名发现这个跑步打卡的部分并不是学校自己做的, 是整合了一个第三方公司的产品到学校的 APP 中, 根据相关的软件说明发现它应该会进行步频(加速度传感器), GPS定位, 配速, 以及设备本身几个方面的检查.

酷安上搜索对应产品讨论区发现很多人在使用 Fake Location 虚拟定位进行跑步打卡, 不过这显然一点都不优雅, 而且还有人反馈较新版本会被检测到取消成绩, 同时该 APP 也会进行虚拟机检测. (有实机的我无所畏惧~)

0x01 抓包

遇事不决先抓包, 在高版本安卓使用 Http Canary 进行抓包需要 Xposed 模块绕过 SSL 证书校验. 由于我日用主力机一直是 GSI 类原生 + Magisk + LSPosed, 所以抓个包自然不成问题, 很容易就抓到了通信相关的 http 包. (省略了无关 Header, 部分敏感数据用 * 替代.)

1
2
3
4
5
POST /v3/api.php/Run/getTimestampV278 HTTP/1.1
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Host: kwpb.***.edu.cn

version=927&version_name=9.0.27&mobileModel=M2104K10AC&mobileDeviceId=48f45996-d20a-45f5-b724-488bc9b1a031&mobileOsVersion=13&ostype=3&student_num=*********&token=&uid=*******&timestamp=1680081429&nonce=824310&sign=a716e5e57430cc30eef9317f1dee6dee
1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: text/json; charset=utf-8

{
"data": "LsUKkEjPvbNJr8FzUOs408n+t9DAO4o9ukqbLnuimVf6ZyJ5D2yMFUEMWe\/pfwhDZg97yywJelFcXU\/pH0bKCfVy9Z91qT8oj4dn6VL9aH92qFWqkyAlGfZXDYNN0kSAYYFaQSVeFF7T3Rxk+sgjsw==",
"status": 1,
"info": "",
"is_encrypt": 1
}

观察请求和响应, 发现请求中包含了 timestamp, nonce 和 sign, 响应显然是加密的, 所以要通过模拟相关的请求先要搞定签名和加密的问题.

0x02.0 解密方法一: Hook 加密相关 API 拦截密钥

最偷懒也是相对比较万金油的逆向加密的方法就是直接 Hook 相关的 API, 并获取密钥. (只要使用的是通用的加密/哈希算法, 且密钥和用户无关是定值/密钥变化但规律比较容易推测.)

虽然使用条件看起来比较苛刻, 但实际上很多用了签名&加密的 APP 都符合这个要求, 很简单就能模仿请求&解密响应.

Google 能搜到不少 Hook 加密 API 的 Frida 脚本, 不过我自己使用的是 Inspeckage, 它 Hook 的接口相对更多更全.

当然, 很多工具支持的安卓版本都没那么高, 要做逆向的话一台谷歌亲儿子还是很需要的, 配置好相关环境, 运行目标应用, 查看 ADB Log, 通过 grep 筛选很快就能得到下面的关键信息.

(btw. 我们学校的 APP 真的是不知道整合了多少家公司的(垃圾)软件… 调用的加密 API 就乱七八糟千奇百怪. 简直就是在我手机里养蛊.)

1
2
3
Inspeckage_Crypto:SecretKeySpec(****************,AES) , Cipher[AES/CBC/PKCS5Padding]  IV: ****************

Inspeckage_Hash:Algorithm(MD5) [mobileDeviceIdc44ba680-311d-44aa-a306-07c0d485b5f4mobileModelAOSP_on_BullHeadmobileOsVersion8.1.0nonce707812ostype3student_num*********timestamp1679995802tokenuid********version927version_name9.0.27************ : 68431b948342f6ee9352a6f680f0f869]

不难发现加密使用的是 AES/CBC/PKCS5Padding, 并得到了相关的 Key 和 IV, 发现能解密所有的请求响应. 签名使用的是把所有 Http Param 排列拼接后加上一个字符串进行 MD5.

0x02.1 解密方法二: 脱壳+IDA Pro

本来其实方法一已经达成目的了, 只是因为我使用了 FART, 所以一不小心就完成了脱壳的工作… 既然如此, 也就顺便来分析一下源代码, 看看能否从中找出密钥.

使用 jadx 打开脱壳后的 dex, 发现相关代码虽然加了壳, 但完全没有混淆, 所以所有的代码逻辑和变量名都一清二楚. 直接搜索相关关键词, 不难发现加密最终调用的方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class JNIUtils {
public static String decrypt(String str, String str2, String str3, String str4, String str5) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
return AESUtils.decrypt(str3 + str, str2, str4, str5);
}

public static String encrypt(String str, String str2, String str3, String str4, String str5) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, UnsupportedEncodingException {
return AESUtils.encrypt(str3 + str, str2, str4, str5);
}

public static native String myDecrypt(String str, String str2, String str3, String str4);

public static native String myEncrypt(String str, String str2, String str3, String str4);

public static native String myGetAppKey(String str);

public static native String myGetDbKey(String str);

static {
System.loadLibrary(StubApp.getString2(24957));
}
}

相关的密钥显然保存在 Native 层中, 直接找出相关的 .so 文件, 丢进 IDA Pro.

IDA Pro 逆向IDA Pro 逆向

也没有遇到任何阻碍, 直接就能找到需要的密钥. (甚至都不一定需要脱壳… 直接解包 apk 在 so 里搜索就完了. 这个 JNI 保护的力度还没壳强, 壳至少把字符串都保护了.🤡)

0x03 抓包分析全流程

上面已经找到了签名和解密的方法, 现在不难写出对应的 Python 代码对请求进行模拟并解密响应.

1
2
3
4
5
6
7
8
9
10
def sign(ret):
str2sign = "".join(map(lambda x: str(x[0]) + str(x[1]), sorted(ret.items(), key=lambda x: x[0]))) + SIGN_KEY
return hashlib.md5(str2sign.encode()).hexdigest()


def aes_decrypt(b64_data):
enc = base64.b64decode(b64_data)
cipher = AES.new(RESP_KEY, AES.MODE_CBC, RESP_IV)
unpad = lambda s: s[:-ord(s[len(s) - 1:])]
return unpad(cipher.decrypt(enc)).decode('utf-8')

同时我们也可以解密 Http Canary 抓包抓到的请求了, 于是拿着手机开着小黄鸟出去跑一小会儿步, 并整理一下相关的请求, 不难发现一次跑步中需要依次发送下面这些请求.

  1. /User/User 获取当前登录用户的信息. (登录是使用的学校 APP 的 OAuth 认证.)

  2. /Run2/beforeRunV260 返回一些跑步地点的相关信息, 包括速度, 时间, 地点等要求.

  3. /Run/getTimestampV278 获得时间戳和一个 “record_str”, 结束跑步时需要用到.

  4. 在跑步过程中每隔一段时间(约 1-2min)调用 /Run/setRunLocationRecord, 上报当前的经纬度.

  5. 结束跑步前调用 /Report/getOssSign, 获得一个阿里云 OSS 的签名, 用于上传一张图片和一个文本文档.

    图片是跑步过程中手机屏幕的截图, 文本文档是 AES 加密过的 json, json 内容是实时采集(2s 一次)到的所有经纬度信息和手机侧记录的配速.

  6. 最后综合上述所有信息(包括跑步开始时间, 结束时间, 时长, 公里数, 刚刚上传的两个文件链接, record_str, 每分钟步频等), 调用 /Run/stopRunV278, 提交一次跑步记录到服务器, 即完成了一次跑步过程.

0x04 编写程序模拟

理顺了流程之后写一些 Python 代码来随机生成一份看起来比较合法的记录, 并依次发送上面的请求到服务器, 即完成了一次赛博跑步.(

跑步结果跑步结果

本文采用 CC BY-NC-SA 4.0 许可协议发布.

作者: lyc8503, 文章链接: https://blog.lyc8503.net/post/nju-cyber-run/
如果本文给你带来了帮助或让你觉得有趣, 可以考虑赞助我¬_¬