金
金铠甲加固 Android APK/AAB 原生加固
← 返回技术博客

安卓 APP 为什么需要加固:加固前后,静态反编译工具分别看到什么

把 APK 的后缀改成 .zip 解压,你会看到 classes.dex、AndroidManifest.xml、 res/、lib/。而 jadx、apktool 这类工具是免费、开源、一键运行的。 本文用一个授权校验方法,逐层展示加固前后静态工具实际能看到什么。

下文的代码是按实现机制还原的示意输出:结构、调用形态与真实产物一致, 但符号名会因版本、任务与配置而变(壳类名是随机生成的)。 判断你自己包的效果,请以加固任务报告里的方法覆盖率、未保护类清单、字符串加密站点数为准。

一、加固前:jadx 打开你的 APK,能看到什么

假设你写了一个授权校验类,源码大致是这样(这里把密钥和地址写在方法内部,而不是静态常量里):

你写的源码
public final class LicenseManager {
    public boolean isPro(String deviceId) {
        String signKey = "a7f3c1-lic-2026";
        String verifyUrl = "https://api.example.com/v1/license/verify";
        String sig = hmacSha256(signKey, deviceId);
        String body = "{\"device\":\"" + deviceId + "\",\"sig\":\"" + sig + "\"}";
        String resp = post(verifyUrl, body);
        return "pro".equals(parsePlan(resp));
    }
}

如果你没有开 R8 / ProGuard,jadx 反编译出来的结果和上面几乎一模一样—— 类名、方法名、字符串常量、控制流、算法步骤全部保留:

加固前 · jadx 看到的 Java
public final class LicenseManager {
    public boolean isPro(String str) {
        String hmacSha256 = hmacSha256(
            "a7f3c1-lic-2026", str);
        return "pro".equals(parsePlan(post(
            "https://api.example.com/v1/license/verify",
            "{\"device\":\"" + str
              + "\",\"sig\":\"" + hmacSha256 + "\"}")));
    }
}
加固前 · apktool 看到的 smali
const-string v0, "a7f3c1-lic-2026"
const-string v1, "https://api.example.com/v1/license/verify"
invoke-static {v0, p1}, \
  LLicenseManager;->hmacSha256(...)Ljava/lang/String;
move-result-object v0
new-instance v2, Ljava/lang/StringBuilder;
...
invoke-virtual {v2}, Ljava/lang/StringBuilder;
  ->toString()Ljava/lang/String;
move-result-object v2
invoke-static {v1, v2}, LLicenseManager;
  ->post(...)Ljava/lang/String;

也就是说,加固之前,任何一个拿到你 APK 的人都能直接得到:

  • 接近源码的 Java:类结构、方法体、分支与循环,可读性很高;
  • 明文字符串:接口地址、签名密钥、SQL、加密盐、第三方 API key;
  • AndroidManifest.xml:所有组件、权限、入口、deeplink;
  • resources.arsc 与 res/:布局、drawable、字符串资源;
  • 可改的 smali:改完重新打包、随便签个名就能在设备上跑起来。

于是盗版分发、破解内购、扒接口刷量、抄袭核心算法、注入广告 SDK 再二次打包—— 这些事情的第一步都是同一件:用静态工具把它打开看看。

二、加固后:同一个方法变成什么样

加固做的本质是一件事:把"代码"变成"数据"。具体分五步:

  1. 解析方法的 code_item,把里面的指令原样搬出 DEX,放进加密容器 assets/zorvault_data.bin;
  2. 原来的方法位置替换成一个 exec shell:把参数装箱,交给原生层;
  3. 方法内的 const-string 改成 stringFromPool(索引),字符串本体进数据池;
  4. 方法名、私有字段名与类名混淆默认开启,符合兼容性条件的名称改写为短名(a、b、c…);
  5. 注入 libzorvault.so,启动时用 RegisterNatives 把入口动态绑定到一个随机类名的壳类上,由解释器读数据、还原 opcode 后执行。

于是 jadx 再打开同一个包,同一个方法变成这样:

