某智赢 App 逆向分析:从强制更新绕过到Sign签名还原
前言
今天分享一篇某车圈App(低版本)的逆向分析实战记录。由于该 App 一直在迭代更新,且最新版本已经加了壳,为了方便演示核心逻辑的逆向过程,本次实战我选择未加固的低版本进行切入。
本文将从强制更新绕过、网络抓包分析、核心 Sign 签名还原 以及 UDID 算法浅析 四个维度,带大家一步步抽丝剥茧。
一、强制更新绕过
在分析低版本 App 时,最常遇到的拦路虎就是“强制更新弹窗”。如果不绕过它,连主界面都进不去。
1. 物理断网法
这是最简单、成本最低的绕过方式,适用于很多 App:
-
在无网络环境下打开 App。 -
等待 App 基础资源加载完成。 -
恢复网络,即可正常使用。
2. Frida Hook绕过法
如果断网法失效,我们就需要从代码层面解决。安装低版本并打开网络,App 弹出了强制更新的弹窗:

将 App 拖入反编译工具,全局搜索关键字:更新。搜索出来的结果要根据弹窗内容来判断

进一步查看代码,发现了一行关键判断:this.mButtonOK.setText(this.isDownload ? "立即安装" : "更新APP程序");。这行代码存在于 updateUI() 方法中。

