某嘟牛app登录接口请求参数逆向分析

逆向目标: 登录接口请求参数Encrypt逆向分析

1、抓包分析


点击登录通过charles抓包,看到登录接口的请求参数中存在一个加密参数Encrypt

2、通过jadx定位加密位置

jadx打开apk文件,直接通过搜索关键字的方式进行定位

这里搜索出来很多结果,优先去看跟apk包名相关的位置,这里发现了两处可疑的位置,点击进入到源码位置

3、通过frida hook的方式确定是否是加密位置

以上的Encrypt参数是通过encrypt进行赋值的,encrypt在上方进行的赋值

1
String encrypt = RequestUtil.encodeDesMap(code, this.desKey, this.desIV);

我们可以通过hook RequestUtil类下的encodeDesMap方法来得到encrypt的值

hook代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function hook() {
let RequestUtil = Java.use("com.dodonew.online.http.RequestUtil");
RequestUtil["encodeDesMap"].overload('java.lang.String', 'java.lang.String', 'java.lang.String').implementation = function (data, desKey, desIV) {
console.log(`RequestUtil.encodeDesMap is called: data=${data}, desKey=${desKey}, desIV=${desIV}`);
let result = this["encodeDesMap"](data, desKey, desIV);
console.log(`RequestUtil.encodeDesMap result=${result}`);
return result;
};
}

function main() {
Java.perform(function () {
hook()
})
}

main()

hook结果如下:

1
2
3
4
5
6
RequestUtil.encodeDesMap is called: data={"equtype":"ANDROID","loginImei":"Android010139023152113","sign":"6390CC1EE2406073DB9FEF4E9CB80A29","timeStamp":"1747969001281","userPwd":"123456","username":"13456798976"}, desKey=65102933, desIV=32028092

RequestUtil.encodeDesMap result=NIszaqFPos1vd0pFqKlB42Np5itPxaNH//FDsRnlBfgL4lcVxjXiiyqIScii6OFy//cZnTrYzQcr
ozbW4axiV+SeLV71aIQ4AeSd5UD0yf5ogdESrlwVkyfuDkmwz3FX/lKWbALMS5qEp0jbw2vtbEni
E8tVSrSd3H0yGIPI8M+3YsMqBRVm+Sy6XFGe+QmHprBkLA+cSz1iZP/tKcwFi/zeVAfchRVIJq7m
1A/eQyI=

我们对比charles的抓包结果可以发现,这里就是加密的位置,那么我们接下来就需要去重点分析RequestUtil类下的encodeDesMap方法

4、逆向分析

通过frida hook RequestUtil.encodeDesMap方法的hook结果分析入参

1
RequestUtil.encodeDesMap is called: data={"equtype":"ANDROID","loginImei":"Android010139023152113","sign":"6390CC1EE2406073DB9FEF4E9CB80A29","timeStamp":"1747969001281","userPwd":"123456","username":"13456798976"}, desKey=65102933, desIV=32028092

我们多调用几次登录接口可以发现equtype和loginImei参数是固定的,timeStamp参数是时间戳,userPwd和username分别是用户输入的密码和用户名,那么现在需要找到sign参数加密的位置

在encrypt的上方可以看到sign,我们直接跟进去RequestUtil.paraMap方法看一下,跟进去后看到该方法的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static String paraMap(Map<String, String> addMap, String append, String sign) {
try {
Set<String> keyset = addMap.keySet();
StringBuilder builder = new StringBuilder();
List<String> list = new ArrayList<>();
for (String keyName : keyset) {
list.add(keyName + "=" + addMap.get(keyName));
}
Collections.sort(list);
for (int i = 0; i < list.size(); i++) {
builder.append(list.get(i));
builder.append("&");
}
builder.append("key=" + append);
String checkCode = Utils.md5(builder.toString()).toUpperCase();
addMap.put("sign", checkCode);
String result = new Gson().toJson(sortMapByKey(addMap));
Log.w(AppConfig.DEBUG_TAG, result + " result");
return result;
} catch (Exception e) {
e.printStackTrace();
return "";
}
}

很明显sign的值是checkCode的值,checkCode的值通过Utils.md5加密得到的,那么我们先编写frida hook脚本hook一下Utils.md5接收的参数是什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function hook3() {
let Utils = Java.use("com.dodonew.online.util.Utils");
Utils["md5"].implementation = function (string) {
console.log(`Utils.md5 is called: string=${string}`);
let result = this["md5"](string);
console.log(`Utils.md5 result=${result}`);
return result;
};
}

function main() {
Java.perform(function () {
hook3()
})
}

main()

hook的结果为

1
2
Utils.md5 is called: string=equtype=ANDROID&loginImei=Android010139023152113&timeStamp=1747981884623&userPwd=123456&username=13456798976&key=sdlkjsdljf0j2fsjk
Utils.md5 result=00cfb423f3e7c8894f25f5069f4fbd7d

