车智赢登录页面
车智赢登录页面
1.安装apk
老规矩,先下载相关版本软件


2.抓包分析
clash抓包注意事项(有空再补,今天我们直接用小鸟抓包)

打开小鸟进行抓包

通过返回信息,可以说明成功抓到相应的包

相应的携带数据如下

接下来我们开始进行简单分析
请求地址:https://dealercloudapi.che168.com/tradercloud/sealed/login/login.ashx
请求方式:POST
请求头:

可以发现通过okhttp发送。但没有什么特殊字节不需要破解
请求体:

appid:#定值
sign:#像加密,需要破解
appversion: #app版本,固定的
channelid:#固定值,可以尝试删除,再发包试一下
pwd:#密码加密了,需要破解
udid:#需要破解
username:#很明显就是刚输入的手机号不需要破解
3.反编译分析
进行反编译,查看基础信息

pwd逆向
通过关键字进行搜索
优先搜索部分url,这里使用使用login/login.ashx作为关键字段
我们发现无法检索到相关代码,根据jadx的报错信息猜测进行加固,我们查看验证

我们进行脱壳7f281176793b0d4c17dded39c446ae0c
这里尝试多种脱壳方式暂时没有找到好的解决办法,我们直接降低版本继续进行逆向分析

确定为昨天脱壳网站的网络问题,今天成功脱壳,我们继续往下分析。

脱壳后我们根据关键信息查找到我们的

我们成功查找到登录接口地址,双击进入查看

成功定位到这是一个UserModel 下定义了一个URL常量,我们通过右键查找用例来观察常量的使用。

找到一个位置,进入代码位置进行查看

我们直接观察不难发现其加密关键代码SecurityUtil.encodeMD5(str3)我们进一步进入查看该代码

查看代码可以发现是一个典型的MD5加密
同理我们也可以使用代码和hook来验证位置
py验证:
我们将输入明文放入python中用代码进行验证看是否与抓包得到的参数一致


验证发现结果一致

hook验证:

加密结果与抓包结果进行验证

可以发现完全一致确认位置正确。
逆向签名_sign
老规矩直接搜索关键值”_sign”

观察发现大致可以分为AHAPIHelper和launchModel的类两种情况,我们进入查看以下通过hook确认具体位置

首先AHAPIHelper我们可以通过观察猜测使用toSign进行加密,我们hook尝试一下

我们发现hoo点击后并没有返回值,说明脚本没有执行位置不是这里。
我么不能继续向下排查

下述代码一眼望不到头,那么~老规矩,我们开始查找用例

???没有,斯~我们继续向下排查试一下




好好好不愧是新版本哈不慌我们浅分析一下哈,查找后发现只有下面一处用例

观察可以看出这是一个典型的解析配置或签名文件的逻辑片段,并进行了简单的混淆。
我们从 BufferedReader 中逐行读取文本,解析出以下字段:
| 字段名 | 解析方式 | 存储变量 |
|---|---|---|
ApkHash: |
提取冒号后的值 | f |
KEY_SIGNATURE |
提取冒号后的值 | c |
KEY_SIGNATURE2 |
提取冒号后的值 | d |
KEY_SIGNATURE3 |
提取冒号后的值 | e |
| 其他行 | 拼接到 stringBuffer 中,最终赋值给 g |
g |
那这很明显不是我们本轮分析的重点,我们继续排查

哎?!

这段!这SignManager.INSTANCE.signByType(0, treeMap)看着就让人很有希望
老规矩我们来hook一下

与抓包结果对比可以确定我们这次寻找的位置是正确的

同样我们通过hook结果可以发现每次**udid** 都会改变,造成结果不同

既然问题解决我们也来简单分析这一长串的函数
首先是输入参数的部分:
obj:前端传来的 JSON 对象
callback:JS 回调,用于返回处理后的数据
很明显先开始使用 TreeMap 自动按 key 排序
从输入的JSONObject提取所有键值对
注入用户相关参数(userkey, memberid, dealerid)
treeMap.put(“userkey”, userInfo.userkey); // 用户密钥
treeMap.put(“m”, userInfo.memberid); // 会员ID
treeMap.put(“d”, userInfo.dealerid); // 经销商ID
添加APP_ID、渠道ID、版本号等固定参数
treeMap.put(LaunchModel.KEY_APP_ID, Constants.APP_ID); // 应用ID
treeMap.put(“channelid”, AppUtils.getChannelId(…)); // 渠道ID
treeMap.put(LaunchModel.KEY_APP_VERSION, …); // 应用版本
调用SignManager生成数字签名
treeMap.put(“_sign”, SignManager.INSTANCE.signByType(0, treeMap)); // 签名
签名结果放入 _sign 字段
udid
这段理明白之后,接下来让我们逆向一下刚刚上面那个一致乱变(emm……还要破解,不能说人家乱变,对不起,求简单点)的udid
这里我们上面的_sign值在生成过程中运用到了udid,个人猜测在该代码上边或周围应该有关键函数

