Android逆向实战:Frida动态Hook绕过广告SDK与签名校验

📅 2026/7/3 21:17:13 👁️ 阅读次数 📝 编程学习
Android逆向实战:Frida动态Hook绕过广告SDK与签名校验

1. 项目概述与核心挑战

在Android应用逆向分析这条路上走得久了,你会发现,很多商业应用为了保护自身利益和用户体验,会设置越来越复杂的防御机制。其中,广告SDK的强制加载和动态签名校验,是两道非常典型且棘手的“关卡”。前者直接影响用户的使用体验和应用的纯净度,后者则是开发者为了防止应用被篡改、二次打包而设置的核心防线。我最近在分析一个集成了AdMob广告SDK,并且采用了运行时签名校验的应用时,就遇到了这个组合难题。表面上看,应用运行正常,但一旦尝试通过常规的AndroidManifest.xml修改或资源替换来屏蔽广告,应用要么直接崩溃,要么广告依然“顽强”地显示出来。这背后,就是广告SDK的深度集成校验与动态签名校验在协同工作。本次实战,我将带你一步步拆解这个案例,分享如何定位关键校验点、使用Frida进行动态Hook绕过、以及处理So层加固校验的进阶技巧。无论你是想学习更深层的逆向思路,还是在实际项目中遇到了类似困扰,相信这篇详尽的复盘都能给你带来直接的帮助。

2. 逆向环境与目标应用分析

2.1 环境与工具链准备

工欲善其事,必先利其器。一个稳定、高效的逆向环境是成功的第一步。我的主力环境是一台x86_64架构的Ubuntu 22.04虚拟机,当然,Windows 10/11配合WSL2也是绝佳选择。核心工具链如下:

  1. 反编译与静态分析

    • Jadx-GUI:这是首选的Java反编译器,它的图形化界面和强大的代码搜索、跳转功能,能极大提升静态分析的效率。我通常使用其GitHub仓库发布的最新版本。
    • Apktool:用于解包APK,获取AndroidManifest.xmlresources.arscdex文件。这是修改资源、进行重打包的必经之路。命令很简单:apktool d target.apk -o output_dir
    • Bytecode ViewerFernflower:作为Jadx的补充,有时不同反编译器对混淆代码的呈现略有差异,交叉查看能帮助理解。
  2. 动态调试与运行时干预

    • Frida:本次实战的绝对核心。它是一个动态代码插桩工具,允许你向目标进程注入自己的JavaScript代码,来Hook函数、修改内存、调用方法等。我们需要在电脑上安装Frida客户端(pip install frida-tools),并在目标Android设备(实体机或模拟器)上运行对应架构的Frida-server。
    • ADB (Android Debug Bridge):连接设备和电脑的桥梁,用于安装应用、推送文件、端口转发等。确保adb devices能正确列出你的设备。
    • 一台已Root的Android手机或模拟器:这是运行Frida-server和进行深度Hook的前提。我推荐使用官方Android Studio自带的模拟器(AVD),并选择带有“Google Play”标志的系统镜像,因为它自带Root权限(可通过adb root命令获取),兼容性最好。网上很多教程让你刷Magisk,但对于逆向调试,模拟器是更干净、可快速重置的选择。
  3. 目标应用初步侦察: 拿到目标APK后,不要急于扔进Jadx。先用keytoolapksigner检查其签名信息,了解其签名算法和证书哈希。然后使用apktool解包,快速浏览AndroidManifest.xml,重点关注<application>标签下的android:name(自定义Application类)、<meta-data>标签(可能配置了广告App ID或校验密钥),以及所有<service><receiver><provider>(广告SDK和校验逻辑可能藏身于此)。最后,用Jadx打开APK,进行全局搜索。

2.2 目标应用防御机制初探

