Android逆向基础之ARM汇编语言知识总结

Posted Tr0e

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android逆向基础之ARM汇编语言知识总结相关的知识,希望对你有一定的参考价值。

前言

X86 是英特尔 Intel 首先开发制造的一种微处理器体系结构的泛称,包括 Intel 8086、80186、80286、80386 以及 80486 等以 86 结尾系列,英特尔统治整个 CPU 产业链长达数十年。ARM(Advanced RISC Machines)公司是苹果、Acorn、VLSI、Technology等公司的合资企业,ARM 处理器非常适用于移动通信领域,具有低成本、高性能和低耗电的特性,ARM 的高性价比和低耗能在移动市场比英特尔更具优势。ARM 架构和 x86 架构直观区别:x86 主要用于 PC 领域如笔记本、台式机、小型服务器;arm 主要用于移动领域如手机、平板。

从前面几篇学习二进制文件动态调试的文章中可以看出,exe、elf 等在 PC 服务器上运行的可执行文件的汇编指令由 x86 汇编指令构成,因为其载体(服务器)是基于 x86 架构的 CPU 处理器。但是对于 android 逆向来说则不能只会 x86 汇编指令,因为手机采用的是 arm 架构的 CPU 处理器,需要 arm 汇编指令进行操作,故本文学习记录一下 ARM 汇编语言的知识。

ARM32汇编

Arm 32 位是 ARMv7 架构,32 位的操作长度,对应处理器为 Cortex-A15等, IPhone5 以前均是 32 位的;ARM 64 位采用 ARMv8 架构,64 位操作长度,对应处理器有 Cortex-A53、Cortex-A57、Cortex-A73、iphones的 A7 和 A8 等,苹果手机从 IPhone 5s 开始使用 64 位的处理器。
注意 ARM32 和 ARM64 架构的 CPU 所采用的汇编语言有所差异,需要加以区分学习。

1.1 寄存器

ARM 处理器支持的 7 种运行模式:

用户模式(usr):ARM 处理器正常的程序执行状态
快速中断模式(fiq):用于高速数据传输或通道处理
外部中断模式(irq):用于通用的中断处理
管理模式(svc):操作系统使用的保护模式
数据访问终止模式(abt):当数据或指令预取终止时进入该模式,可用于虚拟存储及存储保护
系统模式(sys):运行具有特权的操作系统任务
未定义指令中止模式(und):当未定义的指令执行时进入该模式

ARM 微处理器的运行模式可以通过软件改变(修改状态寄存器 CPSR 后 5 位),也可以通过中断或者异常处理改变。不同模式下处理器使用的寄存器不尽相同,可供访问的资源也不一样。以上模式除了用户模式,都是特权模式,特权模式下,处理器可任意访问受保护的系统资源。现在只关注 ARM 程序逆向分析技术涉及的用户模式。

