几个简单app的登录逆向

以下app都是来自小肩膀2022安卓逆向课程的案例。

一、今日镇江

1、升级弹窗去除

打开弹出升级框,不想升级所以直接给他干掉。

pE3oShV.png

两种方法:

1、找到获取版本的地方,hook改变返回值使得本地app版本和远程服务器app版本一致,经过比对后就不会弹出该提示框。

2、直接找到弹框部分,给该部分直接用hook给nop掉。

这里直接使用第二种方法。

这个app没有字符串加密,所有首先jadx搜索字符串”升级提示”,定位到strings.xml中的

1
<string name="update_info">升级提示</string>

之后继续搜索update_info找到弹框的部分定位到如下函数,直接frida替换整个函数并且使用spwan模式启动时注入。

pE3Ij7n.png

1
2
3
4
5
6
7
Java.perform(function(){
var ActivityUtils = Java.use("com.cmstop.cloud.base.ActivityUtils");
ActivityUtils.createUpdateDialog.implementation = function (activity)
{
console.log("ActivityUtils is replaced to NULL!")
}
})

2、登录逆向

之后分析登录逻辑,没有抓包检测,使用httpcanary抓到如下包:

pE3Izt0.png

发现只有一个sign是未知的,其余的要么是时间戳,要么是固定的设备id等等。所以分析sign就行了。

同样先用jadx搜索字符串”sign”,定位到

1
public static final String MODULE_SIGN = "sign";

交叉引用有多个,逐个看一下,最后定位到正确位置,在com.cmstop.cloud.b.b.a函数中:

pE3IX0s.png

