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解包APKapktool d target_app.apk -o output_dir。查看解包后的目录结构重点关注AndroidManifest.xml 查看组件、权限、是否设置android:debuggabletrue虽然发布版通常是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?page1ts1646389200signabcdef1234567890。记下这个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中查看。现在你拥有了一个可读的、近乎原始的JavaKotlin代码库。虽然可能被混淆类名、方法名变成a, b, c但控制流和关键字符串尤其是用于签名的密钥、盐值很可能还在。4. 静态分析定位签名算法这是最考验耐心和细心的环节。目标是从数十万行混淆代码中找到生成那个sign字段的几行关键代码。4.1 关键字符串与网络库搜索首先在Jadx中进行全局搜索CtrlShiftF。搜索签名字段名直接搜索你在抓包中看到的签名参数名如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本身按字典序排序然后用和拼接成key1value1key2value2的字符串。添加盐值或密钥在拼接好的字符串前后加上一个固定的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升序排序拼接成keyvalue格式并用连接末尾加上secretMY_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(), keylambda x: x[0]) # 2. 拼接键值对 param_str .join([f{k}{v} for k, v in sorted_items]) # 3. 拼接密钥 sign_str param_str fsecret{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 5f4dcc3b5aa765d61d8327deb882cf995.2 验证与调试收集测试数据从Charles中导出几次完整的请求包括URL和所有参数。确保参数一个不落。运行复现脚本将请求参数填入你的Python脚本运行。比对结果将脚本计算出的sign与抓包中的sign进行逐字符比对。结果不一致怎么办检查参数顺序确认排序规则升序/降序和拼接格式keyvalue还是key:value。检查编码问题参数值是否需要URL编码urllib.parse.quote或保持原样中文字符的处理很关键。检查额外参数是否漏掉了某些固定参数比如appVersion、deviceId等这些可能在网络拦截器里自动添加不在你看到的业务参数里。检查哈希算法确认是MD5、SHA1还是SHA256结果是十六进制还是Base64字母是大写还是小写检查密钥密钥是否正确是否经过了某种变换如反转、截取终极手段——动态调试在Frida Hook的签名方法里不仅打印输入输出还把中间每一步的字符串都打印出来。然后在你Python脚本的对应步骤也打印出来进行逐段比对找到第一个出现差异的地方。只有当你的脚本能够对多组不同的随机请求数据都生成与真实App完全一致的签名时才算真正还原成功。6. 常见问题与排查技巧实录在这一路上我踩过不少坑。这里总结几个典型问题和解决思路希望能帮你少走弯路。6.1 脱壳相关问题1Frida脚本执行后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内存区域。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方法来获得签名。这对于自动化调用接口是可行的但失去了算法的可移植性。6.3 复现验证相关问题5Python复现的签名大部分情况对但偶尔不对。可能原因参数中存在动态值且这些值的生成逻辑你没完全掌握。例如有一个nonce随机数你以为它是时间戳但实际上可能是时间戳加上一个随机后缀。排查技巧对那几次“偶尔不对”的请求单独拿出来分析。用Frida Hook签名方法精确记录下本次调用时传入的每一个参数的值与你Python脚本中使用的值进行逐一比对。重点关注时间戳是秒还是毫秒、设备指纹是否每次启动会变、随机数的生成规则。问题6算法还原出来了但密钥似乎会变。可能原因密钥不是硬编码而是从服务器动态获取的或者根据时间、版本号等因子计算出来的。排查技巧搜索代码中对密钥字符串的使用回溯其赋值的地方。它可能来自一个getSecret()方法而这个方法内部可能发起了网络请求。Hook所有可能返回密钥字符串的方法。在App启动初期和触发签名前观察这些方法是否被调用返回值是什么。如果密钥来自网络那么还需要分析获取密钥的API这可能又涉及到另一套签名或加密逻辑形成了一个“套娃”。这种情况通常意味着逆向难度极大需要权衡投入产出比。整个逆向过程就像是在解一个多维度的谜题。它没有一成不变的公式需要你结合静态分析的洞察力、动态调试的验证能力和对系统原理的深入理解。每一次成功还原不仅是对目标App安全机制的一次突破更是对自己技术能力的一次扎实提升。保持耐心注重细节勤于记录和测试你会发现看似坚固的壁垒其实都有迹可循。