ARM 32 位的微处理器共有 37 个 32 位寄存器:31 个通用寄存器(Inter + 6 个为状态寄存器,也可细致划分为 16 个通用寄存器(R0~R15) + 1个状态寄存器 + 15 个通用影子寄存器 + 5 个状态影子寄存器。

在 ARM 32 位用户模式下,处理器可访问的寄存器为:

不分组寄存器:R0~R7;
分组寄存器:R8~R14(R13通常用作栈指针 SP,R14通常用作子程序链接寄存器 LR);
程序计数器:R15(PC)
当前程序状态寄存器 CPSR。

相关寄存器作用如下:

ARM 处理器有两种工作状态,即 ARM 状态和 Thumb 状态,处理器可在这两种状态间随意切换。处理器处于 ARM 状态时,会执行 32 位对齐的 ARM 指令;处于 Thumb 状态时,会执行 16 位对齐的 Thumb 指令。Thumb 状态下对寄存器的命名与 ARM 状态下有所差异,它们的关系:

Thumb 状态下的 R0 ~ R7ARM 状态下的 R0 ~ R7 相同
Thumb 状态下的 CPSRARM 状态下的 CPSR 相同
Thumb 状态下的 FP 对应 ARM 状态下的 R11
Thumb 状态下的 IP 对应 ARM 状态下的 R12
Thumb 状态下的 SP 对应 ARM 状态下的 R13
Thumb 状态下的 LR 对应 ARM 状态下的 R14
Thumb 状态下的 PC 对应 ARM 状态下的 R15

状态寄存器

1.2 寻址方式

1.3 汇编指令

1、ARM 指令基本格式:

<opcode>{<cond>}{S}  <Rd>,<Rn>{,<opcode2}
opcode      指令助记符,LDR, STR等
cond        执行条件,EQ, NES           是否影响CPSR寄存器的值,书写时影响CPSR,否则不影响
Rd          目标寄存器
Rn          第一个操作数的寄存器
opcode2     第二个操作数
备注:
    中括号<>内的项是必须的
    大括号{}内的项是可选的

2、指令条件码

条件码对应于 ARM 指令格式中的执行条件 cond,16 种条件状态码依次如下:
3、常见指令汇总

1.4 代码识别

下面总结介绍下常见 C/C++ 逻辑代码对应的汇编指令,帮助快速从晦涩难懂的汇编语言中识别背后的代码逻辑。

1、识别 if-else 判断逻辑

2、识别 while-do 循环逻辑


3、识别 for 循环逻辑

4、识别 switch-case 分支逻辑

1.5 IDA 示例

前边介绍了 ARM 汇编,稍微懂得了一些常见简单的 ARM 汇编指令,下面借助 IDA 来对一个具体的 SO 文件进行调试,分析 ARM 汇编程序。

逐行汇编指令详解

在第一行下了断点后(F2),直接步进到此处(F9),左边蓝色的 PC 标志表示当前指令位于此。
1)第一行汇编指令

第一行汇编代码LDR R3, [R0],含义为将 R0 值指向的内存单元的值存入 R3 中,我们注意图中寄存器信息页面的两个框,分别是 R0 和 R3。先查看 R0 所指向内存单元的值,值为 0xF4DB3D20(ARM使用小端序法进行存储数据):
那么根据上面的分析,这条汇编指令执行完成后 R3 的值变为 0XF4DB3D20。小技巧:点击 PC 的小箭头可以返回到指令位置,单击F7单步执行,可以看到和我们的分析完全一致:
2)第二行汇编指令

第二行指令为MOV R2, #4,意思是将立即数 4 赋予到 R2 中。F7 单步执行完该 MOV 指令后 R2 中的值将会被覆盖为 4。
3)第三行汇编指令

第三行指令为STR LR, [SP, #VAR_4]!,表示将 LR 中的值存储至内存单元某处。这里的 #VAR_4 是一个变量,指令上面有给出是 -4。SP 寄存器保存栈顶指针,所以本指令的内存单元为 SP 值减 4 的位置,也就是栈顶指针减 4的位置。并且最后的!表示 SP 值加上偏移量值后会写回到 SP 寄存器中,我们先查看执行前的各值。
F7单步执行后为:
4)第四行汇编指令

第四行指令为SUB SP, SP, #0xC,意思就是将 SP 值减 4 写到 SP 中(注意这里是直接 SP 的值,而不是 SP 值指向的值),这里 SP 的值为 0xFF952CAC 减去 0xC 就是 0xFF952CA0,F7 单步执行看看:
5)第五行汇编指令

第五行指令为ADD R1, SP, R2,含义是将 SP 和 R2 的值相加写到 R1 中(没有加中括号[]都是指寄存器中的值),SP 值为 0xFF952CA0,R2 值为4 ,相加为 0xFF952CA4,F7 单步执行:
6)第六行汇编指令

第六行指令为LDR R3, [R3, #0x18],将 R3 值加上 0x18 值作为地址指向的值写到 R3 中。R3 值为 0xF4DB3D20,加上 0x18 得到 0xF4DB3D38,可以查看到该内存单元的值为 0xF470111:

直接 F7 单步执行:
7)第七行汇编指令

第七行指令为MOVT R2, #1,MOV 后面加了 T 表示只操作高 16 位,即将 1 赋值到 R2 的高 16 位。这里的 R2 值为 0x00000004,操作后应该为 0x00010004,F7 单步执行:
8)第八行汇编指令

第八行指令为BLX R3,是一个带状态切换和带链接的跳转,跳转到 R3 中保存的地址中,即 0XF4A70111。执行前的信息:

