代码混淆App的Frida动态调试与Hook实战:字符串还原与类加载
发布时间:2026/10/19 6:37:26 锦皓数字建站

代码混淆是加固厂商最常用的障眼法也是很多同学在拿到一个复杂App后最先撞上的那堵墙。字符串全变成了乱码、方法名被改成a.b.c、关键流程被拆成了几百个小函数、甚至核心逻辑干脆动态加载或者下沉到Native层。这篇文章是实战系列的延续专门讲代码混淆场景下怎么调试和Hook。今天不聊理论只讲能直接落地的思路和脚本重点覆盖三种典型场景字符串加密还原、动态加载类注册、函数级控制流混淆下的Hook点转移。适合已经了解Frida基础用法、想深入分析混淆样本的同学阅读。1. 混淆场景的整体破解思路先跑起来再定位1.1 为什么静态分析在混淆面前容易失效拿到一个没混淆的App用反编译工具看Java代码基本能还原七八成逻辑。但混淆之后类名和方法名全变成了没有意义的短标识符字符串常量被加密成字节数组扎堆的字符串拼接函数把所有线索都淹没了。尤其是市面上主流的几种加固方案不仅做了名字混淆还会在运行期对DEX做抽取、VMP保护和指令动态化静态工具看到的东西和实际运行的东西完全是两码事。这种情况下纯静态分析投入产出比很低。一个几百KB的DEX展开之后人眼根本不可能从几万个无意义方法里去大海捞针。更合理的路线是先让程序跑起来在动态运行过程中根据行为、调用栈、内存特征来定位关键函数再通过Hook去篡改执行流。用一句通俗的话说静态分析是拿着地图找路动态Hook是开着导航跟着走。在混淆域里导航的可信度远高于地图。1.2 动态Hook在混淆样本中的优先策略优先策略其实只有一条能不还原的代码就尽量不还原通过观察运行现象和主动触发的输出来定位。比如一个登录接口你不需要知道内部到底调用了哪十个加密方法你只需要在最终发出网络请求的前一刻把参数和返回值抓下来甚至直接把加密结果替换成你自己构造的合法值。实际操作中我一般会按这个顺序来做先列出加载的所有类找出包名特征最强的那一批缩小分析范围。对所有可疑类批量Hook构造函数和关键方法先看调用频率和参数类型。主动触发功能比如点击按钮、输入账号密码用调用栈输出反向定位。在拿到关键方法之后再把Hook精细化通过修改参数或者替换返回值来验证逻辑。这样走下来通常在半小时内就能确定一两个核心方法相比之下盲翻代码可能要花上大半天。2. 字符串加密的小通杀抓解密入口与完整还原2.1 从加密池里找到密码本字符串加密是所有混淆方案里最常见的操作。开发者在编译期把字符串常量替换成了类似new String(decrypt(data))的调用运行期再通过解密算法还原。直接的结果是你在DEX里看到的内容全部是密文无法直接搜索关键词。此时核心思路变成找到那个统一的解密函数。因为不管有多少字符串它们最终都会汇聚到同一个或者少数几个解码函数上。怎么找最笨但最有效的办法是批量Hook字符串构造函数和字符串操作相关方法运行一会儿之后看看哪些调用栈频繁出现。一般来说真实解密入口会出现在调用栈的最底层。另一个办法是从特征入手有的加密方案会在解密时申请一个字节数组长度刚好等于密文长度然后逐字节异或这种代码在方法体中会有明显的循环和位移操作。举个最典型的例子如果目标App用了异或加密Java.perform(function() { var StringCls Java.use(java.lang.String); StringCls.$init.overload([B, java.lang.String).implementation function(bytes, enc) { var result this.$init(bytes, enc); if (bytes.length 4) { // 打印所有字符串构造参数用于分析哪部分是解密结果 console.log([StringInit] len bytes.length , str result.toString()); } return result; }; });这个钩子能抓到绝大多数运行期通过new String(byte[])创建的字符串。如果解密函数返回的是String类型往往就能顺着这一层直接看明文。如果App用的是StringBuilder就要额外看toString()方法。2.2 完整还原字符串池的Hook模板定位到解密函数后你可以直接调用它遍历整张字符串表格把密文全部传进去批量还原。假设找到的解密函数是某个类的静态方法decode(byte[])还原脚本可以写成这样Java.perform(function() { var TargetClass Java.use(com.example.core.StringDecrypt); var encryptedStrings [A1B2C3, D4E5F6]; // 这里替换为从DEX里提取的密文列表 encryptedStrings.forEach(function(enc) { var bytes Java.array(byte, stringToBytes(enc)); var decrypted TargetClass.decode(bytes); console.log(enc enc - decrypted.toString()); }); });这样你就能在日志里看到所有字符串明文了。这个方法在不同版本上踩过很多坑这里先说明几个容易翻车的点注意编码参数。有的decode方法依赖字符集名称作为外部输入比如new String(bytes, charsetName)此时你直接调用decode时如果没有传对charset结果会是乱码。注意解密函数是否有状态。部分加固方案会把解密密钥放在全局变量或者某个加密上下文里首次调用前必须先触发类的构造函数否则直接调用静态方法会抛空指针。注意是否有多层加密。有些字符串会经过两层甚至三层变换比如先解密再解压此时要顺着调用栈继续往上一次性抓全链路而不是在中间层就停手。有同学会问直接调用解密函数把全表还原会不会触发完整性校验如果App做了反调试确实存在这种风险。稳妥的做法是不修改任何内存只读取解密结果并且在执行还原操作前先把Debug.isDebuggerConnected()之类的反调试调用Hook掉。2.3 自定义内存中密码本的替换部分App不是运行期频繁调用解密函数而是启动时一次性解密所有字符串然后缓存到内存里。这种情况下更推荐直接将解密函数的返回值替换为你自定义的常量尤其是某个字符串被反复用于签名计算时。比如登录请求里的secret字符串参与了全局签名你可以在解密函数里直接返回MY_CUSTOM_SECRETJava.perform(function() { var TargetClass Java.use(com.example.core.StringDecrypt); TargetClass.decode.implementation function(data) { var result this.decode(data); var str result.toString(); if (str.indexOf(token) ! -1 || str.indexOf(secret) ! -1) { console.log([Decrypt] str); } return result; }; });抓到明文的同时还能观察哪些字段名是规律性的。很多时候你只需要改一个签名值整个加密链路就能按你的预期重新组合。这也是做重打包测试之前最常用的手段之一。3. 动态加载与隐藏类DexClassLoader场景的Hook注册时机3.1 类加载后Hook失效的三种原因在Android加固里核心代码经常不会明文写在App的原始DEX里而是运行时从某个加密目录解密出一个DEX再用DexClassLoader加载。这种情况下如果直接在Java.perform里Java.use目标类大概率会报ClassNotFoundException。原因很简单那一刻类根本还没出现。常见的有三种情况App启动后很久才解密DEX比如登录成功后才加载业务模块。类加载器不是默认的PathClassLoader普通Java.use找不到自定义加载器里的类。类被加载后又被及时卸载或者被放在原生层的隐藏目录里。3.2 用ClassEnumerator遍历所有已加载类应对动态加载场景第一板斧是把“已知类扫描”升级为“全类枚举”。Frida的Java.enumerateLoadedClasses能列出当前进程所有已加载的类其中就包括自定义ClassLoader加载进来的类。扫描到目标类名之后再对这个类做进一步Hook。Java.perform(function() { var targetPrefix com.heavy.core; Java.enumerateLoadedClasses({ onMatch: function(className) { if (className.indexOf(targetPrefix) 0) { console.log([Loaded] className); // 在这里对类进行Hook hookClass(className); } }, onComplete: function() { console.log([Done] class enumeration finished); } }); });这里有个经验如果目标类是在某个按钮点击后才被加载的最好在按钮的点击事件回调上做Hook等点击后再触发枚举。或者偷懒一点在onMatch里只打印日志等类出现后再另外起一个线程定时调用一次枚举直到找到目标类为止。3.3 动态定义子类实现劫持类加载完毕后还有一个更隐蔽的坑目标类可能是接口或者某个抽象类真正干活的是它的匿名子类子类名里带有$0、$1这样的后缀。你直接Hook父类方法往往拦截不到因为调用点实际是在子类上。要解决这个问题可以枚举所有已加载类筛选出父类是你的目标接口的类Java.perform(function() { var interfaceName com.heavy.core.IEncryptor; Java.enumerateLoadedClasses({ onMatch: function(className) { try { var clazz Java.use(className); var interfaces clazz.getClass().getInterfaces(); for (var i 0; i interfaces.length; i) { if (interfaces[i].getName() interfaceName) { console.log([Impl] className); hookAllMethods(clazz); } } } catch (e) {} }, onComplete: function() {} }); });这种动态定义子类的方式本质上是把Frida的脚本当成了另一种“动态加载器”先找到宿主加载进来的真实类再用自定义实现的逻辑把关键行为替换掉。提示如果你需要处理的是加固App类可能被抽到独立的DEX里并且类加载器是加固厂商自定义的。此时先不要急着盲搜类名可以枚举ClassLoader列表看看有哪些非默认的加载器再对每个加载器调loadClass探测目标。4. 函数级控制流混淆Hook点被拆碎后的转移策略4.1 方法过长拆分导致的Hook断点失效现在很多商用混淆不只是改名字还会做控制流平坦化。一个原本只有几十行的函数经过混淆后会变成几百行的状态机里面全是switch-case和循环跳转正常断点调试几乎不可能。再加上方法拆分同一个功能的代码被塞到几十个函数里Frida按方法名Hook虽然还能生效但你看到的调用栈会是几十层不知道哪里才是真正改返回值的地方。这种情况下我的做法是放弃在单一方法上做精确定位改为在更外层做“整体替换”。比如某个登录方法内部调用了十几个混淆过的辅助方法与其逐个去研究不如直接把这个方法体替换成我们自己写的逻辑Java.perform(function() { var LoginActivity Java.use(com.heavy.ui.LoginActivity); LoginActivity.doLogin.implementation function(username, password) { console.log([Login] username / password); // 直接绕过原本复杂的加密逻辑返回一个伪造的响应 this.doLogin(test_user, custom_token); }; });能绕就不进去这是处理重度控制流混淆的第一原则。真的需要还原内部逻辑时优先用调用栈输出加主动调用的小步验证而不是默认把所有函数都Hook一遍。4.2 跳转指令与JNI调用的主动调用有些场景绕不开比如核心算法在Native层Java层只是通过System.loadLibrary和native方法调过去。这时候Frida的Java层Hook作用有限需要切到Native层去拦截JNI调用。一个常见的场景是某个native函数接收字符串参数返回布尔值用来判断当前环境是否安全var nativeFunc Module.findExportByName(null, Java_com_heavi_app_NativeBridge_checkEnv); Interceptor.attach(nativeFunc, { onEnter: function(args) { var env Java.vm.tryGetEnv(); if (env ! null) { var arg1 env.getStringUtfChars(args[2], null).readCString(); console.log([Native] checkEnv arg1 arg1); } }, onLeave: function(retval) { console.log([Native] retval retval.toInt32()); retval.replace(0x1); // 强制修改返回值 } });如果你在Native层看到的导出函数名也非常混乱比如Java_com_heavi_app_NativeBridge_00024b这种带十六进制偏移的那就是JNI函数名做了编码混淆。此时建议换一种更底层的思路在RegisterNatives调用上下手拦截注册过程把真正的方法名和函数指针对应关系打印出来。Interceptor.attach(Module.findExportByName(null, RegisterNatives), { onEnter: function(args) { var env args[0]; var clazz args[1]; var methods args[2]; var count args[3].toInt32(); console.log([RegisterNatives] count count); // 遍历methods数组打印每个名称和签名 } });4.3 核心写操作定位从内存访问到目标流程被拆碎之后还有一种思路是从数据侧反推不管逻辑怎么拆最终结果总要写到某个全局变量或者字段里。我们可以Hook目标类的所有方法打印每个方法的读取字段和写入字段检查某个关键字段在哪几个方法里被赋值。这个技巧在有大量对象复用的代码里特别有效能用较小的代价锁定真正的“写操作”函数。实际操作方式先通过反编译工具的字段引用搜索确认目标类有哪些字段然后写一个脚本在类的构造函数里追踪所有字段的写入点。比如Java.perform(function() { var TargetClass Java.use(com.heavy.core.DataStore); TargetClass.encryptedData.implementation function(value) { console.log([FieldWrite] encryptedData value); this.encryptedData(value); }; });如果你的目标是某个类的成员变量这招可以直接找到所有赋值点。遇到的是静态字段也可以先取当前值替换掉再往下走。数据流指向哪里真相就在哪里。5. 让对抗手法更进一步内存漫游与动态克星5.1 模块加载监控与本地数据快照在处理多个加固方案的组合时我习惯先做一个“全局监控脚本”把模块加载、类加载、Native函数注册、网络请求这几个事件全部铺开。好处是能在App运行的早期阶段就看到关键模块的初始化顺序这比翻代码快得多。模块加载监控怎么写核心是遍历当前内存里的加载模块并且监控后续loadvar modules Process.enumerateModules(); modules.forEach(function(m) { if (m.name.indexOf(libsecur) ! -1 || m.name.indexOf(libprotect) ! -1) { console.log([Module] m.name base m.base size m.size); } });对于dlopen动态加载的模块可以通过拦截dlopen和android_dlopen_ext来实时打印var dlopen Module.findExportByName(null, dlopen); Interceptor.attach(dlopen, { onEnter: function(args) { var path args[0].readCString(); if (path ! null path.indexOf(secur) ! -1) { console.log([dlopen] path); } } });5.2 加解密结果的内存落地分析字符串和加密数据跑在内存里最直接的办法是把内存里命中的目标dump下来。比如已知某个类在解密后会持有明文数据那么可以先用Java.choose找到该类的实例再反射读取字段得到二进制数组最后把数据写入文件。Java.perform(function() { Java.choose(com.heavy.crypto.BufferHolder, { onMatch: function(instance) { var data instance.buffer.value; var bytes Java.array(byte, data); var file new File(/data/data/com.heavy.app/dump.bin, wb); file.write(bytes); file.close(); console.log([Dump] buffer saved, size bytes.length); }, onComplete: function() {} }); });dump出来的文件可以直接丢给本地分析工具。如果你拿到的是加密后的密文和对应明文那就能直接跑已知明文攻击或者还原出密钥后续即使没有Hook环境也能独立算出所有结果。5.3 校验绕过与全局白名单机制遇到App有签名校验或者完整性校验时通常不是单独一个方法而是一套流程。比如启动时校验签名登录时才校验DEX哈希。处理技巧是先找到校验入口把校验函数返回值改成合法值并且把缓存也同步替换掉。比如常见的checkSignature返回boolean如果已经是true就不用动如果是false脚本就强制写true。同时注意可能在校验后把结果写入某个SharedPreferences字段该字段也要主动替换否则后续读取缓存还是会校验失败。有一个跨场景的经验在白名单机制里你改一个点往往不够需要把所有读取该校验结果的读取点全部替换。简单做法是Hook掉SharedPreferences.getString、getBoolean对特定key返回合法值。var SharedPrefs Java.use(android.app.SharedPreferencesImpl); SharedPrefs.getString.implementation function(key, defVal) { if (key verified_state) { return valid; } return this.getString(key, defVal); };如果目标App对校验结果做了内存缓存比如全局变量isVerified别忘了一起替换。总体思路就是让App在运行时永远相信环境是合法的但不去篡改任何外部文件这样反调试的触发概率会低很多。6. 常见问题与排查技巧实录6.1 速查表混淆环境下的典型坑与解法这里把我在多个混淆样本上踩过的高频问题整理成表格方便直接对照报错或现象常见原因解决思路ClassNotFoundException类还没有被加载或者不在默认ClassLoader用enumerateLoadedClasses轮询等类出现后再HookMethod Not Found方法名被混淆或签名不对先反编译确认方法实际名称和重载签名再写对应overload调用解密函数直接崩溃解密函数依赖动态key或初始化未完成先Hook构造函数强制走一次初始化流程Hook了父类方法不触发实际调用的是匿名子类枚举接口/父类实现类在具体实现类上HookJNI函数名找不到导出名做了混淆或动态注册拦截RegisterNatives或枚举Symbol篡改返回值无效有二次校验或缓存Hook所有校验点包括缓存读取和SharedPreferencesdump文件全是乱码加密层在Java层之上还有Native层沿着调用栈往上找更底层的Native函数脚本注入后App闪退反调试或者关键组件的完整性校验先全局替换校验函数返回值再对反调试函数做无操作处理6.2 从日志到定位一通实例操作的完整复盘有一次处理一个重度混淆的金融类Demo目标是想拿到网络请求里用于签名的原始字符串。第一次运行脚本报了ClassNotFoundException因为核心DEX是在输入密码后才动态加载的。我把枚举逻辑放在点击登录按钮的onClick回调之后第二次拿到了类列表但是类名完全不可读全是单字母。于是我对所有新出现的类统一Hook构造函数打印了所有实例的toString()。抓到某个实例后发现它的字段里面有一个StringBuilder内容正好是一段base64。我怀疑这个就是签名原文于是直接对这个字段的读取方法做拦截输出后成功定位到加密前的明文。整个流程用了大约四十分钟比静态分析快了非常多。6.3 合法边界与逆向分析伦理最后提醒一句以上所有技巧只能应用在你具备合法授权的目标上比如你自己开发的App、公司内部授权的安全测试项目、或者公开的CTF题目。针对他人的应用做逆向分析可能违反用户协议和相关法律普通开发者应当把精力放在技术研究和自我提升上而不是用于灰产。当你真正把Frida用熟了之后在分析和调试自己项目时也能事半功倍。个人经验谈在实际操作中我最大的体会是混淆场景下Hook脚本的核心价值不是“看得懂”而是“让运行结果说话”。每次遇到一长串不可读的调用链我都优先想这个函数到底在保护什么、在满足什么条件时会执行、结果被谁消费掉。想清楚这三个问题再决定Hook点位效率会直线上升。另外给大家一个很实用的小技巧配置好Frida的环境变量和持续输出日志尽量用 Python 驱动的方式跑脚本而不是反复手动注入这样在反复修改脚本和长时间挂机分析时会舒服很多。混淆这堵墙并不低但顺着运行时的光照进去总能找到那扇门。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。