将目标APK载入Jadx后,我首先进行了几轮关键词搜索:

  • 广告相关:搜索“AdMob”、“GoogleAd”、“ads”、“banner”、“interstitial”、“rewarded”。很快,我发现了com.google.android.gms.ads包下的类被大量引用,确认了AdMob SDK的存在。此外,应用自身还有一个AdManager类,负责统一控制广告的加载、显示与隐藏。
  • 签名校验相关:搜索“signature”、“package”、“getPackageManager”、“PackageInfo”。静态分析发现了多处context.getPackageManager().getPackageInfo(...).signatures的调用。但这只是静态校验,更关键的是动态校验——即应用在运行时,可能从服务器获取一个预期的签名哈希,与当前应用的签名进行比对。这种校验逻辑可能被混淆,且触发时机不定。
  • Native层线索:在Jadx中搜索“System.loadLibrary”或“native”关键字,发现应用加载了一个名为securitycheck的本地库(.so文件)。这强烈暗示核心的、高强度的校验逻辑可能放在So层,用C/C++实现,逆向难度更大。

初步分析结论是:这是一个“Java层动态签名校验 + So层加固校验 + 广告SDK深度集成”的复合型防御案例。简单的修改AndroidManifest.xml或替换广告ID的方法很可能失效,因为应用在启动或执行关键功能前,会进行多重验证。

3. 广告SDK绕过策略深度解析

3.1 广告加载流程与Hook点定位

广告的展示并非无迹可寻。以AdMob为例,其展示广告的核心最终都会调用到com.google.android.gms.ads.AdViewloadAd方法,或是InterstitialAdshow方法。我们的目标不是阻止这些方法的调用(可能导致空指针异常),而是让它们“安静地失败”,或者返回一个无害的空广告。

在Jadx中,我聚焦于应用自有的AdManager类。它有一个关键方法public void loadBannerAd(Context context, String adUnitId)。在这个方法内部,它创建了AdView实例,设置了AdUnit ID,然后调用了adView.loadAd(new AdRequest.Builder().build())

注意:直接HookAdView.loadAd()有时并不够,因为广告SDK可能有异步回调或状态监听。更稳妥的方法是找到广告请求生成的源头或响应处理的环节。

通过跟踪代码,我发现应用在收到广告后,会调用一个onAdLoaded()回调方法。我的策略是:Hook这个回调方法,使其永远不会被成功触发,或者在被触发时执行一个空操作,同时阻止真正的广告视图被添加到界面布局中。

3.2 Frida Hook脚本编写与实践

我编写了以下Frida JavaScript脚本(hook_ads.js):