R3: 0XF4A70111  跳转的地址
0xF3B00058      下一个指令的地址(之后会保存至LR寄存器中)
T:0             标志位

F7 单步执行后,可以看到跳转到了指定地址并且 LR 寄存器中保存了相应的返回地址,标志位 T 置为 1 表示目标代码解释为 Thumb 代码。

9)第九行汇编指令

第九行指令为CMP R0, #0,将 R0 的值和 0 做减法比较,根据结果修改标志寄存器中的标志位(一般看 Z 标志位),R0 值 0 和立即数 0 相等,Z 标志位置为 1:
10)第十行汇编指令

第十行指令为BNE loc_F3B000B4,根据状态位进行跳转,BNE 看 Z 标志位,Z=1 跳转,Z=0 不跳转。

对于刚入门安卓逆向,能看懂一些常见的、简单的 ARM 汇编就是我们的目标,不用追求理解 ARM 汇编指令中的所有内容。不同学习阶段有不同的任务,之后深入学习下去遇到不会的 ARM 汇编指令再查就是了。所以,对于一段 ARM 汇编程序代码,能看懂大部分即可。

ARM64汇编

2.1 寄存器

可以对比下 8086 CPU 寄存器:

2.2 变址寻址方式

2.3 常用汇编指令

2.4 实例程序分析

程序员在学习任何一门语言的起点都是从学习写 helloworld 程序开始的,所以下面讲解用 ARM 汇编编写一个 helloworld 程序。

#include <stdio.h>

int main() {
   printf("Hello World!\\n");
   return 0;
}

先看看 helloworld 程序的 main 函数汇编代码:

.type   main,@function
main:                            // @main
// %bb.0:
    sub sp, sp, #32              // sp栈顶指针下移=栈扩大32字节内存空间
    stp x29, x30, [sp, #16]      // 将main函数返回地址、栈底地址压栈
    add x29, sp, #16             // x29 fp寄存器保存新的栈底地址
    mov w8, wzr                  // w8寄存器置0,用它待接收调用的子函数的返回值
    stur    wzr, [x29, #-4]      // 将[x29, #-4]地址的内存重置为0
    adrp    x0, .L.str           // 将“hello world”所在的页的基地址加载到x0寄存器中
    add x0, x0, :lo12:.L.str     // 计算“hello world”的偏移地址保存到x0寄存器中
    str w8, [sp, #8]             // 将w8寄存器中的0值保存到[sp, #8]的内存地址上       
    bl  printf                   // 使用 BL 指令来调用 printf 函数,将当前的地址作为返回地址保存在x30(lr)寄存器中
    ldr w8, [sp, #8]             // 将 w8 寄存器的这个返回值保存到栈内存上       
    mov w0, w8                   // 将w8寄存器的值移动到w0寄存器上
    ldp x29, x30, [sp, #16]      // 还原保存在栈内存上的FP/LR的值到x29/x30寄存器       
    add sp, sp, #32              // 出栈完毕,栈缩小32bytes的内存空间       
    ret                          // 函数返回,返回值通过x0寄存器返回给调用者

在 GNU ARM 汇编里面所有以 : 结尾的都会视为标签 ( label ),在这里我们定义一个叫做 main 的标签,并且使用 .type 伪指令定义这个标签的类型是一个函数(function),到此我们就定义了我们的 main 函数。

第1行代码

先看第一句汇编:sub sp, sp, #32,它作为我们 main 函数的第一句,即在栈上面开启了一个全新的栈帧 stack frame ,那么第一件事情就是申请这个栈帧(或者函数)里面所需的栈内存空间,因为我们知道栈内存的生长方式是从高位向低位生长的,那么从基地址做减法就是增长,做加法就是收缩。在这里我们的 main 函数大概需要 32 bytes 的栈空间来实现一个 helloworld 的功能,所以先将栈帧指针 sp 向下移动了一点内存空间出来,即可在函数中使用栈来分配内存,放置我们的局部变量等。

​从下面开始,我们在讲解每一句汇编时,都会主要通过下面的图标形式来说明,我们重点关注的是 CPU 是如何使用寄存器和内存来做计算的,因此只需要关注每执行一行汇编指令后,寄存器和内存的变化即可(红色标注的),例如我们进入到 main 函数时的初始状态下,内存和寄存器是这样的:
其中我们重点关注的是 sp 寄存器,因为我们这一句汇编主要就是修改 sp 寄存器的值来达到申请栈内存空间的目的。我们的第一行汇编会将 sp 栈帧往低位移动 32 bytes,因此在 CPU 执行完这一句汇编指令后,内存和寄存器会变成如下的状态(即栈扩大 32 bytes 的内存空间):

第2行代码

在我们开辟了新的栈内存后,我们就开始用这些栈内存来保存数据了,这里我们的 helloworld 程序的逻辑其实很简单,那就是在 main 函数里面调用 printf 来打印一行 Hello World! 的信息出来。那么现在我们在 main 函数里面,准备去调用另一个函数 printf ,这就意味着我们需要在 main 函数这个栈帧里面开启一个新的栈帧来调用 printf 。

每个线程的栈内存其实是按照 栈帧 (Stack Frame )为单位分割的,每个函数都有一个单独的栈帧。随着调用栈,在每个栈帧中我们需要一些专用的寄存器来保存当前的 CPU 上下文,例如我们在每个栈帧(或函数)都需要如下的寄存器来记录这些信息:

pc 寄存器,记录当前CPU正在哪个指令。
sp 寄存器,记录当前栈顶。
fp 寄存器,记录当前栈的栈底。
lr 寄存器,记录当前栈的返回地址,即这个函数调用完成后应该返回到哪里。

其中 pc 和 sp 寄存器,随着程序的运行,都是实时更新的,但是例如 fp 和 lr 寄存器随着程序的调用栈,在每个栈帧中的值都不一样,例如我们 hello world 的调用栈大概会这样的:

#0 printf()
#1 main()       <- current pc
#2 libc.init()

当前我们正处在 main 函数中,我们的 lr 寄存器记录的是 main 函数的返回值地址,即它的调用这的地址,在执行完 main 函数后,我们是需要返回到这个地址去的。但是现在我们准备在 main 函数中调用 printf 函数,那么到 printf 函数中后,例如 lr 寄存器就需要用来保存 main 函数的地址作为返回地址,因为 printf 函数执行完了以后,我们希望能回到它的调用者即 main 函数中来继续执行 main 函数里面后面的指令。

因此为了能让 printf 函数能使用 lr 和 fp 寄存器,可以修改它用来保存它栈帧的上下文状态,那么就需要在 main 函数里面,在准备调用 printf 函数之前,将现在咱们 main 函数的 lr 和 fp 寄存器(以及其他所有需要保存的寄存器)的数据都先备份到栈内存上面,那么 printf 函数就可以自由使用这些寄存器,执行自己的逻辑,并在执行完毕后通过 lr 寄存器返回到 main 函数中来,这时我们就可以再将之前备份到栈上面的旧的寄存器的值重新还原到寄存器中。
所以我们的第二句汇编,就是备份 fp 和 lr 两个寄存器的值,例如 lr 寄存器里面,现在保存着 main 函数的返回地址 (即它的调用者 __libc_init() 函数的地址),我们将这些寄存器的值从寄存器里面保存到栈内存上去。在 ARM64 汇编里面,以 ST 开头的指令都是将寄存器的值 Store 到内存地址上。所以第二行代码:stp x29, x30, [sp, #16],就是备份 x29(fp) 寄存器、x30(lr) 寄存器的值到栈上内存。

第3行代码

在我们备份了 fp 寄存器的值到栈内存上之后,我们就可以开始修改 fp 寄存器的值了,将它设置成新的栈帧的栈底,即 调用 printf 函数这个栈帧的栈底,在 printf 函数中,就可以通过 fp 寄存器来获取到它的栈帧基地址。故第 3 行代码add x29, sp, #16 用 x29(fp)寄存器保存新的栈底地址,准备调用子函数。
第 4 行代码

mov w8, wzr ;将 w8 寄存器重置为0,准备用它来接收调用的子函数的返回值

然后,我们希望调用 printf 函数,这个函数是有返回值的,类型为一个 int 值,在调动完 printf 函数后,printf 函数会希望能把它的返回值传递给它的调用者(即我们的 main 函数),那么一般情况下都是通过寄存器传值的,例如这里我们提前将 w8 寄存器的值重置为0,printf 函数就可以将返回值放到 w8 寄存器中,它的调用者 main 函数就可以通过读取 w8 寄存器来接收到 printf 函数的返回值。

这里我们通过 MOV 指令,将零寄存器(其值永远是0)的值移动到 w8 寄存器上,说人话就是将 w8 寄存器里面的值都设置为 0 , 这个操作和我们写代码时,初始化一个int型的变量,将其先设置为0一样,然后将其传入到被调用的函数中去,被调用的函数将返回值设置到该变量上的逻辑是一样的。
第 5 行代码

stur  wzr, [x29, #-4] ;[x29, #-4] 地址的内存重置为 0

使用 STUR 指令,将栈上的一个 32bit 的内存全部重置为 0 。
第 6 行代码

adrp x0, .L.str ;将字符串 “hello world” 所在的页的基地址加载到 x0 寄存器中

在调用一个函数前,我们准备了接收和保存函数的返回值,接下来我们就准备去真正去调用 printf 函数了,但是我们还忘了一点,那就是函数的传参,printf 函数需要能接收到我们的参数,即 printf 函数的第一个参数:一个用于打印的字符串,在我们这里就是 “Hello World!” 这个字符串,因为我们的字符串是一个字面量,它是一个静态全局的字符串,已经保存到内存里面了,我们只需要查到这个字符串的地址即可。

我们通过 ADRP 指令去查找这个字符串的所在内存的页的基地址,我们的字符串的标签是 .L.str ,它的 .type 类型是一个 object 的字符串。(这部分是由伪指令定义的,具体可查看文末完整的汇编代码)。
第 7 行代码

add x0, x0, :lo12:.L.str ;即计算 “hello world” 的偏移地址保存到 x0 寄存器中

上一句,我们得到的只是字符串所在的页的基地址,我们还需要通过偏移地址计算出这个字符串的具体内存地址在哪里。我们通过在上一句查出来的基地址的基础上再增加一个偏移量即得到字符串的内存地址,并且我们用 w0 寄存器来保存它,用于将这个字符串作为 printf 函数的参数传递进去。
第 8 行代码

str w8, [sp, #8] ;将w8寄存器中的值保存到[sp, #8]的内存地址上

虽然我们在 line #4 里面重置了 w8 寄存器用于接收 printf 函数的返回值,但当我们通过寄存器接收到返回值后,我们还需要栈上的一个内存空间来保存这个返回值,因此在调用这个函数前提前在栈内存上为它准备一个内存地址来存放函数的返回值(即 w8 寄存器里的值)。

这里我们也是通过 MOV 指令,将零寄存器(WZR )的值(即0)移动到栈内存的 32bit 内存空间,说人话就是初始化一个 32bit 的内存空间,将这个内存块的数据都清零,准备用来保存 printf 函数的返回值。
第 9 行代码

bl  printf  ;x0寄存器保存着 printf 函数的传参,即指向字符串“hello world”的地址 NOTE: 调用并跳转到 printf 函数之前,将当前的地址作为返回地址保存在x30(lr)寄存器中

一切准备好了,我们就可以真正使用 BL 指令来调用 printf 函数了,printf 函数的地址是通过 linker 链接到的 libc 内的 printf 函数,一般来说调用指令有多个,例如 B 指令,就是单纯的跳转到另一个地方去执行了,不准备返回了,是一张单程船票,而这里我们使用的 BL 指令在跳转到另一个地方,会先将当前指令的地址保存到 lr 寄存器中,便于跳转到另一个地方之后还有坐标可以传送回来,是一张往返的套票。
第 10 行代码

ldr w8, [sp, #8]   ;将w8寄存器的值保存到[sp,#8]的内存地址上

在 printf 函数执行完了以后,它会把函数的返回值(一个32bit 的 int 值)放在 w8 寄存器中,就和电影里面的特务接头一样,我们按照事前约定好的去某个指定的地方(这里是 w8 寄存器)里面去拿结果,即可得到最新的情报(即 printf 函数的返回值),并且我们使用 LDR 指令将 w8 寄存器的这个返回值保存到栈内存上。
第 11 行代码

mov w0, w8 ;将w8寄存器的值移动到w0寄存器上

这里使用 MOV 指令,将 w8 寄存器的值移动到 w0 寄存器上,即将之前用于传参的 w0 寄存器重置回了 0 了。
第 12 行代码

ldp x29, x30, [sp, #16]  ;还原之前保存在栈内存上的FP的值到x29(fp)寄存器中、还原之前保存在栈内存上的LR的值到x30(lr)寄存器中

到这里,我们的 main 函数已经通过调用 printf 函数在屏幕上打印出来的 Hello World! 的文字,printf 函数已经返回到了我们的 main 函数,我们也重置了用于传参的寄存器,接下来我们还需要恢复在调用 printf 函数之前备份的寄存器的值。之前我们将 fp 和 lr 两个寄存器的值,保存在栈内存上,现在我们做一个反操作,将栈内存上保存的值通过 LD 指令还原到寄存器中去。
第 13 行代码

add sp, sp, #32   ;全部出栈,栈缩小32bytes的内存空间

咱们的 main 函数已经完成了它的历史使命,成功的打印出了 Hello World!,它作为一个栈帧也准备退出了,在进入 main 函数一开头的时候,我们在第一句汇编里面,通过 SUB 指令申请了一个 32 Bytes 大小的栈内存空间用来搞事情,现在事情办妥了以后,我们有借有还,把申请的 32 Bytes 栈内存空间通过 ADD 指令给还回去,将栈顶还原到调用 main 函数之前的位置,我们轻轻的来轻轻的走,不带着一byte的内存。
第 14 行代码

ret ;函数返回,返回值通过x0寄存器返回给调用者

最后一步,我们使用 RET 指令退出函数,它就是我们的 helloworld 程序里 main 函数的 return 语句,到此我们的程序就写完了。
最后附上本例中完整的汇编代码:

.text
    .file   "main.c"
    .globl  main                            // -- Begin function main
    .p2align    2
    .type   main,@function
main:                                   // @main
// %bb.0:
    sub sp, sp, #32                     // =32   申请32bytes的栈空间
    stp x29, x30, [sp, #16]             // 16-byte Folded Spill  将 FP(x29), LR(x30) 保存在栈上
    add x29, sp, #16                    // =16   缩小栈大小16bytes 
    mov w8, wzr                         // 将 zero寄存器的值0 移动到 w8 寄存器
    stur    wzr, [x29, #-4]             // 
    adrp    x0, .L.str
    add x0, x0, :lo12:.L.str
    str w8, [sp, #8]                    // 4-byte Folded Spill
    bl  printf
    ldr w8, [sp, #8]                    // 4-byte Folded Reload
    mov w0, w8
    ldp x29, x30, [sp, #16]             // 16-byte Folded Reload
    add sp, sp, #32                     // =32
    ret
.Lfunc_end0:
    .size   main, .Lfunc_end0-main
                                        // -- End function
    .type   .L.str,@object                  // @.str
    .section    .rodata.str1.1,"aMS",@progbits,1
.L.str:
    .asciz  "Hello World!\\n"
    .size   .L.str, 14

    .ident  "Android (7155654, based on r399163b1) clang version 11.0.5 (https://android.googlesource.com/toolchain/llvm-project 87f1315dfbea7c137aa2e6d362dbb457e388158d)"
    .section    ".note.GNU-stack","",@progbits

总结

本文参考文章:

  1. ARM汇编入门指南(知乎)
  2. 第七章 ARM 反汇编基础(四)(ARM 汇编语言);
  3. arm32位和arm64位架构、寄存器和指令差异分析总结
  4. 移动安全之Android逆向系列:ARM汇编&IDA动态分析源码

以上是关于Android逆向基础之ARM汇编语言知识总结的主要内容,如果未能解决你的问题,请参考以下文章

Android逆向-Android逆向基础10(so文件分析大合集)

Android 逆向arm 汇编 ( 使用 IDA 解析 arm 架构的动态库文件 | 分析 malloc 函数的 arm 汇编语言 )

Android 逆向arm 汇编 ( 使用 IDA 解析 arm 架构的动态库文件 | 分析 malloc 函数的 arm 汇编语言 )

Android逆向基础知识Smali

iOS逆向工具之hopper的使用

iOS逆向工程之Hopper中的ARM指令