一、为什么把加密放在 Native 层?
在 Android 中,Java/Kotlin 代码对逆向工程相对透明。即使使用了 ProGuard/R8 混淆,关键逻辑仍可通过 smali/baksmali、JADX、JEB 等工具还原。将加密逻辑迁移到 Native 层(C/C++ via NDK)可以显著提高逆向门槛:
- 机器码阅读难度:ARM/ARM64 汇编远比 smali 字节码难以阅读和还原。
- 工具链支持弱:IDA Pro / Ghidra 可以反编译 native 代码,但还原出的 C 代码质量远不如 Java 反编译工具。
- 混淆加固空间大:OLLVM(Obfuscator-LLVM)可以对 native 代码施加控制流平坦化、指令替换、虚假控制流等混淆。
- 关键常量和密钥隐藏:密钥可以分散在代码段中,通过运算动态拼装,避免明文字符串扫描。
但需要清醒认识:native 层的保护并非绝对安全。足够有决心的攻击者仍然可以使用 IDA Pro + Frida 动态调试来分析和 Hook native 函数。安全的目标是提高攻击成本,使攻击的经济投入超过收益。
二、AES 加密算法实现
AES(Advanced Encryption Standard)是对称加密的事实标准,Android NDK 中通常使用 OpenSSL 或 mbed TLS 库。
2.1 使用 OpenSSL 的 AES-256-CBC
#include <openssl/aes.h> #include <openssl/rand.h>
int generate_iv(unsigned char *iv, int iv_len) { return RAND_bytes(iv, iv_len); }
int aes_encrypt(const unsigned char *plaintext, int plaintext_len, const unsigned char *key, const unsigned char *iv, unsigned char *ciphertext) { AES_KEY aes_key; if (AES_set_encrypt_key(key, 256, &aes_key) != 0) { return -1; }
int padded_len = ((plaintext_len / AES_BLOCK_SIZE) + 1) * AES_BLOCK_SIZE; unsigned char *padded = (unsigned char *)malloc(padded_len); memcpy(padded, plaintext, plaintext_len); int pad_value = padded_len - plaintext_len; memset(padded + plaintext_len, pad_value, pad_value);
AES_cbc_encrypt(padded, ciphertext, padded_len, &aes_key, iv, AES_ENCRYPT);
free(padded); return padded_len; }
int aes_decrypt(const unsigned char *ciphertext, int ciphertext_len, const unsigned char *key, const unsigned char *iv, unsigned char *plaintext) { AES_KEY aes_key; AES_set_decrypt_key(key, 256, &aes_key);
AES_cbc_encrypt(ciphertext, plaintext, ciphertext_len, &aes_key, iv, AES_DECRYPT);
int pad_value = plaintext[ciphertext_len - 1]; if (pad_value > 0 && pad_value <= AES_BLOCK_SIZE) { return ciphertext_len - pad_value; } return ciphertext_len; }
|
2.2 AES 模式选择
| 模式 |
描述 |
优点 |
缺点 |
| CBC |
密文分组链接,前一个密文块与当前明文块 XOR 再加密 |
安全性好,同一明文不同密文(靠 IV) |
不可并行加密(解密可并行) |
| CTR |
计数器模式,加密一个递增计数器,结果与明文 XOR |
可并行,不需要填充,随机访问 |
计数器不能重用(同 key + IV 安全灾难) |
| GCM |
Galois/Counter Mode,CTR + 认证 |
同时提供加密和完整性认证(AEAD) |
实现稍复杂 |
| ECB |
电子密码本,每个块独立加密 |
简单 |
不安全!相同明文块产生相同密文块 |
推荐使用 AES-GCM,因为它提供了认证加密(AEAD, Authenticated Encryption with Associated Data),在解密后能验证数据未被篡改,省去了单独的 MAC 校验:
#include <openssl/evp.h>
int aes_gcm_encrypt(const unsigned char *plaintext, int plaintext_len, const unsigned char *key, const unsigned char *iv, int iv_len, unsigned char *ciphertext, unsigned char *tag) { EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv_len, NULL); EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv);
int len; EVP_EncryptUpdate(ctx, ciphertext, &len, plaintext, plaintext_len); int ciphertext_len = len;
EVP_EncryptFinal_ex(ctx, ciphertext + len, &len); ciphertext_len += len;
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag);
EVP_CIPHER_CTX_free(ctx); return ciphertext_len; }
int aes_gcm_decrypt(const unsigned char *ciphertext, int ciphertext_len, const unsigned char *key, const unsigned char *iv, int iv_len, const unsigned char *tag, unsigned char *plaintext) { EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv_len, NULL); EVP_DecryptInit_ex(ctx, NULL, NULL, key, iv);
int len; EVP_DecryptUpdate(ctx, plaintext, &len, ciphertext, ciphertext_len); int plaintext_len = len;
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, (void *)tag);
int ret = EVP_DecryptFinal_ex(ctx, plaintext + len, &len); EVP_CIPHER_CTX_free(ctx);
if (ret > 0) { plaintext_len += len; return plaintext_len; } return -1; }
|
OpenSSL 在 Android NDK 中的集成:Android 系统自带精简版 OpenSSL(libcrypto.so),但版本和 API 暴露不全。推荐使用 NDK 静态链接自己编译的 OpenSSL 或使用 mbed TLS。
三、文件拆分混淆
除了加密内容,还可以在文件层面进行拆分——将一个文件切成多段,每段独立加密,且保存到不同位置。这增加了逆向分析的难度。
typedef struct { uint32_t index; uint32_t total_chunks; uint32_t data_len; unsigned char iv[16]; } FileChunk;
int split_and_encrypt_file(const char *input_path, const char *output_dir, const unsigned char *master_key) { FILE *fp = fopen(input_path, "rb"); fseek(fp, 0, SEEK_END); size_t file_size = ftell(fp); fseek(fp, 0, SEEK_SET); unsigned char *data = malloc(file_size); fread(data, 1, file_size, fp); fclose(fp);
const size_t CHUNK_SIZE = 64 * 1024; uint32_t total_chunks = (file_size + CHUNK_SIZE - 1) / CHUNK_SIZE;
for (uint32_t i = 0; i < total_chunks; i++) { unsigned char chunk_key[32]; derive_chunk_key(master_key, i, chunk_key);
unsigned char iv[16]; generate_iv(iv, 16);
size_t offset = i * CHUNK_SIZE; size_t chunk_len = (i == total_chunks - 1) ? file_size - offset : CHUNK_SIZE;
unsigned char *ciphertext = malloc(chunk_len + 16); int cipher_len = aes_gcm_encrypt( data + offset, chunk_len, chunk_key, iv, 16, ciphertext, NULL);
char chunk_path[512]; snprintf(chunk_path, sizeof(chunk_path), "%s/chunk_%08x.dat", output_dir, rand() ^ i);
FILE *out = fopen(chunk_path, "wb"); FileChunk header = {i, total_chunks, cipher_len}; memcpy(header.iv, iv, 16); fwrite(&header, sizeof(header), 1, out); fwrite(ciphertext, 1, cipher_len, out); fclose(out);
free(ciphertext); }
free(data); return 0; }
|
文件混淆的附加策略:
- 将分片存储在不同目录(如
/data/data/<pkg>/files/ 和 /sdcard/Android/data/<pkg>/)。
- 分片的文件名用随机哈希,顺序信息仅存储在索引文件中(索引文件本身也加密)。
- 将少量关键分片(如文件头)与普通分片混合,增加重组难度。
四、密钥管理
密钥管理是加密系统中最脆弱的一环。如果密钥硬编码在代码中,逆向工程师用 strings 命令即可发现。
4.1 密钥派生(避免硬编码)
#include <sys/system_properties.h>
void derive_master_key(unsigned char *key_out) { char android_id[65]; char build_fingerprint[256]; char app_signature[129];
__system_property_get("ro.boot.serialno", android_id);
__system_property_get("ro.build.fingerprint", build_fingerprint);
unsigned char salt[] = "com.example.app.crypto.salt";
unsigned char combined[1024]; snprintf((char *)combined, sizeof(combined), "%s|%s|%s", android_id, build_fingerprint, app_signature);
HMAC(EVP_sha256(), salt, sizeof(salt) - 1, combined, strlen((char *)combined), key_out, NULL); }
|
注意:更换设备或系统升级后,上述因子会变化,导致无法解密旧数据。需要设计密钥迁移/备份机制(如用户登录后从服务端获取恢复密钥)。
4.2 Android Keystore 集成
Android Keystore System(Keymaster HAL)提供了硬件支持的密钥存储,密钥在 TEE(Trusted Execution Environment)中生成和操作,私钥永远不会离开安全硬件:
KeyGenerator keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); keyGenerator.init(new KeyGenParameterSpec.Builder( "my_file_encryption_key", KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) .build()); SecretKey key = keyGenerator.generateKey();
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key); byte[] ciphertext = cipher.doFinal(plaintext); byte[] iv = cipher.getIV();
|
Keystore 的密钥无法被导出(即使用 root 也无法获取),提供了最高的安全等级。但需要注意的是,Keystore 操作比纯软件 AES 慢(需要 IPCs 到 Keymaster),不适合加密大量文件。更好的方案是:用 Keystore 保护的密钥加密数据加密密钥(DEK, Data Encryption Key),用 DEK 对文件进行批量加密。
五、Native 代码混淆
加密实现本身需要保护。以下是对 native 代码的硬化措施:
5.1 OLLVM 混淆
# 使用 OLLVM 编译 native 代码 -DCMAKE_CXX_FLAGS="-mllvm -fla -mllvm -sub -mllvm -bcf"
-fla: 控制流平坦化 (Control Flow Flattening) -sub: 指令替换 (Instruction Substitution) -bcf: 虚假控制流 (Bogus Control Flow)
|
输入代码:
int check_license(const char *key) { if (strlen(key) != 16) return 0; int hash = 0; for (int i = 0; i < 16; i++) hash ^= key[i] << (i % 4) * 8; return hash == 0x12345678; }
|
OLLVM 混淆后:条件判断被替换为状态机,算术运算被替换为语义等价但更复杂的序列,插入了不会执行的虚假分支。这使静态分析变得极其困难。
5.2 反调试
#include <sys/ptrace.h> #include <unistd.h>
static void anti_debug() { if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) { __android_log_print(ANDROID_LOG_WARN, "AntiDebug", "Debugger detected!"); _exit(1); }
FILE *fp = fopen("/proc/self/status", "r"); if (fp) { char line[256]; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, "TracerPid:", 10) == 0) { int tracer_pid = atoi(line + 10); if (tracer_pid != 0) { fclose(fp); _exit(1); } break; } } fclose(fp); } }
|
5.3 字符串加密
不要在 native 代码中硬编码明文字符串:
const char *API_KEY = "sk-xxxx-secret-key-12345";
char api_key[32]; void init_api_key() { const char part1[] = {0x73, 0x6b, 0x2d, 0x78, 0x78, 0x78, 0x78, 0x2d, 0}; const char part2[] = {0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x2d, 0x6b, 0}; for (int i = 0; i < 8; i++) api_key[i] = part1[i] ^ 0x12; for (int i = 0; i < 9; i++) api_key[i + 8] = part2[i] ^ 0x34; api_key[24] = '\0'; }
|
六、Java vs Native 加密性能对比
C 层加密速度约为 Java 层的 4-5 倍。差异来自:
- JNI 调用开销被分摊(大块数据一次性传递)。
- OpenSSL 使用了 ARM NEON 指令集加速(
AES 和 PMULL 指令)。
- Java Cipher 每次
update/doFinal 都需要 JNI 调用到 BouncyCastle/OpenSSL。
对于 1MB 以下的小数据,Java 层加密足够(差异在毫秒级)。对于大文件(视频、游戏资源、数据库备份),Native 加密的性能优势显著。
七、面试常问题目
Q1: 为什么推荐 AES-GCM 而不是 AES-CBC + HMAC?
AES-GCM 同时提供加密和认证(AEAD),在算法层面统一了加密和完整性验证,减少了开发者自己实现 Encrypt-then-MAC 可能出错的风险。CBC + HMAC 需要分别管理加密密钥和 MAC 密钥,且如果顺序错了(如 MAC-then-Encrypt)可能引入安全漏洞(如 POODLE 攻击)。GCM 使用 CTR 模式加密,可并行处理,且不需要填充(避免了 Padding Oracle 攻击)。不过 GCM 对 Nonce 重用的容忍度为零——同一密钥同一个 IV 用两次就彻底破坏安全性。
Q2: 为什么密钥要动态派生而不是硬编码?怎么从代码中提取密钥?
硬编码密钥可以通过 strings 命令或 IDA 的字符串窗口直接发现。动态派生将密钥的计算分散到多个参数(设备 ID + 签名 + 系统指纹 + 算法),即使攻击者拿到派生函数的代码,也需要获取所有输入参数才能复现密钥。推荐的方案是:根密钥存储在 Android Keystore 中(硬件保护),数据密钥通过 HKDF 从根密钥派生,文件使用数据密钥加密。这样即使 native 代码被完全逆向,也无法获取根密钥。
Q3: NDK 加密相比 Java 加密的主要优势是什么?
(1) 逆向难度:Java 代码反编译后逻辑几乎完全可读,native 代码需要 ARM 汇编分析和反编译,复杂度和成本高一个数量级。(2) 性能:使用 OpenSSL + NEON 指令集,比 Java Cipher 快 4-5 倍。(3) 密钥保护:native 层的字符串和常量不像 Java 那样容易被 jadx 等工具直接提取。(4) 混淆空间:OLLVM 等工具可以对 native 代码施加控制流混淆,Java 层的混淆(ProGuard/R8)仅限于名称混淆和简单的控制流。
Q4: 文件拆分后如何提高重组的难度?
(1) 分片信息(顺序、长度、密钥)存储在加密的索引文件中,与分片分离。(2) 分片使用随机文件名(SHA256 的前 8 字节),存储在多个不同路径。(3) 每个分片使用不同的加密密钥(从主密钥 + 序号派生)和独立 IV。(4) 混合真实分片和诱饵分片(假分片,包含随机数据)。(5) 关键分片(如文件头)进行额外加密或存储在异常位置。
参考源码路径:
- OpenSSL EVP:
https://github.com/openssl/openssl/tree/master/crypto/evp
- mbed TLS:
https://github.com/Mbed-TLS/mbedtls
- Android Keystore:
frameworks/base/keystore/
- Keymaster HAL:
hardware/interfaces/keymaster/
- Android libcrypto:
external/boringssl/ (Android 的 OpenSSL fork)
- OLLVM:
https://github.com/obfuscator-llvm/obfuscator
- AES-GCM RFC 5116:
https://tools.ietf.org/html/rfc5116
- NIST SP 800-38D (GCM 标准):
https://csrc.nist.gov/publications/detail/sp/800-38d/final