一、为什么把加密放在 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。
2.3 Android BoringSSL
Android 从 6.0 开始使用 BoringSSL(Google 的 OpenSSL fork)替代 OpenSSL。NDK 开发中,如果使用预置的 libcrypto.so,实际上是 BoringSSL。部分 OpenSSL 的 API 在 BoringSSL 中不可用或被修改。
find_library(crypto-lib crypto)
target_link_libraries(nativecrypto ${crypto-lib})
|
三、密钥管理
密钥管理是加密系统中最脆弱的一环。如果密钥硬编码在代码中,逆向工程师用 strings 命令即可发现。
3.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); }
|
注意:更换设备或系统升级后,上述因子会变化,导致无法解密旧数据。需要设计密钥迁移/备份机制(如用户登录后从服务端获取恢复密钥)。
3.2 HKDF(标准密钥派生)
#include <openssl/kdf.h>
int derive_key_with_hkdf(const unsigned char *master_key, size_t master_key_len, const unsigned char *info, size_t info_len, unsigned char *out_key, size_t out_key_len) { EVP_PKEY_CTX *pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, NULL); EVP_PKEY_derive_init(pctx); EVP_PKEY_CTX_set_hkdf_md(pctx, EVP_sha256()); EVP_PKEY_CTX_set1_hkdf_salt(pctx, "app_salt", 8); EVP_PKEY_CTX_set1_hkdf_key(pctx, master_key, master_key_len); EVP_PKEY_CTX_add1_hkdf_info(pctx, info, info_len);
size_t out_len = out_key_len; int ret = EVP_PKEY_derive(pctx, out_key, &out_len); EVP_PKEY_CTX_free(pctx); return ret; }
void derive_chunk_key(const unsigned char *master_key, uint32_t chunk_index, unsigned char chunk_key[32]) { derive_key_with_hkdf(master_key, 32, (const unsigned char *)&chunk_index, sizeof(chunk_index), chunk_key, 32); }
|
3.3 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 慢(需要 IPC 到 Keymaster),不适合加密大量文件。更好的方案是:用 Keystore 保护的密钥加密数据加密密钥(DEK, Data Encryption Key),用 DEK 对文件进行批量加密。
3.4 双层密钥架构
根密钥(KEK - Key Encryption Key) ├── 存储在 Android Keystore 中(硬件保护) └── 用于加密/解密:DEK 数据加密密钥(DEK - Data Encryption Key) ├── 存储在文件头部(被 KEK 加密后存储) └── 用于加密/解密:实际文件数据
文件结构: ┌──────────────────────────────────────┐ │ Encrypted DEK (被 KEK 加密,256 byte) │ ├──────────────────────────────────────┤ │ IV (12 byte for GCM) │ ├──────────────────────────────────────┤ │ Ciphertext (AES-256-GCM 加密的文件体) │ ├──────────────────────────────────────┤ │ GCM Tag (16 byte) │ └──────────────────────────────────────┘
|
这种设计的优势:
- 数据密钥(DEK)可以定期轮换而不需要重新加密根密钥
- 根密钥被硬件保护,无法提取
- 每个文件可以有单独的 DEK
四、文件拆分混淆
除了加密内容,还可以在文件层面进行拆分——将一个文件切成多段,每段独立加密,且保存到不同位置。这增加了逆向分析的难度。
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; }
|
4.1 文件混淆的附加策略
- 分片存储在不同目录(如
/data/data/<pkg>/files/ 和 /sdcard/Android/data/<pkg>/)。
- 分片的文件名用随机哈希,顺序信息仅存储在加密的索引文件中(索引文件本身也加密)。
- 将少量关键分片(如文件头)与普通分片混合,增加重组难度。
- 混合真实分片和诱饵分片(填充随机数据的假文件)。
- 分片数量随机化:不要固定分片大小,在合理范围内随机变化,使得攻击者无法从分片数量推断原始文件大小。
- 存储路径随机化:将分片路径哈希到不同目录,增加静态分析查找难度。
4.2 分片重组的完整流程
int merge_and_decrypt_chunks(const char *input_dir, uint32_t total_chunks, const unsigned char *master_key, const char *output_path) { FILE *out = fopen(output_path, "wb"); if (!out) return -1;
for (uint32_t i = 0; i < total_chunks; i++) { char chunk_path[512]; snprintf(chunk_path, sizeof(chunk_path), "%s/chunk_%08x.dat", input_dir, rand_seed ^ i);
FILE *in = fopen(chunk_path, "rb"); if (!in) continue;
FileChunk header; fread(&header, sizeof(header), 1, in);
unsigned char *ciphertext = malloc(header.data_len); fread(ciphertext, 1, header.data_len, in); fclose(in);
unsigned char chunk_key[32]; derive_chunk_key(master_key, header.index, chunk_key);
unsigned char *plaintext = malloc(header.data_len); int plain_len = aes_gcm_decrypt(ciphertext, header.data_len, chunk_key, header.iv, 16, NULL, plaintext);
if (plain_len < 0) { free(ciphertext); free(plaintext); fclose(out); return -1; }
fwrite(plaintext, 1, plain_len, out);
free(ciphertext); free(plaintext); }
fclose(out); return 0; }
|
五、流式加密:处理大文件
对于 GB 级别的大文件(如视频、游戏资源),一次性加载到内存是不现实的。需要流式加密:
typedef struct { EVP_CIPHER_CTX *ctx; unsigned char iv[16]; unsigned char key[32]; } StreamEncryptor;
int stream_encrypt_init(StreamEncryptor *se, const unsigned char *key, const unsigned char *iv) { se->ctx = EVP_CIPHER_CTX_new(); memcpy(se->key, key, 32); memcpy(se->iv, iv, 16); EVP_EncryptInit_ex(se->ctx, EVP_aes_256_ctr(), NULL, key, iv); return 0; }
int stream_encrypt_chunk(StreamEncryptor *se, const unsigned char *plaintext, int plaintext_len, unsigned char *ciphertext) { int len; EVP_EncryptUpdate(se->ctx, ciphertext, &len, plaintext, plaintext_len); return len; }
void stream_encrypt_cleanup(StreamEncryptor *se) { EVP_CIPHER_CTX_free(se->ctx); }
void encrypt_large_file(const char *input_path, const char *output_path, const unsigned char *key, const unsigned char *iv) { FILE *in = fopen(input_path, "rb"); FILE *out = fopen(output_path, "wb");
StreamEncryptor se; stream_encrypt_init(&se, key, iv);
unsigned char buffer[64 * 1024]; unsigned char encrypted[64 * 1024 + 16]; size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), in)) > 0) { int encrypted_len = stream_encrypt_chunk(&se, buffer, bytes_read, encrypted); fwrite(encrypted, 1, encrypted_len, out); }
stream_encrypt_cleanup(&se); fclose(in); fclose(out); }
|
六、完整性验证(HMAC)
除了 GCM 的认证标签外,对于整个文件的完整性验证也是必要的:
#include <openssl/hmac.h>
int compute_file_hmac(const char *file_path, const unsigned char *hmac_key, size_t hmac_key_len, unsigned char *hmac_out) { HMAC_CTX *ctx = HMAC_CTX_new(); HMAC_Init_ex(ctx, hmac_key, hmac_key_len, EVP_sha256(), NULL);
FILE *fp = fopen(file_path, "rb"); unsigned char buffer[8192]; size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) { HMAC_Update(ctx, buffer, bytes_read); } fclose(fp);
unsigned int len; HMAC_Final(ctx, hmac_out, &len); HMAC_CTX_free(ctx); return 0; }
int verify_file_integrity(const char *file_path, const unsigned char *expected_hmac, const unsigned char *hmac_key, size_t hmac_key_len) { unsigned char computed_hmac[EVP_MAX_MD_SIZE]; compute_file_hmac(file_path, hmac_key, hmac_key_len, computed_hmac); return CRYPTO_memcmp(computed_hmac, expected_hmac, 32) == 0; }
|
七、Native 代码混淆
加密实现本身需要保护。以下是对 native 代码的硬化措施:
7.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 混淆后:条件判断被替换为状态机,算术运算被替换为语义等价但更复杂的序列,插入了不会执行的虚假分支。这使静态分析变得极其困难。
7.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); } }
|
7.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) 关键分片(如文件头)进行额外加密或存储在异常位置。(6) 分片大小随机化,使攻击者无法从分片数量推断原始文件大小。
Q5: 如何处理 Android Keystore 中密钥的备份和迁移问题?
Android Keystore 的密钥与设备绑定(Keymaster HAL 生成的密钥存储在 TEE 中)。用户更换设备后,Keystore 中的密钥不可用,导致无法解密旧数据。解决方案:(1) 双层密钥架构——Keystore 中保护 KEK,用服务端备份的 KEK 恢复 DEK;(2) 用户在服务端账号体系中备份 KEK(用账号密码/恢复码加密);(3) 提供数据重新加密功能(用户登录后,在新设备上从服务端恢复 KEK,重新加密本地 DEK,存入新设备的 Keystore)。
参考源码路径:
- 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
- HKDF RFC 5869:
https://tools.ietf.org/html/rfc5869