运气这一块./,当然我们也可以通过科学的查找方法来完成,具体示例在上边两个参数的逆向过程中已经详细展示过,这里不再赘述,我们继续跟踪getUDID函数

getUDID() 的返回值就是一段 3DES 加密串,SecurityUtil.encode3Des()
3DES 加密逻辑

这串加密逻辑也很简单,我们简单分析一下这串代码(对不起,不简单,找着找者进so里去了)

加密逻辑分析
**
encode3Des(Context context, String str)**接收上下文和待加密字符串
AHAPIHelper.getDesKey(context)- 从AHAPIHelper类获取3DES加密密钥那么我们分析查找des-key的值

这里的des-key很明显是从和getSignDesKey中得到,我们继续向下查找

继续查找,哎!在这里我们见到了一个不太亲切的老朋友
System.loadLibrary("native-lib");
那还说啥了?so层今天是肝不动了,我们换个逻辑。一般des加密的key是固定的,我们直接多次hook验证一下。是的话我们直接用。

经验证key值确实是固定的result=appapiche168comappapiche168comap
那么我们接着往下分析
byte[] bArr = null- 声明并初始化为null,用于存储加密后的字节数据TextUtils.isEmpty(desKey)检查密钥是否为空如果密钥无效,直接返回null,避免后续加密操作异常然后开始try-catch块,捕获加密过程中可能出现的所有异常
那么接下里就是我们重要的加密逻辑
SecretKeyFactory.getInstance("desede")- 获取3DES算法密钥工厂"desede"- 3DES算法的标准名称
new DESedeKeySpec(desKey.getBytes())- 根据字节数组创建3DES密钥规范generateSecret()- 生成SecretKey对象- 完整逻辑:将字符串密钥转换为Java加密API可用的SecretKey对象
创建密码器实例
Cipher.getInstance("desede/CBC/PKCS5Padding")- 获取3DES密码器实例- 算法模式详解:
desede- 3DES算法CBC- 密码块链模式(Cipher Block Chaining)PKCS5Padding- 填充方案,确保数据块大小符合要求
初始化密码器
cipher.init(1, generateSecret, new IvParameterSpec(iv.getBytes()))1- 常量值,对应Cipher.ENCRYPT_MODE(加密模式)generateSecret- 上一步生成的密钥对象new IvParameterSpec(iv.getBytes())- 创建初始化向量iv- 类中定义的静态字符串变量(初始化向量)IvParameterSpec- 确保CBC模式的安全性
执行加密操作
str.getBytes("UTF-8")- 将输入字符串转换为UTF-8编码的字节数组cipher.doFinal()- 执行加密操作,返回加密后的字节数组- 逻辑:使用配置好的密码器对输入数据进行加密
异常处理
catch (Exception unused)- 捕获所有异常但忽略不处理- 设计问题:静默吞掉异常,不利于调试和错误排查
返回编码结果
encode(bArr)- 调用encode方法(可能是Base64编码)toString()- 确保返回字符串类型- 逻辑:将加密后的字节数组转换为可读的字符串格式(通常是Base64)

观察就可以发现是一个明显的Base64编码的实现,不行了,写不动了,直接hook吧
加密字符串逻辑
明文格式固定为:IMEI + “|” + 秒级时间戳 + “.000000” + “|” + SPUtils.getDeviceId()
emm……这个明文格式就是上边字符串的拼接,好难弄,不想写了。算啦,第一次完整写这种案例,稍微解释一下context, AHDeviceUtil.getDeviceId(context) #看到context很明显就是我们手机的IMEI
context至于
context的这个概念想必我们刚刚看完第一行代码的小朋友们就很熟悉了,不过看了自己当初的笔记感觉还是有点稚嫩了,正好这里让我们来详细补充一下。
在安卓(Android)开发中,context是一个非常重要的概念,它代表了应用程序的当前状态信息。
每个Android应用程序都有一个context,它允许应用程序访问系统资源和执行各种操作。
context通常是由Android系统传递给应用程序的各个组件(如Activity、service、BroadcastReceiver等),以便它们能够与系统和其他组件进行交互。
Context的主要作用包括:
#访问资源:通过context,您可以访问应用程序的资源,如布局文件、字符串、图片等。这是因为context持有对应用程序资源的引用,使我们能够在应用程序中加载和使用这些资源。
#启动组件:通过context,您可以启动其他组件,如Activity、service、BroadcastReceiver等。例如,我们可以使用context启动一个新的Activity来打开新的界面。
#获取系统服务:通过context,我们可以获取系统级别的服务,例如获取系统的传感器、网络状态、存储管理等。这些服务是通过系统提供的服务注册表(service Registry)来获取的。
#应用程序级别的操作:context还可以用于执行应用程序级别的操作,如发送广播、获取应用程序包名、获取应用程序的数据目录等。
我们进入getDeviceId来简单分析一下