再步入到m9920

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
public static String m9920a(HashMap<String, String> paramsMap, String time) {
LinkedHashMap<String, String> sortParams = new LinkedHashMap<>();
Object[] key_arr = paramsMap.keySet().toArray();
Arrays.sort(key_arr);
for (Object key : key_arr) {
try {
sortParams.put(key.toString(), URLEncoder.encode(paramsMap.get(key).toString(), "UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
StringBuilder result = new StringBuilder();
for (Map.Entry<String, String> entry : sortParams.entrySet()) {
if (result.length() > 0) {
result.append("&");
}
result.append(entry.getKey());
result.append("=");
result.append(entry.getValue());
}
String replace = result.toString().replace("*", "%2A").replace("%7E", "~").replace("+", "%20");
crack.log(replace);
String resultMD5 = MD5.md5(replace);
crack.log(resultMD5);
String str = resultMD5 + "1fa50ba25ed527f3fd1eb9467686f2bb" + time;
crack.log(str);
String md5Result = MD5.md5(str);
crack.log(md5Result);
return md5Result;
}

分析可知,sign是两次md5的结果,首先把GET请求中的一些参数排序后了拼接在一起,拼接完成后取md5值,再把第一次的md5和1fa50ba25ed527f3fd1eb9467686f2bb以及时间戳拼接后再次取md5即为最后的sign值。

3、登录协议复现

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
import requests
import hashlib
import time

#timestamp = str(int(time.time() * 1000))
timestamp = "1740639186157" #这里是为了演示算法自吐,正常模拟登录需使用当前的时间
phone = "17860581502" #只需替换phone和password
password = "123456"
data1 = "account=17860581502&clientid=1&device_id=9719046b-4f61-45b5-9201-d7d878588158&ip=100.100.100.100&modules=cloudlogin%3A1&password=123456&siteid=10001&system_name=android&type=android"
sign1 = hashlib.md5(data1.encode('utf-8')).hexdigest()
data2 = sign1 + "1fa50ba25ed527f3fd1eb9467686f2bb" + timestamp
sign2 = hashlib.md5(data2.encode('utf-8')).hexdigest()
print(sign2)
url = "http://m.api.zt.jsw.com.cn/v2/member?password=" + password + "&clientid=1&device_id=9719046b-4f61-45b5-9201-d7d878588158&ip=100.100.100.100&system_name=android&sign=" + sign2 + "&siteid=10001&time=" + timestamp + "&type=android&account=" + phone + "&modules=cloudlogin%3A1"

# headers = {
# "Content-Length": "0",
# "Host": "m.api.zt.jsw.com.cn",
# "Connection": "Keep-Alive",
# "Accept-Encoding": "gzip"
# }
r = requests.get(url=url)
recv = r.text
print(recv)
import json
# 解析 JSON 字符串
data = json.loads(recv) #这里json能自动把unicode解析成中文。

# 获取 'error' 字段的内容
error_value = data['error']
print(error_value)

输出如下:

1
2
3
9aa0acfd26cf5252424c5f8369dbc7c0
{"state":false,"error":"\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef","info":""}
账号或密码错误

与抓包的响应内容一致。

pE3Ixkq.jpg

4、算法自吐

既然是标准md5,那么可以直接算法自吐解决,输出到log.txt后。

首先搜索sign值9aa0acfd26cf5252424c5f8369dbc7c0找到如下信息:

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
MD5 update data Utf8: a3075ce87e36cc6736d92cd8056036301fa50ba25ed527f3fd1eb9467686f2bb1740639186157
MD5 update data Hex: 6133303735636538376533366363363733366439326364383035363033363330316661353062613235656435323766336664316562393436373638366632626231373430363339313836313537
MD5 update data Base64: YTMwNzVjZTg3ZTM2Y2M2NzM2ZDkyY2Q4MDU2MDM2MzAxZmE1MGJhMjVlZDUyN2YzZmQxZWI5NDY3Njg2ZjJiYjE3NDA2MzkxODYxNTc=
=======================================================
MessageDigest.digest() is called!
java.lang.Throwable
at java.security.MessageDigest.digest(Native Method)
at com.cmstopcloud.librarys.utils.MD5.md5(MD5.java:48)
at com.cmstop.cloud.b.b.a(APIRequestService.java:822)
at com.cmstop.cloud.b.b.a(APIRequestService.java:840)
at com.cmstop.cloud.b.b.a(APIRequestService.java:1252)
at com.cmstop.cloud.activities.LoginCloudActivity.a(LoginCloudActivity.java:141)
at com.cmstop.cloud.activities.LoginCloudActivity.onClick(LoginCloudActivity.java:65)
at android.view.View.performClick(View.java:7140)
at android.view.View.performClickInternal(View.java:7117)
at android.view.View.access$3500(View.java:801)
at android.view.View$PerformClick.run(View.java:27351)
at android.os.Handler.handleCallback(Handler.java:883)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)

MD5 digest result Hex: 9aa0acfd26cf5252424c5f8369dbc7c0
MD5 digest result Base64: mqCs/SbPUlJCTF+DadvHwA==

发现传入的参数就是sign1 + "1fa50ba25ed527f3fd1eb9467686f2bb" + time,且定位也是我们分析的位置

所以接着搜索sign1:a3075ce87e36cc6736d92cd805603630,找到如下信息:

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
MD5 update data Utf8: account=17860581502&clientid=1&device_id=9719046b-4f61-45b5-9201-d7d878588158&ip=100.100.100.100&modules=cloudlogin%3A1&password=123456&siteid=10001&system_name=android&type=android
MD5 update data Hex: 6163636f756e743d313738363035383135303226636c69656e7469643d31266465766963655f69643d39373139303436622d346636312d343562352d393230312d6437643837383538383135382669703d3130302e3130302e3130302e313030266d6f64756c65733d636c6f75646c6f67696e253341312670617373776f72643d313233343536267369746569643d31303030312673797374656d5f6e616d653d616e64726f696426747970653d616e64726f6964
MD5 update data Base64: YWNjb3VudD0xNzg2MDU4MTUwMiZjbGllbnRpZD0xJmRldmljZV9pZD05NzE5MDQ2Yi00ZjYxLTQ1YjUtOTIwMS1kN2Q4Nzg1ODgxNTgmaXA9MTAwLjEwMC4xMDAuMTAwJm1vZHVsZXM9Y2xvdWRsb2dpbiUzQTEmcGFzc3dvcmQ9MTIzNDU2JnNpdGVpZD0xMDAwMSZzeXN0ZW1fbmFtZT1hbmRyb2lkJnR5cGU9YW5kcm9pZA==
=======================================================
MessageDigest.digest() is called!
java.lang.Throwable
at java.security.MessageDigest.digest(Native Method)
at com.cmstopcloud.librarys.utils.MD5.md5(MD5.java:48)
at com.cmstop.cloud.b.b.a(APIRequestService.java:815)
at com.cmstop.cloud.b.b.a(APIRequestService.java:840)
at com.cmstop.cloud.b.b.a(APIRequestService.java:1252)
at com.cmstop.cloud.activities.LoginCloudActivity.a(LoginCloudActivity.java:141)
at com.cmstop.cloud.activities.LoginCloudActivity.onClick(LoginCloudActivity.java:65)
at android.view.View.performClick(View.java:7140)
at android.view.View.performClickInternal(View.java:7117)
at android.view.View.access$3500(View.java:801)
at android.view.View$PerformClick.run(View.java:27351)
at android.os.Handler.handleCallback(Handler.java:883)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)

MD5 digest result Hex: a3075ce87e36cc6736d92cd805603630
MD5 digest result Base64: owdc6H42zGc22SzYBWA2MA==

观察参数发现就是排序后的请求体信息,直接搞定!

二、0715圈

1、登录逆向

分析登录逻辑,同样没有抓包检测,使用httpcanary抓到如下包:

pE8uFY9.png

同样只有一个codeSign是未知的,字符串未加密,jadx直接字符串”codeSign”搜定位到如下函数代码。

pE8uCo4.png

发现有两个分支,两个分支的codeSign一个走的C6485v.m7977a,另一个走的C6485v.m79778,这俩函数是一个类下的重载函数。

pE8uiFJ.png

那就都hook一下,看具体走的哪个流程。

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
var v = Java.use("com.quan0715.forum.util.v");
v.a.overload('java.lang.String', 'com.alibaba.fastjson.JSONObject', 'java.lang.String', 'long').implementation= function (a,b,c,d)
{
console.log("param1: " + a);
console.log("param2: " + b);
console.log("param3: " + c);
console.log("param4: " + d);
return this.a(a,b,c,d);
}
v.a.overload().implementation= function ()
{
console.log("param0");
return this.a();
}
v.a.overload('java.lang.String', 'java.lang.String').implementation= function (a,b)
{
console.log("param1: " + a);
console.log("param2: " + b);
return this.a(a,b);
}
v.a.overload('java.lang.String', 'com.alibaba.fastjson.JSONObject', 'long').implementation= function (a,b,c)
{
console.log("param1: " + a);
console.log("param2: " + b);
console.log("param3: " + c);
return this.a(a,b,c);
}

最后发现四个参数的没被调用,所以走的是三个参数的流程,也就是下边的else的逻辑(这里不太明白,按照if的isLogin()判断感觉应该走上边的逻辑才对)

接着分析发现,在C6485v.m79778中又进入了m8007a函数,而该函数是一个标准的md5,所以只需要分析出参数就行了。

md5参数由字符串拼接组成,其中第一个jSONObject.toString()的值是:

1
{"params":{"password":"123456","username":"17860581502"}}   

而str和j分别是前边的随机数和时间戳:

1
2
String replaceAll = UUID.randomUUID().toString().replaceAll("-", "");
long currentTimeMillis = System.currentTimeMillis();

所以只需分析m7979a()即可。

m7979a()里又调用了m7976a(),然后再调用了C6420af.m8129a,而该函数是把参数字符串拼接后逆序。

1
2
3
4
/* renamed from: a */
public static String m8129a(String str, String str2) {
return new StringBuffer(str + str2).reverse().toString();
}

所以我们只需要得到两个字符串常量C6420af.m8127b((int) C3357R.string.forum_key)C6420af.m8127b((int) C3357R.string.upload_key)即可。这俩个参数直接通过hook获得,或者在res/values/strings.xml中直接查找(后一种仅支持字符串未加密的,若加密需要hook获得)。

得到

1
2
forum_key = "94ac5cfb69e87bd7"
upload_key = "860f50db3569e448"

2、登录协议复现

分析完成后,就可以复现协议了

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
import hashlib
import time
import uuid

import requests

forum_key = '94ac5cfb69e87bd7'
upload_key = '860f50db3569e448'
timestamp = str(int(time.time() * 1000))
phone = "17860581502" #只需修改phone和password
password = "123456"
random_string = str(uuid.uuid4()).replace("-", "")

md5_param = '{"params":{"password":"' + password + '","username":"' + phone + '"}}' + random_string + (forum_key + upload_key)[::-1] + timestamp
print(md5_param)
codeSign = hashlib.md5(md5_param.encode('utf-8')).hexdigest().upper()
print(codeSign)
url = "http://cbrx.qianfanapi.com/v2_2/user/login"

data = "nonce=" + random_string + "&codeSign=" + codeSign + "&timestamp=" + timestamp + "&data=%7B%22params%22%3A%7B%22password%22%3A%22123456%22%2C%22username%22%3A%2217860581502%22%7D%7D&version=2.2.1&product_version=220&platform=marlin&network=1&device=000000000000000&access_token=c3890a98d08168702f924d92282e1014&screen_width=1440&screen_height=2392&bbsnopic=0&system=2&system_version=29&theme=4"
headers = {
"User-Agent": "QianFan;quan0715;Android;Mozilla/5.0;AppleWebkit/533.1;PixelXLgoogle29;",
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": "415",
"Host": "cbrx.qianfanapi.com",
"Connection": "Keep-Alive",
"Accept-Encoding": "gzip"
}
r = requests.post(url=url, data=data, headers=headers)
recv = r.text
print(recv)

3、算法自吐

因为是标准md5,同样直接出。

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
MD5 update data Utf8: {"params":{"password":"123456","username":"17860581502"}}60cf8a291f414eba8c725bbabfa8a65b844e9653bd05f0687db78e96bfc5ca491740729987243
MD5 update data Hex: 7b22706172616d73223a7b2270617373776f7264223a22313233343536222c22757365726e616d65223a223137383630353831353032227d7d3630636638613239316634313465626138633732356262616266613861363562383434653936353362643035663036383764623738653936626663356361343931373430373239393837323433
MD5 update data Base64: eyJwYXJhbXMiOnsicGFzc3dvcmQiOiIxMjM0NTYiLCJ1c2VybmFtZSI6IjE3ODYwNTgxNTAyIn19NjBjZjhhMjkxZjQxNGViYThjNzI1YmJhYmZhOGE2NWI4NDRlOTY1M2JkMDVmMDY4N2RiNzhlOTZiZmM1Y2E0OTE3NDA3Mjk5ODcyNDM=
=======================================================
MessageDigest.digest() is called!
java.lang.Throwable
at java.security.MessageDigest.digest(Native Method)
at java.security.MessageDigest.digest(MessageDigest.java:448)
at java.security.MessageDigest.digest(Native Method)
at com.quan0715.forum.util.r.a(TbsSdkJava:41)
at com.quan0715.forum.util.v.a(TbsSdkJava:30)
at com.quan0715.forum.util.v.a(Native Method)
at com.quan0715.forum.base.b.a(TbsSdkJava:68)
at com.quan0715.forum.a.f.a(TbsSdkJava:38)
at com.quan0715.forum.fragment.login.PasswordLoginFragment.b(TbsSdkJava:424)
at com.quan0715.forum.fragment.login.PasswordLoginFragment.onClick(TbsSdkJava:347)
at android.view.View.performClick(View.java:7140)
at android.view.View.performClickInternal(View.java:7117)
at android.view.View.access$3500(View.java:801)
at android.view.View$PerformClick.run(View.java:27351)
at android.os.Handler.handleCallback(Handler.java:883)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)

MD5 digest result Hex: 700011ab119b7942dcfaf9f96b512c76
MD5 digest result Base64: cAARqxGbeULc+vn5a1Esdg==

因为174往后是时间戳,所以接着搜索60cf8a291f414eba8c725bbabfa8a65b得知是hook的第一个str,最后剩个844e9653bd05f0687db78e96bfc5ca49因为是逆序,测试一下那两个key就行了。

三、小红书

1、登陆逆向

同样没有抓包检测,httpcanary抓到如下登录请求包:

pE8hB5V.png

发现其中未知的有passwordsign,其余的android_id、deviceId经测试都是固定的,device_fingerprint也是固定的,应该是当前的年月日(测试发现应该是第一次运行的时间,第一次运行后可能会生成一个对设备的标记)后拼接一个固定字符串,t是时间戳。

2、加密密码

password比较简单,用自吐算法直接跑出来是md5,并且参数就是输入的明文:123456,所以password就是直接对密码进行一次md5。

3、加密sign

同样是md5,自吐算法搜索到如下:

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
MD5 update data Utf8: f383e68a30a2ef440fb4b6359099798b475f4daa-8c7e-3969-a14c-275bcfb795ff
MD5 update data Hex: 663338336536386133306132656634343066623462363335393039393739386234373566346461612d386337652d333936392d613134632d323735626366623739356666
MD5 update data Base64: ZjM4M2U2OGEzMGEyZWY0NDBmYjRiNjM1OTA5OTc5OGI0NzVmNGRhYS04YzdlLTM5NjktYTE0Yy0yNzViY2ZiNzk1ZmY=
=======================================================
MessageDigest.digest() is called!
java.lang.Throwable
at java.security.MessageDigest.digest(Native Method)
at com.xingin.common.util.MD5Util.a(SourceFile:29)
at com.xingin.common.util.MD5Util.a(SourceFile:15)
at com.xingin.skynet.XYValueRewrite.a(SourceFile:124)
at com.xingin.skynet.XYValueRewrite.a(SourceFile:91)
at retrofit2.RequestBuilder.a(SourceFile:216)
at retrofit2.ServiceMethod.a(SourceFile:114)
at retrofit2.OkHttpCall.f(SourceFile:178)
at retrofit2.OkHttpCall.a(SourceFile:162)
at retrofit2.adapter.rxjava.CallOnSubscribe.a(SourceFile:45)
at retrofit2.adapter.rxjava.CallOnSubscribe.call(SourceFile:29)
at retrofit2.adapter.rxjava.BodyOnSubscribe.a(SourceFile:33)
at retrofit2.adapter.rxjava.BodyOnSubscribe.call(SourceFile:25)
at rx.Observable.unsafeSubscribe(SourceFile:10144)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:48)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:33)
at rx.Observable.unsafeSubscribe(SourceFile:10144)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:48)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:33)
at rx.internal.operators.OnSubscribeLift.call(SourceFile:48)
at rx.internal.operators.OnSubscribeLift.call(SourceFile:30)
at rx.Observable.unsafeSubscribe(SourceFile:10144)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:48)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:33)
at rx.Observable.unsafeSubscribe(SourceFile:10144)
at rx.internal.operators.OperatorSubscribeOn$1.call(SourceFile:94)
at rx.internal.schedulers.ScheduledAction.run(SourceFile:55)
at rx.internal.schedulers.ExecutorScheduler$ExecutorSchedulerWorker.run(SourceFile:107)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.lang.Thread.run(Thread.java:919)

