1. 项目概述:一次真实的APP逆向之旅
最近在技术社区里,看到不少朋友对移动安全、逆向工程感兴趣,但总觉得门槛高,资料零散。作为一个在这行摸爬滚打了十来年的老逆向,我深知从“知道工具”到“搞定一个真实目标”之间,隔着无数个坑。今天,我就拿一个最近实际分析过的、具有一定防护的真实APP作为案例,从头到尾拆解一遍我的逆向流程。这不是一个教学Demo,而是一个真实的、完整的实战复盘。我会用到Frida这个“瑞士军刀”,但重点不在于工具本身,而在于如何像侦探一样,结合静态分析与动态调试,一步步揭开一个APP的核心逻辑。整个过程,我会把思路、踩过的坑、以及那些教程里不会写的“骚操作”都分享出来。无论你是刚入门的安全爱好者,还是想提升实战能力的开发者,相信都能从中获得一些启发。
这个案例中的APP,我们暂且称它为“目标应用”。它涉及一些本地化的业务逻辑验证,具有一定的代码混淆和反调试机制,正好用来展示一个相对完整的逆向工程应对策略。我不会涉及任何敏感或非法操作,所有分析都基于技术学习的角度,探讨如何理解一个APP的运行机制。核心工具链就是Frida配合一些基础的静态分析工具。接下来,我们就进入正题。
2. 逆向工程的整体思路与前期准备
逆向工程不是拿着工具乱戳,它更像外科手术,需要清晰的思路和充分的准备。我的整体思路通常遵循“由外而内,动静结合”的原则。
2.1 核心思路拆解:动静结合分析法
所谓“由外而内”,是指先从应用的外部行为入手。我会先作为一个正常用户,把APP的主要功能点都跑一遍,用抓包工具(如Charles或Fiddler)记录下所有的网络请求和响应。这一步的目标是理解APP的业务流:它在哪个环节发了什么请求,请求参数是什么,服务器返回了什么。很多时候,关键的加密、签名逻辑就藏在这些请求里。
“动静结合”则是核心方法论。“静”指的是静态分析,即在不运行APP的情况下,反编译其安装包(APK或IPA),阅读反编译出来的代码(如Smali、Java或Objective-C),理解其程序结构和关键函数。“动”指的是动态分析,即在APP运行时,通过注入、Hook等手段,实时观察和修改其内存状态、函数参数和返回值。Frida正是动态分析的利器。静态分析能给你一张“地图”,但地图可能模糊不清(代码混淆);动态分析则让你能“实地行走”,验证地图的正确性并发现隐藏路径。两者必须结合使用,互相印证。
对于这个“目标应用”,我前期的发现是:它的登录和关键业务请求都带有加密参数,且抓包时发现证书绑定(SSL Pinning)导致无法直接解密HTTPS流量。同时,启动时会有延迟,疑似存在反调试检测。这初步勾勒出了一个有基本防护能力的APP画像。
2.2 工具选型与环境搭建
工欲善其事,必先利其器。我的移动端逆向环境主要搭建在Android平台上,因为其开放性更适合深入学习。以下是核心工具清单及其选型理由:
- 测试设备:一部已经Root的Android物理手机。模拟器(如Genymotion)虽然方便,但很多应用会检测模拟器环境,且高版本Android的某些特性在模拟器上支持不佳。物理机是最真实的环境。
- 逆向分析平台:Frida。选择它是因为其跨平台(支持Android/iOS/Windows/macOS等)、脚本语言友好(JavaScript/Python)、以及强大的动态插桩能力。它允许我们在目标进程运行时,注入自己的JS脚本,任意Hook Java层和Native层(C/C++)的函数。
- 静态分析工具:
- Jadx-GUI:用于将APK反编译成可读性较高的Java代码。它比早期的dex2jar+jd-gui组合更稳定、直观,支持搜索、跳转,是快速浏览代码结构的首选。
- Apktool:用于反编译APK得到资源文件、清单文件和关键的
classes.dex文件对应的Smali汇编代码。当Jadx反编译的Java代码因为混淆导致难以阅读时,直接分析Smali代码是必经之路。 - IDA Pro/Ghidra:用于分析APP内的原生库(
.so文件)。如果核心算法用C/C++实现并放在so库里,就必须用这类反汇编工具进行静态分析,再结合Frida进行动态调试。
- 抓包与调试工具:
- Charles/Fiddler:用于拦截和查看HTTP/HTTPS流量。需要先在设备上安装并信任抓包工具的CA证书。
- adb (Android Debug Bridge):必备命令行工具,用于安装应用、推送文件、端口转发、查看日志等。
注意:环境搭建的坑很多。比如Frida的版本需要与Frida-server运行在设备上的版本严格一致。我习惯在电脑上用
pip install frida-tools安装最新版,然后去Frida的GitHub releases页面下载对应设备架构(通常是arm64)的相同版本的frida-server文件,推送到设备上运行。
2.3 目标APP的初步侦察
在开始动刀前,需要对目标有足够了解。我通常会做以下几件事:
- 安装与基础信息收集:使用
adb install安装APP。通过adb shell dumpsys package [package.name]获取应用的包名、主Activity、权限列表。包名是后续所有操作的标识。 - 抓包观察:启动Charles,设置手机代理,尝试运行APP。果不其然,由于证书绑定,大部分HTTPS请求显示为
unknown。这是一个明确的防护信号。 - 反编译初窥:使用
jadx-gui打开APK文件。首先查看AndroidManifest.xml,了解应用组件、权限和可能存在的android:debuggable标志(虽然正式版通常为false)。然后全局搜索一些关键词,如“encrypt”、“decrypt”、“sign”、“key”、“http”、“okhttp”、“retrofit”等,快速定位可能负责网络和加密的类。
在这个案例中,通过搜索“ssl”、“pinning”、“certificate”,我很快找到了一个名为NetworkSecurityManager的类,里面实现了证书绑定的逻辑。同时,搜索“encrypt”找到了几个名为CryptoUtil、AESHelper的类,这很可能就是我们的主战场。
3. 突破第一道防线:绕过证书绑定
证书绑定是阻止我们抓包看清明文数据的第一只拦路虎。它的原理是APP内置了服务器证书或公钥,在建立HTTPS连接时比对,如果不匹配就断开,从而防止中间人攻击(比如我们的抓包工具)。
3.1 证书绑定的常见实现与定位
现代Android开发中,证书绑定通常通过以下方式实现:
- Network Security Configuration(Android 7.0+):在
res/xml/目录下配置network_security_config.xml文件。 - 第三方库:如OkHttp的
CertificatePinner。 - 自定义X509TrustManager:重写
checkServerTrusted方法,实现自定义校验逻辑。
在Jadx中,我发现了NetworkSecurityManager类,它内部持有一个OkHttpClient.Builder,并调用了.certificatePinner()方法。这就是使用OkHttp库实现的证书绑定。我们需要让这个校验失效。
3.2 使用Frida Hook绕过校验
思路是不修改APP本身,而是在运行时,通过Frida注入代码,替换掉关键的校验函数,让它直接“放行”。以下是详细的步骤和脚本。
首先,确保Frida环境就绪:
# 电脑端 adb push frida-server-arm64 /data/local/tmp/ adb shell su cd /data/local/tmp chmod 755 frida-server-arm64 ./frida-server-arm64 & # 保持这个shell窗口,让server在后台运行 # 另开一个终端,测试连接 frida-ps -U如果能看到设备上的进程列表,说明连接成功。
接下来,编写Frida JavaScript脚本。我们的目标是HookOkHttpClient.Builder的certificatePinner方法,或者更直接地,HookCertificatePinner类的check方法。
// bypass_ssl_pinning.js Java.perform(function () { console.log("[*] 开始尝试绕过SSL Pinning..."); // 方法一:尝试Hook OkHttp的CertificatePinner (常见) var CertificatePinner = Java.use("okhttp3.CertificatePinner"); if (CertificatePinner) { console.log("[+] 找到okhttp3.CertificatePinner类"); // 替换其check方法,让它什么都不做 CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function(hostname, pins) { console.log("[*] 拦截到CertificatePinner.check: hostname => " + hostname); // 直接return,不执行原有的校验逻辑 return; }; // 另一个重载方法,针对Android 10+的OkHttp版本 CertificatePinner.check.overload('java.lang.String', 'kotlin.jvm.functions.Function0').implementation = function(hostname, pinSupplier) { console.log("[*] 拦截到CertificatePinner.check (Supplier版本): hostname => " + hostname); return; }; console.log("[+] OkHttp CertificatePinner Hook 成功!"); } // 方法二:针对自定义TrustManager的通用Hook var X509TrustManager = Java.use('javax.net.ssl.X509TrustManager'); var TrustManagerImpl; try { // 尝试找到应用自定义的TrustManager实现类,类名可能被混淆 Java.choose('javax.net.ssl.X509TrustManager', { onMatch: function(instance) { console.log('[+] 发现X509TrustManager实例: ' + instance.$className); TrustManagerImpl = Java.use(instance.$className); // Hook checkServerTrusted方法 TrustManagerImpl.checkServerTrusted.implementation = function(chain, authType) { console.log('[+] 绕过自定义TrustManager校验: ' + instance.$className); // 同样,什么也不做,相当于信任所有证书 return; }; }, onComplete: function() {} }); } catch (e) { console.log('[-] 未找到自定义X509TrustManager: ' + e); } // 方法三:更暴力的,Hook所有SSLContext的init方法,替换掉TrustManager var SSLContext = Java.use('javax.net.ssl.SSLContext'); SSLContext.init.overload('[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom').implementation = function(keyManagers, trustManagers, secureRandom) { console.log('[*] SSLContext.init被调用,尝试替换TrustManager...'); // 创建一个接受所有证书的TrustManager var TrustAllManager = Java.registerClass({ name: 'com.bypass.TrustAllManager', implements: [X509TrustManager], methods: { checkClientTrusted: function(chain, authType) {}, checkServerTrusted: function(chain, authType) {}, getAcceptedIssuers: function() { return []; } } }); var newTrustManagers = [TrustAllManager.$new()]; // 用我们自己的TrustManager调用原方法 this.init(keyManagers, newTrustManagers, secureRandom); }; console.log("[*] SSL Pinning绕过脚本加载完成。"); });脚本执行与验证:
frida -U -f com.target.app.package.name -l bypass_ssl_pinning.js --no-pause-f表示启动应用,-l加载脚本,--no-pause立即执行。
执行后,再观察Charles,原本unknown的HTTPS请求现在应该能显示出明文域名和请求体了。如果还不行,可能需要检查APP是否使用了更底层的Native代码(如Cronet网络库)或自定义的Socket实现,那需要更复杂的Hook策略。
实操心得:SSL Pinning绕过脚本最好写成“组合拳”。因为不同APP、不同版本、不同网络库的实现方式差异很大。我提供的脚本包含了三种常见情况的Hook,成功率更高。在实际操作中,需要结合
jadx的代码分析,确定目标APP具体用了哪种方式,然后有针对性地启用脚本中的对应部分,避免不必要的性能开销和潜在冲突。
4. 深入核心:定位与Hook加密函数
抓包成功只是第一步,现在我们能看到请求和响应,但关键参数(如sign、data字段)往往是加密的。下一步就是找到负责加密/签名的函数,并Hook它,获取算法细节或直接获取明文。
4.1 静态分析寻找线索
回到Jadx,我们已经找到了CryptoUtil和AESHelper类。现在需要仔细阅读这些类的代码。
- 查看方法名:寻找诸如
encrypt、decrypt、encode、sign、generateSignature等方法。 - 查看调用关系:在
CryptoUtil类中,右键点击方法名,选择“查找用例”,看看哪些地方调用了它。通常会在网络请求的拦截器或工具类中被调用。 - 分析参数和返回值:注意加密函数的输入(参数)和输出(返回值)。参数很可能就是我们需要获取的明文,返回值则是我们抓包看到的密文。
例如,在CryptoUtil类中,我发现了如下方法:
public static String encryptData(String plainText, String key) { // ... AES加密实现 ... }同时,在一个名为RequestInterceptor的类中,发现了如下调用:
String encryptedBody = CryptoUtil.encryptData(jsonBody, AppConstants.SECRET_KEY); requestBuilder.post(RequestBody.create(encryptedBody, MediaType.parse("application/json")));这非常清晰:jsonBody是明文JSON字符串,encryptedBody是加密后的密文,作为请求体发送。我们的目标就是Hook这个encryptData方法。
4.2 编写Frida Hook脚本获取明文
知道了类名和方法名,Hook起来就有的放矢了。但要注意,代码可能被混淆,类名和方法名可能是a.a.a.a这种无意义字符。这时就需要结合静态分析和动态搜索。
脚本一:Hook特定类的特定方法
// hook_encrypt.js Java.perform(function () { console.log("[*] 开始定位加密函数..."); // 情况一:类名和方法名清晰 var CryptoUtil = Java.use("com.target.app.util.CryptoUtil"); if (CryptoUtil) { console.log("[+] 找到CryptoUtil类"); // Hook encryptData方法 CryptoUtil.encryptData.overload('java.lang.String', 'java.lang.String').implementation = function(plainText, key) { console.log("\n========== CryptoUtil.encryptData被调用 =========="); console.log("[+] 明文 (plainText): " + plainText); console.log("[+] 密钥 (key): " + key); // 调用原方法获取加密结果 var result = this.encryptData(plainText, key); console.log("[+] 密文 (result): " + result); console.log("=============================================\n"); // 返回原结果,不影响程序正常运行 return result; }; console.log("[+] CryptoUtil.encryptData Hook 成功!"); } // 情况二:类名被混淆,通过方法特征查找 // 如果知道方法可能属于某个包,可以枚举所有类 Java.enumerateLoadedClasses({ onMatch: function(className) { // 过滤出可能包含加密逻辑的包下的类 if (className.includes("crypto") || className.includes("encrypt") || className.includes("util") || className.indexOf(".") < 0) { // 也检查混淆类(无包名或短类名) // console.log("扫描到类: " + className); try { var clazz = Java.use(className); var methods = clazz.class.getDeclaredMethods(); for (var i = 0; i < methods.length; i++) { var methodName = methods[i].getName(); // 根据方法名特征判断,例如包含encrypt, encode, cipher等 if (methodName.toLowerCase().includes("encrypt")) { console.log("[?] 发现疑似加密类: " + className + " -> 方法: " + methodName); // 可以尝试Hook,但需要知道参数类型,这里需要更精细的处理 } } } catch (e) { // 忽略无法使用的类 } } }, onComplete: function() { console.log("[*] 类枚举完成。"); } }); });运行此脚本后,在APP中触发一个网络请求(比如登录),控制台就会打印出加密前的明文和使用的密钥。这样,我们就成功“看到”了客户端发送的真实数据。
4.3 处理Native层加密与复杂混淆
有些APP为了安全,会把核心加密算法放在Native层(.so库文件)用C/C++实现。这时,就需要分析so库。
- 定位Native方法:在Java代码中,寻找用
native关键字声明的方法,如public static native String encryptNative(String data);。 - 查找对应的JNI函数:在so库中,JNI函数的命名规则通常是
Java_包名_类名_方法名。使用IDA Pro或Ghidra打开so文件,搜索这个模式。 - 使用Frida Hook Native函数:这比Hook Java复杂,需要知道函数在内存中的地址或导出符号。
// hook_native_encrypt.js Java.perform(function () { console.log("[*] 尝试Hook Native加密函数..."); // 首先找到Java的native方法所在的类 var NativeCrypto = Java.use("com.target.app.NativeCrypto"); // 获取native方法的引用(这里假设方法名为encrypt) var encryptAddr = Module.findExportByName("libnative-lib.so", "Java_com_target_app_NativeCrypto_encrypt"); if (encryptAddr) { console.log("[+] 找到Native函数地址: " + encryptAddr); // 使用Interceptor拦截该函数 Interceptor.attach(encryptAddr, { onEnter: function(args) { // args[1]是JNIEnv*, args[2]是jobject, args[3]是jstring参数 console.log("[*] Native encrypt函数被调用"); // 将jstring转换为JavaScript字符串需要调用JNI函数,这里简化处理 // 实际中可能需要使用Memory.readCString等复杂操作 // 这里只是演示框架 this.inputArg = args[3]; }, onLeave: function(retval) { // retval是返回值,也是jstring console.log("[*] Native encrypt函数执行完毕"); // 可以在这里打印或修改返回值 } }); } else { console.log("[-] 未找到指定的Native导出函数,可能需要分析so内部逻辑。"); } });注意事项:Native Hook对逆向者的要求更高,需要了解基本的ARM/ARM64汇编、JNI接口和内存操作。如果APP做了反调试(如检测
ptrace),在Native层可能会触发。这时就需要先绕过反调试,再进行分析。一个常见的技巧是使用Frida的Process.enumerateThreads()和Interceptor来检测和绕过ptrace调用。
5. 实战中的疑难杂症与排查技巧
逆向过程中,一帆风顺的情况很少。下面记录几个我在这个案例中遇到的实际问题及解决方法。
5.1 Frida脚本注入失败或APP崩溃
- 现象:执行
frida -U -f命令后,APP启动即闪退,或Frida提示连接失败、超时。 - 可能原因与排查:
- 反Frida检测:APP在启动时检测了Frida的存在。常见检测手段:检查特定端口(如27042,Frida默认端口)、检查进程名(是否存在
frida-server)、检查加载的模块(是否存在libfrida相关so文件)。 - 解决方案:
- 修改Frida默认端口:启动
frida-server时指定非默认端口:./frida-server -l 0.0.0.0:8080,然后Frida客户端连接时用-H 192.168.x.x:8080。 - 重命名frida-server:将
frida-server文件改名为其他名字,如fs,再运行。 - 使用对抗工具:如
objection(基于Frida)的android anti-root disable命令可以尝试绕过一些检测。或者寻找专门对抗反调试的Frida脚本。 - 静态Patch:如果检测逻辑在Java层且不太复杂,可以直接用反编译工具(如
apktool)修改Smali代码,将检测分支直接goto到成功流程,然后重打包签名。但这会改变APP,属于静态修改。
- 修改Frida默认端口:启动
- 反Frida检测:APP在启动时检测了Frida的存在。常见检测手段:检查特定端口(如27042,Frida默认端口)、检查进程名(是否存在
5.2 Hook不到目标函数
- 现象:脚本成功注入,但预期的日志没有打印出来。
- 可能原因与排查:
- 类名/方法名错误:混淆后的名称可能每次编译都变。使用
Java.enumerateLoadedClasses和Java.choose()动态查找。 - 时机问题:脚本注入时,目标类可能还未被加载。Frida提供了
Java.ensureClassInitialized()或可以在类加载时Hook。 - 重载方法不匹配:使用
overload时参数类型必须完全匹配。使用obj.class.getDeclaredMethods()查看所有方法签名,或者使用overload不指定参数来Hook所有重载。 - 方法不在主线程被调用:确保Hook代码在
Java.perform内,它保证了在Java VM线程中执行。
- 类名/方法名错误:混淆后的名称可能每次编译都变。使用
改进的查找与Hook脚本示例:
Java.perform(function () { // 通过实例来定位被混淆的类 Java.choose('**可能存在加密逻辑的父类或接口,如 java.lang.Object**', { onMatch: function(instance) { var className = instance.$className; // 通过实例的方法行为来判断 // 例如,调用实例的某个方法,看返回值或参数是否符合加密特征(此方法较高级,需结合动态调用) // 更简单的方法:如果知道加密后的字符串格式,可以遍历所有方法,传入已知明文,看输出是否匹配密文(暴力但有效) console.log('[*] 检查实例: ' + className); }, onComplete: function() {} }); // 另一种:Hook所有String返回类型且参数为String的方法(风险高,可能卡顿) // Java.enumerateLoadedClasses(...) 内遍历所有类的方法,对疑似方法进行Hook并打印输入输出。 });5.3 数据格式复杂与算法还原
- 现象:Hook到了加密函数,拿到了明文和密钥,但想独立复现算法时发现内部逻辑复杂,不仅仅是标准AES。
- 解决方案:
- 深入静态分析:在Jadx中仔细阅读
encryptData内部的实现。可能包含自定义的填充模式、编码方式(Base64、Hex)、或者结合了多个加密步骤。 - “黑盒”记录法:如果算法过于复杂,可以不急于完全理解。用Frida脚本将输入(明文、密钥)和输出(密文)大量地、成对地记录下来。收集足够多的样本后,可以尝试用机器学习或密码分析的方法推测,或者直接在你的代码中模拟调用原函数。
- 使用Frida RPC:Frida提供了RPC(Remote Procedure Call)功能,允许你的外部Python脚本主动调用APP内存中的这个加密函数。这样,你无需还原算法,直接把它当做一个“加密服务”来调用。
在Python中:// rpc_encrypt.js Java.perform(function () { var CryptoUtil = Java.use("com.target.app.util.CryptoUtil"); // 将加密函数暴露给RPC rpc.exports = { encrypt: function(plaintext) { var result = CryptoUtil.encryptData(plaintext, "固定的密钥或从其他地方获取"); return result; } }; });import frida session = frida.get_usb_device().attach("目标APP") with open("rpc_encrypt.js", "r") as f: script = session.create_script(f.read()) script.load() # 现在可以像调用本地函数一样调用加密 encrypted_data = script.exports.encrypt("我的明文数据") print(encrypted_data)
- 深入静态分析:在Jadx中仔细阅读
5.4 对抗反调试与代码保护
- 现象:APP运行后不久自动退出,或Frida断开连接。
- 进阶对抗:除了前面提到的检测Frida,还有更高级的保护:
- 定时器检测:在子线程循环检测
/proc/self/status中的TracerPid字段,不为0则说明被调试。 - 信号处理:设置
SIGTRAP等信号的处理函数,干扰调试器。 - 代码混淆与虚拟化:使用商业加固方案,将关键代码转换为自定义的虚拟机指令(VMP),极大增加静态分析和动态Hook的难度。
- 定时器检测:在子线程循环检测
- 应对策略:
- 对于定时器检测,可以Hook读取
/proc/self/status的文件操作,返回伪造的内容。 - 对于商业加固,逆向难度呈指数级上升。可能需要脱壳、分析自定义解释器。这超出了基础逆向的范畴,需要深厚的系统底层知识和耐心。有时,从业务逻辑的“外围”或网络协议层面寻找突破口,可能比硬刚VMP更有效。
- 对于定时器检测,可以Hook读取
6. 案例复盘:从Hook到协议理解
通过上述步骤,我最终成功Hook了目标APP的加密函数。发现其加密流程如下:
- 将JSON请求体进行Gzip压缩。
- 使用一个固定的
AES-128-CBC密钥对压缩后的字节数组进行加密。 - 将加密结果进行Base64编码,作为
data字段。 - 另外,还有一个
sign字段,是对“data+时间戳+固定盐值”的MD5哈希。
这个流程非常典型。有了这个理解,我就可以完全脱离APP,用Python编写一个等价的请求生成器:
import json, gzip, base64, hashlib, time from Crypto.Cipher import AES from Crypto.Util.Padding import pad def make_request(payload_dict): # 1. Gzip压缩 json_str = json.dumps(payload_dict) compressed = gzip.compress(json_str.encode('utf-8')) # 2. AES加密 key = b'16-byte-long-key!' # 从Hook中获得 iv = b'\x00' * 16 # 从Hook中得知使用零向量IV cipher = AES.new(key, AES.MODE_CBC, iv) encrypted = cipher.encrypt(pad(compressed, AES.block_size)) # 3. Base64编码 data_field = base64.b64encode(encrypted).decode('utf-8') # 4. 生成签名 timestamp = str(int(time.time())) salt = 'some_salt_string' sign_str = data_field + timestamp + salt sign_field = hashlib.md5(sign_str.encode('utf-8')).hexdigest() # 构建最终请求体 final_payload = { 'data': data_field, 'timestamp': timestamp, 'sign': sign_field, 'version': '1.0' } return final_payload # 使用示例 req = make_request({'username': 'test', 'password': '123456'}) print(req)至此,整个逆向分析的核心目标已经达成:我们理解了APP与服务器通信的协议细节,并能够模拟构造合法的请求。这个过程锻炼的是定位关键代码、动态分析、数据理解和协议还原的综合能力。
逆向工程就像解谜,工具(Frida)只是你的放大镜和镊子,真正的核心是你的思维方式和耐心。每一个防护措施都是一道锁,而你的任务就是找到那把对的钥匙,或者学会自己制作一把。这个过程充满挑战,但也正是其魅力所在。希望这个完整的案例解析,能为你打开移动端逆向世界的大门。记住,保持好奇,合法探索。