拓冰建站拓冰建站
首页 / 资讯中心 / 正文

免费开源替代商业加固:自研Android轻量级APK加固实践

做Android开发这几年APK加固是个老话题也是个绕不开的话题。市面上商业加固服务一直都很贵按年收费动辄几千上万小团队和个人开发者根本扛不住。就算咬牙用了还会经常遇到加固后包体变大、启动变慢、部分厂商系统上闪退的破事。真正让人头疼的是商业加固服务本身是个黑盒出了问题只能干瞪眼。我自从踩过三次商业加固的坑之后就开始认真研究“免费开源”的替代路线用AOSP生态里现成的工具链加上几个开源项目做了一个自研的轻量级加固方案。效果不能说和商业产品打平但应对日常的防反编译、防篡改诉求已经完全够用最关键的是整个过程自己完全可控。这篇内容我整理了整套实操思路从最基础的R8混淆、资源混淆到用NDK手写一个可以保护核心DEX的轻量壳再到反调试、反注入和常见崩溃排查。无论你是个人开发者、小团队还是想了解加固原理的学生这篇文章都能给你一条可落地的免费技术路线。1. 为什么你需要放弃商业加固转向免费开源方案1.1 商业加固的日常痛点先聊聊我在商业加固服务上花的冤枉钱。以市面比较常用的几个商业加固平台为例基础版往往只提供最原始的DEX整体加密稍微好用一点的协议防护、VMP、内存校验这些功能全部塞在高级版里。高级版的价格小团队一年下来够买好几台测试机了。更气人的是这些商业平台经常改签名校验规则改完就导致旧版本App升级失败用户要卸载重装才能用这谁受得了。除了价格兼容性也是个大坑。商业加固服务为了追求高强度保护默认会开启各种跟ROM较劲的开关。我在做一款工具类App的时候用某大型厂商的加固方案在Android 11的浪潮手机上一切正常但换到Android 8.0的老平板上一闪就退查了半个月最后定位到是加固壳对老版本ART虚拟机的解释器兼容出了问题。那会儿我就想如果代码是我自己控制的至少能精准定位到是哪一行逻辑不兼容。再说一个平时容易被忽略的问题包体膨胀。商业加固为了防脱壳会在原始DEX外面套好几层壳有的还会塞一堆so文件动辄给安装包增加20-30MB的体积。对于工具类小应用来说用户从应用商店下载时的流量成本和安装等待时间都是实打实的体验损失。1.2 开源方案到底能防住谁很多开发者一听“开源加固”第一反应就是“靠开源工具做出来的壳是不是很容易被脱”这里我得说句公道话加固的本质是提高逆向成本而不是做到绝对不可破解。商业加固也做不到不可破解你看市面上的脱壳工具基本跟着新壳迭代走没有哪个壳能永远不被脱。开源自研方案的价值在于你的壳代码是自己写的攻击者没法直接找到现成的脱壳脚本一键脱掉这就已经达到了绝大多数App的防护需求。我用开源方案搭建的加固体系主要能防住这几类人刚入门的逆向小白拿着APK反编译工具就想把代码翻个底朝天的人想提取图片、音视频资源、改个Logo或文案的“搬运工”以及试图篡改支付逻辑、绕过客户端校验的普通脚本小子。至于遇到真正的逆向高手任何方案都只能延缓破解速度这是行业常识。因此我给项目定下的原则是用开源方案实现“市面上商业加固60%的防护强度”覆盖大多数业务场景把剩余的预算和精力放在服务端安全上。客户端外壳做到一定程度就够了过度投入反而影响开发效率。2. 先从最简单的入手代码混淆与资源保护2.1 R8/ProGuard的正确打开方式很多人以为在build.gradle里开个minifyEnabled true就是做加固了其实这只是第一步而且是很粗浅的一步。R8作为Android官方推荐的代码压缩与混淆工具能把代码中的类名、方法名改成a、b、c这种短名字能有效提升阅读难度。但绝大多数人只是默认开着完全没发挥它的真正实力。我习惯的配置不是用默认值而是显式打开R8的优化开关android { buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } }注意这里用的是proguard-android-optimize.txt而不是proguard-android.txt。两者的区别就是前者会在混淆的同时做字节码级优化比如方法内联、常量传播、无效代码删除。实测下来同样是release构建包开启optimize之后DEX体积能再小8%左右执行效率也会高一点。光开这个还不够我强烈建议在proguard-rules.pro里加一个-printmapping配置这样每次构建后都会生成一个mapping.txt文件。这个文件记录了原始类名和混淆后类名的对应关系线上崩溃日志一定要用它在后台符号化否则你只能看到a.a.b()这种完全没意义的堆栈。别问我怎么知道的我第一次上线混淆包看到几百条com.a.a.a.a()的崩溃日志时整个人都懵了。2.2 资源混淆的正确姿势代码混淆能防一部分人但APT回编译之后资源文件的名字和路径还是原样暴露着。商业加固服务通常会把资源文件名改成随机的两个字母让反编译工具拿到一堆没有语义的资源名。这个功能在开源社区里也有现成方案AndResGuard一个小而美的资源混淆工具由腾讯开源社区维护。接入方式非常直接在根目录build.gradle里声明插件app模块里应用然后在gradle里配置白名单列表。为什么需要白名单因为有些资源名是不能改的比如AndroidManifest里声明的activity名称、process名称以及一些第三方SDK里用反射读取的资源ID。我把常见的vivo、oppo厂商推送SDK的资源都加进了白名单避免混淆后推送服务出问题。apply plugin: AndResGuard andResGuard { mappingFile file(./resource_mapping.txt) // 重命名时指定的字典 dictionaryFile file(./resguard_dictionary.txt) // 白名单 keepRoot [res/drawable/ic_launcher.xml] // 需要压缩的资源类型 compressFilePattern [*.png, *.jpg, *.jpeg, *.gif] }AndResGuard的核心理念是把res/drawable/xxx.png重命名为res/drawable/a.png这种极短路径同时把resource.arsc表里对应的字符串也改掉。这样别人用APKTool反编译后看到的资源文件夹里全是短名字基本没法一眼看出哪个是启动图、哪个是按钮背景。实测下来一个原本资源文件占6MB的App混淆加压缩之后能压到4.2MB左右整体安装包体积也能再缩小一些。2.3 混淆不是万能的关键是混淆策略关于混淆我最想劝大家的一点是不要把所有的类都一股脑混淆掉。经常有人图省事只配了keep规则保证App能跑其他全交给R8自由发挥。这样做的后果是一旦某个第三方SDK内部通过反射调用你的类或者你在JNI层用到了Java类的完整类名运行时直接抛NoSuchFieldException。我的习惯做法是在写代码阶段就规划好哪些是“对外不设防”的公共接口比如给服务端回调用的Callback类、存进数据库的Model类这些必须keep。而真正包含核心逻辑的模块比如登录校验、支付签名、业务算法一定不能在一些框架里被反射调用那么就可以放心混淆。此外任何写在AndroidManifest.xml里的组件类名和native方法名字都要记得加keep规则。有一回我在native层用JNI_OnLoad里动态注册方法时不小心把Java层的关键方法名混淆了导致so文件找不到签名函数整个App在启动阶段就崩了排查了整整一天。这里放一个我常用的通用keep规则片段仅供参考-keepattributes Signature -keepattributes *Annotation* -keep class com.yourpackage.data.** { *; } -keepclasseswithmembernames class * { native methods; } -keepclassmembers class * extends android.app.Activity { public void *(android.view.View); }资源混淆这块我还要提醒一句千万不能跟微信Tinker热修复一起用。资源混淆会把resource.arsc里的文件映射关系重写热修复框架对资源ID的强依赖会让补丁直接失效。如果你的项目用了热修复AndResGuard这步可以跳过或者只在不用热修复的分发渠道上做混淆。3. 核心玩法手写轻量级壳保护核心DEX3.1 加壳原理一句话讲透代码混淆和资源混淆都是“入门级”的防护真正能让反编译工具栽跟头的是“加壳”。加壳的基本原理特别简单把原本可以被系统直接加载的Classes.dex文件加密成一段乱码数据塞进APK里然后提供一个专门的Loader程序来还原这段乱码再加载到内存中运行。攻击者直接拿APK解包只会看到一堆加密文件和一把空壳看不到真正的业务代码。商业加固服务几十万的报价核心也就是这么回事剩下的功夫都花在防脱壳和防调试上。之所以说“轻量级壳”是因为我不会像商业加固那样把整个DEX都加密而是做一个“双DEX”结构业务代码仍然放在原始DEX里只把最核心的校验模块、密钥和签名算法单独抽成一个受保护的DEX文件加密后放在assets目录。这样主业务逻辑可以被系统直接加载启动性能不受影响同时核心安全模块的安全性又得到保障。3.2 用NDK实现DEX加密与动态解密怎么在Android上实现DEX的动态解密加载我用的是AOSP标准的PathClassLoader配合InMemoryDexClassLoader。打个比方系统默认的加载器就像餐厅服务员只有你把菜端到他面前才能帮你上桌而InMemoryDexClassLoader就像自助餐台你直接把加密文件解密成内存字节流塞给系统就能用。第一步是选一个足够强的对称加密算法。我用了AES/GCM/NoPadding用AES-256密钥加密应用的核心DEX文件。运行前在NDK的so层通过JNI拿到密钥解密得到字节数组然后调用DexClassLoader加载到内存。整个过程里加密的DEX文件和密钥分别放在两个地方一个在assets目录一个藏在so里的字符串表中甚至可以通过机器硬件标识动态生成。这样攻击者就算把APK脱壳也拿不到完整的业务代码。下面是核心代码片段public class DexProtector { static { System.loadLibrary(dexProtector); } public static native byte[] getKey(); public static void loadEncryptedDex(Context context, String assetName) { byte[] encrypted getAssetBytes(context, assetName); byte[] key getKey(); byte[] decrypted CryptoUtils.decrypt(encrypted, key); ByteBuffer buffer ByteBuffer.wrap(decrypted); ClassLoader engine new InMemoryDexClassLoader(buffer, context.getClassLoader()); // 通过反射或接口调用其中的核心方法 Class? clazz Class.forName(com.core.SecurityEngine, false, engine); } }对应的so代码要注意隐私的妥善保护。不建议把密钥和加密逻辑都放在一个so里那样攻击者只要定位到so就能同时拿到钥匙和锁。我的做法是把密钥拆成几段一段编译在so里一段放在native层通过设备属性动态生成还有一段藏在JNI的字符串拼接中。这样即使so被静态分析也不容易直接还原出完整密钥。加壳之后的加载方式不能再用默认的Application入口。我改造了attachBaseContext在系统构造Application之前提前解密并加载核心DEX然后由解出来的SecurityEngine接管后续初始化。这个顺序特别关键因为Android系统在Application创建早期就会加载主DEX里的类如果你不抢在它构造Application之前把加密的DEX解开调用时就会触发ClassNotFoundException。3.3 让Shell代码活下来签名校验与完整性保护很多开发者以为加完壳就完事了结果上架之后发现被人二次打包。二次打包就是攻击者把加固方案直接剥掉把你的APK反编译、篡改进去重新签个名再发出去。为了防止这种情况必须在壳里做签名校验和完整性保护。签名校验的思路是在Java层或者Native层读取当前安装APK的签名信息与编译打包时预埋的正确签名哈希做对比。我建议放在native层做调用系统的PackageManager获取签名证书字节数组然后传入JNI方法与so里的预埋值比较。public static boolean checkSignature(Context context) { Signature[] signatures context.getPackageManager() .getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES).signatures; byte[] cert signatures[0].toByteArray(); String hash SignatureUtils.sha256(cert); return nativeVerify(hash); }完整性校验则是对APK内的关键文件如lib/目录下的so文件、assets里的加密DEX做SHA256校验一旦发现异常直接退出程序或者进入假数据模式。这里我建议不要直接闪退那样很容易暴露校验逻辑。更好的策略是校验失败后仍然正常运行但返回给服务端的请求里带上一个隐藏标记让服务端识别这是被篡改的包。这种“隐性检测”比硬怼更让攻击者抓狂因为他根本不知道自己已经暴露了。4. 再进一步反调试、反注入与日志清理4.1 检测调试器不止是ptrace攻击者对加固App做动态分析第一步往往就是挂上调试器。反调试的原理就是在关键代码路径上探测当前进程是否被调试器附加。Android上最常见的调试器检测手段是检查/proc/self/status里的TracerPid字段如果不是0说明当前进程正被其他进程跟踪。int checkTracerPid() { FILE *fp fopen(/proc/self/status, r); char line[256]; int tracerPid 0; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, TracerPid:, 10) 0) { tracerPid atoi(line 10); break; } } fclose(fp); return tracerPid; }但TracerPid太容易被绕过比如攻击者先让自己的Ptrace进程退出再重新附加。所以我在项目中加了多套检测策略一是主动触发ptrace(PTRACE_TRACEME)如果返回失败说明已经被其他进程调试二是在关键函数里检测线程是否被注入。这些检测代码不要集中在一个地方分散到业务的各个调用路径上增加分析者的定位难度。4.2 对抗Xposed、Frida这类Hook框架现在搞逆向的人很少直接上调试器折腾最多的反而是Xposed和Frida这类Hook框架。Xposed通过替换app_process进程在应用启动前插入Hook逻辑Frida则是把JavaScript引擎注入到目标进程动态修改Java层和Native层方法。针对这两类框架最实用的检测方式不是去扫描包名列表而是直接检测它们运行时留下的痕迹。对于Xposed可以检查ClassLoader里是否存在de.robv.android.xposed.XposedBridge类。但这类检测很容易被Hook掉。比较好的做法是在Native层检查关键函数的入低地址是否在共享库映射之外或者利用maps文件查看是否有可疑库被加载。我会在Native层做一个“定时器轮询”机制每隔3秒检查一次进程内所有线程的入口点如果某个线程的指令地址落在非自身模块的范围内就认为有Frida的Stalker跟踪或Xposed的inline Hook存在理论上可以用来做熔断。注意这里不要一检测到就闪退因为很多攻击者会先用脱壳工具绕过阈值然后再慢慢分析。建议是检测到异常后记录计数器连续三次异常再触发自我保护动作动作本身也可以是伪造崩溃数据而不是杀进程让攻击者摸不清楚到底哪里触发了保护。4.3 字符串加密与日志清理别把钥匙放在门口最后这个大坑我必须专门说一说低频开发者加固忙活半天却把密钥明文写在Java代码里。我之前做项目时就见过同事把AES密钥、Url Scheme甚至服务端接口签名串直接定义成常量。这等于在门口放了一把钥匙任何有反编译工具的人都能快速拿到所有敏感信息。针对这种情况最简单的处理方案是把字符串拆散用拼接的方式动态组装再配合R8的优化做字符串加密。比如用第三方开源库StringFog它会在编译阶段自动把Java层字符串加密成字节数组运行时通过解密函数还原。这样反编译出来的APK里敏感字符串全是一串乱码。虽然这个方案遇到Hook高手会被绕过但至少能让普通反编译者头疼很久。我的建议是把动态密钥、签名逻辑这些关键信息尽量下沉到Native层Java层只保留业务逻辑。要在Native层编译的时候使用-fvisibilityhidden把非导出函数都隐藏掉。再用strip把so文件中的符号表去掉这样攻击者用IDA打开so的时候只能看到sub_1234这种匿名地址阅读成本直线上升。5. 实战遇到的那些坑兼容性、崩溃与脱壳5.1 加固后崩溃的堆栈还原用了R8混淆之后线上崩溃日志全是”a.a.a”这种让人抓狂的短类名。想要还原就需要用上我前面提过的mapping.txt文件。每次用Gradle构建Release包时都会在build/outputs/mapping/release/mapping.txt生成映射表。拿到崩溃堆栈后用Retrace工具或者Android Studio自带的-applymapping功能就能还原。实际操作里那些InMemoryDexClassLoader加载出来的类崩溃堆栈可能显示的是UnknownSource这很正常。真正巧妙的方案是写一个简单的Java脚本读入mapping.txt和崩溃日志自动把堆栈里的a.a.a()替换成原始的com.xxx.SecurityEngine.checkSign()。我花了两个小时写了个Python小工具之后就再也不用手工去匹配了。如果你不想自己写直接搜一下“R8 mapping retrace”也能找到很多开源脚本。5.2 Android版本差异踩坑记录加壳和反调试遇到最大的兼容性坑就是不同Android版本的ClassLoader加载机制不一样。InMemoryDexClassLoader在Android 8.0之后才被官方支持如果你的App最低版本是Android 6.0或7.0就需要用传统的DexClassLoader传入一个临时目录先把解密后的DEX文件写入文件系统再加载。我实际测试下来直接从内存加载的方式在低版本上会偶发IllegalStateException。最终我做了按版本分支处理if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { loader new InMemoryDexClassLoader(byteBuffer, context.getClassLoader()); } else { File tmp new File(context.getCacheDir(), core_ System.currentTimeMillis() .dex); writeBytes(tmp, decryptedBytes); loader new DexClassLoader(tmp.getAbsolutePath(), context.getCacheDir().getAbsolutePath(), null, context.getClassLoader()); }另外还要注意Google Play对签名方案的强制要求。从Android 11开始新上架的应用必须使用APK Signature Scheme V2或V3。如果你在加固过程中修改了APK底层的文件结构却没有做新的V2签名安装时就会报OriginalError。我有一次在自动化打包脚本里加固完忘了重新签名结果测试机上一堆资源未找到的崩溃最后才发现是签名被破坏了。5.3 脱壳工具搞不定别慌看这个聊到加固肯定绕不开“脱壳”这个话题。市面上的脱壳工具比如常见的反射大师、FART、Youpk等主要针对商业加固方案的特征来开发的。对于自研壳它们往往直接失效因为脱壳脚本根本不认识你自定义的加密格式和ClassLoader。遇到这种既不知道壳的特征又找不到加载点的工具很多时候就只能靠纯手动逆向。这恰恰说明了开源自研壳的防护价值。但这不意味着你的自研壳可以掉以轻心。攻击者虽然没有现成脚本他还可以手动跟踪你的加载逻辑找到解密函数的入口然后dump出堆内存中解密后的DEX。针对这种手动脱壳我做了几个欺骗性的措施一是解密后只在内存中存在并且马上把密钥所在的内存区域清零二是在业务代码里埋了一些“诱饵”DEX解密出来是看似重要的假逻辑用来消耗攻击者的时间三是定期对已经解密的核心类做自我完整性校验如果发现被单一修改就触发自毁。这些手段虽然不算什么高级玩意但组合起来足够把大多数半吊子脱壳者劝退。写在最后把商业加固换掉用开源和自研方案替代我最大的感受是整个加固过程从“黑盒依赖”变成了“透明可控”。虽然初期搭壳、做JNI、配置防调试代码花了不少时间但后续每次升级Android SDK、适配新机型、排查崩溃我都能直接定位到问题而不是看着商业加固服务商那点可怜的日志抓瞎。最后再分享一个小技巧在打包发版之前一定要用反编译工具和模拟抓包工具对自己的APK做一轮“自测”以攻击者的视角检查一遍看看哪些关键信息还没糊好。自己先攻一遍远比等用户被攻击后再补救有效得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门