绕过的思路有很多,比如把 if 判断直接修改为 false,获取让这个方式不执行,大家可以都试试,这里只提供了一种方式的hook
Java.perform(function () {console.log("[*] 开始执行更新绕过 Hook...");var UpgradeAppDialog = Java.use("com.che168.autotradercloud.upgradeapp.UpgradeAppDialog");// 直接置空 updateUI 方法,阻止弹窗显示UpgradeAppDialog.updateUI.implementation = function () {console.log("[+] 成功拦截 updateUI,弹窗已绕过!");}});
二、抓包核心加密点定位
成功进入 App 后,我们配置好抓包工具,抓取登录接口的数据包。

一眼望去,安全措施做得很全:
-
pwd:密码被加密(MD5)。 -
_sign:接口签名校验。 -
udid:设备指纹也进行了加密。
为了实现接口的重放或协议伪造,我们接下来的核心目标就是拿下 _sign 的生成逻辑。
三、Sign 签名算法还原
密码就不分析了,一眼顶真md5加密,直接硬刚 _sign。
1. 全局搜索与分类
在源码中全局搜索 "_sign",搜索结果不多不少,刚好可以分为以下三类:
-
jSUrl.addParams("_sign", ...); -
treeMap.put("_sign", ...); -
声明 String KEY_SIGN = "_sign";(未发现实际调用,可忽略)。

2. 批量Hook探明真身
由于前两类包含了多个可能生成 Sign 的方法(如 ADCollectAgent.toSign、AHAPIHelper.toSign、SignManager.signByType 等),为了精准定位到底走的是哪一条路,我编写了批量 Hook 脚本,把可疑的点全部打印出来:
Java.perform(function () {//更新console.log("[*] 开始执行更新绕过 Hook...");var UpgradeAppDialog = Java.use("com.che168.autotradercloud.upgradeapp.UpgradeAppDialog");// 直接置空 updateUI 方法,阻止弹窗显示UpgradeAppDialog.updateUI.implementation = function () {console.log("[+] 成功拦截 updateUI,弹窗已绕过!");}// treeMap.put("_sign", ADCollectAgent.toSign(treeMap));var ADCollectAgent = Java.use("com.autohome.ahanalytics.adanalytics.ADCollectAgent");ADCollectAgent.toSign.implementation = function (map) {console.log(`ADCollectAgent.toSign is called: map=${map}`);let result = this.toSign(map);console.log(`ADCollectAgent.toSign result=${result}`);return result;};// treeMap.put("_sign", toSign(context, treeMap));var AHAPIHelper = Java.use("com.autohome.ahkit.AHAPIHelper");AHAPIHelper.toSign.implementation = function (context, map) {console.log(`AHAPIHelper.toSign is called: context=${context}, map=${map}`);let result = this.toSign(context, map);console.log(`AHAPIHelper.toSign result=${result}`);return result;};// treeMap.put("_sign", SignManager.INSTANCE.signByType(var SignManager = Java.use("com.che168.atclibrary.base.SignManager");SignManager.signByType.implementation = function (signType, paramMap) {console.log(`SignManager.signByType is called: signType=${signType}, paramMap=${paramMap}`);let result = this.signByType(signType, paramMap);console.log(`SignManager.signByType result=${result}`);return result;};// treeMap.put("_sign", strSignByType);var LaunchModel = Java.use("com.che168.autotradercloud.launch.model.LaunchModel");LaunchModel.checkNullParams.implementation = function (map) {console.log(`LaunchModel.checkNullParams is called: map=${map}`);this.checkNullParams(map);};//jSUrl.addParams("_sign",)var JSUrl = Java.use("com.che168.autotradercloud.base.js.bean.JSUrl");JSUrl["addParams"].overload('java.lang.String', 'java.lang.String').implementation = function (str, str2) {console.log(`JSUrl.addParams is called: str=${str}, str2=${str2}`);this["addParams"](str, str2);};});
运行结果:
重新触发网络请求,控制台成功打印出日志。根据日志顺藤摸瓜,确认最终生成 Sign 的核心方法是:com.che168.atclibrary.base.SignManager.signByType()。

3. 算法逻辑还原
跟进 signByType() 方法查看源码,逻辑其实非常清晰:
-
动态加盐 (Salt): 根据传入的 signType(整数),选择不同的 Key。 -
字典排序: 遍历请求参数的 Map,按照 Key 的字典序进行排序。 -
字符串拼接: 将 Salt+有序的 Key + Value+Salt拼接成一个超长字符串(使用 StringBuilder 提升效率)。 -
哈希与大写: 对拼接好的字符串进行 MD5 计算,并将结果转换为大写。 
4. Python验证
弄懂了逻辑,直接用 Python 复现算法:
import hashlib# 预设的盐值池KEY_V1 = "com.che168.www"KEY_V2 = "W@oC!AH_6Ew1f6%8"KEY_SHARE = "moc.861ehc.relaed.bup.wyfv"KEY_AUTOHOME = "@7U$aPOE@$"def generate_sign(sign_type: int, param_map: dict) -> str:if param_map is None:return ""# 1. 根据 signType 匹配 Saltsalt = KEY_V1if sign_type == 1:salt = KEY_V2elif sign_type == 2:salt = KEY_SHAREelif sign_type == 3:salt = KEY_AUTOHOMEsb = []# 2. 头部加盐sb.append(salt)# 3. 按字典序排序并拼接 Key 和 Valuefor key in sorted(param_map.keys()):sb.append(str(key))value = param_map[key]# 容错处理:None 转空串sb.append(str(value) if value is not None else "")# 4. 尾部加盐sb.append(salt)raw_string = "".join(sb)# print(f"[*] 待加密字符串:{raw_string}")# 5. MD5 加密并转大写md5_hash = hashlib.md5(raw_string.encode('utf-8'))return md5_hash.hexdigest().upper()if __name__ == "__main__":# 填入抓包拿到的实际参数进行测试sample_params = {"_appid": "atc.android","appversion": "3.77.1","channelid": "csy","pwd": "e10adc3949ba59abbe56e057f20f883e","signkey": "","type": "","udid": "GYlLS/7nABYIiKXn3mrbFGcwNmoIGzSeS0sabY3NG+TOV9UV7thT8la/ua0W sWvd/hVbDvD3SOvXz11mDUae2g==","username": "18888888888"}signature = generate_sign(1, sample_params)print(f"[+] Python 还原生成的 Sign: {signature}")
运行脚本,生成的 Sign 与抓包数据完全一致,Sign 签名算法宣告攻破!

四、UDID逆向浅析
最后,我们简单看一眼数据包里的 udid。
依然是全局搜索,挨个排查,最终定位到 treeMap.put("udid", AppUtils.getUDID(ContextProvider.getContext()))。

跟进方法后发现:
-
它通过 getIMEI(context)获取了设备的 IMEI 码,这意味着该字段是一机一码绑定的。 -
获取到设备码后,调用了 encode3Des进行了 3DES 加密。

因为在常规的数据包重放和自动化测试中,只要保持现有的udid不变就能通过服务端的校验(与时间戳无关),所以这里的分析就点到为止,不再深挖其 3DES 的具体密钥。
总结
本次逆向虽然针对的是低版本 App,但其 Sign 签名的“前后加盐+字典排序+MD5”模式是业界非常经典且通用的设计。希望通过这篇笔记,能给大家在日常分析时提供一些思路参考。
免责声明
-
仅供学习研究: 本文涉及的所有技术细节、代码片段及逆向分析思路,仅供网络安全技术爱好者学习、安全审查和技术研究交流使用。 -
严禁非法用途: 请勿将本文内容用于任何商业盈利、非法抓取数据、破坏目标系统或损害相关企业及个人合法权益的行为。 -
遵守法律法规: 读者在测试、复现本文涉及的技术时,必须严格遵守《中华人民共和国网络安全法》、《数据安全法》等相关法律法规。 -
风险及责任自负: 因读者利用本文提供的信息而造成的任何直接或间接的法律后果及损失,均由读者本人自行承担,本文作者及发布平台概不负责。 -
侵权即删: 若本文内容无意中侵犯了相关公司或个人的知识产权及合法权益,请及时通过后台留言联系作者,我们将在核实后第一时间进行修改或删除处理。
夜雨聆风
