ArturoYi
安卓代码加固

安卓代码加固(2):Native解密

libhiddendecrypt.so 自实现 AES-256-GCM。Java 不接触明文 DEX,用完立刻清零。

解密不走 javax.crypto.Cipher。HiddenSdk.init 把 64 个 hex 字符交给 nativeInit,由 libhiddendecrypt.so 读取 assets、拆开密文、校验 tag。Java 层只检查密钥格式。

仓库里仍保留 HiddenDecrypt.decryptAesGcm JNI,供旧路径使用。当前主流程是 hidden_bridge.cpp 的 nativeInit,一次完成解密和加载。

so 如何编译

CMake 把三份源码链成同一个动态库,并隐藏符号、设置页对齐。

hidden-sdk/src/main/cpp/CMakeLists.txt
add_library(hiddendecrypt SHARED
        hidden_decrypt.cpp
        hidden_bridge.cpp
        aes256_gcm.c
)

target_compile_options(hiddendecrypt PRIVATE -fvisibility=hidden -fno-exceptions -fno-rtti)
target_link_options(hiddendecrypt PRIVATE "-Wl,-z,max-page-size=16384")

拆分密文头

头文件固定了各段长度:HDX1 头 4 字节 + nonce 12 字节 + tag 16 字节。短于这个长度会直接失败。

hidden-sdk/src/main/cpp/aes256_gcm.h
#define HIDDEN_GCM_MAGIC_LEN 4
#define HIDDEN_GCM_NONCE_LEN 12
#define HIDDEN_GCM_TAG_LEN 16
#define HIDDEN_GCM_KEY_LEN 32
#define HIDDEN_GCM_OVERHEAD (HIDDEN_GCM_MAGIC_LEN + HIDDEN_GCM_NONCE_LEN + HIDDEN_GCM_TAG_LEN)

/* 密文:HDX1 || nonce(12) || ciphertext || tag(16)。成功返回 0。 */
int hidden_aes256_gcm_decrypt(...);
void hidden_secure_zero(void *p, size_t n);

nativeInit 先把 hex 解成 32 字节,再读取整份 blob:

hidden-sdk/src/main/cpp/hidden_bridge.cpp
if (hex_len != 64 || !parse_key_hex(hex, key)) {
    return jerr(env, "密钥格式无效");
}
uint8_t *blob = read_asset(env, context, &blob_len);
const int rc = hidden_aes256_gcm_decrypt(
        key, sizeof(key), blob, blob_len, plain, &out_len);
hidden_secure_zero(key, sizeof(key));
hidden_secure_zero(blob, blob_len);

密钥和密文缓冲用完立刻执行 hidden_secure_zero。这是用 volatile 逐字节写 0,避免编译器把清零优化掉。

校验 tag 后再输出明文

解密函数先用 memcmp 核对 HDX1 头,再计算 GHASH,最后比较 tag 时不会提前返回。tag 不对就清空输出缓冲,返回 -1。

hidden-sdk/src/main/cpp/aes256_gcm.c
if (memcmp(blob, kMagic, HIDDEN_GCM_MAGIC_LEN) != 0) {
    return -1;
}
ghash(h, ct, ct_len, s);
aes256_encrypt_block(&aes, j0, expected);
xor16(expected, expected, s);
if (!ct_eq(expected, tag, HIDDEN_GCM_TAG_LEN)) {
    goto done;
}
gctr(&aes, j0, ct, ct_len, out);

ct_eq 用 OR 累加差异,不会在第一个不等字节处返回。这样可以降低根据比较耗时猜测 tag 的价值。

GCM 解密失败和「不是 dex」在 Java 侧都会被当成密钥错误(INVALID_KEY)。使用错误密钥时,不会留下半截明文 DEX。

确认明文是 dex

so 不信任「解密成功」这一结果,还会检查前三个字节是否为 d e x:

hidden-sdk/src/main/cpp/hidden_bridge.cpp
if (rc != 0 || out_len != pt_cap || plain == nullptr || out_len < 4 ||
    plain[0] != 'd' || plain[1] != 'e' || plain[2] != 'x') {
    return jerr(env, rc != 0 ? "解密失败" : "不是 dex");
}

密钥错误、文件被截断,或把其他资源改名为 hidden.dex.enc,都会在这里停止。

对照 Java 入口

Java 只检查「64 个 hex 字符」,不解析算法:

hidden-sdk/src/main/java/com/example/minidex/sdk/HiddenSdk.kt
if (!HiddenCrypto.isUsableKey(normalized)) {
    return HiddenInitResult.invalidKey(
        "需要 ${HiddenCrypto.KEY_HEX_LENGTH} 个 hex 字符(32 字节密钥)",
    )
}
val error = nativeInit(context.applicationContext, normalized)

HiddenCrypto.ASSET_PATH 仍写着 "payload/hidden.dex.enc",供文档和旧代码对照。真正打开 assets 的路径在 so 里做 XOR,见 XOR混淆。

下一步

  • 内存加载:明文如何交给 InMemoryDexClassLoader
  • XOR混淆:路径和类名为什么不写明文字符串
Copyright © 2026