车智赢登录页面

车智赢登录页面

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 - 返回字符串类型的设备ID
    • getDeviceId(Context context) - 接收Android上下文参数

    同步块

    • synchronized (AHDeviceUtil.class) - 使用类对象作为锁,确保同一时间只有一个线程能执行此代码块
    1
    2
    3
    if (!TextUtils.isEmpty(mCompanyDeviceid)) {
    return mCompanyDeviceid;
    }

    内存缓存检查

    • mCompanyDeviceid - 静态变量,作为内存级别的设备ID缓存
    • TextUtils.isEmpty() - 检查字符串是否为null或空字符串
    • 逻辑:如果内存中已有缓存的设备ID,直接返回,避免重复生成
    1
    2
    3
    4
    5
    6
    OnDeviceIdListener onDeviceIdListener = mDeviceIdListener;
    if (onDeviceIdListener != null) {
    String deviceId = onDeviceIdListener.getDeviceId();
    mCompanyDeviceid = deviceId;
    return deviceId;
    }

    监听器检查

    • OnDeviceIdListener - 设备ID生成监听器接口
    • mDeviceIdListener - 静态监听器实例
    • 逻辑:如果设置了自定义监听器,通过监听器获取设备ID并缓存到内存
    1
    2
    3
    4
    String 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
    3
    if (PreferenceHelper.isShowPrivacyPolicyDialog(context)) {
    return UUID.randomUUID().toString();
    }

    隐私政策检查

    • isShowPrivacyPolicyDialog() - 检查是否需要显示隐私政策对话框
    • UUID.randomUUID().toString() - 生成随机UUID作为临时设备ID
    • 逻辑:如果用户未同意隐私政策,返回随机UUID(避免收集真实设备信息)
    1
    if (udidIsNull(readString)) {

    有效性复查

    • 再次确认从SharedPreferences读取的设备ID是否无效
    1
    2
    3
    if (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
    5
    TelephonyManager 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
    7
            if (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+ 获取IMEI
    • telephonyManager.getDeviceId() - Android 8.0以下获取设备ID
    • 逻辑:根据Android版本使用不同的设备标识获取方法
    1
    if (udidIsNull(readString)) {

    系统ID有效性检查

    • 检查从系统获取的设备ID是否有效
    1
    2
    3
    4
    5
    6
    7
    8
    9
    AHWifiInfo 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
    4
        if (udidIsNull(readString)) {
    readString = getUUID(context);
    }
    }

    最终备用方案

    • getUUID(context) - 调用其他方法生成UUID
    • 逻辑:如果所有方法都失败,使用最后的UUID生成方案
    1
    2
    3
    4
        if (!udidIsNull(readString)) {
    PreferenceHelper.write(context, "usedcar_pre", "udid", readString);
    }
    }

    持久化存储

    • PreferenceHelper.write() - 将设备ID写入SharedPreferences
    • 逻辑:如果成功生成了有效的设备ID,保存到本地存储供下次使用
    1
    2
    3
            return 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一样。


车智赢登录页面
https://cc-nx.github.io/2025/10/31/车智赢/车智赢登录页面/
作者
CC
发布于
2025年10月31日
许可协议