ARM 汇编入门 (Android Native)

ARM 汇编入门 (Android Native)

当应用的核心逻辑、加密算法或性能密集型任务用 C/C++ 编写时,它们会被编译成原生库 (.so 文件)。在 Android 上,这些库主要是 ARM 架构的。理解 ARM 汇编是分析 .so 文件的基础。本指南将介绍逆向工程师需要了解的 ARMv7 (32-bit) 和 ARMv8 (64-bit/A64) 的基础知识。

思考:为什么必须学习汇编?

当你遇到以下场景时,该如何应对?

  • 用 Jadx 打开 APK,发现关键的加密逻辑都在 native 方法中
  • Frida Hook 到了某个 JNI 函数,但参数是指针,不知道如何读取
  • IDA 打开 .so 文件,满屏的汇编指令让你无从下手

这些场景的共同点是:核心逻辑被编译成了机器码。不理解汇编,就像试图在不懂外语的情况下阅读外文书籍——你只能靠猜。

基本概念:ARM vs x86

  • RISC vs CISC: ARM 是精简指令集计算机 (RISC),指令长度固定,种类较少,操作简单。x86 是复杂指令集计算机 (CISC)

  • Load/Store 架构: ARM 是一种"加载/存储"架构。这意味着数据处理(如加法、减法)只能在寄存器之间进行。你必须先用加载指令 (LDR) 将内存中的数据加载到寄存器,计算完成后再用存储指令 (STR) 将结果存回内存。

  • 指令模式: ARMv7 (32-bit) 支持两种指令集:

  • ARM: 32-bit 定长指令,功能强大。

  • Thumb: 16-bit/32-bit 变长指令,代码密度更高,是移动设备上的主流。在 IDA 等工具中,你通常会分析 Thumb 模式下的代码。


ARM 与 ARM64 (AArch64) 架构

在 Android 逆向中,你会同时遇到 32 位 (ARMv7/armeabi-v7a) 和 64 位 (ARMv8/arm64-v8a) 的 .so 文件。现代设备几乎都是 64 位,但很多应用为了兼容性仍然提供 32 位库。

ARM 32-bit (ARMv7) 寄存器模型

共有 16 个通用寄存器 (R0 - R15),加上一个当前程序状态寄存器 (CPSR)。

寄存器别名用途
R0 - R3A1 - A4参数寄存器 (Argument)。用于传递函数的前 4 个参数。R0 也用作返回值寄存器
R4 - R11V1 - V8变量寄存器 (Variable)。被调用者保存,用于保存函数的局部变量。
R12IP过程调用间临时寄存器 (Intra-Procedure call scratch register)。
R13SP栈指针 (Stack Pointer)。指向栈顶,8 字节对齐。
R14LR链接寄存器 (Link Register)。存储函数调用的返回地址。
R15PC程序计数器 (Program Counter)。指向当前正在执行的指令地址 +8 (ARM) 或 +4 (Thumb)。

CPSR (当前程序状态寄存器)

CPSR 存储了条件标志位和处理器状态信息。条件分支指令正是根据 CPSR 中的标志位来决定是否跳转的。

标志位名称含义
NNegative运算结果为负数时置 1
ZZero运算结果为零时置 1
CCarry无符号运算产生进位/借位时置 1
VOverflow有符号运算产生溢出时置 1
TThumb处理器处于 Thumb 模式时置 1,ARM 模式时为 0
快速定位关键寄存器

在分析一个陌生函数时,如何快速抓住重点?

  • 函数入口:先看 R0-R3 (32 位) 或 X0-X7 (64 位),这些是参数
  • 函数返回:关注 R0/X0,这是返回值存放的地方
  • 函数调用BL 指令前后,检查参数寄存器的变化
  • 栈操作SP 的变化反映了局部变量的分配

这种"重点优先"的阅读策略,能让你快速理解函数的输入输出,而不必逐行分析每条指令。