Java.perform(function () { console.log("[*] 开始Hook广告相关类..."); // 场景1:Hook应用自有的AdManager的loadBannerAd方法,使其什么都不做 var AdManager = Java.use("com.example.app.AdManager"); if (AdManager) { AdManager.loadBannerAd.implementation = function (context, adUnitId) { console.log("[+] 拦截 AdManager.loadBannerAd(),广告单元ID: " + adUnitId); // 直接返回,不执行父类方法,广告加载流程被中断 // 注意:这可能导致调用方期待一个AdView对象,需根据实际情况调整 // 更安全的做法是:创建一个空的AdView返回,但将其可见性设为GONE try { var fakeAdView = this.mBannerAdView; // 假设有这个字段 if (fakeAdView) { fakeAdView.setVisibility(Java.use("android.view.View").GONE.value); } } catch (e) { console.log("[-] 处理fakeAdView时出错: " + e); } // 不调用原方法,广告请求根本不会发出 }; console.log("[+] AdManager.loadBannerAd Hook 成功"); } // 场景2:Hook AdMob的AdView.loadAd方法,传入一个空的AdRequest var AdView = Java.use("com.google.android.gms.ads.AdView"); if (AdView) { AdView.loadAd.implementation = function (adRequest) { console.log("[+] 拦截 AdView.loadAd()"); // 可以选择调用原方法,但传入一个无效或空的请求,使广告请求失败 // 但更好的方法是:不让广告视图被添加到任何父布局 var currentActivity = Java.use('android.app.ActivityThread').currentActivity(); if (currentActivity) { // 查找可能是广告的View并移除 // 这是一个更激进但有效的方法,需要适配具体UI结构 } // 这里我们选择直接返回,不加载广告 // this.loadAd(adRequest); // 注释掉,不执行原逻辑 }; console.log("[+] AdView.loadAd Hook 成功"); } // 场景3:Hook广告加载成功回调,使其失效 var AdListener = Java.use("com.google.android.gms.ads.AdListener"); if (AdListener) { AdListener.onAdLoaded.implementation = function () { console.log("[+] 拦截 onAdLoaded() 回调"); // 不执行任何操作,广告加载成功的信号不会被上层应用感知 // 也可以在这里触发一个假的 onAdFailedToLoad 回调 // this.onAdFailedToLoad(Java.use("com.google.android.gms.ads.LoadAdError").$new()); }; console.log("[+] AdListener.onAdLoaded Hook 成功"); } });

实操心得

  1. Hook的时机:脚本需要在广告加载代码执行之前注入。最稳妥的方式是在应用启动的早期,例如HookApplication.attachBaseContext()Application.onCreate()方法时,就执行我们的广告Hook逻辑。
  2. 错误处理:Frida脚本中的try-catch非常重要。目标应用可能经过混淆,类名或方法名不准确,或者在不同版本中有所变化。良好的错误处理可以避免脚本因单个Hook失败而整体崩溃。
  3. 多线程考虑:广告加载和回调可能发生在非UI线程。Frida的Java.perform确保了代码在Java VM线程中执行,但你的Hook逻辑本身应尽量简单、原子化,避免复杂的同步操作。

使用命令frida -U -f com.example.targetapp -l hook_ads.js --no-pause启动应用并注入脚本。观察日志,当广告加载被触发时,你应该能看到拦截成功的提示,并且界面上对应的广告位应该保持空白或消失。

4. 动态签名校验的定位与绕过

4.1 签名校验原理与常见位置

Android应用的签名信息存储在PackageInfo.signatures数组中。动态签名校验的流程通常是:

  1. 获取当前运行应用的签名(context.getPackageManager().getPackageInfo(...).signatures)。
  2. 对签名进行哈希计算(通常是MD5或SHA1)。
  3. 将计算出的哈希值与一个“合法”值进行比对。这个“合法”值可能:
    • 硬编码在代码或资源文件中:相对容易找到和修改。
    • 从网络服务器动态获取:需要拦截网络请求。
    • 隐藏在So库中,通过JNI调用返回:难度较大。

校验代码可能出现在:

  • Application.onCreate():应用一启动就检查。
  • Activity.onCreate():用户看到界面之前检查。
  • 某个关键业务逻辑的入口处:例如支付页面、核心功能调用前。
  • 定时任务或广播接收器中:定期或不定期检查。

4.2 使用Frida主动调用与内存修改进行绕过

我们的绕过思路是:让签名校验方法始终返回“真”(通过)

首先,需要在Jadx中定位到具体的校验方法。搜索“signature”,找到类似checkSignature(Context context)isValidApp()的方法。假设我们找到了一个类SecurityUtil,其中有一个方法public static boolean verifySignature(Context ctx)

绕过方案一:Hook并修改返回值这是最直接的方法。如果校验逻辑集中在某一个方法里。

Java.perform(function () { console.log("[*] 寻找签名校验方法..."); var SecurityUtil = Java.use("com.example.app.util.SecurityUtil"); if (SecurityUtil) { SecurityUtil.verifySignature.implementation = function (ctx) { console.log("[+] 拦截 verifySignature(),强制返回 true"); // 可以选择性地打印原始返回值,了解其正常逻辑 // var originalResult = this.verifySignature(ctx); // console.log("原始校验结果: " + originalResult); return true; // 强制通过校验 }; console.log("[+] SecurityUtil.verifySignature Hook 成功"); } else { // 如果类名被混淆,尝试枚举所有类,查找方法特征 Java.enumerateLoadedClasses({ onMatch: function (className) { if (className.includes("util") || className.toLowerCase().includes("sign")) { console.log("[?] 发现潜在类: " + className); try { var clazz = Java.use(className); var methods = clazz.class.getDeclaredMethods(); for (var i = 0; i < methods.length; i++) { if (methods[i].getName().toLowerCase().includes("verify") || methods[i].getName().toLowerCase().includes("check")) { console.log("[!] 尝试Hook方法: " + className + "." + methods[i].getName()); // 这里需要根据方法签名动态Hook,较为复杂,通常静态分析更可靠 } } } catch (e) { /* 忽略无法使用的类 */ } } }, onComplete: function () { console.log("[*] 类枚举完成"); } }); } });

绕过方案二:篡改获取到的签名信息如果校验逻辑是分散的,或者我们需要更底层的绕过,可以直接HookPackageManager.getPackageInfo方法,返回一个我们构造的、带有“合法”签名的PackageInfo对象。

Java.perform(function () { var PackageManager = Java.use("android.app.ApplicationPackageManager"); var PackageInfo = Java.use("android.content.pm.PackageInfo"); var Signature = Java.use("android.content.pm.Signature"); PackageManager.getPackageInfo.overload('java.lang.String', 'int').implementation = function (packageName, flags) { var originalResult = this.getPackageInfo(packageName, flags); console.log("[*] 拦截 getPackageInfo for: " + packageName); if (packageName.equals("com.example.targetapp")) { console.log("[+] 目标应用包名匹配,尝试篡改签名信息"); // 创建一个伪造的签名对象(这里需要填入正确的合法签名字节) // 通常,你需要从原版APK中提取,或从网络响应/So库中获取合法的签名哈希,然后反向构造。 // 这是一个复杂步骤,此处仅演示思路。 // var fakeSignature = Signature.$new(fakeSignatureData); // originalResult.signatures = [fakeSignature]; } return originalResult; }; });

重要提示:方案二非常复杂,因为需要构造合法的签名对象。在实际操作中,更常见的做法是结合方案一(修改校验结果)和静态Patch(直接修改校验方法的Smali代码,使其永远返回0x1)。对于So层的校验,则需要使用Frida的Interceptor去Hook Native函数。

4.3 应对So层(Native)签名校验

当签名校验逻辑在libsecuritycheck.so中时,我们需要使用Frida的Native Hook能力。

  1. 定位Native函数:使用readelf -s libsecuritycheck.soobjdump -T libsecuritycheck.so查看导出函数。更常见的是,Java层通过native boolean nativeCheckSignature(String param)这样的声明来调用,那么函数名可能是Java_com_example_app_SecurityUtil_nativeCheckSignature

  2. 使用Frida Interceptor Hook Native函数

Java.perform(function () { // 首先确保So库已加载 var libName = "libsecuritycheck.so"; var module = Process.getModuleByName(libName); if (module) { console.log("[+] 找到模块: " + libName + " 基址: " + module.base); // 假设我们通过逆向So,知道了校验函数的偏移地址或符号 // 方法A:通过导出函数名Hook(如果有) var checkFuncAddr = Module.findExportByName(libName, "native_check_signature"); // 方法B:通过偏移地址Hook(更常见) // var checkFuncAddr = module.base.add(0x1234); if (checkFuncAddr) { Interceptor.attach(checkFuncAddr, { onEnter: function (args) { console.log("[+] Native签名校验函数被调用,参数: ", args[0], args[1]); // 可以在这里打印或修改传入的参数 }, onLeave: function (retval) { console.log("[+] Native函数原始返回值: ", retval); // 将返回值修改为1(表示成功) retval.replace(ptr(0x1)); console.log("[+] 已将返回值修改为 1"); } }); console.log("[+] Native层签名校验Hook成功"); } else { console.log("[-] 未找到指定的Native函数地址"); } } else { console.log("[-] 未加载模块: " + libName); } });

踩坑记录:So层函数Hook对函数签名的把握要求极高(参数类型、调用约定)。一个错误的参数读取可能导致进程崩溃。务必使用IDA ProGhidra等工具对So库进行初步的静态分析,确定函数原型后再进行Hook。

5. 整合绕过与稳定性测试

5.1 编写综合Hook脚本

将广告绕过和签名校验绕过的逻辑整合到一个脚本中,并合理安排Hook顺序。通常,签名校验的Hook需要最早执行,确保应用在后续任何逻辑(包括广告初始化)执行前,就已经处于“校验通过”的状态。

// comprehensive_hook.js Java.perform(function () { console.log("======================================="); console.log("[*] 注入综合绕过脚本"); console.log("======================================="); // 第一阶段:绕过签名校验 (优先级最高) // ... (插入上述签名校验Hook代码) ... // 短暂延迟,确保校验逻辑已生效(非必需,但更稳妥) setTimeout(function() { Java.perform(function () { // 第二阶段:绕过广告SDK // ... (插入上述广告Hook代码) ... console.log("[*] 所有Hook点设置完毕。"); }); }, 500); // 延迟500毫秒 });

5.2 测试与问题排查

  1. 启动测试:使用frida -U -f com.example.targetapp -l comprehensive_hook.js --no-pause启动应用。观察控制台输出,确认所有预期的Hook点都成功拦截。
  2. 功能遍历:手动操作应用,进入每一个包含广告的页面,触发每一个可能调用签名校验的功能(如登录、支付、解锁高级功能)。观察应用是否出现崩溃、广告是否依然出现、功能是否受限。
  3. 日志分析:密切关注Frida控制台和logcat输出。任何崩溃都会产生堆栈跟踪,这是定位问题的最重要线索。常见的崩溃原因包括:
    • Hook了错误的方法签名(参数数量或类型不匹配)。
    • 在Hook方法中进行了不安全的操作(如在非UI线程操作UI)。
    • Native Hook时访问了无效的内存地址。
  4. 稳定性验证:让应用在后台运行一段时间,或反复切换前后台,检查是否有定时触发的校验逻辑导致后续崩溃。

5.3 常见问题与解决方案速查表

问题现象可能原因排查与解决方案
注入后应用秒退Frida-server版本与客户端不匹配;目标应用有反调试/反Frida检测。1. 确保Frida-server与frida-tools版本兼容。
2. 检查logcat崩溃日志。
3. 尝试使用frida -U --no-pause -f com.example.app先不注入脚本,看应用能否正常启动。若不能,说明有基础的反调试。需先绕过反调试(如Hookptracefopen等函数)。
广告依然显示Hook点不正确或时机不对;广告由WebView或第三方组件加载。1. 确认Hook的类和方法名完全正确(注意混淆)。
2. 尝试更早注入脚本(HookApplication初始化)。
3. 检查是否还有其他广告SDK(如穿山甲、腾讯广点通)。
4. 尝试HookWebView.loadUrl拦截广告请求URL。
签名校验绕过后,其他功能异常校验方法有多处,只绕过了一处;返回值修改影响了其他依赖逻辑。1. 全局搜索所有调用verifySignature的地方,确保全部Hook。
2. 不要简单返回true,可以尝试先调用原方法获取结果,仅当结果为false时改为true,避免影响正常流程。
Native Hook导致崩溃函数地址错误;参数读写越界;调用约定错误。1. 使用Module.enumerateExports()再次确认函数地址。
2. 在onEnter中仅打印参数地址,不进行深度读取。
3. 详细分析So文件,确定函数确切的参数类型和个数。
Frida脚本执行一段时间后失效应用可能动态加载了新的Dex或So,覆盖了原有代码。1. 监听ClassLoader,在新类加载时重新应用Hook。
2. 对于So,可以Hookdlopen函数,在目标库加载时立即执行Native Hook代码。

6. 进阶:对抗加固与混淆

在实际的高强度对抗中,你可能会遇到以下情况:

  • 代码整体加固:Dex被加密,运行时解密。Jadx打开后代码量极少。这时需要动态脱壳,在内存中dump出解密后的Dex。可以使用Frida脚本在ClassLoader加载类时,或者dexFile相关函数被调用时进行dump。
  • 字符串加密:所有关键的类名、方法名、URL、密钥都是加密的,运行时解密。你需要找到通用的解密函数,然后用Frida Hook它,批量解密并打印出原文,从而还原出可读的代码逻辑。
  • 反Hook与反调试:应用会检测Frida、Xposed等框架的存在,检测调试端口,或使用ptrace自身防止附加。绕过这些需要更底层的知识,例如:
    • 修改Frida的默认端口和特征。
    • Hookfopenread等函数,阻止应用读取/proc/self/status等文件来检测TracerPid
    • 使用inline-hook技术绕过基于ptrace的检测。

这些属于更高级的逆向工程范畴,每一步都需要对Android系统底层有深入的理解。本次实战聚焦于相对常见的广告SDK和动态签名校验,掌握了这些基础且核心的Hook技巧,就为应对更复杂的挑战打下了坚实的基础。记住,逆向是一个不断学习和迭代的过程,每一个应用都是一次新的谜题,而工具和思路是你的钥匙。