车智赢APP登录协议逆向分析:签名算法与RSA加密还原实战

1. 项目概述:从登录请求到算法核心

做移动端安全分析的朋友,对登录协议的逆向分析应该都不陌生。这几乎是进入一个APP安全研究领域的“敲门砖”,也是理解其客户端与服务器交互逻辑最直接的切入点。最近,我花了些时间深入分析了“车智赢”这款APP的登录流程,目标很明确:找到其登录请求中核心参数的生成算法,特别是那个关键的签名(sign)或令牌(token)是如何被构造出来的。

为什么是登录协议?因为登录环节往往是安全机制最集中、最典型的地方。一个设计良好的登录协议,会包含设备指纹、请求签名、数据加密、防重放攻击等多种防护手段。逆向分析它,不仅能让我们理解其安全架构,更能为后续的自动化测试、协议模拟或安全审计打下坚实基础。对于“车智赢”这类涉及车辆控制、状态查询等敏感操作的APP,其安全性的强弱直接关系到用户隐私和资产安全,因此分析其底层实现具有很高的参考价值。

本次分析将聚焦于“核心算法篇”,这意味着我们会绕过基础的抓包、定位等步骤,直接深入到最关键的加密/签名函数内部,剖析其输入、输出、逻辑流程以及关键的密钥或盐值(salt)是如何参与运算的。整个过程会涉及静态分析(反编译)、动态调试(Hook)、算法还原等多个环节。无论你是移动安全研究员、爬虫工程师,还是对安卓逆向感兴趣的开发者,相信这篇详尽的拆解都能给你带来直接的帮助和启发。

2. 分析环境与工具链搭建

工欲善其事,必先利其器。一套稳定、高效的分析环境是逆向工程成功的基础。对于安卓APP的协议分析,我的工具链通常分为几个层次:抓包层、动态调试层、静态分析层和辅助工具层。

2.1 核心工具选型与配置

首先,抓包工具我首选CharlesFiddler,两者都能很好地完成HTTPS流量的拦截和解密。关键在于手机端证书的安装与信任。这里有个细节:在安卓高版本(特别是7.0以上)系统中,系统不再信任用户安装的证书,导致无法解密HTTPS流量。解决方案通常有两种:一是将抓包工具的证书安装到系统证书目录(需要Root权限),二是使用像VirtualXposed太极这类无需Root即可修改APP运行环境,从而使其信任用户证书的工具。对于“车智赢”,我采用了Root过的真机环境,直接将Charles证书推送为系统证书,一劳永逸。

注意:部分APP会启用SSL Pinning(证书绑定)技术,检测到非预期的证书会中断连接。此时就需要使用JustTrustMeSSLUnpinning等Xposed模块,或者使用Frida脚本在内存中绕过证书校验。在分析初期,如果发现抓包工具无法捕获到登录请求,大概率就是遇到了证书绑定。

动态调试和代码注入方面,Frida是目前当之无愧的王者。它是一个动态代码插桩工具,可以让我们在运行时拦截、修改函数调用,或者直接调用APP内的任何函数。我通常会准备一套Frida Server(运行在手机端)、Frida Python库(运行在电脑端)以及一系列常用的脚本。对于登录算法,最常用的就是Hook Java层函数,打印其输入参数和返回值。

静态分析则依赖于反编译工具。Jadx-GUI是我的首选,它能够将APK文件中的DEX字节码反编译成可读性相当高的Java代码,并且支持全局搜索、跳转引用,效率极高。对于混淆严重的代码,有时还需要结合IDA ProGhidra来分析底层的so库(Native层代码)。

我的完整工作流是:先用抓包工具捕获到登录请求,定位到关键的加密参数(如sign);然后在Jadx中搜索这个参数名,或者搜索其可能出现的URL路径、接口名;找到疑似函数后,编写Frida脚本进行Hook验证;最后,结合静态分析,理清整个算法的逻辑,并用Python或JavaScript进行还原。

2.2 关键环境配置细节

  1. Root环境选择:我使用了一台刷了Magisk的Pixel手机。Magisk的Systemless Root特性对APP的隐藏性更好,可以绕过一些基础的Root检测。
  2. Frida版本匹配:务必确保电脑端fridafrida-tools的版本与手机端frida-server的版本一致,否则会出现连接失败或无法识别API的问题。我当前使用的是Frida 16.x版本。
  3. 抓包过滤器设置:在Charles中,我会设置一个Focus主机,比如*.che-zhi-ying.com,这样能过滤掉大量无关的静态资源请求,让登录请求一目了然。
  4. Jadx的优化:在Jadx的设置中,开启“反混淆”选项(如果支持),并调整反编译器为“Fallback”模式,有时能获得更好的代码。对于大型APK,首次反编译和索引会较慢,耐心等待即可。