ARM 64-bit (AArch64) 寄存器模型

AArch64 有 31 个通用寄存器,设计比 ARM32 更加规整。

寄存器64 位名32 位名用途
第 0-7 号X0-X7W0-W7参数/返回值寄存器。传递前 8 个参数,X0 也是返回值。
第 8 号X8W8间接结果位置寄存器 (用于返回大结构体)。
第 9-15 号X9-X15W9-W15调用者保存的临时寄存器 (Caller-saved)。函数可自由修改。
第 16-17 号X16-X17W16-W17IP0/IP1,过程内调用临时寄存器(链接器使用)。
第 18 号X18W18平台寄存器。Android 上被保留给 TLS (线程本地存储)。
第 19-28 号X19-X28W19-W28被调用者保存的寄存器 (Callee-saved)。函数使用前必须保存。
第 29 号X29W29帧指针 FP (Frame Pointer)。
第 30 号X30W30链接寄存器 LR (Link Register)。
SPWSP栈指针。不是通用寄存器,有专门的编码。
XZRWZR零寄存器。读取永远返回 0,写入被丢弃。

W 寄存器与 X 寄存器的关系

W 寄存器是对应 X 寄存器的低 32 位视图。当你写入一个 W 寄存器时,高 32 位会被自动清零

1
2
3
; X0 = 0x00000001_FFFFFFFF
MOV W0, #0x42        ; X0 变为 0x00000000_00000042 (高32位被清零)
MOV X0, #0x42        ; X0 变为 0x00000000_00000042 (整个64位赋值)

NZCV (AArch64 条件标志)

AArch64 中条件标志存储在 NZCV 系统寄存器中,功能与 ARM32 的 CPSR 标志位相同:

1
2
3
4
5
6
7
CMP X0, X1           ; 执行 X0 - X1,更新 NZCV 标志
B.EQ label           ; 如果 Z=1 (相等) 则跳转
B.NE label           ; 如果 Z=0 (不等) 则跳转
B.GT label           ; 如果有符号大于 则跳转
B.LT label           ; 如果有符号小于 则跳转
B.HI label           ; 如果无符号大于 则跳转
B.LO label           ; 如果无符号小于 则跳转 (等同于 B.CC)

栈指针 (SP) 要求

在 AArch64 中,栈指针必须16 字节对齐。这意味着 SUB SP, SP, #N 中的 N 必须是 16 的倍数,否则会触发对齐异常。这是初学者容易忽略的重要细节。


指令集详解

数据处理指令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
; === 移动指令 ===
MOV  R1, R2           ; R1 = R2
MOV  X1, X2           ; X1 = X2 (AArch64)
MOV  R0, #0xFF        ; R0 = 255 (立即数)
MVN  R0, R1           ; R0 = ~R1 (按位取反)
MOVZ X0, #0x1234      ; X0 = 0x1234 (将16位立即数放入寄存器,其余位清零)
MOVK X0, #0x5678, LSL #16  ; 将 0x5678 放到 X0 的 [31:16] 位,保持其他位不变

; === 算术指令 ===
ADD  R0, R1, R2       ; R0 = R1 + R2
ADDS R0, R1, R2       ; R0 = R1 + R2,并更新 CPSR 标志位 (注意末尾的 S)
SUB  R0, R1, R2       ; R0 = R1 - R2
RSB  R0, R1, #0       ; R0 = 0 - R1 (反向减法,常用于取负数)
MUL  R0, R1, R2       ; R0 = R1 * R2
SDIV X0, X1, X2       ; X0 = X1 / X2 (有符号除法,AArch64)

; === 逻辑指令 ===
AND  R0, R1, #0xFF    ; R0 = R1 & 0xFF (按位与)
ORR  R0, R1, R2       ; R0 = R1 | R2  (按位或)
EOR  R0, R1, R2       ; R0 = R1 ^ R2  (按位异或)
BIC  R0, R1, R2       ; R0 = R1 & ~R2 (位清除)
TST  R0, #0x1         ; 测试 R0 的最低位 (AND 但不存储结果,只更新标志)

