ARM64 指令集逆向深度实操:移动端 Native SO 核心算法逆向与还原

在现代移动应用安全与业务攻防对抗中,无论是电商 App 的双 11 秒杀请求签名算法、风控设备指纹采集 SDK,还是高密支付模块,开发团队为了防范逆向与篡改,早已不再将核心逻辑留在易于反编译为 Java 代码的 DEX/Smali 层。几乎所有的商业核心资产,均被全面下沉至通过 C/C++ 编写、采用 Clang 交叉编译为 ARM64(AArch64)架构的底层原生动态链接库(.so 文件)。
与传统的 x86-64 架构不同,ARM64 属于典型的精简指令集(RISC):其拥有整整 31 个 64 位通用寄存器(X0-X30)、严格对齐的 4 字节定长指令、极为独特的桶形移位运算(Barrel Shifter)以及丰富的按位提取与融合乘加指令。面对一个去除了符号表、混淆了函数名、且通过动态注册 JNI 函数调用的 ARM64 样本,初级逆向分析人员往往容易在密集的位移操作与非线性寄存器流转中迷失方向。
本文立足实战视角,系统剖析面向 Android 移动端 Native SO 的逆向全流程,从 JNI 动态注册定位、ARM64 标志性数学指令解密,到动静结合提取核心算法并手写 Python 脚本无损还原。
一、 突破第一道关卡:快速定位 JNI 真实 Native 函数
在 Android 系统中,Java 层调用底层 C/C++ 代码有两种方式:静态绑定与动态注册。现代商业 SO 库 95% 以上均采用动态注册机制以隐匿真实函数名。
1. 动态注册的生命周期中枢:JNI_OnLoad
当 Java 代码执行 System.loadLibrary("sec_core") 时,Android 运行时虚拟机(ART)会自动在加载该 .so 时第一时间调用其导出的 JNI_OnLoad 函数。
在 IDA Pro 中打开目标 SO 库,直接按下 G 键跳转至导出表中的 JNI_OnLoad 函数:
// IDA F5 反编译后的 JNI_OnLoad 经典逻辑
jint JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env = NULL;
if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
// 寻找目标 Java 类
jclass clazz = (*env)->FindClass(env, "com/sec/mall/security/SignEngine");
// 关键调用: RegisterNatives 动态注册原生方法映射表
(*env)->RegisterNatives(env, clazz, gMethods, 2);
return JNI_VERSION_1_6;
}
2. 解剖 JNINativeMethod 结构体数组
RegisterNatives 的第三个参数 gMethods 是一个指向结构体数组的指针。每个结构体占用 24 个字节(在 64 位系统下包含 3 个 8 字节指针):
typedef struct {
const char* name; // Java 端声明的原生方法名 (如 "computeSign")
const char* signature; // 方法签名 (如 "(Ljava/lang/String;[B)Ljava/lang/String;")
void* fnPtr; // 核心目标! 底层真实的 C 语言实现函数物理虚拟内存地址
} JNINativeMethod;
双击跟进 gMethods 内存地址,我们可以在只读数据段(.rodata)中直接提取出第三个字段指向的真实函数地址(例如 0x1A420),瞬间绕开所有外部混淆,直捣黄龙锁定真实算法的核心入口点。
二、 ARM64 算法核心指令集的特征解密
进入算法内部后,我们需要识别一系列由编译器优化生成的、极具 RISC 特色的指令组合。
1. 桶形移位运算(Barrel Shifter)的单指令融合
在 ARM64 中,算术指令(如 add, sub, eor)可以在执行计算的同时,对第二个操作数进行硬件级的“免费位移”:
eor x0, x1, x2, lsl #3 # 将 X2 逻辑左移 3 位后,再与 X1 执行异或运算,结果存入 X0
add w3, w4, w5, lsr #5 # 将 W5 逻辑右移 5 位后,与 W4 相加,存入 W3
在 x86-64 下需要 3 条指令完成的位操作,ARM64 仅需 1 条指令即可完成。这在自定义加密、哈希摘要循环(如 MD5 / SHA-256 变异算法)中是绝对的高频特征。
2. 位提取与合并指令(UBFX 与 EXTR)
ubfx Xd, Xn, #lsb, #width(无符号位段提取):
从寄存器Xn中,从第lsb位开始提取width个二进制位,并将其右对齐零扩展存入Xd。常用于快速提取密钥掩码;extr Xd, Xn, Xm, #lsb(从两个寄存器串联中提取位段):
将Xn与Xm串联成 128 位寄存器并右移lsb位提取低 64 位。这是 ARM64 实现循环移位(Rotate Left/Right)的终极优化指令。
3. 乘加融合指令(MADD 与 MSUB)
madd x0, x1, x2, x3 # 计算: X0 = (X1 * X2) + X3
msub w0, w1, w2, w3 # 计算: W0 = W3 - (W1 * W2)
在加密算法的伪随机数线性同余生成器(LCG,如 $X_{n+1} = (aX_n + c) \bmod m$)或大数乘法中无处不在。
三、 动静协同:利用 Frida 动态 Hook 剥离算法边界
静态分析 ARM64 汇编虽然严谨,但面对数千行的复杂数学变换,盲目硬啃容易耗费巨量时间。此时,使用 Frida 挂钩入参和出参,能够让我们快速验证算法的“黑盒输入与输出特征”。
编写动态追踪脚本:
// Frida 动态挂钩 Android SO 库的核心签名算法函数
Java.perform(function () {
const targetModule = Process.findModuleByName("libsecurity.so");
// 假设分析出的核心加密算法偏移为 0x1A420
const targetAddress = targetModule.base.add(0x1A420);
Interceptor.attach(targetAddress, {
onEnter: function (args) {
console.log("\n[+] ====== 成功触发 Native 核心加密算法 ======");
// JNI 规约: X0 是 JNIEnv*, X1 是 jobject (this), X2 开始才是真实业务参数
const userInputStr = Java.vm.getEnv().getStringUtfChars(args[2], null).readCString();
console.log("[*] 入参 String (Arg2):", userInputStr);
// 打印入参内存 HexDump
console.log("[*] 原始内存数据:\n" + hexdump(args[2], { length: 32 }));
},
onLeave: function (retval) {
const resultStr = Java.vm.getEnv().getStringUtfChars(retval, null).readCString();
console.log("[+] 输出结果 (Retval):", resultStr);
}
});
});
通过这一步,我们在真机上发起一次正常的抢购请求,瞬间捕获到了:“当输入为 "timestamp=1791518400&uid=502" 时,底层经过算法处理后的十六进制输出为 "e4a89f310b7c..."”。我们拥有了验证还原算法绝对正确性的“金牌基准测试用例(Gold Test Vector)”。
四、 终局决战:手写纯 Python 算法脱壳还原
在明确了汇编指令与输入输出后,我们顺着控制流将一段核心的非标准哈希变换逻辑还原为纯 Python 脚本:
def arm64_rotate_left(val: int, shift: int, bits: int = 32) -> int:
"""模拟 ARM64 的 32 位循环左移操作"""
return ((val << shift) | (val >> (bits - shift))) & ((1 << bits) - 1)
def recover_native_algorithm(data_bytes: bytes, salt_key: int = 0x5A827999) -> str:
"""
根据逆向 ARM64 汇编反编译提取的核心位变换与非线性状态机逻辑
"""
# 状态寄存器初始化 (对应汇编中的 W4, W5, W6, W7 初始常量)
state = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476]
# 将输入数据按 4 字节小端序打包处理
padding_len = 64 - (len(data_bytes) % 64)
padded_data = data_bytes + bytes([0x80]) + bytes([0x00] * (padding_len - 1))
for chunk_offset in range(0, len(padded_data), 64):
chunk = padded_data[chunk_offset:chunk_offset + 64]
# 轮函数展开 (模拟汇编中的 EOR, MADD, EXTR 密集流水线)
a, b, c, d = state
for i in range(16):
word = int.from_bytes(chunk[i*4:(i+1)*4], byteorder='little')
# 还原 ARM64 的单指令融合异或与移位逻辑
temp = (a + ((b & c) | ((~b) & d)) + word + salt_key) & 0xFFFFFFFF
temp = arm64_rotate_left(temp, (i % 4) * 3 + 5)
# 状态轮转
a, b, c, d = d, temp, b, c
# 累加回写
state[0] = (state[0] + a) & 0xFFFFFFFF
state[1] = (state[1] + b) & 0xFFFFFFFF
state[2] = (state[2] + c) & 0xFFFFFFFF
state[3] = (state[3] + d) & 0xFFFFFFFF
# 格式化输出为最终的十六进制签名串
return "".join(f"{val:08x}" for val in state)
# 验证测试
test_input = b"timestamp=1791518400&uid=502"
recovered_signature = recover_native_algorithm(test_input)
print(f"[+] 算法还原成功!计算出的签名值: {recovered_signature}")
当 Python 脚本在本地终端运行输出的字符串,与之前 Frida 抓取的内存返回值逐字逐句完全对齐的一刹那,整座基于动态注册、指令级混淆与寄存器打乱的移动端 Native 堡垒被彻底完全还原。
五、 结语
移动安全攻防对抗,从来不是 Java 层的猫鼠游戏,而是深入到 ARM64 指令集与操作系统内核层面的硬核博弈。静态分析能够理清算法的骨架,动态 Hook 能够验证数据的血肉,而对指令集体系结构的深刻理解则是直击核心命门的手术刀。深谙 ARM64 的寄存器模型与指令拓扑,方能在移动端复杂的加固丛林中披荆斩棘、游刃有余。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)