根据上面的代码我们可以发现sign的值就是将Map中的键值对循环遍历出来然后通过key=value的形式中间拼接一个&符号得到最终的字符串,然后将字符串进行md5加密转为大写,只不过在进行字符串拼接前往Map中添加了一个key键,值为sdlkjsdljf0j2fsjk,那么我们就需要确认一下Utils.md5加密是否是一个标准的md5加密,我们可以通过frida主动调用的方式来确定

1
2
3
4
5
6
7
8
9
10
function main() {
Java.perform(function () {
let Utils = Java.use("com.dodonew.online.util.Utils");
console.log(Utils["md5"]('1'));
})
}

main()

// 打印结果:c4ca4238a0b923820dcc509a6f75849b

那么可以确定Utils.md5就是一个标准的md5加密

接下来hook一下paraMap方法,看看paraMap方法最终返回的结果是什么

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
function hook2() {
let RequestUtil = Java.use("com.dodonew.online.http.RequestUtil");
RequestUtil["paraMap"].overload('java.util.Map', 'java.lang.String', 'java.lang.String').implementation = function (addMap, append, sign) {
console.log(`RequestUtil.paraMap is called: append=${append}, sign=${sign}`);

// 打印 Map 内容
console.log("addMap content:");
let entrySet = addMap.keySet();
let iterator = entrySet.iterator();
while (iterator.hasNext()) {
var keyStr = iterator.next()
var valueStr = addMap.get(keyStr)
console.log(` ${keyStr} => ${valueStr}`);
}
let result = this["paraMap"](addMap, append, sign);
console.log(`RequestUtil.paraMap result=${result}`);
return result;
};
}

function main() {
Java.perform(function () {
hook2()
// hook3()
// hook()
})
}

main()

hook结果:

1
2
3
4
5
6
7
8
RequestUtil.paraMap is called: append=sdlkjsdljf0j2fsjk, sign=sign
addMap content:
timeStamp => 1747982496340
loginImei => Android010139023152113
equtype => ANDROID
userPwd => 123456
username => 13456798976
RequestUtil.paraMap result={"equtype":"ANDROID","loginImei":"Android010139023152113","sign":"2205707C5A4F0944792BD57E8257C68F","timeStamp":"1747982496340","userPwd":"123456","username":"13456798976"}

通过hook paraMap方法可以看到最终方法返回的结果是一个字符串,该字符串是将加密后的sign参数push到addMap中,然后将addMap参数转为对应的字符串

最终回到RequestUtil.encodeDesMap方法,点击进入到encodeDesMap方法中

跟进DesSecurity类中看看

发现这是一个des加密方式,只是key又被经过了一次md5的加密,在刚才hook RequestUtil.encodeDesMap的时候就已经知道了key的值为65102933,iv的值为32028092,那么我们只需要进行算法还原就行了

python算法还原

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
import base64
from urllib.parse import urlencode
import hashlib
import json
from Crypto.Cipher import DES
from Crypto.Util.Padding import pad, unpad
from Crypto.Hash import MD5


class DESCipher:
def __init__(self, sec_key: bytes, sec_iv: bytes):
# Java 中:md = MessageDigest.getInstance("MD5")
# md.update(secKey)
# key = new DESKeySpec(md.digest()[0:8])
md5 = MD5.new()
md5.update(sec_key)
des_key = md5.digest()[:8] # DES 需要 8 字节密钥

# Java 中的 Cipher.getInstance("DES/CBC/PKCS5Padding")
self.en_cipher = DES.new(des_key, DES.MODE_CBC, sec_iv)
self.de_cipher = DES.new(des_key, DES.MODE_CBC, sec_iv)

def encrypt64(self, data: bytes) -> str:
from Crypto.Util.Padding import pad
encrypted = self.en_cipher.encrypt(pad(data, DES.block_size))
return base64.b64encode(encrypted).decode('utf-8')

def decrypt(self, ciphertext: bytes) -> bytes:
decrypted = self.de_cipher.decrypt(ciphertext)
return unpad(decrypted, DES.block_size)


data = {"equtype": "ANDROID", "loginImei": "Android010139023152113",
"timeStamp": "1747973461904", "userPwd": "123456", "username": "13456798976"}

query_string = urlencode(data)
# 添加 key 参数
final_string = query_string + "&key=sdlkjsdljf0j2fsjk"

print(final_string)

sign = hashlib.md5(final_string.encode('utf-8')).hexdigest().upper()
print(sign)

data['sign'] = sign
print(data)

# 按 key 自动排序
sorted_dict = dict(sorted(data.items()))

code = json.dumps(sorted_dict).replace(" ", "")
print(code)

sec_key = b"65102933" # 相当于 Java 中的 secKey
sec_iv = b"32028092" # 必须是 8 字节,Java 中的 secIv

cipher = DESCipher(sec_key, sec_iv)
encrypted_b64 = cipher.encrypt64(code.encode())
print("Base64 加密结果:", encrypted_b64)

最终只需要将入参保持一致,然后把最终的结果和frida hook的结果对比一下,结果一致的话就说明算法还原的没问题