MD5 digest result Hex: c205f9e983a7b47f44dd6ab2adac597c
MD5 digest result Base64: wgX56YOntH9E3WqyraxZfA==

很明显了,参数是一串字符串"f383e68a30a2ef440fb4b6359099798b" 拼接上device_id的值。

接着搜索字符串"f383e68a30a2ef440fb4b6359099798b"找到如下:

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
MD5 update data Utf8: 85898120911356268927043326101501029864686733846908151086879131017411353641119430148383091909083112736821128726781162779159023757143952558085782181080798095841420848782772689486760011899480202270949118178737832910867862928313308568087794814848584918210874238487951281818629108638721101392857138220714410828385888978831863478127634997226822909275822830378458376868469187950861212748682080818484867510838193210927081661584178010530138483839061258422888665091221268301243489195928082778821175842521548690808467646936771156914911042372741694106712588919288211717728625524128718101139480
MD5 update data Hex: 3835383938313230393131333536323638393237303433333236313031353031303239383634363836373333383436393038313531303836383739313331303137343131333533363431313139343330313438333833303931393039303833313132373336383231313238373236373831313632373739313539303233373537313433393532353538303835373832313831303830373938303935383431343230383438373832373732363839343836373630303131383939343830323032323730393439313138313738373337383332393130383637383632393238333133333038353638303837373934383134383438353834393138323130383734323338343837393531323831383138363239313038363338373231313031333932383537313338323230373134343130383238333835383838393738383331383633343738313237363334393937323236383232393039323735383232383330333738343538333736383638343639313837393530383631323132373438363832303830383138343834383637353130383338313933323130393237303831363631353834313738303130353330313338343833383339303631323538343232383838363635303931323231323638333031323433343839313935393238303832373738383231313735383432353231353438363930383038343637363436393336373731313536393134393131303432333732373431363934313036373132353838393139323838323131373137373238363235353234313238373138313031313339343830
MD5 update data Base64: ODU4OTgxMjA5MTEzNTYyNjg5MjcwNDMzMjYxMDE1MDEwMjk4NjQ2ODY3MzM4NDY5MDgxNTEwODY4NzkxMzEwMTc0MTEzNTM2NDExMTk0MzAxNDgzODMwOTE5MDkwODMxMTI3MzY4MjExMjg3MjY3ODExNjI3NzkxNTkwMjM3NTcxNDM5NTI1NTgwODU3ODIxODEwODA3OTgwOTU4NDE0MjA4NDg3ODI3NzI2ODk0ODY3NjAwMTE4OTk0ODAyMDIyNzA5NDkxMTgxNzg3Mzc4MzI5MTA4Njc4NjI5MjgzMTMzMDg1NjgwODc3OTQ4MTQ4NDg1ODQ5MTgyMTA4NzQyMzg0ODc5NTEyODE4MTg2MjkxMDg2Mzg3MjExMDEzOTI4NTcxMzgyMjA3MTQ0MTA4MjgzODU4ODg5Nzg4MzE4NjM0NzgxMjc2MzQ5OTcyMjY4MjI5MDkyNzU4MjI4MzAzNzg0NTgzNzY4Njg0NjkxODc5NTA4NjEyMTI3NDg2ODIwODA4MTg0ODQ4Njc1MTA4MzgxOTMyMTA5MjcwODE2NjE1ODQxNzgwMTA1MzAxMzg0ODM4MzkwNjEyNTg0MjI4ODg2NjUwOTEyMjEyNjgzMDEyNDM0ODkxOTU5MjgwODI3Nzg4MjExNzU4NDI1MjE1NDg2OTA4MDg0Njc2NDY5MzY3NzExNTY5MTQ5MTEwNDIzNzI3NDE2OTQxMDY3MTI1ODg5MTkyODgyMTE3MTc3Mjg2MjU1MjQxMjg3MTgxMDExMzk0ODA=
=======================================================
MessageDigest.digest() is called!
java.lang.Throwable
at java.security.MessageDigest.digest(Native Method)
at com.xingin.common.util.MD5Util.a(SourceFile:29)
at com.xingin.common.util.MD5Util.a(SourceFile:15)
at com.xingin.skynet.XYValueRewrite.a(SourceFile:124)
at com.xingin.skynet.XYValueRewrite.a(SourceFile:91)
at retrofit2.RequestBuilder.a(SourceFile:216)
at retrofit2.ServiceMethod.a(SourceFile:114)
at retrofit2.OkHttpCall.f(SourceFile:178)
at retrofit2.OkHttpCall.a(SourceFile:162)
at retrofit2.adapter.rxjava.CallOnSubscribe.a(SourceFile:45)
at retrofit2.adapter.rxjava.CallOnSubscribe.call(SourceFile:29)
at retrofit2.adapter.rxjava.BodyOnSubscribe.a(SourceFile:33)
at retrofit2.adapter.rxjava.BodyOnSubscribe.call(SourceFile:25)
at rx.Observable.unsafeSubscribe(SourceFile:10144)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:48)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:33)
at rx.Observable.unsafeSubscribe(SourceFile:10144)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:48)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:33)
at rx.internal.operators.OnSubscribeLift.call(SourceFile:48)
at rx.internal.operators.OnSubscribeLift.call(SourceFile:30)
at rx.Observable.unsafeSubscribe(SourceFile:10144)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:48)
at rx.internal.operators.OnSubscribeMap.call(SourceFile:33)
at rx.Observable.unsafeSubscribe(SourceFile:10144)
at rx.internal.operators.OperatorSubscribeOn$1.call(SourceFile:94)
at rx.internal.schedulers.ScheduledAction.run(SourceFile:55)
at rx.internal.schedulers.ExecutorScheduler$ExecutorSchedulerWorker.run(SourceFile:107)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.lang.Thread.run(Thread.java:919)

