记录一次逆向某校园软件

    教程仅供学习交流,学习研究软件设计思路,请勿用于任何非法用途

去更新

    在解包后的软件文件夹中,从smali代码中查找“1.2.0”,将其全部替换为9.9.9

修改登录接口

    自v2版本更新后,v1版本的登录接口就无法登陆了,但是想开挂只能用v1,所以通过抓包拿到v2版本的登录接口替换给v1

    抓包拿到v2的登录接口

    去除域名,得到剩余的接口地址

1
run-front/account/login_v2

    同理,再抓到v1版本的登录接口,将v1接口替换为v2即可

1
2
3
4
5
// v1
run-front/account/login

// v2
run-front/account/login_v2


过wi-fi检测

    搜索关键词,并复制搜索到的关键词变量名

    搜索变量名,查找调用处,在RunningSettingsActivity和GPSOptimizeActivity类中调用了该字符串

    分析RunningSettingsActivity类中的代码

    跳转到isForbiddenWiFi()方法所在的类,分析发现isForbiddenWiFi()返回this.forbiddenWiFi,而setForbiddenWiFi()给this.forbiddenWiFi赋值,只要在setForbiddenWiFi()方法中永远给this.forbiddenWiFi赋值为false就好了

过root检测

    搜索关键词,并复制搜索到的关键词变量名,留意一下上边的cheat_warn_1到cheat_warn_5

    搜索变量名cheat_warn_running,发现HomeActivity和RunningDetailActivity两个类调用了它,很可惜两个类都和过root检测没有直接关系,但是两个类中的代码可以确定,cheat_warn_running和cheat_warn_1到cheat_warn_5是有直接关系的,二者必定同时出现

    通过第一部的搜索可知,cheat_warn_1 = “在root环境下使用闪动校园”,所以去到代码中搜索cheat_warn_1,发现SunshineRunningFragment类和FreeRunningFragment类也在调用它,把这两个类名翻译为中文,又刚好对应软件的“阳光跑”和“自由跑”

    打开对应阳光跑的类看下,一目了然,而自由跑中除了没有wifi检测外,也一模一样

    也就是说,只要RootDetectUtil.b().a()永远返回false,将不会检测root环境

过虚拟机检测

    有了上面的经验,过虚拟机检测就比较简单了,只要让这个判断不进入if代码块就好

    在SunshineRunningFragment中加入一行const/4 v0, 0x0

    同理,FreeRunningFragment中,但是之前的完成品中并没有过掉自由跑的虚拟机检测,因为我忘了

违规时不记录

    搜索字符串cheat_warn_running,在HomeActivity的onEvent方法中

    使paramCheatStopEvent值为null,则不进入方法体

    搜索字符串cheat_warn_running,在RunningDetailActivity的O方法中,使i = 0

    搜索字符串cheat_warn_1,在SunshineRunActivity类的e方法中

    使schoolRule变量值为null

过35秒检测

    闪动校园在开始运动的第35秒必定会有一次检测,这里提供一个简单的解决思路

1
2
3
4
5
6
7
35s == 35000ms
35000(10进制) == 88B8(16进制)

1h == 3600s == 3600000ms
3600000(10进制) == 36EE80(16进制)

找到88B8修改为36EE80就好了

非法请求修复

    2021年11月21日,官方在服务器添加了版本验证,其实具体是怎么个验证法我现在都搞不清,因为之前apk包中的版本号全部修改为9.9.9,所以请求数据时提交的json数据中也是9.9.9版本,此时接口将会返回“非法请求,验签不通过”并在app中Toast
    所有的数据请求接口,包括但不限于:用户资料、学校阳光跑要求条件、学校自由跑要求条件,都被添加了这种验签,所以必须修复

    我原以为要重构它的json拼接方式和解析方式,忙活了一天没有半点进展,误打误撞下,将其中一个版本号从9.9.9修改成了2.1.1(当前最新版本2.1.1),成功了,惊喜之外

sign验签算法逆向

“非法请求,验签不通过”的原因:

  1. 版本号超出当前最新版本版本号或版本号格式错误
  2. post中的提交的json和sign值不对应,sign值由json加密获得
  3. 2.2.1版本前与2.2.1版本由于加密key不同所以得到的sign值不同,即服务器验证中json与sign不对应

2.2.1版本前

定位关键词

    这里使用1.2.0版本演示
    搜索关键词”sign”,1.2.0版本共有两处,在huachenjie.sdk.http.e包的b类a(K, B,Map)方法中出现了一次,将”sign”修改为”signs”,抓包会发现post中的sign响应头也变成了”signs”,所以可以猜测,a(K, B,Map)中调用的a(String, B, Map, String)添加了”sign”响应头和对应的sign值,那么它一定与sign值有关联

