在移动应用安全领域,Native层加密一直是保护敏感数据的黄金标准。当我们打开携程旅行App的lib目录时,那个不起眼的libctripenc.so文件里,藏着整个通信安全体系的核心钥匙。今天,我将带您深入ARM汇编的世界,用IDA Pro的手术刀解剖这个加密黑盒,还原其AES加解密的完整逻辑链条。
逆向工程就像考古发掘,合适的工具能让我们事半功倍。我的工作台上常年备着这些利器:
首先用adb pull获取设备中的/data/app/com.ctrip-1/lib/arm64/libctripenc.so,这个约2.3MB的二进制文件就是我们的主战场。通过readelf -s查看符号表,可以快速定位到两个关键函数:
bash复制$ readelf -s libctripenc.so | grep -E 'cd|ce'
1234: 0000a520 456 FUNC GLOBAL DEFAULT 12 Java_com_ctrip_EncodeUtil_cd
1235: 0000a6e8 512 FUNC GLOBAL DEFAULT 12 Java_com_ctrip_EncodeUtil_ce
在JNI调用约定中,函数名遵循Java_包名_类名_方法名的格式。这验证了我们在Java层看到的EncodeUtil类确实将加密逻辑下沉到了Native层。
用IDA Pro加载so文件后,直接跳转到Java_com_ctrip_EncodeUtil_cd函数地址。反编译视图显示了这个典型JNI函数的骨架:
c复制JNIEXPORT jbyteArray JNICALL
Java_com_ctrip_EncodeUtil_cd(JNIEnv *env, jobject obj, jbyteArray data, jint flag) {
const jbyte *input = (*env)->GetByteArrayElements(env, data, NULL);
jsize len = (*env)->GetArrayLength(env, data);
// 核心加密逻辑
unsigned char *output = aes_encrypt(input, len);
(*env)->ReleaseByteArrayElements(env, data, input, 0);
jbyteArray result = (*env)->NewByteArray(env, output_len);
(*env)->SetByteArrayRegion(env, result, 0, output_len, output);
free(output);
return result;
}
真正的玄机藏在aes_encrypt这个内部函数里。通过交叉引用追踪,我们在.rodata段发现了关键的AES S盒数据——这是识别AES算法最直接的证据。继续分析加密流程,可以看到以下特征:
openssl_CRYPTO_cbc128_encrypt调用确认是CBC模式关键的伪代码如下:
c复制void aes_encrypt(const uint8_t *input, size_t len, uint8_t *output) {
AES_KEY aes_key;
uint8_t iv[16];
memcpy(iv, &global_data[0x1F4], 16);
AES_set_encrypt_key(master_key, 256, &aes_key);
CRYPTO_cbc128_encrypt(input, output, len, &aes_key, iv, AES_encrypt);
}
静态分析只能看到冰山一角。我们祭出Frida进行运行时验证:
javascript复制Interceptor.attach(Module.findExportByName("libctripenc.so", "AES_set_encrypt_key"), {
onEnter: function(args) {
console.log("AES Key:");
console.log(hexdump(args[0], { length: 32 }));
}
});
运行后捕获到密钥生成的关键时刻:
code复制 0 1 2 3 4 5 6 7 8 9 A B C D E F
00000000 2A 7E 15 16 28 AE D2 A6 AB F7 15 88 09 CF 4F 3C *~..(.........O<
00000010 D0 EF AA FB 43 4D 33 85 45 11 21 1B 1D 33 61 42 ....CM3.E.!..3aB
有趣的是,这个32字节的master_key并非硬编码,而是通过pthread_once在首次调用时动态生成。逆向密钥生成函数发现其核心逻辑:
c复制void generate_master_key() {
// 从设备指纹获取种子
char device_id[32];
get_device_fingerprint(device_id);
// PBKDF2密钥派生
PKCS5_PBKDF2_HMAC(device_id, strlen(device_id),
salt, 8, 10000, EVP_sha256(),
32, master_key);
}
这种设计使得加密结果具有设备唯一性,即使相同的明文在不同设备上也会得到不同的密文。
理解了加密原理后,我们可以构建完整的通信流程:
请求构造:
python复制def build_request(params):
body = Protobuf.encode(params)
compressed = zlib.compress(body)
encrypted = aes_cbc_encrypt(compressed, iv=iv)
header = build_header(len(encrypted))
return header + encrypted
响应解密:
java复制public byte[] decryptResponse(byte[] data) {
byte[] iv = Arrays.copyOfRange(data, 16, 32);
byte[] ciphertext = Arrays.copyOfRange(data, 32, data.length);
return EncodeUtil.ce(ciphertext, 0); // 调用native解密
}
通过对比Java层和Native层的加密实现,我们发现关键差异:
| 特性 | Java层实现 | Native层实现 |
|---|---|---|
| 反编译难度 | 容易(JADX直接查看) | 困难(需ARM逆向) |
| 密钥保护 | 字符串常量 | 动态派生+设备指纹绑定 |
| 性能 | 较慢(JVM开销) | 快(NEON指令优化) |
| 白盒防护 | 无 | 控制流混淆+符号剥离 |
携程的加密方案设计体现了多层防御思想:
传输层防护:
代码保护:
armasm复制; 典型的控制流混淆片段
LDR R3, =0xBADF00D
CMP R0, R3
BNE loc_1234
BLX aes_encrypt
B loc_5678
loc_1234:
BLX fake_function
完整性校验:
/proc/self/maps检测调试器附着这些措施使得直接内存dump或hook变得困难。我在实际测试中发现,当检测到Frida注入时,so会主动触发abort()终止进程。
面对如此严密的防护,我们需要更精巧的逆向策略:
内存断点技巧:
python复制# 使用Frida追踪密钥使用
MemoryAccessMonitor.enable({
base: Module.findBaseAddress("libctripenc.so"),
size: 0x1000
}, {
onAccess: function(details) {
if (details.operation == 'read' && details.from == key_addr) {
console.log("Key accessed from:",
DebugSymbol.fromAddress(details.from));
}
}
});
ARM指令级补丁:
armasm复制; 修改条件跳转绕过反调试
LDR R0, [R1,#0x10]
CMP R0, #0
BEQ normal_flow
; 修改为
NOP
NOP
在逆向过程中,我总结出几个关键经验点:
.init_array段的初始化函数OPENSSL_init_crypto等密码学函数调用通过三周的深度逆向,最终还原出的加密流程图如下:
code复制[明文] → Protobuf序列化 → zlib压缩 → AES-CBC加密 → 私有协议封装
↑ ↑
设备指纹派生密钥 随机IV+填充
这种立体化的安全设计,使得即使获得加密算法细节,没有特定设备的指纹信息也无法正确加解密。在最近一次版本更新中,我发现携程又新增了运行时自校验机制,这给逆向工作带来了新的挑战。不过正如安全圈的老话所说:"没有绝对的安全,只有不断提升的成本"。