MD5 digest result Hex: f383e68a30a2ef440fb4b6359099798b
MD5 digest result Base64: 84PmijCi70QPtLY1kJl5iw==

这时候参数不是明文字符串了,也看不出来是什么东西,我们就根据调用栈去静态分析。

jadx打开,定位到com.xingin.skynet.XYValueRewrite.a

同样有两个重载函数。

三个参数函数:

pE8hwEq.png

一个参数函数:

pE8habn.png

分析发现,sign的赋值语句在这一句。

1
list.add(new RequestParam("sign", m13042a(hashMap), false, z2));

所以是多个参数的函数那个将hashMap传入搭配一个参数的函数里。

我们直接hook一个参数的函数即可。

1
2
3
4
5
6
7
XYValueRewrite.a.overload('java.util.Map').implementation= function (map)
{
//Map没有toString方法,而HashMap中定义了toString方法,但是HashMap继承了Map,所以可以向下转换成HashMap
var hashmap = Java.cast(map,Java.use("java.util.HashMap"))
console.log("XYValueRewrite.a param1: " + hashmap.toString());
return this.a(map);
}

输出如下:

1
XYValueRewrite.a param1:  {device_fingerprint=20250301154347d4b6f16629e3bbd7cb9850702542898501d6919591cc41ba, password=e10adc3949ba59abbe56e057f20f883e, t=1740814966, zone=86, phone=17860581502, channel=Store360, type=phone, lang=zh-CN, android_id=7966307525d1c382, versionName=5.14.0, deviceId=475f4daa-8c7e-3969-a14c-275bcfb795ff, platform=Android}