分析关键词调用方法

    分析一下调用a(String, B, Map, String)方法传入的4个参数

    1、String值”sign”: 不用多说,一个响应头的键名而已

    2、B类型paramB: 该类中的String[]类型的a数组记录了所有的键名和键值,包括post中的sign等响应头的键值对,以及post的json中的键值对,B类的构造函数为其赋值,将一个集合转换为数组并赋值给a数组

    2.1、而集合的值来源于构造函数中传入的a类的a变量,a类是B.class文件中的一个内部类,它的a变量记录了一个String类型的集合,该集合作为post中所有数据的源头,该内部类中的b(String,String)方法为其题添加值,b(String)方法为其删除值


    2.2、总而言之,传入B类的最大作用就是可以通过它调用post中所有的数据

    3、强转为Map类型的hashMap: 在当前方法中刚创建的,主要用于记录sign的值,当前方法被调用时return出去

    4、b(paramMap): 通过添加Log,Map类型的paramMap转为String后竟然是post提交的json值,那么有极大可能b(Map)方法返回的String字符串就是sign值

分析json加密

    将传入的Map转换成了String赋值给str,这个str就是post中的json值,str传入了f类的b(String)方法

    f类的b(String)将传入的json值提交给a(String,String)方法,一并提交的还有”SHA-256”,能看出来对json值进行了一次SHA-256加密

    跳转到a(String,String)方法,果然先经历了一次SHA-256加密,开始我还以为是base64
    具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class f {
public static String a(String paramString) {
try {
MessageDigest messageDigest = MessageDigest.getInstance("md5");
messageDigest.update(paramString.getBytes("UTF-8"));
byte[] arrayOfByte = messageDigest.digest();
StringBuilder stringBuilder = new StringBuilder();
this(arrayOfByte.length * 2);
int i = arrayOfByte.length;
for (byte b = 0; b < i; b++) {
int j = arrayOfByte[b] & 0xFF;
if (j < 16)
stringBuilder.append("0");
stringBuilder.append(Integer.toHexString(j));
}
return stringBuilder.toString();
} catch (NoSuchAlgorithmException noSuchAlgorithmException) {
noSuchAlgorithmException.printStackTrace();
} catch (Exception exception) {
exception.printStackTrace();
}
return "";
}

private static String a(String paramString1, String paramString2) {
String str1;
boolean bool = TextUtils.isEmpty(paramString1);
String str2 = "";
if (bool)
return "";
try {
MessageDigest messageDigest = MessageDigest.getInstance(paramString2);
messageDigest.update(paramString1.getBytes());
byte[] arrayOfByte = messageDigest.digest();
StringBuffer stringBuffer = new StringBuffer();
this();
for (byte b = 0; b < arrayOfByte.length; b++) {
String str = Integer.toHexString(arrayOfByte[b] & 0xFF);
if (str.length() == 1)
stringBuffer.append('0');
stringBuffer.append(str);
}
str1 = stringBuffer.toString();
} catch (NoSuchAlgorithmException noSuchAlgorithmException) {
noSuchAlgorithmException.printStackTrace();
str1 = str2;
}
return str1;
}

public static String b(String paramString) {
return a(paramString, "SHA-256");
}
}

    根据这个加密代码编写一个用例测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//  拟定json数据
String json_content = "{"appCode":"SD001","appVersion":"1.2.0","buildVersion":"13","deviceId":"ffffffff-ffff-ffff-ffff-ffffffffffffffffffff-ffff-ffff-ffff-ffffffffffff","modelName":"Phone|test 1","platform":"2","systemVersion":"11","timestamp":"1637652538168","userId":"201225*****661220"}";
// 加密方式
String type = "SHA-256";

// SHA-256加密
private String SHA256Encryption() {
String value;
try {
MessageDigest messageDigest =MessageDigest.getInstance(type);//SHA-256类型
messageDigest.update(json_content.getBytes());//取json数据的byte
byte[] arrayOfBy = messageDigest.digest();
StringBuffer stringBuffer = new StringBuffer();
for (int i = 0; i < arrayOfBy.length; i++) {
String str =Integer.toHexString(arrayOfBy[i] & 0xFF);
if (str.length() == 1){
stringBuffer.append('0');
}
stringBuffer.append(str);
}
value = stringBuffer.toString();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
value = null;
}
return value;
}

    得到加密值,但是并不是最终值,那说明还有一步加密,肯定就在刚刚f.b(String)作为参数的a.b(String,String)方法中

1
3f5711c7f2e4880579fc0f2286c1578b031087ccd86d8af099fdfc40d1697db4

分析key值获取

    现在已知,f.b(String)就是SHA-256加密后的json值,在a.b(String,String)中还有一次加密,想要弄明白a.b(String,String),先来分析传入的第二个参数a.a()
    解知,a.a()方法返回一个字符串,字符串源自其静态a方法中的第一个参数,图中有一个错误。上面的箭头应该指向b变量却指向了d变量

    通过查找知,b的值为F44B0282BEA83557,在某些特殊情况下为huachenjie(不需要考虑)

