安卓代码加固
安卓代码加固(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")
默认导出的 C 符号会少很多。JADX 和
nm 不容易扫到 hidden_aes256_gcm_decrypt 这类内部函数。JNI 入口仍按 Java_com_example_... 命名导出,否则 JVM 无法找到。aes256_gcm.c 自带 S-box、密钥扩展、GHASH、GCTR。不链接系统 crypto,可以减少通过 hook 常见 JNI 或 OpenSSL 符号截取明文的路径。代价是必须自行保证实现正确。构建期由 PayloadEncryptor 先加密再解密核对。-Wl,-z,max-page-size=16384 满足 Android 15 及以上对 64 位 so 的 LOAD 段要求。这是上架兼容,不是混淆。缺少这项时,新设备会直接加载失败。拆分密文头
头文件固定了各段长度: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混淆。