所以,多参数的函数做的处理就是将请求头中的一些信息放到一个HashMap集合里。

之后,继续分析一个参数的函数,发现return的时候调用了两次m15461a进行两次md5,正好对应我们自吐的结果,所以sb2.toString()应该就是我们上边搜索到的参数85898120开头的那一串。

所以,分析sb2,发现sb2是由bArr和bytes数组异或得到,而bytes是divece_id,bArr是 URLEncoder.m15386a函数的返回值,那么我们hook一下这个函数。

1
2
3
4
5
6
7
8
9
var URLEncoder = Java.use("com.xingin.common.util.URLEncoder");
URLEncoder.a.implementation = function(str1,str2)
{
console.log("URLEncoder.a param1: " + str1);
console.log("URLEncoder.a param2: " + str2);
var res = this.a(str1,str2);
console.log("URLEncoder res:" + res);
return res;
}

输出如下:

1
2
3
URLEncoder.a param1: android_id=7966307525d1c382channel=Store360deviceId=475f4daa-8c7e-3969-a14c-275bcfb795ffdevice_fingerprint=20250301154347d4b6f16629e3bbd7cb9850702542898501d6919591cc41balang=zh-CNpassword=e10adc3949ba59abbe56e057f20f883ephone=17860581502platform=Androidt=1740814966type=phoneversionName=5.14.0zone=86
URLEncoder.a param2: UTF-8
URLEncoder res:android_id%3D7966307525d1c382channel%3DStore360deviceId%3D475f4daa-8c7e-3969-a14c-275bcfb795ffdevice_fingerprint%3D20250301154347d4b6f16629e3bbd7cb9850702542898501d6919591cc41balang%3Dzh-CNpassword%3De10adc3949ba59abbe56e057f20f883ephone%3D17860581502platform%3DAndroidt%3D1740814966type%3DphoneversionName%3D5.14.0zone%3D86