我不行了,我开发没学好遭报应了,既然如此,那让我们来详细分析一下

getDeviceId()代码详细解1
public static synchronized String getDeviceId(Context context) {方法声明
public- 公共方法,可以被其他类访问static- 静态方法,属于类而非实例synchronized- 同步方法,确保多线程环境下线程安全String- 返回字符串类型的设备IDgetDeviceId(Context context)- 接收Android上下文参数
同步块
synchronized (AHDeviceUtil.class)- 使用类对象作为锁,确保同一时间只有一个线程能执行此代码块
1
2
3if (!TextUtils.isEmpty(mCompanyDeviceid)) {
return mCompanyDeviceid;
}内存缓存检查
mCompanyDeviceid- 静态变量,作为内存级别的设备ID缓存TextUtils.isEmpty()- 检查字符串是否为null或空字符串- 逻辑:如果内存中已有缓存的设备ID,直接返回,避免重复生成
1
2
3
4
5
6OnDeviceIdListener onDeviceIdListener = mDeviceIdListener;
if (onDeviceIdListener != null) {
String deviceId = onDeviceIdListener.getDeviceId();
mCompanyDeviceid = deviceId;
return deviceId;
}监听器检查
OnDeviceIdListener- 设备ID生成监听器接口mDeviceIdListener- 静态监听器实例- 逻辑:如果设置了自定义监听器,通过监听器获取设备ID并缓存到内存
1
2
3
4String readString = PreferenceHelper.readString(context, "usedcar_pre", "udid");
if (!udidIsNull(readString)) {
return readString;
}SharedPreferences读取
PreferenceHelper.readString()- 从SharedPreferences读取数据"usedcar_pre"- SharedPreferences文件名
SharedPreferences是 Android 提供的一种 轻量级本地持久化存储方案,用来以 键值对(key-value) 形式保存 少量、结构化、可跨进程读取 的配置数据,比如用户设置、首次启动标志、设备 ID 等。项目 说明 存储位置 /data/data/<包名>/shared_prefs/*.xml数据格式 纯文本 XML,可读可改(需 root) 作用域 默认 私有( MODE_PRIVATE),仅本应用可读写;支持MODE_MULTI_PROCESS等读写方式 自动加锁,线程安全;读 用 getXxx(),写 用edit().putXxx().apply()容量 官方无硬性限制,建议 < 1 MB;过大请用数据库 数据类型 boolean/int/long/float/String/Set<String>"udid"- 存储设备ID的键名udidIsNull()- 自定义方法,检查设备ID是否有效- 逻辑:如果本地已存储有效的设备ID,直接返回
1
2
3if (PreferenceHelper.isShowPrivacyPolicyDialog(context)) {
return UUID.randomUUID().toString();
}隐私政策检查
isShowPrivacyPolicyDialog()- 检查是否需要显示隐私政策对话框UUID.randomUUID().toString()- 生成随机UUID作为临时设备ID- 逻辑:如果用户未同意隐私政策,返回随机UUID(避免收集真实设备信息)
1
if (udidIsNull(readString)) {有效性复查
- 再次确认从SharedPreferences读取的设备ID是否无效
1
2
3if (Build.VERSION.SDK_INT >= 29) {
readString = Settings.Secure.getString(context.getContentResolver(), SocializeProtocolConstants.PROTOCOL_KEY_ANDROID_ID);
} else {Android版本分支 - Android 10+
Build.VERSION.SDK_INT >= 29- 判断是否为Android 10及以上版本Settings.Secure.getString()- 获取系统安全设置中的值SocializeProtocolConstants.PROTOCOL_KEY_ANDROID_ID- 常量,值为”android_id”- 逻辑:Android 10+ 使用Android ID作为设备标识(因权限限制无法获取IMEI)
java
1
2
3
4
5TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService("phone");
if (telephonyManager != null) {
if (ActivityCompat.checkSelfPermission(context, "android.permission.READ_PHONE_STATE") != 0) {
return mCompanyDeviceid;
}Android 10以下版本 - 权限检查
context.getSystemService("phone")- 获取电话管理器服务ActivityCompat.checkSelfPermission()- 检查READ_PHONE_STATE权限- 逻辑:如果没有电话状态读取权限,返回内存缓存值(可能为空)
1
2
3
4
5
6
7if (Build.VERSION.SDK_INT >= 26) {
readString = telephonyManager.getImei();
} else {
readString = telephonyManager.getDeviceId();
}
}
}IMEI/DeviceId获取
Build.VERSION.SDK_INT >= 26- 判断是否为Android 8.0及以上telephonyManager.getImei()- Android 8.0+ 获取IMEItelephonyManager.getDeviceId()- Android 8.0以下获取设备ID- 逻辑:根据Android版本使用不同的设备标识获取方法
1
if (udidIsNull(readString)) {系统ID有效性检查
- 检查从系统获取的设备ID是否有效
1
2
3
4
5
6
7
8
9AHWifiInfo aHWifiInfo = AHNetworkUtil.getAHWifiInfo(context);
String macAddress = aHWifiInfo != null ? aHWifiInfo.getMacAddress() : null;
if (!TextUtils.isEmpty(macAddress)) {
try {
readString = UUID.nameUUIDFromBytes(macAddress.getBytes("utf8")).toString();
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}MAC地址备用方案
AHNetworkUtil.getAHWifiInfo()- 获取Wifi信息getMacAddress()- 获取MAC地址UUID.nameUUIDFromBytes()- 根据MAC地址字节生成UUID- 逻辑:如果系统设备ID无效,尝试使用MAC地址生成UUID
1
2
3
4if (udidIsNull(readString)) {
readString = getUUID(context);
}
}最终备用方案
getUUID(context)- 调用其他方法生成UUID- 逻辑:如果所有方法都失败,使用最后的UUID生成方案
1
2
3
4if (!udidIsNull(readString)) {
PreferenceHelper.write(context, "usedcar_pre", "udid", readString);
}
}持久化存储
PreferenceHelper.write()- 将设备ID写入SharedPreferences- 逻辑:如果成功生成了有效的设备ID,保存到本地存储供下次使用
1
2
3return readString;
}
}返回结果和方法结束
- 返回最终获取到的设备ID
- 结束同步块和方法
通过分析这三种方案,我们相对也可以直接用UUID
或者从xml中找到它存储的字符串
/data/data/com.che168.autotradercloud/shared_prefs/

谁爱找谁找吧,我是不找了

或者通过hook得到等多种方案。
这是我们hook得到的结果81f2e9f2_fe62_43fa_a273_e425d929ba47
+ HiAnalyticsConstant.REPORT_VAL_SEPARATOR #一个固定字符串,我们可以跳转查看一下是个“|”

+ System.nanoTime() #手机开机时间。
java中是一个前系统时间的纳秒数,但是在android系统中,这个表示手机开机时间
#注意,它跟java的这个函数返回值是不一样的
+ HiAnalyticsConstant.REPORT_VAL_SEPARATOR #嗯呐又一个“|”
+ UserInfoUtil.getUserDeviceId(context) #DeviceId,就可以猜到哈,是我们的设备id,不放心就跳转过去瞄一眼(好吧,承认是我多疑)

很明显就是一个只读的的程序代码。从哪里来的呐?“嘿”我们看到一位老朋友“usedcar_pre”,我们就知道读取自.xml文件,那么从哪里写入的呐?我们再继续逆向分析一下

好好这么一长串, write() / readInt() / readLong() … 只是 “带内存缓存的 SharedPreferences 万能工具箱”,它们和 getUserDeviceId() 的关系非常单纯:getUserDeviceId() 仅仅是 readInt() 的一个普通调用者文件名为 “usedcar_pre”,key 为 “device_id”,返回值被当成 int 型设备序号 使用。不找了,我们选择直接通过hook来得到返回字串。

代码实现

最终逆向_sign
当我们得到这些信息后我们进行最后的解密

我们进入signByType

通过代码分析我们可以得到拼接公式为
构建字符串: “secret_key_v2” + “appid” + “test” + “version” + “1.0” + “secret_key_v2”
= “secret_key_v2appidtestversion1.0secret_key_v2”
MD5加密: MD5(“secret_key_v2appidtestversion1.0secret_key_v2”)
= “5d41402abc4b2a76b9719d911017c592”
转换为大写: “5D41402ABC4B2A76B9719D911017C592”

至此就完成后了我们一个登录页面的复现

4.so层key值分析
既然我们任务完成了,那我们就看一下des-key值的逻辑实现

根据我们之前分析到的代码我们应该到libnative-lib.so文件中查看这个注册

然后通过java_包名_类名_方法名读c的源码,我们打开后直接在Exports中查询

发现可以查询到关键字,是一个静态注册,我们进入分析一下

有一说一,烦so的一大部分原因就是很混乱分析起来真的很恶心人啊
为了我们的脑细胞,那是说啥了,我们先引入一下jni头文件

emm……报错了,那还说啥了,直接改吧。

Hide casts再show casts,隐藏投射,也就是隐藏掉我们的类型转换

简单查看一下就知道return前的第一个参数是env,第二个DES3_KEY就是我们要找的值,进入观察

可以发现这个固定值就是我们的key,与我们frida得到的so一样。