加固后 · jadx 看到的 Java
public final class a {
    // 类名混淆默认开启;需要兼容的类保留原名
    public boolean a(String str) {   // 方法名已混淆
        return ((Boolean) VmBridge.exec(
            (0 << 16) | 42,       // data_idx
            new Object[]{ this, str }
        )).booleanValue();
    }
}
加固后 · apktool 看到的 smali
const/4 v0, 0x2a          # data_idx
const/4 v1, 0x2
new-array v1, v1, [Ljava/lang/Object;
const/4 v2, 0x0
aput-object p0, v1, v2
const/4 v2, 0x1
aput-object p1, v1, v2
invoke-static {v0, v1}, LVmBridge;
  ->exec(I[Ljava/lang/Object;)Ljava/lang/Object;
move-result-object v0
check-cast v0, Ljava/lang/Boolean;
invoke-virtual {v0}, Ljava/lang/Boolean;
  ->booleanValue()Z
move-result v0
return v0

signKey、verifyUrl、hmac 算法、JSON 拼接、返回判断—— 全都不在 DEX 里了。剩下的只有"把参数装进数组,调一次 native,把结果拆箱"。

包里多了两个文件

加固后包内新增
lib/arm64-v8a/libzorvault.so     ← 解释器 + 校验逻辑(约 0.66 MB)
lib/armeabi-v7a/libzorvault.so                (约 0.46 MB)
assets/zorvault_data.bin         ← 加密后的方法数据

zorvault_data.bin 布局:
  NMS2\x01 | nonce(12B) | AES-256-GCM 密文 + tag
  AAD: "ZORVAULT-ANDROID-STRING-POOL-V2"

二进制编辑器打开 zorvault_data.bin,看到的是密文。 把它单独抠出来也没用:任务密钥不在这个文件里,而在该任务专属的那份 SO 副本的 .zorvault.key 槽位里——数据与运行时一对一绑定,换一份 SO 就解不开。

这是"代码虚拟化"和"加壳"的关键区别。 加壳是把整个 DEX 加密藏起来,运行时解密落回 DEX,抓一次内存 dump 就全还原了; 虚拟化之后,DEX 里从来没有出现过你的指令——即使攻击者逆向了 libzorvault.so, 拿到的也只是一个通用解释器。业务逻辑不在解释器里,而在加密数据里。 想还原,必须同时拿到解释器、密钥槽位和那份数据,并把数据格式反解回 DEX。

三、逐项对比

你关心的东西加固前(jadx / apktool)加固后(金铠甲加固)
业务方法逻辑 接近源码,可逐行读 方法体消失,只剩调用原生层的壳
方法内字符串 明文:接口地址、密钥、SQL 移入加密数据池,DEX 内不可见
方法名 原始命名(未开 R8 时) 短名 a / b / c(默认开启)
私有字段名 原始命名 短名(默认开启)
类名 原始命名 默认开启;符合兼容性条件的类改为短名,需要兼容的类保留原名
AndroidManifest.xml 明文可读 仍可读(组件名必须保留,否则系统找不到)
resources.arsc / res 可读 资源项名与路径已改写
改 smali 后重打包 可行,随便签名就能装 DEX SHA-256 完整性校验 + 签名校验会触发
Frida / Xposed 挂钩 无阻碍 有检测(默认开启),但非绝对
体积代价 — 每选一个 ABI 约 0.46–0.82 MB + 随方法数增长的数据文件

四、加固做不到什么

这部分比"能做到什么"更重要。我们把边界写清楚,免得你按错的预期去选型:

  • 提高的是攻击成本,不是绝对防护。 攻击者完全控制设备时,任何客户端侧保护最终都可能被绕过。
  • 定位是 L3 增强级,不是 L4 白盒。 没有白盒加密、没有密钥远程下发:密钥材料随包分发,具备足够静态分析能力的攻击者原则上可以还原。 支付、银行、证券这类场景不应把它当唯一手段。
  • 部分方法不参与保护。 <init>、<clinit>、abstract、native、 bridge、synthetic 方法会被跳过;生命周期中的 invoke-super 已纳入转换,具体保护范围以规则与任务报告为准。
  • 由此产生一个很容易踩的坑: <clinit> 不受保护,所以写成 static final String 常量的密钥, 字符串仍会以明文留在 DEX 里。别把密钥写成静态常量,放进方法内、并让该方法被保护。
  • Manifest 与资源内容仍可读。 资源名被改写了,但资源本身不是加密的;组件名必须保留,否则系统加载不到。
  • 有性能代价。 被保护方法由原生解释器执行、无 JIT 参与,热点路径会慢于原 DEX。
  • 动态检测可绕过。 Frida 检测覆盖默认端口(27042 / 27043)与已知进程 / 库特征, 但自编译 Frida、自定义端口仍可能绕过。

五、怎么用才划算

  1. 先用 R8 / ProGuard 做一层基础混淆,再提交加固——两者可叠加,各管一层。
  2. 只保护核心方法,不要全量加固。 按规则挑授权校验、算法、协议、反作弊外围这类真正值钱的逻辑; 全量加固只会白白付出体积和性能代价。
  3. 看任务报告:方法覆盖率、未保护类清单、字符串加密站点数, 据覆盖率判断保护范围是否符合预期。
  4. 用真机跑一遍,确认功能与性能没有回退,再发布。
TRY IT

拿你自己的包,先看一眼加固前长什么样

新账户含 5 次永久有效的免费额度。加固后自己用 jadx 打开对比一遍,比任何介绍都直观。

打开加固控制台 →