发现参数和返回值只有=变成了%3D,所以就是单纯做了URL encode(这个函数之前还对请求头Map做了排序处理)。

最后,我们写异或脚本测试一下:

pE8h0U0.png

结果一致,搞定!

4、算法总结

两个未知。

password是由输入的密码直接md5得到。

sign是由请求头中的信息经过排序后进行URL encode得到一个bytes数组,然后对bytes数组和device_id进行异或处理,再对异或的结果进行md5得到sign1,对sign1拼接device_id后再次进行md5得到sign(转小写)。

5、登录协议复现

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
import hashlib
import json
import time
import requests

timestamp = str(int(time.time() * 1000))
phone = "17860581502"
password = "123456"
s1 = "android_id%3D7966307525d1c382channel%3DStore360deviceId%3D475f4daa-8c7e-3969-a14c-275bcfb795ffdevice_fingerprint%3D20250301154347d4b6f16629e3bbd7cb9850702542898501d6919591cc41balang%3Dzh-CNpassword%3De10adc3949ba59abbe56e057f20f883ephone%3D17860581502platform%3DAndroidt%3D" + timestamp + "type%3DphoneversionName%3D5.14.0zone%3D86"
s1Arr = bytes(s1.encode('utf-8'))
s2 = "475f4daa-8c7e-3969-a14c-275bcfb795ff"
s2str = bytes(s2.encode('utf-8'))
list = []
i = 0
for j in s1Arr:
list.append(str(j ^ s2str[i]))
i = (i + 1) % len(s2str)

