1. 项目概述与核心目标
最近在分析一款主流的新闻资讯类App时,遇到了一个典型的“加固+签名校验”组合拳。这个App的APK文件被某款主流商业加固方案保护,同时其核心的新闻内容请求接口,使用了自定义的签名算法来验证请求的合法性。这几乎是当前移动应用安全防护的标配。我的目标很明确:首先,要绕过加固保护,拿到可读的DEX代码;其次,要从这些代码中,逆向分析出用于API请求签名的算法逻辑,并最终能够用脚本复现这个签名过程。这个过程不仅考验逆向工程的基本功,更考验对Android运行时、加密算法和网络协议的理解。如果你也正在为某个App的加固和签名而头疼,或者想系统性地了解安卓逆向从脱壳到算法还原的完整链条,那么我这次踩坑和填坑的经历,或许能给你提供一个清晰的参考路径。
2. 逆向环境与工具链准备
工欲善其事,必先利其器。一个稳定、高效的逆向环境是成功的第一步。我习惯在Windows 11子系统(WSL2)的Ubuntu 22.04环境下进行核心的静态分析和脚本编写,同时配合一台Root后的安卓真机(Android 10)进行动态调试和运行。这样的组合既能利用Linux命令行工具链的高效,又能保证动态调试的真实性。
2.1 核心工具选型与配置
脱壳工具:针对不同的加固方案,脱壳策略也不同。对于基于Dex文件整体加密的早期加固,frida-dexdump或DumpDex这类基于内存Dump的工具往往能奏效。但面对当前更流行的、使用VMP(虚拟机保护)或高级混淆的加固(例如从热词中看到的dnguard等),就需要更底层的抓取手段。我这次使用的是frida-unpack的某个改进版本,它通过在ClassLoader加载DEX的关键时机进行Hook,能更稳定地获取到解密后的字节码。为什么不选Xposed模块?因为很多加固会检测Xposed框架,导致App闪退,而Frida的隐蔽性相对更好,可以通过各种反检测技巧来绕过。
反编译与静态分析工具:Jadx-gui是我的首选。它开源、免费,反编译Java代码的可读性在众多工具中堪称优秀,并且支持全局搜索、跳转引用,对于快速理清代码结构至关重要。对于Jadx无法正确反编译的复杂混淆代码,我会用Bytecode Viewer查看Smali代码,或者直接用IDA Pro分析so库。Android Killer或ApkTool则用于APK的解包、重打包等资源操作。
动态调试与抓包工具:Frida是动态分析的瑞士军刀。我主要用它来Hook关键函数、打印参数返回值、动态修改逻辑以及辅助脱壳。Charles或Burp Suite用于拦截和观察网络请求,这是分析签名算法的入口——你总得先知道哪个HTTP请求的哪个字段是签名,长什么样。对于HTTPS抓包,需要在手机和电脑上都安装并信任抓包工具的CA证书,并配置好代理。如果App使用了证书绑定(SSL Pinning),还需要先用Frida脚本绕过。
脚本与辅助工具:Python环境是必须的,用于编写算法还原后的复现脚本。frida-tools、objection(基于Frida的运行时探索工具)能极大提升效率。一个高亮显示的代码编辑器(如VSCode)和笔记软件,用于记录分析过程和关键点。
注意:所有工具请从官方仓库或可信源下载。逆向工程可能涉及法律风险,请确保你的分析对象是你拥有合法权限测试的App(如自己开发的、或已获得明确授权的),并仅在安全的学习环境中进行。本文所有技术讨论仅限安全研究与学习目的。
2.2 目标App初步侦察
在开始“硬碰硬”之前,先进行非侵入式的侦察,可以事半功倍。
首先,使用adb install安装目标App。然后,通过adb shell dumpsys package [package.name]查看其包名、主Activity、申请的权限等信息。这能帮你快速定位入口。
接着,用ApkTool解包APK:apktool d target_app.apk -o output_dir。查看解包后的目录结构,重点关注:
AndroidManifest.xml: 查看组件、权限、是否设置android:debuggable="true"(虽然发布版通常是false,但有些加固会意外留下)。lib/目录: 看看有哪些架构的so库,库的名字有时能暗示其功能(如libsign.so,libcrypto.so)。assets/和res/目录: 可能藏有加密密钥、配置文件或网页资源。- 原始的
classes.dex或classesN.dex: 直接用文本编辑器打开,如果开头不是dex\n035而是乱码或其他字符,基本可以确定被整体加密了,这就是需要脱壳的对象。
最后,启动Charles设置好代理,运行App,触发几个关键的新闻列表和内容加载请求。观察请求的URL、Header和Body。寻找那些看起来像sign、signature、token、ts(时间戳)、nonce(随机数)的字段。一个典型的签名请求可能长这样:https://api.xxx.com/news/list?page=1&ts=1646389200&sign=abcdef1234567890。记下这个sign值,它是我们算法还原后要匹配的目标。
3. 脱壳实战:获取解密后的DEX
面对加固的APK,直接反编译classes.dex只会得到无意义的代码或壳程序本身。脱壳的本质,是让App在内存中自己完成解密工作后,我们再从内存中把解密好的DEX文件“捞”出来。
3.1 脱壳原理与时机选择
Android系统加载DEX文件,最终都会通过DexClassLoader或PathClassLoader,并调用底层的DexFile相关API。加固方案会在APK中植入一个壳程序,这个壳程序负责在运行时解密原始的、被加密的DEX文件,然后再通过DexClassLoader动态加载它。我们的Hook点,就选在dalvik.system.DexFile的openDexFile系列方法,或者DexClassLoader的构造函数上。当壳程序解密完毕,调用这些方法加载真正的DEX时,其传入的DEX文件路径或字节数组,就是我们的目标。
我使用的frida-unpack脚本,核心就是Hook了DexFile.loadDex方法。当这个方法被调用时,脚本会将其第二个参数(输出路径)对应的文件内容直接DUMP到我们指定的目录。因为此时,壳已经将解密后的DEX字节码写入这个路径了。
3.2 使用Frida进行动态脱壳
首先,确保手机已Root,并且电脑上安装了frida-tools。在手机上运行frida-server。
- 启动Frida脚本:编写或使用现成的脱壳脚本。一个简化的脚本逻辑如下(实际脚本更复杂,包含错误处理和多重Hook点):
Java.perform(function () { var DexFile = Java.use("dalvik.system.DexFile"); DexFile.loadDex.overload('java.lang.String', 'java.lang.String', 'int').implementation = function (sourcePath, outputPath, flags) { console.log("[*] loadDex called: "); console.log(" sourcePath: " + sourcePath); console.log(" outputPath: " + outputPath); // 核心:读取outputPath文件并保存 var file = new File(outputPath, "rb"); var dexBytes = file.read(); file.close(); var dumpPath = "/data/local/tmp/dex_dump_" + Math.random().toString(36).substr(2) + ".dex"; var dumpFile = new File(dumpPath, "wb"); dumpFile.write(dexBytes); dumpFile.close(); console.log("[+] Dumped dex to: " + dumpPath); // 继续执行原方法 return this.loadDex(sourcePath, outputPath, flags); }; }); - 附加到目标进程:
frida -U -f com.example.newsapp -l unpack.js --no-pause-U: 连接到USB设备。-f: 启动目标App。-l: 加载脚本。--no-pause: 立即启动主线程。
- 触发解密:Frida附加成功后,手动操作App,尽量多地点击、滑动,触发各个功能模块的加载,让壳程序解密更多的DEX文件。在Frida的控制台,你会看到一连串的
[*] loadDex called和[+] Dumped dex to日志。 - 收集DEX文件:根据日志中的路径,使用
adb pull将dump下来的所有.dex文件拉取到电脑上。你可能会得到多个DEX文件(classes.dex,classes2.dex, ...)。
实操心得:脱壳过程可能不会一帆风顺。有些加固会检测Frida,导致脚本失效或App崩溃。此时需要尝试Frida的反检测技巧,比如修改默认的监听端口、隐藏Frida的特征字符串、使用
frida的--debug模式等。也有加固会延迟解密,或者按需解密,所以需要充分操作App,确保所有关键代码都被加载和解密。如果loadDex这个点被加固方保护了,就需要寻找更底层的Hook点,比如libart.so中的相关函数,这需要一定的ARM汇编知识。
3.3 合并与反编译
拿到一堆DEX文件后,直接用Jadx-gui打开其中一个主DEX(通常是第一个),然后通过File->Add Files将其他DEX都添加进去。这样Jadx就能建立跨DEX的引用关系。点击File->Save All,可以导出为Gradle项目,方便在IDE中查看。
现在,你拥有了一个可读的、近乎原始的Java(Kotlin)代码库。虽然可能被混淆(类名、方法名变成a, b, c),但控制流和关键字符串(尤其是用于签名的密钥、盐值)很可能还在。
4. 静态分析定位签名算法
这是最考验耐心和细心的环节。目标是从数十万行混淆代码中,找到生成那个sign字段的几行关键代码。
4.1 关键字符串与网络库搜索
首先,在Jadx中进行全局搜索(Ctrl+Shift+F)。
- 搜索签名字段名:直接搜索你在抓包中看到的签名参数名,如
sign、signature。这可能会直接定位到网络请求封装类中设置参数的地方。 - 搜索网络库特征:搜索
okhttp3、retrofit2、HttpURLConnection等网络库的类名或方法名。找到网络请求的拦截器(Interceptor)或封装类,这里往往是添加公共参数(包括签名)的地方。特别是查找实现了Interceptor接口的类,它的intercept方法会处理每一个请求。 - 搜索加密算法相关字符串:搜索
MD5、SHA-1、SHA-256、HmacSHA256、AES、RSA、Base64等关键词。这能帮你快速定位到加密工具类。 - 搜索API域名或路径:搜索你抓包看到的核心API域名或URL路径的一部分,这能帮你找到具体的API接口定义。
4.2 关键代码分析与Hook验证
假设我们通过搜索sign,找到了一个名为com.xxx.network.a.a的类(混淆后的名字),其中有一个方法public static String a(String str, String str2),它被一个网络拦截器调用,传入的参数看起来像是请求参数和当前时间戳。
这时,不能完全相信静态分析。因为混淆和代码优化可能让逻辑变得难以理解。我们需要用Frida进行动态验证。
- Hook可疑方法:编写Frida脚本,Hook这个
a方法。Java.perform(function () { var targetClass = Java.use("com.xxx.network.a.a"); targetClass.a.overload('java.lang.String', 'java.lang.String').implementation = function (paramStr, timestampStr) { console.log("[*] 签名方法被调用: "); console.log(" paramStr: " + paramStr); console.log(" timestampStr: " + timestampStr); var result = this.a(paramStr, timestampStr); // 调用原方法 console.log(" sign结果: " + result); // 可以在这里将输入输出保存下来,用于后续分析 send({param: paramStr, ts: timestampStr, sign: result}); return result; }; }); - 运行Hook并触发请求:运行脚本,然后在App里刷新新闻列表。观察控制台输出,看打印出的
paramStr、timestampStr和计算出的sign,是否与你抓包到的数据吻合(例如,timestampStr是否等于抓包中的ts,计算出的sign是否等于抓包中的sign)。 - 参数追踪:如果吻合,那么这个方法就是签名方法。接下来需要分析
paramStr和timestampStr是怎么来的。继续向上回溯,Hook调用这个签名方法的地方,看传入的参数是如何拼接的。
通过“静态分析定位可疑点 -> 动态Hook验证 -> 回溯参数来源”的循环,可以一步步逼近核心算法。
4.3 算法逻辑还原
在确定了签名方法后,在Jadx中仔细分析这个方法。即使被混淆,算法的骨架通常也能看出来。常见的签名算法模式有:
- 参数排序拼接:将所有请求参数(不包括
sign本身)按字典序排序,然后用&和=拼接成key1=value1&key2=value2的字符串。 - 添加盐值或密钥:在拼接好的字符串前后,加上一个固定的
secret(盐值)或者appKey。 - 进行哈希或HMAC:将上述字符串进行
MD5、SHA-256或HmacSHA256计算。 - 二次处理:将哈希结果转换为十六进制字符串(大写或小写),或者再进行一次
Base64编码。
你需要像侦探一样,在代码中寻找这些步骤:
- 寻找
TreeMap或Arrays.sort(),这可能是排序。 - 寻找
StringBuilder或StringBuffer的循环拼接。 - 寻找调用
MessageDigest.getInstance("MD5")或Mac.getInstance("HmacSHA256")的地方。 - 寻找
secret、key等常量的定义,它们可能藏在static final字段里,或者从某个init方法中赋值。
注意事项:密钥可能不是硬编码在Java层,而是放在so库(Native层)中,通过
System.loadLibrary加载,然后由native方法返回。如果你在Java层找不到明显的密钥字符串,就需要用IDA Pro去分析lib/目录下的so文件,搜索字符串或分析JNI_OnLoad和对应的Java_com_xxx_xx函数。这增加了逆向的难度,但原理相通。
5. 算法复现与验证
当我们自认为已经理解了算法逻辑后,就必须用代码(比如Python)将其复现出来,并与真实App产生的签名进行对比验证。
5.1 Python复现示例
假设我们分析出的算法是:将所有GET参数(除sign外)按key升序排序,拼接成key=value格式并用&连接,末尾加上&secret=MY_SECRET_KEY,然后计算其MD5值(32位小写)。
对应的Python复现代码如下:
import hashlib import urllib.parse def generate_sign(params, secret_key): """ 生成签名 :param params: dict, 请求参数字典 :param secret_key: str, 密钥 :return: str, 32位小写MD5签名 """ # 1. 过滤掉sign参数本身,并排序 filtered_params = {k: v for k, v in params.items() if k != 'sign'} sorted_items = sorted(filtered_params.items(), key=lambda x: x[0]) # 2. 拼接键值对 param_str = '&'.join([f"{k}={v}" for k, v in sorted_items]) # 3. 拼接密钥 sign_str = param_str + f"&secret={secret_key}" # 4. 计算MD5 md5 = hashlib.md5() md5.update(sign_str.encode('utf-8')) return md5.hexdigest() # 测试用例 if __name__ == "__main__": # 模拟抓包到的参数 test_params = { 'page': '1', 'ts': '1646389200', 'channel': 'news' } secret = "abcdef123456" # 这是从代码中逆向出来的密钥 calculated_sign = generate_sign(test_params, secret) print(f"计算得到的签名: {calculated_sign}") # 这里应该与你抓包中看到的sign值一致 # 例如:抓包sign = "5f4dcc3b5aa765d61d8327deb882cf99" # assert calculated_sign == "5f4dcc3b5aa765d61d8327deb882cf99"5.2 验证与调试
- 收集测试数据:从Charles中导出几次完整的请求(包括URL和所有参数)。确保参数一个不落。
- 运行复现脚本:将请求参数填入你的Python脚本,运行。
- 比对结果:将脚本计算出的
sign与抓包中的sign进行逐字符比对。 - 结果不一致怎么办?
- 检查参数顺序:确认排序规则(升序/降序)和拼接格式(
key=value还是key:value)。 - 检查编码问题:参数值是否需要URL编码(
urllib.parse.quote)或保持原样?中文字符的处理很关键。 - 检查额外参数:是否漏掉了某些固定参数,比如
appVersion、deviceId等,这些可能在网络拦截器里自动添加,不在你看到的业务参数里。 - 检查哈希算法:确认是MD5、SHA1还是SHA256?结果是十六进制还是Base64?字母是大写还是小写?
- 检查密钥:密钥是否正确?是否经过了某种变换(如反转、截取)?
- 终极手段——动态调试:在Frida Hook的签名方法里,不仅打印输入输出,还把中间每一步的字符串都打印出来。然后在你Python脚本的对应步骤也打印出来,进行逐段比对,找到第一个出现差异的地方。
- 检查参数顺序:确认排序规则(升序/降序)和拼接格式(
只有当你的脚本能够对多组不同的随机请求数据,都生成与真实App完全一致的签名时,才算真正还原成功。
6. 常见问题与排查技巧实录
在这一路上,我踩过不少坑。这里总结几个典型问题和解决思路,希望能帮你少走弯路。
6.1 脱壳相关
问题1:Frida脚本执行后,App立刻崩溃或无反应。
- 可能原因:加固有强烈的Frida检测。
- 排查技巧:
- 使用
frida -U -f com.xxx.app --no-pause先不加载任何脚本,看App能否正常启动。如果能,说明是脚本本身或脚本加载时机的问题。如果不能,说明是Frida本身被检测。 - 尝试Frida反检测:修改Frida-server文件名和端口;使用
frida的--debug选项并配合-D指定设备;使用第三方修改过的、隐藏更好的Frida版本(需自行寻找)。 - 尝试在App启动完成后再附加(
spawn模式):frida -U com.xxx.app -l script.js。
- 使用
问题2:能Hook到loadDex,但dump下来的DEX用Jadx打开全是乱码或无效。
- 可能原因:Hook的时机不对,dump到的可能还是加密数据,或者壳有多层解密。
- 排查技巧:
- 尝试Hook更底层的函数,如
libart.so中的OpenMemory或DexFile::Open。 - 尝试在多个不同的时机进行dump,比如在
ClassLoader.loadClass某个特定类的时候再dump,因为那时该类所在的DEX肯定已解密并映射到内存。 - 使用内存扫描工具,在内存中搜索
dex\n035这个魔数,直接dump内存区域。
- 尝试Hook更底层的函数,如
6.2 签名算法定位相关
问题3:全局搜索搜不到任何明显的签名参数或加密关键字。
- 可能原因:字符串被加密或混淆了;签名逻辑完全在Native层(so库)实现。
- 排查技巧:
- 关注网络库的通用封装。即使参数名被混淆,设置参数的代码模式是不变的。寻找类似
request.addQueryParameter(var1, var2)或builder.addHeader(var1, var2)的调用,分析其参数来源。 - 使用Frida Hook所有
MessageDigest或Mac类的getInstance和update/doFinal方法,看哪些被调用了,调用时的参数是什么。 - 如果怀疑在Native层,用
IDA Pro打开so库,搜索Java_开头的函数,找到对应的JNI方法。或者用Frida的Interceptor去Hook so库中的加密函数,如MD5_Init,SHA256_Update等(需要一些ARM汇编知识)。
- 关注网络库的通用封装。即使参数名被混淆,设置参数的代码模式是不变的。寻找类似
问题4:找到了签名方法,但算法看起来非常复杂,涉及很多位运算和魔数。
- 可能原因:这是自定义的哈希算法,或者是标准算法的魔改版(如修改了初始向量IV、增加了额外的循环移位)。
- 排查技巧:
- 动态黑盒测试:用Frida Hook该方法,输入大量有规律的数据(如
”a”,”aa”,”ab”),观察输出。尝试判断其是否具有哈希算法的特性(固定长度输出、雪崩效应等)。 - 对照标准算法:将标准算法(如MD5)的每一步用代码实现,并打印中间状态。同时,用Frida在目标App的算法关键点打印中间状态。两者进行比对,找到第一个产生差异的步骤,那就是被魔改的地方。
- 如果实在太复杂,可以考虑不还原算法,而是直接“借用”:用Frida将这个签名方法暴露成一个RPC服务,让外部脚本直接调用这个Java方法来获得签名。这对于自动化调用接口是可行的,但失去了算法的可移植性。
- 动态黑盒测试:用Frida Hook该方法,输入大量有规律的数据(如
6.3 复现验证相关
问题5:Python复现的签名,大部分情况对,但偶尔不对。
- 可能原因:参数中存在动态值,且这些值的生成逻辑你没完全掌握。例如,有一个
nonce随机数,你以为它是时间戳,但实际上可能是时间戳加上一个随机后缀。 - 排查技巧:
- 对那几次“偶尔不对”的请求,单独拿出来分析。用Frida Hook签名方法,精确记录下本次调用时传入的每一个参数的值,与你Python脚本中使用的值进行逐一比对。
- 重点关注时间戳(是秒还是毫秒?)、设备指纹(是否每次启动会变?)、随机数的生成规则。
问题6:算法还原出来了,但密钥似乎会变。
- 可能原因:密钥不是硬编码,而是从服务器动态获取的,或者根据时间、版本号等因子计算出来的。
- 排查技巧:
- 搜索代码中对密钥字符串的使用,回溯其赋值的地方。它可能来自一个
getSecret()方法,而这个方法内部可能发起了网络请求。 - Hook所有可能返回密钥字符串的方法。在App启动初期和触发签名前,观察这些方法是否被调用,返回值是什么。
- 如果密钥来自网络,那么还需要分析获取密钥的API,这可能又涉及到另一套签名或加密逻辑,形成了一个“套娃”。这种情况通常意味着逆向难度极大,需要权衡投入产出比。
- 搜索代码中对密钥字符串的使用,回溯其赋值的地方。它可能来自一个
整个逆向过程,就像是在解一个多维度的谜题。它没有一成不变的公式,需要你结合静态分析的洞察力、动态调试的验证能力和对系统原理的深入理解。每一次成功还原,不仅是对目标App安全机制的一次突破,更是对自己技术能力的一次扎实提升。保持耐心,注重细节,勤于记录和测试,你会发现看似坚固的壁垒,其实都有迹可循。