这套环境搭建起来可能需要一些时间,但一旦就绪,它就是一个强大的、可复用的分析平台,能够应对大多数安卓APP的逆向需求。

3. 登录请求抓包与关键参数定位

一切准备就绪后,我们启动“车智赢”APP,进行登录操作(可以使用测试账号),同时在Charles中观察捕获到的网络请求。

很快,我们就能锁定登录接口。通常,它的路径会是类似于/api/v1/user/login/auth/login这样的形式。以我抓取到的请求为例:

请求方法: POST请求URLhttps://api.chezhijing.com/mobile/login请求头: 包含常见的Content-Type: application/jsonUser-Agent等。请求体(JSON格式):

{ “username”: “13800138000”, “password”: “加密后的字符串”, “timestamp”: “1646389200000”, “nonce”: “a1b2c3d4e5”, “sign”: “7f8e9a0b1c2d3e4f5a6b7c8d9e0f1a2b”, “deviceId”: “android_xxxxxx” }

从这个请求体中,我们可以立刻识别出几个关键角色:

  • username: 明文用户名或手机号。
  • password: 明显是经过加密处理后的密文,这是首要分析目标。
  • timestamp: 时间戳,常用于防重放攻击。
  • nonce: 随机数,同样用于防重放,确保每次请求唯一。
  • sign: 签名,这通常是整个请求安全性的核心。它由其他参数(可能包括请求体、请求头、甚至一个密钥)按照特定算法生成,用于服务端验证请求的完整性和合法性。
  • deviceId: 设备标识符。

我们的核心目标就是password的加密算法和sign的签名算法。通常,password的加密可能相对独立(如RSA公钥加密、AES加密),而sign的生成则会综合多个参数。

定位技巧: 在Jadx中,我们可以使用全局搜索(快捷键Shift + Shift)。首先搜索sign这个关键词,会找到很多地方。我们需要结合上下文判断,比如查找赋值语句(sign =),或者查找包含MapTreeMap(常用于参数排序)以及MD5SHA256HMAC等加密相关字符串的代码。另一个有效方法是搜索接口URL中的关键字,如“/mobile/login”,直接定位到处理登录请求的代码附近。

4. 静态分析与算法函数定位

通过搜索“sign”和登录URL,我在Jadx中找到了一个名为com.chezhijing.network.security.SignGenerator的类。这个类名非常直观,很可能就是我们要找的目标。

打开这个类,发现其核心是一个generateSign方法。代码经过了混淆,但关键逻辑依然可辨。该方法接收一个Map<String, String>参数(存放所有待签名的参数)和一个String参数(可能是密钥或盐值),返回计算出的签名。

public class SignGenerator { public static String generateSign(Map<String, String> params, String secret) { // 1. 参数排序 List<String> keys = new ArrayList<>(params.keySet()); Collections.sort(keys); // 2. 拼接键值对 StringBuilder sb = new StringBuilder(); for (String key : keys) { String value = params.get(key); if (value != null && !value.isEmpty()) { sb.append(key).append(“=”).append(value).append(“&”); } } // 去掉最后一个“&” if (sb.length() > 0) { sb.deleteCharAt(sb.length() - 1); } // 3. 拼接密钥 sb.append(secret); // 注意:这里是直接拼接,也可能是其他方式 // 4. 进行哈希计算 String signStr = sb.toString(); return md5(signStr); // 这里看到是MD5,也可能是SHA256等 } private static String md5(String input) { try { MessageDigest md = MessageDigest.getInstance(“MD5”); byte[] digest = md.digest(input.getBytes(“UTF-8”)); StringBuilder hexString = new StringBuilder(); for (byte b : digest) { String hex = Integer.toHexString(0xff & b); if (hex.length() == 1) hexString.append(‘0’); hexString.append(hex); } return hexString.toString(); } catch (Exception e) { e.printStackTrace(); return “”; } } }

从这段代码可以清晰还原出签名算法:

  1. 参数排序:将所有待签名参数(不包括sign本身)按键名进行字典序排序。
  2. 拼接字符串:将排序后的参数按key=value&格式拼接成一个字符串。
  3. 添加密钥:在拼接好的字符串末尾,直接追加上一个密钥(secret)。这个secret是分析的关键,它可能硬编码在代码中,也可能从服务器动态获取。
  4. 计算MD5:对最终的字符串进行MD5哈希,得到32位小写的十六进制字符串作为签名。

接下来需要找到secret是什么。继续在代码中搜索SignGenerator.generateSign的调用处,发现在一个网络请求拦截器或封装类里,调用时传入的secret是一个从SecurityConfig.getAppSecret()获取的值。追踪SecurityConfig类,发现appSecret是一个静态常量,其值为“CheZhiYing_2023@Sec”(此处为示例,实际值需分析确认)。这里是一个重要的注意事项:很多APP会将密钥进行简单的编码或拆分,不会明文写在代码里,可能需要分析初始化流程或通过动态调试来获取。

同时,我们也需要分析password的加密。搜索password的赋值处,可能找到类似params.put(“password”, EncryptUtil.rsaEncrypt(rawPassword))的代码。追踪EncryptUtil,发现它使用了RSA加密,并且公钥(PUBLIC_KEY)也以字符串常量形式存储在代码中。RSA加密通常采用分段加密和Base64输出。

至此,通过静态分析,我们已经初步掌握了两个核心算法的逻辑框架:签名是排序拼接+MD5,密码加密是RSA公钥加密。但这还不够,我们需要用动态调试来验证这些逻辑,并获取确切的密钥和参数。

5. 动态调试验证与密钥获取

静态分析给出的代码逻辑是“应该是这样”,而动态调试能告诉我们“实际上就是这样”。我们使用Frida来验证SignGenerator.generateSign函数。

编写一个Frida脚本:

Java.perform(function() { var SignGenerator = Java.use(‘com.chezhijing.network.security.SignGenerator’); var overloads = SignGenerator.generateSign.overloads; for (var i = 0; i < overloads.length; i++) { if (overloads[i].hasOwnProperty(‘argumentTypes’)) { // Hook所有重载 SignGenerator.generateSign.overloads[i].implementation = function(params, secret) { console.log(“[SignGenerator.generateSign] called!”); console.log(“Params: “ + JSON.stringify(params)); console.log(“Secret: “ + secret); var result = this.generateSign(params, secret); console.log(“Result Sign: “ + result); console.log(“---”); return result; }; } } });

运行脚本,触发登录操作。在Frida控制台,我们看到了真实的调用:

[SignGenerator.generateSign] called! Params: {“username”:“13800138000”, “password”:“xxx”, “timestamp”:“1646389200000”, “nonce”:“a1b2c3d4e5”, “deviceId”:“android_xxxx”} Secret: CheZhiYing_2023@Sec Result Sign: 7f8e9a0b1c2d3e4f5a6b7c8d9e0f1a2b

动态Hook完美验证了我们的静态分析:传入的参数Map、使用的密钥secret,以及计算出的sign值,都与抓包数据吻合。这证实了签名算法无误。

接下来验证密码加密。同样用Frida HookEncryptUtil.rsaEncrypt方法,打印出明文密码和加密后的结果,确认其与请求中的password字段一致,并验证使用的公钥。

实操心得:在动态调试时,可能会遇到函数重载(overload)的情况。上面的脚本通过遍历.overloads来处理所有重载版本,是一种稳妥的做法。另外,如果APP启用了反调试或Frida检测,可能需要使用一些对抗手段,比如使用定制版的Frida、或者先使用其他工具(如Objection)进行附着。

6. 核心算法还原与Python实现

经过静态和动态分析,我们已经掌握了所有细节。现在,用Python将这两个核心算法还原出来。这不仅是分析的成果,也是后续进行协议模拟、自动化测试的基础。

6.1 签名算法(Sign)还原

签名算法的关键在于参数的排序和拼接顺序,必须与APP端完全一致。

import hashlib import time import uuid def generate_sign(params, secret): “”” 生成请求签名 :param params: dict, 待签名的参数字典 :param secret: str, 密钥 :return: str, 32位小写MD5签名 “”” # 1. 参数排序 sorted_keys = sorted(params.keys()) # 2. 拼接键值对 sign_str_parts = [] for key in sorted_keys: value = params.get(key) if value is not None and str(value) != ‘’: # 注意:value需要转换为字符串,拼接格式为 key=value& sign_str_parts.append(f“{key}={value}”) # 用‘&’连接所有键值对 sign_str = “&”.join(sign_str_parts) # 3. 拼接密钥 sign_str += secret # 4. 计算MD5 md5 = hashlib.md5() md5.update(sign_str.encode(‘utf-8’)) return md5.hexdigest().lower() # 示例使用 login_params = { “username”: “13800138000”, “password”: “RSA加密后的密文”, # 此处先占位,下面会生成 “timestamp”: str(int(time.time() * 1000)), # 毫秒级时间戳 “nonce”: uuid.uuid4().hex[:8], # 生成8位随机字符串 “deviceId”: “android_test_123” } app_secret = “CheZhiYing_2023@Sec” # 此为示例密钥,实际分析中获取 signature = generate_sign(login_params, app_secret) print(f“生成的签名: {signature}”)

6.2 密码加密算法(RSA)还原

密码通常使用RSA公钥加密。我们需要将从代码中提取的公钥字符串(通常是PEM格式,但可能去掉了头尾标记)正确加载。

from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 import base64 def rsa_encrypt_password(plain_password, public_key_str): “”” 使用RSA公钥加密密码 :param plain_password: str, 明文密码 :param public_key_str: str, PEM格式的公钥字符串(可能不含头尾) :return: str, Base64编码后的密文 “”” # 1. 处理公钥字符串:如果缺少头尾标记,则加上 if not public_key_str.startswith(‘—–BEGIN PUBLIC KEY—–‘): public_key_str = ‘—–BEGIN PUBLIC KEY—–\n‘ + public_key_str + ‘\n—–END PUBLIC KEY—–‘ # 2. 导入公钥 public_key = RSA.import_key(public_key_str) # 3. 创建加密器,使用PKCS1_v1_5填充模式(这是最常见的) cipher = PKCS1_v1_5.new(public_key) # 4. 加密。RSA加密有长度限制,需要分段。但密码通常较短,直接加密。 # 输入需要是bytes plaintext = plain_password.encode(‘utf-8’) ciphertext = cipher.encrypt(plaintext) # 5. Base64编码 encrypted_b64 = base64.b64encode(ciphertext).decode(‘utf-8’) return encrypted_b64 # 示例使用(公钥为示例,非真实) sample_public_key = “““MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1W1…””” # 省略部分 password = “my_password_123” encrypted_password = rsa_encrypt_password(password, sample_public_key) print(f“加密后的密码: {encrypted_password}”) # 将加密后的密码填入上面的login_params中 login_params[‘password’] = encrypted_password

6.3 完整登录请求组装

现在,我们可以组装一个完整的、可发送的登录请求了。

import requests import json def simulate_login(username, password): # 1. 准备参数 params = { “username”: username, “timestamp”: str(int(time.time() * 1000)), “nonce”: uuid.uuid4().hex[:8], “deviceId”: “android_模拟设备ID” } # 2. RSA加密密码 encrypted_pwd = rsa_encrypt_password(password, REAL_PUBLIC_KEY) # 替换为真实公钥 params[‘password’] = encrypted_pwd # 3. 生成签名(注意:签名时通常不包含sign字段本身) sign = generate_sign(params, REAL_APP_SECRET) # 替换为真实密钥 params[‘sign’] = sign # 4. 发送请求 headers = { ‘Content-Type’: ‘application/json; charset=UTF-8’, ‘User-Agent’: ‘Dalvik/2.1.0 (Linux; U; Android 11; Pixel 5 Build/RQ3A.210805.001.A1)’ } login_url = “https://api.chezhijing.com/mobile/login” try: response = requests.post(login_url, data=json.dumps(params), headers=headers, timeout=10) response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: print(f“请求失败: {e}”) return None # 测试调用 # result = simulate_login(“13800138000”, “123456”) # print(result)

注意事项:在实际还原时,务必注意字符串编码(UTF-8)、参数排序规则(字典序)、空值处理(是否参与签名)、以及密钥的准确性和完整性。一个字符的差异都会导致签名校验失败。建议将Hook到的原始参数和计算中间结果,与自己的还原算法进行逐行对比调试。

7. 常见问题排查与深度避坑指南

在算法还原和模拟请求的过程中,你几乎一定会遇到各种问题导致请求失败。下面是我总结的常见问题排查清单和避坑经验。

7.1 签名校验失败(Sign Error)

这是最常见的问题。服务端返回“签名错误”或“sign invalid”。请按以下顺序排查:

  1. 参数遗漏或多余:检查参与签名的参数是否齐全。关键点:有些签名会包含所有请求参数(包括URL查询参数),而有些只包含Body参数。甚至可能包含一些隐藏的固定参数。通过HookgenerateSign函数,对比你组装的参数字典和APP实际传入的字典,确保键值对完全一致。
  2. 参数值格式不一致:时间戳是字符串还是数字?deviceId的格式是否完全一致?布尔值true/false是字符串还是布尔类型?在拼接签名串时,所有值都必须转换为字符串,且格式要与APP端完全一致。Hook时打印出拼接前的paramsMap,仔细核对每个值的类型和字符串形式。
  3. 拼接顺序与分隔符:确认是key=value&还是key:value\n?末尾的&是否去除?密钥是直接拼接还是在前面加其他字符(如&secret=)?最可靠的方法是在Hook时,打印出最终传入MD5函数的那个原始字符串(signStr),然后在你自己的代码里还原出完全一样的字符串。可以使用print(repr(sign_str))来查看不可见字符。
  4. 密钥错误:确认使用的secret是否正确且完整。它可能不是简单的字符串,而是经过一次MD5哈希后的结果,或者需要从其他接口动态获取。动态Hook是获取其真实值的不二法门。
  5. 编码问题:确保拼接和哈希计算时使用的编码是UTF-8。Python的hashlib.md5().update()默认接受bytes,用.encode(‘utf-8’)转换。如果参数值包含中文等非ASCII字符,编码不一致会导致签名不同。

7.2 密码解密失败(Password Error)

如果服务端提示密码错误,但确认明文密码正确,问题出在加密环节。

  1. 公钥格式:从代码中提取的公钥字符串可能不是标准的PEM格式。它可能去掉了—–BEGIN PUBLIC KEY—–头尾标记,或者是以X.509格式存储。你需要根据代码中加载密钥的方式(例如使用KeyFactory.getInstance(“RSA”)X509EncodedKeySpec)来判断其格式,并在Python中用相应方式加载。有时公钥是Base64编码的,需要先解码。
  2. 加密填充模式:RSA加密必须指定填充模式。最常见的是PKCS1_v1_5,这也是Java默认的。但有些APP可能使用OAEP填充。在Java代码中,查找Cipher.getInstance()的传参,如果是“RSA/ECB/PKCS1Padding”,则对应PKCS1_v1_5;如果是“RSA/ECB/OAEPWithSHA-256AndMGF1Padding”,则对应OAEP。Python的Crypto库需要指定对应的填充模式。
  3. 分段加密:如果密码很长,可能需要分段加密。但登录密码通常不会超过RSA密钥长度(如2048位)所能加密的最大明文长度(例如PKCS1_v1_5填充下约245字节)。如果遇到超长密码,需要查看APP代码是否实现了分段加密逻辑。

7.3 其他常见防御与绕过

  1. 设备指纹(deviceId):这个deviceId可能不是简单的Android ID或IMEI,它可能是由多个设备参数(如品牌、型号、序列号、MAC地址等)组合后经过特定算法(如MD5)生成的。你需要找到生成这个ID的代码,并模拟生成一个固定的或符合规则的ID。频繁更换可能触发风控。
  2. 非对称加密协商:更复杂的方案是,客户端先请求一个临时的公钥(或会话密钥),再用这个临时密钥来加密密码。这意味着public_key不是写死在客户端的。你需要分析登录前的握手流程。
  3. 算法混淆与Native层实现:核心算法可能被移到C/C++编写的so库中,以增加逆向难度。此时需要分析JNI接口,或者使用Frida直接Hook Native层的函数(使用Interceptor.attach)。这需要更高的技巧,但思路不变:定位函数、Hook输入输出、分析逻辑。
  4. 请求重放与时效性timestampnonce就是用于防止重放攻击的。确保你的时间戳与服务器时间同步(可以取服务器时间),nonce每次请求必须不同。

7.4 调试与验证技巧

  • 本地验证:在完全模拟发送网络请求前,可以先进行本地验证。用Frida Hook到APP计算签名和加密密码的瞬间,记录下:① 输入参数 ② 输出的签名/密文。然后,暂停你的Python脚本,用记录下的输入参数运行你的算法,看输出是否与Hook到的结果完全一致。这是最有效的调试方法。
  • 分步替换:在模拟请求时,可以先尝试用抓包工具(如Charles)的“断点”或“重写”功能,将原始请求中的signpassword替换成你自己生成的,然后发送。观察服务器响应。这样可以隔离问题,确定是哪个参数计算错误。
  • 日志比对:在APP代码中,可能会在调试模式下输出日志。可以尝试Hookandroid.util.Log类,查看是否有关于网络请求或安全模块的日志输出,这能提供宝贵线索。

逆向分析是一个需要耐心和细致的过程,每一个细节都可能成为突破口或绊脚石。对于“车智赢”登录协议的分析,从抓包定位到算法还原,整套流程体现了移动端协议分析的典型方法论。掌握这套方法后,面对其他APP的类似需求,你都能有条不紊地层层深入,最终达成目标。记住,动态验证是检验真理的唯一标准,多Hook,多比对,成功还原就在眼前。