; === 移位指令 ===
LSL  R0, R1, #3       ; R0 = R1 << 3 (逻辑左移)
LSR  R0, R1, #3       ; R0 = R1 >> 3 (逻辑右移,高位补0)
ASR  R0, R1, #3       ; R0 = R1 >> 3 (算术右移,高位补符号位)
ADDS vs ADD

ARM 指令的一个重要特点:大多数数据处理指令默认不更新条件标志。只有在指令末尾加上 S 后缀(如 ADDSSUBS),才会更新 CPSR/NZCV。而 CMPTST 是特殊的——它们总是更新标志。在 AArch64 中这个规则同样适用。

加载/存储指令

这是 ARM 的核心。所有内存访问都必须通过 LDR/STR 指令完成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
; === 基本加载/存储 ===
LDR  R0, [R1]         ; R0 = *R1  (从 R1 指向的地址加载 4 字节)
STR  R0, [R1]         ; *R1 = R0  (将 R0 存储到 R1 指向的地址)

; === 不同大小的访问 ===
LDRB R0, [R1]         ; 加载 1 字节 (零扩展到 32 位)
LDRH R0, [R1]         ; 加载 2 字节 (半字, 零扩展)
LDRSB R0, [R1]        ; 加载 1 字节 (符号扩展到 32 位)
STRB R0, [R1]         ; 存储 R0 的最低 1 字节

