Android逆向实战:Frida指定ClassLoader Hook动态加载类

发布时间:2026/8/26 5:04:26
Android逆向实战:Frida指定ClassLoader Hook动态加载类 1. 项目背景与核心挑战在Android逆向与安全测试的日常工作中我们常常会遇到一个非常棘手的问题目标应用使用了动态加载技术。你可能已经熟练掌握了使用Frida去Hook一个普通APK中的类和方法但当你的脚本返回一个令人沮丧的“Error: Class not found”或者“Error: unable to find class”时十有八九是撞上了动态加载这堵墙。这不仅仅是加固应用的专利很多正常的应用为了模块化、热更新或者减小主包体积也会将部分功能代码打包到额外的Dex文件或APK中在运行时通过自定义的ClassLoader加载。我最近在分析一个电商应用时就遇到了这个典型场景。主APK只包含核心框架商品详情、支付、客服等模块都以独立插件APK的形式存在在用户点击对应功能时才动态下载并加载。直接用Frida去Hook插件里的类脚本直接报错根本找不到类路径。这就是标题中“指定classloader”和“hook动态加载的类”要解决的核心痛点。它不是一个炫技的操作而是解决实际分析障碍的必备技能。无论是分析多APK架构的App还是应对某些基础的代码混淆保护理解并掌控ClassLoader的机制都至关重要。简单来说ClassLoader是Java/Android中加载类的“搬运工”。系统默认的PathClassLoader只知道主APKbase.apk里的类。那些后来被动态加载进来的类存在于另一个“仓库”比如另一个APK文件或Dex字节数组里是由另一个专门的ClassLoader比如DexClassLoader负责管理的。Frida默认情况下只会在当前JavaScript上下文关联的ClassLoader通常是默认的里查找类。如果你不告诉它“去那个新开的仓库里找”它自然一无所获。因此整个技术路径就清晰了首先定位到加载了目标类的那个特定的ClassLoader实例然后在Frida脚本中显式地使用这个ClassLoader去查找和操作目标类。2. 深入理解Android中的ClassLoader机制要在Frida中游刃有余地指定ClassLoader必须对其在Android中的运作方式有扎实的理解。这不仅仅是调用一个API那么简单知其然更要知其所以然。2.1 ClassLoader的双亲委托模型Android的ClassLoader继承自Java的双亲委托模型。当一个类加载请求发生时子ClassLoader不会立即尝试自己加载而是先委托给父ClassLoader。只有当父ClassLoader反馈无法加载在其搜索路径中找不到该类时子ClassLoader才会尝试自己去加载。这样做的好处是保证了核心类库如java.lang.Object的唯一性和安全性避免用户自定义类覆盖核心类。在Android中我们通常接触的ClassLoader链是这样的BootClassLoader-PathClassLoader-DexClassLoader(或其他自定义ClassLoader)。BootClassLoader 由C实现用于加载Android框架层和Java核心库android.*,java.*等是所有ClassLoader的终极父类。PathClassLoader 系统默认用于加载已安装APK的类它只能加载已经安装到Android系统中的APK文件即/data/app/目录下的base.apk。DexClassLoader 这是实现动态加载的关键。它可以加载未安装的APK文件、Jar包或直接Dex文件。其构造函数需要指定Dex文件的路径、优化后odex的输出目录、原生库路径以及父ClassLoader。动态加载的插件APK其内部的类几乎都是由DexClassLoader或其子类加载的。2.2 动态加载的常见形式与ClassLoader创建动态加载并非只有一种形态理解不同的形式有助于我们更精准地定位目标ClassLoader。插件化/多APK架构 这是最经典的场景。主App宿主通过PackageManager安装插件APK但更常见的做法是宿主App将插件APK下载到私有目录如/data/data/宿主包名/files/plugin.apk然后使用DexClassLoader去加载它。// 伪代码示例 File dexOutputDir context.getDir(“dex”, 0); DexClassLoader cl new DexClassLoader( pluginApkPath, // 插件APK路径 dexOutputDir.getAbsolutePath(), // 优化后Dex输出目录 null, // 原生库路径可为null context.getClassLoader() // 父ClassLoader通常是宿主的PathClassLoader );此时插件中的所有类都由这个新创建的DexClassLoader实例cl来加载。这个cl实例就是我们需要在Frida中指定的目标。热修复与热更新 为了修复线上Bug应用可能会从网络下载一个补丁Dex文件然后用DexClassLoader加载并利用反射等技术替换原有类的逻辑。这个补丁Dex的加载者也是一个独立的ClassLoader。壳应用与动态下发代码 一些加固方案会将核心业务代码加密后放在assets或网络运行时解密成Dex字节数组再通过InMemoryDexClassLoaderAndroid 8.0引入或自定义ClassLoader直接加载在内存中不落盘。这种情况下ClassLoader的实例化更加隐蔽。关键点 每一次new DexClassLoader(...)或类似操作都会产生一个新的ClassLoader实例。不同的插件、不同的补丁可能由不同的ClassLoader实例加载。我们的目标就是找到承载了我们要Hook的那个类的具体实例。2.3 如何定位目标ClassLoader实例在Frida脚本中我们不能凭空变出一个ClassLoader。我们必须从内存中已经存在的对象里找到它。有以下几种思路静态字段追溯 这是最可靠的方法。宿主App在加载插件后通常会将创建好的DexClassLoader实例保存在某个类的静态字段如PluginManager.mPluginClassLoader或某个单例对象的成员变量中方便后续使用。我们需要逆向分析宿主App的代码找到这个存储点。枚举已加载的ClassLoader Java提供了java.lang.ClassLoader的静态方法getSystemClassLoader()可以获取系统ClassLoader但我们需要的是其子节点。可以通过遍历当前线程的上下文ClassLoader或者遍历所有类收集它们的ClassLoader来实现。这种方法比较“暴力”但在找不到明确引用时可以作为备选。Hook ClassLoader构造函数 这是一个非常主动且有效的方法。我们可以在插件被加载之前就Hook住DexClassLoader的构造函数。当宿主App创建插件ClassLoader时我们的Frida脚本就能第一时间捕获到这个实例对象并把它保存下来备用。// 伪代码思路 var dexClassLoader Java.use(“dalvik.system.DexClassLoader”); dexClassLoader.$init.overload(‘java.lang.String’, ‘java.lang.String’, ‘java.lang.String’, ‘java.lang.ClassLoader’).implementation function(dexPath, optimizedDirectory, librarySearchPath, parent) { console.log(“[] DexClassLoader created!”); console.log(“ dexPath: “ dexPath); // 这里就能看到插件APK的路径 console.log(“ instance: “ this); // this就是新创建的ClassLoader实例 // 将这个this保存到全局变量中 myTargetClassLoader this; // 继续执行原构造函数 return this.$init(dexPath, optimizedDirectory, librarySearchPath, parent); };注意 在Android 8.0API 26及以上版本DexClassLoader的文档标记为已废弃推荐使用BaseDexClassLoader或InMemoryDexClassLoader。但在实践中很多应用为了兼容性依然在使用DexClassLoaderHook其构造函数仍然是通用方法。如果遇到新版本API需要调整为目标类。3. Frida指定ClassLoader的三种实战方法理论清晰之后我们进入实战环节。假设我们已经通过分析知道了目标类com.example.plugin.PaymentService存在于一个插件中并且我们也有了或即将获取加载它的ClassLoader实例targetClassLoader。下面介绍三种在Frida中指定ClassLoader进行Hook的方法。3.1 方法一Java.use与Java.classFactory.use这是最直接、最常用的方法。Frida的Java.use函数在查找类时默认使用当前上下文ClassLoader。我们可以通过Java.classFactory.use来临时切换类工厂所使用的ClassLoader。// 假设我们已经获得了目标ClassLoader实例存储在变量pluginClassLoader中 // 1. 保存当前的类工厂 var originalClassFactory Java.classFactory; // 2. 切换类工厂使用的ClassLoader Java.classFactory.loader pluginClassLoader; // 3. 现在使用Java.use它会在指定的pluginClassLoader中查找类 var PaymentService Java.use(‘com.example.plugin.PaymentService’); // 4. 进行正常的Hook操作 PaymentService.processPayment.implementation function(amount, orderId) { console.log(“[] PaymentService.processPayment hooked!”); console.log(“ Amount: “ amount “, OrderId: “ orderId); // 调用原方法 return this.processPayment(amount, orderId); }; // 5. 可选操作完成后可以切换回原来的ClassLoader // Java.classFactory originalClassFactory;重要细节与避坑指南作用域Java.classFactory.use的影响是全局的直到你再次更改它。这意味着在切换之后后续所有的Java.use调用都会使用新的ClassLoader。如果你需要交替Hook多个不同ClassLoader中的类需要在每次Java.use前精确设置对应的loader。Java.choose的陷阱Java.choose用于枚举已存在的对象实例它不受Java.classFactory.loader的影响。因为对象实例已经存在它的类信息在对象创建时就已经确定了。Java.choose内部会尝试用各种方式去查找类通常能自动找到正确的ClassLoader。如果Java.choose失败问题可能不在ClassLoader而是其他原因如类名错误、对象尚未创建。线程安全 在多线程环境下直接修改全局的Java.classFactory可能存在风险。更稳妥的做法是在需要用到插件类的函数作用域内局部地设置和恢复。3.2 方法二Java.enumerateClassLoaders与手动查找当我们无法直接获得ClassLoader引用或者想验证内存中到底有哪些ClassLoader时可以使用枚举法。// 枚举所有ClassLoader实例 Java.enumerateClassLoaders({ onMatch: function(loader) { // 打印ClassLoader信息帮助识别 console.log(“Found ClassLoader: “ loader); try { // 尝试用这个loader去加载我们的目标类 Java.classFactory.loader loader; var targetClass Java.use(‘com.example.plugin.PaymentService’); // 如果没抛异常说明这个loader能加载目标类 console.log(“[Success] Target class found with this loader!”); // 可以将这个loader保存下来 gTargetLoader loader; } catch (e) { // 加载失败是正常的忽略即可 // console.log(“[Fail] “ e.message); } }, onComplete: function() { console.log(“ClassLoader enumeration complete.”); if (gTargetLoader) { console.log(“Proceeding to hook with loader: “ gTargetLoader); // 使用gTargetLoader进行Hook Java.classFactory.loader gTargetLoader; var PaymentService Java.use(‘com.example.plugin.PaymentService’); // ... Hook逻辑 } } });这种方法在逆向初期、探索阶段非常有用可以帮你快速摸清目标应用的ClassLoader结构。3.3 方法三封装工具函数与稳健性设计在实际项目中我们可能需要Hook多个插件中的多个类。编写一个健壮的辅助函数能让代码更清晰、更安全。function hookWithClassLoader(className, classLoader, hookImpl) { if (!classLoader) { console.error(“[!] ClassLoader is null or undefined!”); return null; } // 保存当前状态 var previousClassFactory Java.classFactory; var targetClass null; try { // 切换ClassLoader Java.classFactory.loader classLoader; // 获取类引用 targetClass Java.use(className); // 执行用户传入的Hook函数 if (typeof hookImpl ‘function’) { hookImpl(targetClass); } console.log(“[] Successfully hooked ‘“ className “‘ with specified ClassLoader.”); } catch (e) { console.error(“[!] Failed to hook ‘“ className “‘: “ e.message); // 在这里可以添加更详细的错误分析例如是否是类找不到还是方法找不到 if (e.message.includes(“Class not found”)) { console.error(“ - This usually means the ClassLoader is incorrect, or the class is not yet loaded.”); } } finally { // 无论成功与否都尝试恢复原来的ClassLoader // 注意直接赋值可能在某些Frida版本有问题更安全的方式是使用use // Java.classFactory previousClassFactory; // 可能不总是有效 // 更推荐的做法是如果后续操作依赖原Loader再显式切换回去。 // 对于一次性Hook不恢复也可以。 } return targetClass; // 返回类引用方便后续操作 } // 使用示例 var myLoader …; // 从某个地方获取的目标ClassLoader hookWithClassLoader(‘com.example.plugin.PaymentService’, myLoader, function(PaymentServiceClazz) { PaymentServiceClazz.processPayment.implementation function(…) { // Hook逻辑 }; });这个函数增加了错误处理并且将ClassLoader的切换限制在函数作用域内减少了全局状态污染的风险。finally块保证了即使Hook出错也能有一定的清理动作虽然这里恢复ClassLoader不是必须的。4. 完整实战案例Hook多APK电商应用插件让我们串联所有知识点完成一个模拟的完整案例。目标是一个电商App其支付功能在独立的插件APKpayment-plugin.apk中该插件在应用启动时被加载。4.1 第一步逆向分析与信息收集首先我们需要对宿主APK进行简单的静态分析使用Jadx-GUI等工具。搜索关键词 在宿主APK的Java代码中搜索DexClassLoader、loadPlugin、PluginManager、install等关键词。定位加载点 假设我们找到了一个类com.example.host.PluginManager其中有一个方法loadPaymentPlugin并且发现它将一个DexClassLoader实例保存在了静态变量PluginManager.sPaymentClassLoader中。确定目标类 通过分析插件APK如果已获得或者通过动态日志猜测我们确定要Hook的类是com.example.payment.PaymentProcessor其关键方法是public boolean handleTransaction(String orderId, double amount)。4.2 第二步编写Frida脚本获取ClassLoader我们的策略是先HookPluginManager的loadPaymentPlugin方法或者直接读取静态字段sPaymentClassLoader来获取目标ClassLoader。// frida_script_hook_multiapk.js Java.perform(function() { console.log(“[] Script loaded. Targeting multi-APK host…”); // 方案AHook加载方法捕获新创建的ClassLoader var PluginManager Java.use(‘com.example.host.PluginManager’); PluginManager.loadPaymentPlugin.implementation function(pluginPath) { console.log(“[] loadPaymentPlugin called! Path: “ pluginPath); // 调用原方法让插件被加载 var result this.loadPaymentPlugin(pluginPath); // 假设原方法执行后sPaymentClassLoader被赋值 // 我们可以直接读取这个静态字段 var targetLoader PluginManager.sPaymentClassLoader.value; if (targetLoader) { console.log(“[] Captured Payment Plugin ClassLoader: “ targetLoader); // 保存到全局变量供后续使用 globalThis.paymentClassLoader targetLoader; // 立即使用这个Loader进行Hook hookPaymentProcessor(targetLoader); } else { console.log(“[!] Failed to get ClassLoader from static field.”); } return result; }; // 方案B如果插件已经加载我们可以直接尝试读取静态字段 // 延迟执行确保Java环境完全准备好 setTimeout(function() { try { var targetLoader PluginManager.sPaymentClassLoader.value; if (targetLoader) { console.log(“[] Directly got Payment Plugin ClassLoader: “ targetLoader); globalThis.paymentClassLoader targetLoader; hookPaymentProcessor(targetLoader); } else { console.log(“[!] Plugin not loaded yet, waiting for loadPaymentPlugin to be called…”); } } catch (e) { console.log(“[!] Error accessing static field: “ e); } }, 1000); // 延迟1秒 // 定义Hook函数 function hookPaymentProcessor(classLoader) { // 使用我们封装的工具函数 hookWithClassLoader(‘com.example.payment.PaymentProcessor’, classLoader, function(PaymentProcessor) { // Hook目标方法 PaymentProcessor.handleTransaction.implementation function(orderId, amount) { console.log(“\n[ Payment Transaction Hooked ]“); console.log(“ Order ID: “ orderId); console.log(“ Amount: $“ amount); console.log(“ Caller Stack:”); // 打印调用栈有助于理解业务逻辑 console.log(Java.use(“android.util.Log”).getStackTraceString(Java.use(“java.lang.Exception”).$new())); // 可以修改参数或返回值 // var newAmount amount * 0.1; // 例如测试1折支付 // console.log(“ Modified Amount to: $“ newAmount); // var result this.handleTransaction(orderId, newAmount); var result this.handleTransaction(orderId, amount); // 调用原方法 console.log(“ Original Result: “ result); // 修改返回值 // return true; return result; }; console.log(“[] PaymentProcessor.handleTransaction hook installed successfully.”); }); } // 工具函数同上此处省略 function hookWithClassLoader(className, classLoader, hookImpl) { /* … */ } });4.3 第三步执行与验证启动应用frida -U -f com.example.host -l frida_script_hook_multiapk.js --no-pause触发插件加载 如果插件不是启动时加载需要手动在App内点击进入支付页面触发loadPaymentPlugin方法。观察日志 脚本会打印出捕获到的ClassLoader信息以及成功安装Hook的日志。触发支付 在App内进行一笔测试支付。查看结果 如果一切顺利Frida控制台会打印出我们Hook到的交易详情和调用栈证明我们成功Hook了动态加载插件中的类。4.4 可能遇到的问题与解决方案Class not found (持续): 即使指定了ClassLoader也报错。检查类名 确认类名包括包名完全正确大小写敏感。确认类已加载 ClassLoader虽然存在但目标类可能还没有被加载Java是懒加载的。尝试在Hook前先调用一个该类的简单静态方法或访问静态字段来触发类加载。例如在hookPaymentProcessor函数内可以先PaymentProcessor.$new()创建一个临时实例如果构造函数允许或者调用Java.choose寻找已存在的实例。ClassLoader不对 你获取的ClassLoader可能不是最终加载目标类的那个。可能存在多层包装或委托关系。尝试用Java.enumerateClassLoaders枚举所有并逐一测试。Java.classFactory.loader设置无效 某些Frida版本或特定环境下直接赋值可能不生效。可以尝试使用Java.vm.getClassLoader()获取当前线程的ClassLoader并进行比较或者使用Java.use的另一种形式Java.use(className, classLoader)注意API版本兼容性较新Frida版本支持。多线程环境下的竞争条件 插件可能在子线程中加载。确保你的Hook代码尤其是读取静态字段的部分在插件加载完成后执行。使用setTimeout延迟检查或Hook一个加载完成后的回调函数是常用方法。加固应用的干扰 如果宿主或插件APK被加固类名可能被混淆DexClassLoader的调用链可能被隐藏。此时需要结合动态调试在内存中搜索特征字符串或分析dalvik.system.DexFile的加载流程来定位关键点。5. 高级技巧与扩展思路掌握了基础方法后我们可以探索一些更深入的应用场景和技巧。5.1 HookloadClass方法实现全自动捕获与其被动地寻找存储ClassLoader的字段不如主动出击HookClassLoader.loadClass方法本身。这样任何类加载请求都逃不过我们的眼睛。Java.perform(function() { // 获取基础的ClassLoader类 var BaseClassLoader Java.use(‘java.lang.ClassLoader’); // Hook loadClass方法 BaseClassLoader.loadClass.overload(‘java.lang.String’).implementation function(name) { // 过滤出我们关心的类 if (name.indexOf(‘com.example.payment’) ! -1) { console.log(“[loadClass] “ name “ is being loaded by ClassLoader: “ this); // 将这个ClassLoader保存下来 if (!globalThis.autoCapturedLoader) { globalThis.autoCapturedLoader this; console.log(“[] Auto-captured target ClassLoader.”); } } // 调用原方法 return this.loadClass(name); }; // 注意loadClass还有另一个重载方法 loadClass(String name, boolean resolve)也需要Hook BaseClassLoader.loadClass.overload(‘java.lang.String’, ‘boolean’).implementation function(name, resolve) { if (name.indexOf(‘com.example.payment’) ! -1) { console.log(“[loadClass] “ name “ (resolve“ resolve “) by: “ this); if (!globalThis.autoCapturedLoader) { globalThis.autoCapturedLoader this; } } return this.loadClass(name, resolve); }; });这种方法的好处是完全自动化无需提前知道ClassLoader存储在哪里。缺点是会产生大量日志需要做好过滤并且Hook系统核心方法可能对性能有轻微影响。5.2 处理多个独立插件与ClassLoader隔离有些应用可能有多个完全独立的插件每个插件使用自己独立的ClassLoader且它们之间类不共享。这时你需要为每个插件维护一个ClassLoader引用映射。var pluginLoaders {}; // Hook 不同的插件加载入口 function hookPluginLoader(pluginName, loaderFieldName) { // … 通过静态字段或Hook方法获取对应loader // pluginLoaders[pluginName] loader; } // Hook时根据类名判断属于哪个插件 function smartHook(className) { var targetLoader null; if (className.startsWith(‘com.example.payment.’)) { targetLoader pluginLoaders[‘payment’]; } else if (className.startsWith(‘com.example.chat.’)) { targetLoader pluginLoaders[‘chat’]; } if (targetLoader) { hookWithClassLoader(className, targetLoader, …); } else { console.log(“[!] No ClassLoader mapped for class: “ className); } }5.3 与Frida Java.choose及对象操作结合指定ClassLoader主要解决的是“找类”的问题。一旦找到了类并成功进行了Hook对于对象实例的操作Java.choose,Java.cast等通常不需要再额外指定ClassLoader因为对象实例本身已经携带了其类的信息。但是如果你需要直接使用目标ClassLoader去$new()一个插件类的实例或者调用其静态方法那么之前设置的Java.classFactory.loader就至关重要了。Java.perform(function() { // 假设已设置正确的 paymentClassLoader Java.classFactory.loader paymentClassLoader; var PaymentUtils Java.use(‘com.example.payment.PaymentUtils’); // 调用插件类的静态方法 var signature PaymentUtils.generateSign(“some_data”); console.log(“Generated signature: “ signature); // 创建插件类实例 (如果构造函数允许) try { var processorInstance PaymentProcessor.$new(); // 使用实例 // processorInstance.someMethod(…); } catch(e) { console.log(“Could not create instance: “ e); } });5.4 应对内存加载与自定义ClassLoader对于使用InMemoryDexClassLoader或完全自定义ClassLoader的高阶保护思路仍然不变找到那个ClassLoader实例。挑战在于其实例化可能更加隐蔽例如在JNI层完成。此时动态分析如Frida Hookdalvik.system.InMemoryDexClassLoader的构造函数或BaseDexClassLoader的初始化方法结合内存搜索搜索Dex文件魔数dex\n035或类名特征字符串可能是更有效的手段。核心依然是理解无论形式如何变化最终都必须通过一个ClassLoader对象来承载这些类我们的任务就是找到它。整个流程走下来你会发现Hook动态加载的类技术核心在于对Android类加载机制的深刻理解而Frida只是提供了便捷的操控接口。从逆向分析定位关键点到编写健壮的脚本捕获ClassLoader再到最终成功Hook目标方法每一步都需要耐心和细致的调试。当你第一次成功拦截到插件中的业务逻辑时那种穿透层层封装直达核心的成就感正是移动安全分析的乐趣所在。记住多实践多思考遇到问题多从系统原理层面寻找答案你就能驾驭越来越复杂的应用场景。