目录
  1. 1. 一、为什么把加密放在 Native 层?
  2. 2. 二、AES 加密算法实现
    1. 2.1. 2.1 使用 OpenSSL 的 AES-256-CBC
    2. 2.2. 2.2 AES 模式选择
  3. 3. 三、文件拆分混淆
  4. 4. 四、密钥管理
    1. 4.1. 4.1 密钥派生(避免硬编码)
    2. 4.2. 4.2 Android Keystore 集成
  5. 5. 五、Native 代码混淆
    1. 5.1. 5.1 OLLVM 混淆
    2. 5.2. 5.2 反调试
    3. 5.3. 5.3 字符串加密
  6. 6. 六、Java vs Native 加密性能对比
  7. 7. 七、面试常问题目
NDK文件拆分加密处理

一、为什么把加密放在 Native 层?

在 Android 中,Java/Kotlin 代码对逆向工程相对透明。即使使用了 ProGuard/R8 混淆,关键逻辑仍可通过 smali/baksmali、JADX、JEB 等工具还原。将加密逻辑迁移到 Native 层(C/C++ via NDK)可以显著提高逆向门槛:

  1. 机器码阅读难度:ARM/ARM64 汇编远比 smali 字节码难以阅读和还原。
  2. 工具链支持弱:IDA Pro / Ghidra 可以反编译 native 代码,但还原出的 C 代码质量远不如 Java 反编译工具。
  3. 混淆加固空间大:OLLVM(Obfuscator-LLVM)可以对 native 代码施加控制流平坦化、指令替换、虚假控制流等混淆。
  4. 关键常量和密钥隐藏:密钥可以分散在代码段中,通过运算动态拼装,避免明文字符串扫描。

但需要清醒认识: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>

// 生成随机 IV(初始化向量)
int generate_iv(unsigned char *iv, int iv_len) {
return RAND_bytes(iv, iv_len); // 硬件随机数
}

// AES-256-CBC 加密
int aes_encrypt(const unsigned char *plaintext, int plaintext_len,
const unsigned char *key, // 32 字节(256-bit)
const unsigned char *iv, // 16 字节
unsigned char *ciphertext) {
AES_KEY aes_key;
if (AES_set_encrypt_key(key, 256, &aes_key) != 0) {
return -1; // key 长度错误
}

// PKCS7 填充:使数据长度为 AES_BLOCK_SIZE 的整数倍
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; // 返回加密后的长度(= padded_len)
}

// AES-256-CBC 解密
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);

// 移除 PKCS7 填充
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>  // OpenSSL EVP 高级接口

// AES-256-GCM 加密(带认证)
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) { // 16 字节认证标签
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;
}

// AES-256-GCM 解密(带认证验证)
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]; // 本分片的 IV
// 后面跟着 data_len 字节的密文
} FileChunk;

int split_and_encrypt_file(const char *input_path,
const char *output_dir,
const unsigned char *master_key) {
// 1. 读取整个文件
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);

// 2. 分片(每片 64KB)
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);

// 生成随机 IV
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); // +16 用于 padding
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>  // Android system properties

void derive_master_key(unsigned char *key_out) {
// 多因子组成主密钥
char android_id[65];
char build_fingerprint[256];
char app_signature[129];

// 因子 1: Android ID(设备绑定)
__system_property_get("ro.boot.serialno", android_id);

// 因子 2: 系统构建指纹(防止跨 ROM 迁移)
__system_property_get("ro.build.fingerprint", build_fingerprint);

// 因子 3: 应用签名(通过 JNI 从 Java 层获取)
// ... get_app_signature(app_signature) ...

// 派生:HKDF (HMAC-based Key Derivation Function)
// 或简化为多次 SHA-256 / HMAC-SHA256
unsigned char salt[] = "com.example.app.crypto.salt";

// 使用 HMAC-SHA256 派生
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); // key_out 长度 = 32 (SHA256)
}

注意:更换设备或系统升级后,上述因子会变化,导致无法解密旧数据。需要设计密钥迁移/备份机制(如用户登录后从服务端获取恢复密钥)。

4.2 Android Keystore 集成

Android Keystore System(Keymaster HAL)提供了硬件支持的密钥存储,密钥在 TEE(Trusted Execution Environment)中生成和操作,私钥永远不会离开安全硬件:

// Java 层使用 Android Keystore 生成 AES 密钥
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();

// 加密时使用 key(实际加密操作在 TEE 中完成)
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() {
// 方法 1: ptrace 自我跟踪(一个进程只能被一个调试器跟踪)
if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
// 已经被调试器 attach,采取保护措施
__android_log_print(ANDROID_LOG_WARN, "AntiDebug", "Debugger detected!");
// 可以退出进程、清除密钥、返回假数据等
_exit(1);
}

// 方法 2: 检查 /proc/self/status 中的 TracerPid
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 代码中硬编码明文字符串:

// BAD: 字符串在 .rodata 段中明文可见
const char *API_KEY = "sk-xxxx-secret-key-12345";

// GOOD: 编译时加密,运行时解密
// 使用宏或脚本在构建时将字符串加密
// 或者将字符串拆分为多个部分动态拼接
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};
// XOR 解码
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 加密性能对比

// 实际测试:加密 10MB 文件
// 测试环境:Snapdragon 865, Android 11

// Java (javax.crypto.Cipher, AES-256-CBC): ~120ms
// Native C (OpenSSL AES-256-CBC): ~25ms
// Native C (mbed TLS AES-256-CBC): ~35ms
// Native C (OpenSSL AES-256-GCM): ~30ms (含认证)

C 层加密速度约为 Java 层的 4-5 倍。差异来自:

  1. JNI 调用开销被分摊(大块数据一次性传递)。
  2. OpenSSL 使用了 ARM NEON 指令集加速(AESPMULL 指令)。
  3. 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
打赏
  • 微信
  • 支付宝

评论