; === 偏移寻址 ===
LDR  R0, [R1, #4]     ; R0 = *(R1 + 4)  (立即数偏移)
LDR  R0, [R1, R2]     ; R0 = *(R1 + R2)  (寄存器偏移)
LDR  R0, [R1, R2, LSL #2]  ; R0 = *(R1 + R2*4)  (带移位的寄存器偏移,常见于数组访问)

; === 前索引 (Pre-indexed) / 后索引 (Post-indexed) ===
LDR  R0, [R1, #4]!    ; R1 += 4; R0 = *R1  (先更新,再加载)
LDR  R0, [R1], #4     ; R0 = *R1; R1 += 4  (先加载,再更新)

; === AArch64 特有 ===
LDP  X0, X1, [SP]     ; 一次加载两个 64 位寄存器 (Load Pair)
STP  X0, X1, [SP, #-16]!  ; 一次存储两个寄存器,带前索引 (Store Pair)
LDUR X0, [X1, #-8]    ; 非对齐偏移加载 (Unscaled)
ADR  X0, label        ; 将 PC 相对地址加载到 X0
ADRP X0, page_label   ; 将 PC 相对页地址加载到 X0 (4KB 页粒度)
LDR 的 PC 相对寻址

在反汇编中你会经常看到 LDR R0, =0x12345678 这种伪指令。汇编器实际上会将常量放在一个"文字池 (literal pool)“中,然后转换为 LDR R0, [PC, #offset]。在 IDA 中你可以看到这种转换后的形式。

分支指令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
; === 无条件分支 ===
B    label            ; 跳转到 label
BL   label            ; 跳转到 label,同时 LR = 下一条指令地址 (函数调用)
BR   X8               ; 跳转到 X8 中的地址 (AArch64,寄存器间接跳转)
BLR  X8               ; 调用 X8 中的地址 (AArch64,间接函数调用)
RET                   ; 返回,等效于 BR X30 (AArch64)
BX   LR               ; 返回 (ARM32,同时可切换 ARM/Thumb 模式)

; === 条件分支 (ARM32) ===
CMP  R0, #10          ; 比较 R0 和 10
BEQ  label            ; Branch if Equal (Z=1)
BNE  label            ; Branch if Not Equal (Z=0)
BGT  label            ; Branch if Greater Than (有符号)
BLT  label            ; Branch if Less Than (有符号)
BHI  label            ; Branch if Higher (无符号大于)
BLO  label            ; Branch if Lower (无符号小于)

; === AArch64 条件分支 (使用点号语法) ===
CMP  X0, X1
B.EQ label            ; 相等
B.NE label            ; 不等

; === AArch64 特有的比较并分支 ===
CBZ  X0, label        ; X0 == 0 则跳转
CBNZ X0, label        ; X0 != 0 则跳转
TBZ  X0, #5, label    ; X0 的第 5 位为 0 则跳转
TBNZ X0, #5, label    ; X0 的第 5 位不为 0 则跳转

常用条件码速查

后缀含义标志条件
EQEqual (相等)Z=1
NENot Equal (不等)Z=0
GTGreater Than (有符号大于)Z=0, N=V
LTLess Than (有符号小于)N!=V
GEGreater or EqualN=V
LELess or EqualZ=1 或 N!=V
HIHigher (无符号大于)C=1, Z=0
LOLower (无符号小于)C=0

栈操作指令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
; === ARM32 栈操作 ===
PUSH {R4, R5, LR}     ; 将 R4, R5, LR 压入栈 (SP 向低地址增长)
POP  {R4, R5, PC}     ; 从栈弹出到 R4, R5, PC (将 LR 弹到 PC 实现返回)

; 等效展开:
; PUSH {R4, LR} 等同于:
;   STR  LR, [SP, #-4]!
;   STR  R4, [SP, #-4]!

; === AArch64 栈操作 ===
; AArch64 没有 PUSH/POP,使用 STP/LDP
STP  X29, X30, [SP, #-16]!  ; 保存 FP 和 LR,SP -= 16
MOV  X29, SP                ; 设置新的帧指针
; ... 函数体 ...
LDP  X29, X30, [SP], #16    ; 恢复 FP 和 LR,SP += 16
RET                          ; 返回

; 分配局部变量空间
SUB  SP, SP, #48             ; 分配 48 字节的栈空间 (必须是 16 的倍数)
; ... 使用 [SP, #0], [SP, #8] 等访问局部变量 ...
ADD  SP, SP, #48             ; 释放栈空间

函数调用约定 (AAPCS)

AAPCS (ARM Architecture Procedure Call Standard) 定义了函数之间如何交互。逆向工程师必须理解这些约定才能正确分析函数的输入输出。

参数传递规则

ARM32 (AAPCS)

参数位置寄存器说明
第 1 个参数R0也用作返回值
第 2 个参数R164 位参数可用 R0:R1 或 R2:R3
第 3 个参数R2
第 4 个参数R3
第 5+ 个参数按顺序压栈,第 5 个在 [SP],第 6 个在 [SP, #4]

AArch64 (AAPCS64)

参数位置寄存器说明
第 1 个参数X0也用作返回值
第 2 个参数X1
第 3-8 个参数X2-X7
第 9+ 个参数
浮点参数D0-D7使用 SIMD/浮点寄存器

返回值

1
2
3
4
5
6
7
8
9
; ARM32: 返回值在 R0 (32位) 或 R0:R1 (64位)
; 例: 返回 int
MOV  R0, #42
BX   LR

; AArch64: 返回值在 X0 (64位)
; 例: 返回 int
MOV  X0, #42
RET

调用者保存 vs 被调用者保存

理解这个概念对识别函数边界非常重要:

类别ARM32 寄存器AArch64 寄存器说明
调用者保存 (Caller-saved)R0-R3, R12X0-X18函数调用后可能被修改,调用者如需保留值必须自行保存
被调用者保存 (Callee-saved)R4-R11X19-X28被调用函数如果要使用,必须先保存、用完后恢复
特殊用途SP, LR, PCSP, X29(FP), X30(LR)有特定约定

实际例子:观察函数序言如何保存寄存器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
; ARM32 函数序言 - 保存要使用的被调用者保存寄存器
my_function:
    PUSH {R4, R5, R6, LR}   ; 保存 R4-R6 (要使用) 和 LR (返回地址)
    SUB  SP, SP, #16         ; 分配 16 字节局部变量空间
    ; R0-R3 是参数,可以直接使用
    MOV  R4, R0              ; 将第一个参数保存到 R4 (调用其他函数后还需要用)
    BL   other_function      ; 调用其他函数 (R0-R3 可能被修改)
    ; R4 仍然保持之前的值,因为它是被调用者保存的
    MOV  R0, R4              ; 准备返回值
    ADD  SP, SP, #16         ; 释放局部变量空间
    POP  {R4, R5, R6, PC}   ; 恢复寄存器并返回

; AArch64 函数序言
my_function_a64:
    STP  X29, X30, [SP, #-32]!  ; 保存 FP, LR
    STP  X19, X20, [SP, #16]    ; 保存 callee-saved 寄存器
    MOV  X29, SP                ; 建立栈帧
    MOV  X19, X0                ; 保存第一个参数
    BL   other_function
    MOV  X0, X19                ; 使用保存的参数作为返回值
    LDP  X19, X20, [SP, #16]
    LDP  X29, X30, [SP], #32
    RET

常见模式识别

逆向工程最重要的技能之一是在汇编中识别高级语言的结构。以下是常见的代码模式。

if/else 结构

1
2
3
4
5
6
// C 代码
if (a > 10) {
    result = 1;
} else {
    result = 0;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
; ARM32 汇编
    CMP   R0, #10         ; 比较 a 和 10
    BLE   .else_branch    ; 如果 a <= 10,跳到 else
    MOV   R0, #1          ; result = 1 (if 分支)
    B     .end_if         ; 跳过 else
.else_branch:
    MOV   R0, #0          ; result = 0 (else 分支)
.end_if:

; AArch64 可能使用条件选择指令代替分支
    CMP   W0, #10
    MOV   W1, #1          ; 准备 if 的值
    MOV   W2, #0          ; 准备 else 的值
    CSEL  W0, W1, W2, GT  ; W0 = (a > 10) ? W1 : W2
识别技巧

看到 CMP 后紧跟条件跳转 B.xx,这几乎一定是 if/else 结构。跳转目标就是 else 分支或 if 块结束的位置。注意条件码是反转的:C 代码中是 > 10,汇编中跳转条件是 <= 10 (BLE),因为是"不满足条件就跳过”。

for 循环

1
2
3
4
5
// C 代码
int sum = 0;
for (int i = 0; i < 10; i++) {
    sum += array[i];
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
; AArch64 汇编
    MOV   W0, #0          ; sum = 0
    MOV   W1, #0          ; i = 0
    ADR   X2, array       ; X2 = 数组基地址
.loop_start:
    CMP   W1, #10         ; i < 10?
    B.GE  .loop_end       ; 如果 i >= 10,退出循环
    LDRSH W3, [X2, X1, LSL #2]  ; W3 = array[i] (假设 int 数组)
    ADD   W0, W0, W3      ; sum += array[i]
    ADD   W1, W1, #1      ; i++
    B     .loop_start     ; 跳回循环头部
.loop_end:
识别 for 循环的三要素
  1. 初始化:循环变量设为初始值 (MOV W1, #0)
  2. 条件检查CMP + 条件跳转到循环体外 (CMP W1, #10; B.GE)
  3. 递增/递减:循环变量更新 (ADD W1, W1, #1) 后跳回条件检查处

编译器可能将条件检查放在循环末尾(do-while 优化),这时结构变为:先跳到末尾检查,循环体在前,条件判断在后。

while 循环

1
2
3
4
5
// C 代码
while (ptr != NULL) {
    process(ptr);
    ptr = ptr->next;
}
1
2
3
4
5
6
7
8
9
; AArch64 汇编
    ; X19 = ptr (使用 callee-saved 寄存器保存)
.while_check:
    CBZ   X19, .while_end    ; if (ptr == NULL) 跳出
    MOV   X0, X19            ; 第一个参数 = ptr
    BL    process             ; 调用 process(ptr)
    LDR   X19, [X19, #8]    ; ptr = ptr->next (假设 next 在偏移 +8)
    B     .while_check       ; 回到条件检查
.while_end:

switch-case 结构

switch-case 有多种编译方式,最常见的是跳转表 (jump table)

1
2
3
4
5
6
7
// C 代码
switch (cmd) {
    case 0: action_a(); break;
    case 1: action_b(); break;
    case 2: action_c(); break;
    default: action_default(); break;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
; AArch64 汇编 (跳转表实现)
    CMP   W0, #2            ; cmd 和最大 case 值比较
    B.HI  .default_case     ; 无符号大于 2,跳到 default
    ADR   X1, .jump_table   ; 取跳转表基地址
    LDRSW X2, [X1, X0, LSL #2]  ; 从跳转表加载偏移
    ADD   X1, X1, X2        ; 计算目标地址
    BR    X1                ; 跳转到对应 case

.jump_table:
    .word .case_0 - .jump_table
    .word .case_1 - .jump_table
    .word .case_2 - .jump_table

.case_0:
    BL    action_a
    B     .switch_end
.case_1:
    BL    action_b
    B     .switch_end
.case_2:
    BL    action_c
    B     .switch_end
.default_case:
    BL    action_default
.switch_end:
在 IDA/Ghidra 中识别 switch

当你看到一个 CMP 后跟范围检查 (B.HIB.HS),然后有一个 ADR + 表加载 + BR 的组合时,这就是一个跳转表实现的 switch。IDA 通常能自动识别并显示为 switch jump 注释。如果识别不了,可以手动指定跳转表。

函数序言/结语 (Prologue/Epilogue)

这是最重要的模式之一,因为它标志着函数的边界。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
; === ARM32 典型函数序言/结语 ===
func_arm32:
    ; --- 序言 (Prologue) ---
    PUSH  {R4-R7, LR}       ; 保存被调用者保存寄存器和返回地址
    SUB   SP, SP, #20       ; 为局部变量分配栈空间
    ; --- 函数体 ---
    ; ...
    ; --- 结语 (Epilogue) ---
    ADD   SP, SP, #20       ; 释放局部变量空间
    POP   {R4-R7, PC}       ; 恢复寄存器,将 LR 弹到 PC 实现返回

; === AArch64 典型函数序言/结语 ===
func_aarch64:
    ; --- 序言 (Prologue) ---
    STP   X29, X30, [SP, #-32]!  ; 保存 FP 和 LR
    STP   X19, X20, [SP, #16]    ; 保存将要使用的 callee-saved 寄存器
    MOV   X29, SP                ; 建立帧指针
    ; --- 函数体 ---
    ; ...
    ; --- 结语 (Epilogue) ---
    LDP   X19, X20, [SP, #16]    ; 恢复 callee-saved 寄存器
    LDP   X29, X30, [SP], #32    ; 恢复 FP 和 LR
    RET                           ; 返回

Thumb/Thumb-2 模式

在 Android 32 位 .so 文件中,你几乎总是在看 Thumb-2 代码,而不是纯 ARM 代码。理解 Thumb 模式对于正确分析至关重要。

什么是 Thumb?

  • Thumb (T1):原始 Thumb 指令集,所有指令都是 16 位宽。代码密度高,但功能有限。
  • Thumb-2 (T2):ARMv6T2 及以上引入。混合使用 16 位和 32 位指令,既有高代码密度,又有完整功能。
  • ARM 模式:所有指令 32 位宽。功能完整,但代码密度低。

ARM 与 Thumb 模式切换

处理器通过 CPSR 的 T 位判断当前模式。关键的切换机制是 BX (Branch and Exchange) 指令:

1
2
3
4
5
6
7
8
; BX 指令通过目标地址的最低位判断切换到哪个模式:
; - 地址最低位 = 1 -> 切换到 Thumb 模式
; - 地址最低位 = 0 -> 切换到 ARM 模式
; (实际跳转时最低位会被忽略,只用于模式判断)

BX   R0          ; 跳转到 R0 中的地址,根据 R0[0] 切换模式
BLX  R0          ; 带链接的分支并交换模式
BLX  label       ; 跳转到 label 并切换 ARM/Thumb 模式

在 IDA 中识别 Thumb 代码

  • 函数地址:如果函数地址是奇数(最低位为 1),说明是 Thumb 代码。例如地址 0x1001 实际是 0x1000 处的 Thumb 代码。
  • 指令长度:IDA 中 Thumb-2 代码的指令长度是 2 或 4 字节,ARM 模式总是 4 字节。
  • 手动切换:如果 IDA 错误地以 ARM 模式解析了 Thumb 代码(或反之),可以按 Alt+G 修改 T 寄存器的值(1 = Thumb,0 = ARM),然后按 C 重新分析。

Thumb-2 vs ARM 指令对比

操作ARM 模式Thumb-2 模式
加法ADD R0, R1, R2 (32-bit)ADD R0, R1, R2 (16-bit 或 32-bit)
压栈STMDB SP!, {R4-R6, LR}PUSH {R4-R6, LR} (16-bit)
条件执行ADDNE R0, R0, #1需要 IT NE + ADD R0, R0, #1
分支B label (32-bit)B label (16-bit, 范围较小)

IT (If-Then) 指令块:Thumb-2 用 IT 指令实现条件执行,这是逆向中常见的模式:

1
2
3
4
5
; Thumb-2 条件执行
    CMP   R0, #0
    ITE   EQ         ; If-Then-Else: 如果 EQ,下一条执行;否则下下条执行
    MOVEQ R1, #1     ; if (R0 == 0) R1 = 1
    MOVNE R1, #0     ; else R1 = 0
AArch64 没有 Thumb

ARM64 (AArch64) 架构不存在 Thumb 模式。AArch64 只使用固定 32 位宽的 A64 指令集。所以当你分析 arm64-v8a.so 文件时,不需要担心 Thumb 模式的问题。


NEON/SIMD 基础

在逆向分析加密算法(如 AES、SHA)的 native 实现时,你可能会遇到 NEON 指令。NEON 是 ARM 的 SIMD (单指令多数据) 扩展,能同时对多个数据元素执行相同操作。

NEON 寄存器

ARM32 (VFPv3/NEON)

寄存器大小说明
S0-S3132-bit单精度浮点
D0-D3164-bit双精度浮点 / NEON 向量
Q0-Q15128-bitNEON 四字向量 (Q0 = D0:D1)

AArch64 (ASIMD)

寄存器大小说明
V0-V31128-bit统一的向量/浮点寄存器
访问方式Vn.16B16 个字节
Vn.8H8 个半字 (16-bit)
Vn.4S4 个单字 (32-bit)
Vn.2D2 个双字 (64-bit)

逆向中常见的 NEON 指令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
; AArch64 NEON 示例 (AES 加密中常见)
LD1   {V0.16B}, [X0]          ; 从 X0 加载 16 字节到 V0
LD1   {V1.16B}, [X1]          ; 加载密钥
AESE  V0.16B, V1.16B          ; AES 单轮加密
AESMC V0.16B, V0.16B          ; AES MixColumns
EOR   V0.16B, V0.16B, V2.16B  ; 异或操作 (向量)
ST1   {V0.16B}, [X0]          ; 将结果存储回内存

; 常见的向量算术
ADD   V0.4S, V1.4S, V2.4S     ; 4 个 32 位整数同时加法
MUL   V0.4S, V1.4S, V2.4S     ; 4 个 32 位整数同时乘法
SHL   V0.4S, V1.4S, #8        ; 4 个 32 位元素同时左移 8 位
TBL   V0.16B, {V1.16B}, V2.16B ; 查表指令 (S-Box 实现常用)
遇到 NEON 不要慌

在逆向中遇到大量 NEON 指令,通常意味着以下几种情况之一:

  1. 加密算法:AES、SHA 等有硬件加速指令 (AESE、SHA256H 等)
  2. 数据处理:图片处理、音视频编解码
  3. 校验和:CRC32 等

通常你不需要逐条理解每个 NEON 指令,而是识别出整体模式(这是一个 AES 加密循环、这是一个 SHA-256 压缩函数等),然后查找对应的标准实现进行对比。


在 IDA/Ghidra 中阅读 ARM

掌握工具的使用技巧能极大提升逆向效率。

IDA Pro 实用技巧

基本操作

快捷键功能
Space在图形视图和文本视图之间切换
F5反编译 (查看伪代码)
G跳转到指定地址
N重命名函数/变量
X查看交叉引用 (Xrefs)
Y修改函数签名/类型
H切换数值显示格式 (十进制/十六进制)
Alt+G修改段寄存器值 (用于切换 ARM/Thumb)
/在反编译视图中添加注释

ARM 特定技巧

  1. 检查 Thumb 模式:如果反汇编看起来不对劲(大量无效指令),可能是 ARM/Thumb 模式判断错误。使用 Alt+G 设置 T 值。

  2. 识别字符串引用:在 ARM64 代码中,字符串加载通常是 ADRP + ADD 的组合:

1
2
3
ADRP  X0, #aHelloWorld@PAGE      ; 加载页地址
ADD   X0, X0, #aHelloWorld@PAGEOFF ; 加上页内偏移
BL    puts                         ; 调用 puts
  1. 结构体偏移:看到连续的 LDR Xn, [X0, #offset] 时,这通常是在访问结构体成员。可以在 IDA 中创建结构体定义来改善可读性。

  2. 函数签名恢复:对于 JNI 函数,第一个参数是 JNIEnv*,第二个是 jobject (实例方法) 或 jclass (静态方法)。在 IDA 中设置正确的函数签名后,反编译结果会清晰得多。

Ghidra 实用技巧

  1. 导入 .so 文件:选择正确的处理器类型(ARM/AARCH64)和字节序(小端序 LE)。
  2. JNI 头文件:通过 File -> Parse C Source 导入 jni.h,可以正确标注 JNI 函数。
  3. 常用操作L 重命名、右键 “Retype Variable” 改类型、Window -> “Decompiler” 看反编译。
  4. 注意事项:Ghidra 可能在 Thumb/ARM 模式混淆、NEON 指令、尾调用优化等场景产生错误。

Frida 配合汇编分析

在静态分析后,可以用 Frida 动态验证你的理解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 在特定地址读取寄存器值 (AArch64)
Interceptor.attach(ptr("0x12345678"), {
    onEnter: function(args) {
        // args[0] 对应 X0, args[1] 对应 X1, ...
        console.log("X0 =", args[0]);
        console.log("X1 =", args[1]);

        // 读取更多寄存器
        console.log("X8 =", this.context.x8);
        console.log("SP =", this.context.sp);
        console.log("LR =", this.context.lr);

        // 读取内存
        console.log("X0 指向的字符串:", args[0].readUtf8String());
        console.log("[SP+0x10]:", this.context.sp.add(0x10).readPointer());
    }
});
从汇编到 Frida hook 的工作流
  1. 在 IDA/Ghidra 中定位目标函数,分析函数签名
  2. 找到关键比较/分支点 (如密码校验、License 检查)
  3. 使用 Frida Interceptor.attach 在对应地址 hook,读取或修改寄存器值
  4. 如果需要绕过检查,用 Interceptor.replace 替换函数或 Memory.patchCode 修改指令

相关内容

如果这篇文章对你有帮助,请我喝杯咖啡吧~
+5 支付宝支付宝
+5 微信微信