分析AES加密

    现已知,f.b(String)是SHA-256加密后的json值,a.a()返回一个不长的字符串,盲猜是加密key
    现在来分析a.b(String,String),豁,好家伙了,AES加密,怪不得那么像base64

    其中调用的一个变量和一个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  this(a.getBytes("UTF-8"));
// a的值为
public static String a = "01234ABCDEF56789";

return b.a(cipher.doFinal(paramString1.getBytes("UTF-8")));
// b.a()方法为
public static String a(byte[] paramArrayOfbyte) {
if (paramArrayOfbyte != null) {
String str1;
try {
String str2 = new String();
this(Base64.encode(paramArrayOfbyte, 2), "utf-8");
str1 = str2;
} catch (UnsupportedEncodingException unsupportedEncodingException) {
unsupportedEncodingException.printStackTrace();
str1 = "";
}
return str1;
}
String str = "";
}

    编写用例,调试成功😊

1
//  调试代码就不贴了

2.2.1版本更新

    当前的最新版本了,不确定以后会不会在这个基础上作出修改
    定位关键词、分析调用方法这些都是一样的,从SHA-256加密json值和获取key值开始说

分析json加密

    相对于1.2.0有所变动,但是无伤大雅,依然可以直接抄

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public class f {
private static String a(String paramString1, String paramString2) {
String str1;
boolean bool = TextUtils.isEmpty(paramString1);
String str2 = "";
if (bool)
return "";
String str3 = str2;
try {
MessageDigest messageDigest = MessageDigest.getInstance(paramString2);
str3 = str2;
messageDigest.update(paramString1.getBytes());
str3 = str2;
byte[] arrayOfByte = messageDigest.digest();
str3 = str2;
StringBuffer stringBuffer = new StringBuffer();
str3 = str2;
this();
byte b = 0;
while (true) {
str3 = str2;
if (b < arrayOfByte.length) {
str3 = str2;
String str4 = Integer.toHexString(arrayOfByte[b] & 0xFF);
str3 = str2;
if (str4.length() == 1) {
str3 = str2;
stringBuffer.append('0');
}
str3 = str2;
stringBuffer.append(str4);
b++;
continue;
}
str3 = str2;
String str = stringBuffer.toString();
str3 = str;
str1 = str;
if (str.length() >= 16) {
str3 = str;
String str4 = str.substring(0, 8);
str3 = str;
str2 = str.substring(str.length() - 8);
str3 = str;
StringBuilder stringBuilder = new StringBuilder();
str3 = str;
this();
str3 = str;
stringBuilder.append(str2);
str3 = str;
stringBuilder.append(str.substring(8, str.length() - 8));
str3 = str;
stringBuilder.append(str4);
str3 = str;
str1 = stringBuilder.toString();
}
return str1;
}
} catch (NoSuchAlgorithmException noSuchAlgorithmException) {
noSuchAlgorithmException.printStackTrace();
str1 = str3;
}
return str1;
}

public static String b(String paramString) {
try {
MessageDigest messageDigest = MessageDigest.getInstance("md5");
messageDigest.update(paramString.getBytes("UTF-8"));
byte[] arrayOfByte = messageDigest.digest();
StringBuilder stringBuilder = new StringBuilder();
this(arrayOfByte.length * 2);
int i = arrayOfByte.length;
for (byte b = 0; b < i; b++) {
int j = arrayOfByte[b] & 0xFF;
if (j < 16)
stringBuilder.append("0");
stringBuilder.append(Integer.toHexString(j));
}
return stringBuilder.toString();
} catch (NoSuchAlgorithmException noSuchAlgorithmException) {
noSuchAlgorithmException.printStackTrace();
} catch (Exception exception) {
exception.printStackTrace();
}
return "";
}

public static String c(String paramString) {
return a(paramString, "SHA-256");
}
}

分析key值获取

    解知,a.c()方法返回一个字符串,字符串源自其静态d方法中的第二个参数,从1.2.0的第一个参数转变成了第二个

    这里就他妈比较烦人了槽
    通过查找知,key值来源于com.huachenjie.shandong_school包中ShandongApplication类c()方法中huachenjie.sdk.http.k.a.a.d(str3, str1, “”, false, false, arrayList)的第二个变量str1

    而str1来源于e()方法,e()方法返回的k.b2s(byte[], int)中就是key值了。如果删除了他们定义的那张图片,那么bitmip就为null,这个方法就会像1.2.0那样返回F44B0282BEA83557,结局就是验签失败

    跳转到b2s,是个native方法🙂

    而我,看不懂so文件里的汇编🙂

分析AES加密

    没变,不分析了