md5_param1 = "".join(list)
md5_sign1 = hashlib.md5(md5_param1.encode('utf-8')).hexdigest()
md5_param2 = md5_sign1 + "475f4daa-8c7e-3969-a14c-275bcfb795ff"
md5_sign2 = hashlib.md5(md5_param2.encode('utf-8')).hexdigest()

url = "https://www.xiaohongshu.com/api/sns/v2/user/login"
data = "password=" + hashlib.md5(password.encode('utf-8')).hexdigest() + "&zone=86&phone=" + phone + "'&type=phone&lang=zh&android_id=7966307525d1c382&platform=Android&deviceId=475f4daa-8c7e-3969-a14c-275bcfb795ff&device_fingerprint=20250301154347d4b6f16629e3bbd7cb9850702542898501d6919591cc41ba&versionName=5.14.0&channel=Store360&lang=zh-CN&t=" + timestamp + "&sign=" + md5_sign2
print(data)
headers = {
"user-agent": "Dalvik/2.1.0 (Linux; U; Android 10; Pixel XL Build/QP1A.191005.007.A3) Resolution/1440*2392 Version/5.14.0 Build/5140002 Device/(Google;Pixel XL)",
"content-type": "application/x-www-form-urlencoded",
"content-length": "357",
"Host": "www.xiaohongshu.com",
"accept-encoding": "gzip"
}
r = requests.post(url=url, data=data, headers=headers)
recv = r.text
print(recv)

输出如下(404是因为app太老了,估计服务器已经不用了):

1
{"timestamp":1740883031348,"status":404,"error":"Not Found","message":"No message available","path":"/api/sns/v2/user/login"}

pE8z6k8.png

与抓包的响应信息一致。

6、验证码登录、注册分析

验证码登录分为两步,发送验证码和验证码登录(由于服务器不可用,所以收不到验证码没法测试验证码登录功能)

不过可以测试发送验证码。

发送验证码算法逻辑同密码登录逻辑,也是两次md5,不过再对请求头put的时候少了passswordandroid_id,如下:

1
XYValueRewrite.a param1: {device_fingerprint=20250301154347d4b6f16629e3bbd7cb9850702542898501d6919591cc41ba, t=1740882238, zone=86, phone=17860581502, channel=Store360, type=login, versionName=5.14.0, lang=zh-CN, deviceId=475f4daa-8c7e-3969-a14c-275bcfb795ff, platform=Android}

注册一样,也是发送验证码和验证码注册。

发送验证码算法逻辑同登录的发送验证码一样,只需修改其中的type=register

四、站酷

1、登陆逆向

同样没有抓包检测和frida检测,httpcanary抓到登录请求包如下:

pEGhcOH.png

发现其中存在%3D,对应’=’,猜测是base64解密,换成’=’号尝试base64解码。

pEGhylD.png

base64解码后,发现是另一串字符串拼接了?keyId=1。对前边的字符串再次尝试base64解码,发现失败,所以前边这一串应该不是base64编码了。

2、代码定位

字符串未加密,用jadx打开搜索keyId找到两处。

pEGhDfK.png

这两处代码是一样的,随便找一个定位到encrypt函数中:

pEGh66e.png

其中,DESedeCoder.encode是标准的DES加密,并且是ECB模式,key是F#C@5IOBULR9L415C~ZX*97C,直接hook该函数获取DES的明文。

1
2
3
4
5
6
7
8
var DESedeCoder = Java.use("com.zcool.base.encrypt.DESedeCoder");
DESedeCoder.encode.implementation = function (str1,str2) {
console.log("DESedeCoder.encode param1: " + str1);
console.log("DESedeCoder.encode param2: " + str2);
var res = this.encode(str1,str2);
console.log("DeSedeCoder.encode res: " + res);
return res;
}

输出如下:

1
2
3
DESedeCoder.encode param1: {"password":"123456","appLogin":"http://www.zcool.com.cn/tologin.do","service":"http://www.zcool.com.cn","appId":"1006","username":"17860581502"}
DESedeCoder.encode param2: F#C@5IOBULR9L415C~ZX*97C
DeSedeCoder.encode res: u7e2kFB6HBN94Cwu23jgUqggAd8q162p4qWY0ktuYn36xSegjFFR0hBx7hfmzOeygf2G9IOMrL7Dsv4N/i4c6JsbXzGAFS8HBQs8GlvSpoiWs3yieUJcAOXRLI9lxHnDhPmT07H+/xEGT5UFiB0bAyfOEu2B38z6wsJvDa98f8AmzKuYnR/4bZRU0sFNX0yhYhGhURLKaqo=

可用看到加密结果就是我们base64解码后的数据。

所以这个算法的加密逻辑就是先DES加密拼接上?keyId=1再base64加密。

3、登陆协议复现

这个题本来复现的话是需要在python里进行算法转发来调用frida,通过frida将DES加密的结果传给python之后base64加密再进行发包。

因为java里用的key是经过封装的key:

1
2
3
DESedeKeySpec dks = new DESedeKeySpec(key);
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DESede");
SecretKey secretKey = keyFactory.generateSecret(dks);

DES要求key是8个字节,但是明文key是24个字节,所以该封装可能对key做了一些操作,在python里没法用Crypto.Cipher.DES进行模拟。

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
import base64
import frida
import requests

# frida js主动调用代码
jsCode = """
function hookTest(phone,password){
var result;
Java.perform(function(){
var DESedeCoder = Java.use("com.zcool.base.encrypt.DESedeCoder");
var str1 = '{"password":"' + password + '","appLogin":"http://www.zcool.com.cn/tologin.do","service":"http://www.zcool.com.cn","appId":"1006","username":"' + phone + '"}'
var str2 = "F#C@5IOBULR9L415C~ZX*97C"
result = DESedeCoder.encode(str1,str2);
console.log(result)
});
return result;
}
rpc.exports = {
hooktest : hookTest //这里进行导出供python接收
}
"""

# 调用frida转发脚本
process = frida.get_device_manager().add_remote_device('10.169.1.60:27042').attach("com.zcool.community")
script = process.create_script(jsCode)
script.load()

phone = "17860581502"
password = "123456"
# 接收js的导出
DES_data = script.exports.hooktest(phone, password)
base64_param = DES_data + "?keyId=1"
data = "key=" + base64.b64encode(base64_param.encode('utf-8')).decode() .replace('=','%3D') + "&app=android"
url = "http://passport.zcool.com.cn/login_jsonp_active.do"
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"Content-Length": "302",
"Host": "passport.zcool.com.cn",
"Connection": "Keep-Alive",
"Accept-Encoding": "gzip",
"Cookie": "HWWAFSESID=51aca76a89015a09bf; HWWAFSESTIME=1740987519360; JSESSIONID=F8E8EBA5E745948546D839FE3E93C399",
"User-Agent": "okhttp/3.1.2"
}
r = requests.post(url=url, data=data, headers=headers)
recv = r.text
print(recv)

输出如下:

1
null({"msg":"账号未注册","result":false,"code":"ACCOUNT.IS.NOT.EXIST","success":false,"isUname":false})

与抓包的响应体信息一致。

pEGhsSO.png

4、算法自吐

这题也可以用算法自吐,不过base64出不来,因为它不是用的标准base64库,而是自己实现了一个同样功能的base64加密类。