iOS逆向之ARM64汇编基础

2021-09-06 11:57:18 浏览数 (2)

ARM处理器

我们知道,目前为止Apple的所有iOS设备都采用的是ARM处理器。ARM处理器的特点是体积小、低功耗、低成本、高性能,所以很多手机处理器都基于ARM,ARM在嵌入式系统中也具有广泛的应用。 ARM处理器的指令集对应的就是ARM指令集。armv6|armv7|armv7s|arm64都是ARM处理器的指令集,这些指令集都是向下兼容的,例如arm64指令集兼容armv7,只是使用armv7的时候无法发挥出其性能,无法使用arm64的新特性,从而会导致程序执行效率没那么高。在iPhone5s及其之后的iOS设备指令集都是ARM64。 还有两个我们也很熟悉的指令集:i386和x86_64是Mac处理器的指令集,i386是针对intel通用微处理器32架构的。x86_64是针对x86架构的64位处理器。所以当使用iOS模拟器的时候会遇到i386|x86_64,因为iOS模拟器没有ARM指令集。

不同的处理器架构使用不同的指令集。或者说,每一个处理器架构都有其特定的指令集。指令集决定了处理器的架构,处理器架构和指令集是强绑定的。因为处理器架构就是用硬件电路实现指令集。至此,我们知道了处理器架构和指令集的强依赖关系。

汇编的诞生

通过上面,我们了解到,处理器和指令集的强相关性。要设计处理器,首先就需要有指令集,规定处理器相应操作,通过指令集去控制处理器实现相应功能。但处理器是一堆硬件电路,只能识别二进制数据,所以指令集也是由一堆二进制数据组成。

而二进制数据对人类来说读起来很麻烦。为了方便人类操作指令集,发明了汇编语言来描述指令集。汇编语言是用类似人类的语言描述指令集,读起来相对容易。

虽然汇编语言读起来方便了,但也有缺陷。首先汇编语言操作起来还是挺麻烦的,一个简单的数学运算需要执行多条指令行,我们需要清楚每一步的操作,完全“面向过程”。其次因为汇编语言是对指令集的描述,汇编语言包括一条条指令,所以当指令集改变时,就得修改相应汇编语言,导致其可移植性很差。不能跨平台使用,比如ARM的汇编语言与Intel X86的就格格不入。因为这种描述指令集的汇编语言移植性差,在跨平台上表现出来了力不从心,于是前辈们就进一步进行了抽象,发明了若干种超越指令集的高级语言,比如C、C 、Java。 但处理器只能识别二进制码,那怎么能识别高级语言呢?于是人们开发了编译器,借助于编译器,我们可以把高级语言进行编译转换为汇编语言,汇编语言进一步解释为二进制机器码。 另外除了编译器之外,还有解释器,对于编译型语言(比如C、C )通常是由编译器进行编译&优化成低级语言或中间语言,然后就可以在目标机器上运行编译后的产物。对于解释性语言(比如PHP、JS)通常是不需要进行编译处理,在运行时直接由解释器逐行的解释执行的。所以通常认为解释型语言比编译型语言执行效率低、性能差。关于编译器&解释器、编译型语言&解释型语言的关系和区别此处不展开讨论,读者可自行查阅搜索资料,这里有一篇文章编译器与解释器的区别和工作原理。

汇编的核心

汇编语言本身并不难,复杂的是汇编语言的操作。汇编的核心就是对寄存器、指令、堆栈的操作。 CPU从逻辑上可以划分成3个模块,分别是控制单元、运算单元和存储单元,又叫控制器、运算器、寄存器。 寄存器用于存放指令、数据、地址等信息供运算器计算。一个CPU内部有多个寄存器。 控制器负责把内存上的指令、数据等读入寄存器,并根据指令的执行结果来控制整个计算机。 堆栈就是指令执行时临时存放函数参数、局部变量的内存空间。栈的入口和出口是同一个,所以栈的特性是先进后出(FILO)后进先出(LIFO)。先入栈的数据在栈底,后入栈的在栈顶,栈顶的数据先出栈,栈底的数据最后出栈。通常栈的增长方向是从高内存地址到低内存地址,所以栈底是高地址,栈顶是低地址。iOS是小端模式,小端模式的特点是从高内存地址到低内存地址存储数据的高字节到低字节。大端模式正好相反。

大小端模式

什么是大小端模式? 我们知道数据在内存中是按字节存储的,有些数据可能占用多个字节。大小端模式就是字节序问题。字节序,顾名思义就是字节的顺序。就是一个数据的多个字节在内存中的存放顺序。如果一个数据就占一个字节也就谈不上字节序问题,毕竟无论如何排列都是相同的。

小端模式(little-endian):从低地址到高地址的顺序存放数据的低位字节到高位字节。即小端模式下低位字节存放在低地址端,高位字节存放在高地址端。 大端模式(big-endian):从低地址到高地址的顺序存放数据的高位字节到低位字节。即大端模式下高位存放在低地址,低位字节存放在高地址端。

小端模式

1.有些文章说栈的增长方向和大小端模式有关,其实这种说法是错误的。无论大小端模式栈的增长方向通常都是从高地址到低地址。大小端模式取决于处理器的架构。 2.关于大小端模式又称字节顺序、字节序。小端模式又称为小端序、小尾序、小端法、低位优先。大端模式又称为大端序、大尾序、大端法、高位优先。

寄存器

ARM处理器共有37个寄存器,被分为若干个组(BANK),这些寄存器包括: 1 31个通用寄存器,包括程序计数器(PC指针)。 2 6个状态寄存器,用以标识CPU的工作状态及程序的运行状态,均为32位,只使用了其中的一部分。

另一个角度,寄存器通常可分为通用寄存器、浮点寄存器、状态寄存器。 ARM64有31个通用寄存器,每个寄存器可以读取一个64位的数据。当使用X0~X30的时候,他就是一个64位的数;当使用W0~W30的时候,他就是一个32位的数;32位的数实际上访问的是寄存器的低32位,写入时会将高32位清零(早期32位通用寄存器是R0~R28)。X代表64位寄存器。W代表32位寄存器,W即为word(一个字),一个字代表32位。

X0~X7:这8个寄存器是用来传递函数的参数的,如果有更多的函数参数则使用栈来传递,实际运算时再从栈上读取函数参数。X0寄存器也用来存放函数的返回值。

FP:全称Frame Pointer,帧指针寄存器,即X29,是指向栈底的寄存器。用于标识栈的底部,以免读取栈数据时“越界”。

SP:全称Stack Pointer,栈指针寄存器,指向栈的顶部。其32位版为WSP

LR:全称Link Register,链接寄存器,即X30。x30无法像普通寄存器那样拆分出低32位来单独使用。LR寄存器用于存放函数跳转前的下一条指令的地址,方便函数执行完后返回到函数的下一条指令继续执行。或者说,LR存储着函数调用完成时的返回地址,用于做函数调用栈跟踪。程序在崩溃时能够将函数调用栈打印出来就是借助LR寄存器来实现的。

PC:全称program counter,程序计数器。用于存储将要执行的下一条指令的内存地址。通常在调试状态下看到的PC值都是当前断点处的地址。所以很多人认为PC用于存储CPU当前执行的指令的地址,记录CPU当前执行的是哪一条指令,实际上这种理解是错误的。

LR和PC的完美配合:通常我们调用BL无条件跳转指令时会先将函数跳转前的下一条指令的地址存放到(X30)LR寄存器中(即把函数调用完成时的返回地址存入X30)。 BL跳转到标记处执行函数代码, 代码执行完之后,会跳回LR存储的指令地址处继续执行。那是如何从LR中取出的指令的呢?BL指令中的ret会把LR(X30)寄存器的地址赋值给PC寄存器,这样CPU取PC寄存器中的指令地址就可以取到执行BL指令跳转前的下一条指令的地址。 程序得以跳回原来的地方继续有序执行。 这里面程序可以跳回继续执行离不开BL、LR、PC的精诚合作。

零寄存器: wzr 32位的零寄存器。可对标 32位的普通寄存器w0~w28 xzr 64位的零寄存器。可对标 32位的普通寄存器w0~w28 专门用来存储数字0。比如null、nil、false、NO 因为汇编不支持将立即数存储到一个地址中,所以需要先将立即数存储到寄存器中,然后将寄存器中的数字存储到存储器。如下:

代码语言:javascript复制
mov xzr, 0 ; 先把立即数存储到64位的零寄存器xzr中
str xzr [x1, #0x8] ; 再把xzr中的数据存储到x1 0x8的存储空间中

指令

1.关于汇编的大小写问题? 汇编不区分大小写,只有字符型数据或者字符串区分大小写。所以汇编中的指令和寄存器可以是大写也可以是小写。例如:如下两行指令是等价的。

代码语言:javascript复制
add x0, x1, x2
ADD X0, X1, X2

2.关于汇编如何添加注释? 汇编语言的注释是以分号";"开头的,分号之后的内容都属于注释。一般而言,汇编语言的注释出现在以下3个地方:

  • 1>. 程序的最前面,注释内容一般是该程序的说明,解释程序的主要功能,程序的版本号,程序的修改日志,程序的编制人等等
  • 2>. 子程序的前面,一般说明该子程序或函数完成的功能,输入参数,输出参数,影响的标志位等等。
  • 3>. 指令行的后面,解释该行指令语句的功能。

常见的指令集通常包括以下几种:

  • 算术指令
  • 跳转指令
  • 逻辑指令
  • 数据传输指令
  • 地址偏移指令
  • 移位运算指令
  • 加载/存储指令

算数指令

ADD:加法运算指令。把一个寄存器中的数据或立即数与另一个寄存器中的数据或立即数进行相加。例如:

代码语言:javascript复制
ADD X0, X1, X2 ; 把寄存器X1、X2的值相加后赋值给寄存器X0。即X0 = X1 X2

SUB:减法运算指令。把一个寄存器中的数据或立即数与另一个寄存器中的数据或立即数进行相减。例如:

代码语言:javascript复制
SUB X0, X1, X2 ; 把寄存器x1、x2的值相减后赋值给寄存器x0。即x0 = X1-X2

MUL:乘法运算指令。把一个寄存器中的数据或立即数与另一个寄存器中的数据或立即数进行相乘。例如:

代码语言:javascript复制
MUL X0, X0, X8 ; 把寄存器x0、x8的值相乘后赋值给寄存器x0。即x0 = X0*X8

SDIV:有符号除法运算指令。 UDIV:无符号除法运算指令。

代码语言:javascript复制
SDIV X0, X0, X1 ; 即X0 = X0 / X1 
UDIV X0, X0, X1 ; 即X0 = X0 / X1

CMP:比较(compare)指令。把两个寄存器的数据进行相减,不存储结果,只更新CPSR中的标志位。例如:

代码语言:javascript复制
CMP X28, X0 ; X28与X0相减,不存储结果只更新CPSR中的标志位。 (CPSR即为current program status register)

跳转指令

跳转指令分为条件跳转和无条件跳转。条件跳转即若条件为真则跳转。无条件跳转是直接跳转到某个位置执行指令。无条件跳转可以类比函数调用。

无条件跳转

B:无条件跳转指令。例如:

代码语言:javascript复制
B label ; 跳转到label标签处开始执行

BL:无条件跳转指令。与B相比,执行BL前返回地址会被保存到X30(LR)寄存器。BL执行完后会把LR保存的地址赋值给PC寄存器。这样就可以回到BL跳转前的位置继续向下执行。。BL实例:

代码语言:javascript复制
BL label ; 

RET:子程序返回指令。返回地址默认保存在X30(LR)寄存器

为什么B指令跳转的代码中有ret也回不到跳转前的下一条指令呢? 原因是B指令在跳转前没有像BL指令那样把下一条指令的地址存储到LR(x30)寄存器中,所以B的ret就不能从LR寄存器中读出正确的地址赋值个PC寄存器。

逻辑指令

AND:逻辑与运算指令。例如:

代码语言:javascript复制
AND X0, X1, X2 ; X1和X2寄存器的数据进行逻辑与运算结果保存到X0中。即:X0 = X1 & X2

ORR:逻辑或运算指令。例如:

代码语言:javascript复制
ORR X0, X1, X2 ; X1和X2寄存器的数据进行逻辑或运算结果保存到X0中。即:X0 = X1 | X2

EOR:逻辑抑或运算指令(else or)。例如:

代码语言:javascript复制
EOR X0, X1, X2 ; X1和X2寄存器的数据进行逻辑抑或运算结果保存到X0中。即:X0 = X1 ^ X2

数据传输指令

MOV:把寄存器中的数据存储到另外一个寄存器中。例如:

代码语言:javascript复制
MOV X19, X1 ; 把寄存器X1 中的数据存储到寄存器X19中。即 X1 = X19

其他常用数据传输指令还有:MOVZ、MOVN、MOVK。

地址偏移指令

ADR:小范围的地址读取指令。ADR 指令将基于PC 相对偏移的地址值读取到寄存器中。例如:

代码语言:javascript复制
ADR Xn, label ; 即Xn = PC   label

ADRP:以页为单位的大范围的地址读取指令,这里的P就是page的意思。

加载/存储指令

1.加载(load)、存储(store)指令都是成对出现的 2.无论是加载还是存储相关的指令,指令后面都只能写寄存器,不能把寄存器放到指令行的最后面。

LDR、LDUR、LDP都是和加载相关的指令。即把内存中的数据读取到寄存器中。LD即为load的缩写,R即为register的缩写,P即为pair的缩写,即同时操作两个寄存器。

LDR & LDUR :从内存中读取8/4字节数据到一个64/32位寄存器中,即从源寄存器中读取数据写入目的寄存器。LDUR中的“U”是unscaled的缩写,代表不需要按字节对齐。LDUR和LDR指令的区别:可以简单理解为地址偏移量为负数则使用LDUR,偏移量为正数则使用LDR。例如:

代码语言:javascript复制
LDR X1, [sp, #0x28] ; 从SP  0x28处开始读取8字节数据到X1寄存器中。
LDUR X30, [X29, #-0x10] ; 从X29 - 0x10出开始读取8字节数据到X30(LR)寄存器中。

LDP:从内存中读取数据放到两个寄存器中。例如:

代码语言:javascript复制
LDP  w0, w1, [x0, #0x10] ; 读取x0 0x10内存的字数据,然后四个字节赋值给w0, 另外四个字节赋值给w1

STR、STUR、STP都是和存储相关的指令。即把寄存器中的数据写入内存中。ST即为store的缩写,R即为register的缩写,P即为pair的缩写,即同时操作两个寄存器。

STR & STUR:将寄存器中的数据写入内存中,即把源寄存器的内容写入目的寄存器。STR&STUR的区别和LDR&LDUR的区别类似。如下:

代码语言:javascript复制
STR X0, [sp, #0x28] ; 将X0寄存器中的数据写入SP   0x28的位置。
STUR X0, [sp, #-0x10] ; 将X0寄存器中的数据写入SP - 0x10的位置。

STP:将两个寄存器的数据写入内存中。例如:

代码语言:javascript复制
STP X1, X2, [X0, #0x28]; 将X1和X2的数据 写入 X0 #0x28的位置

堆栈

基于前面的讲解,我们知道了程序运行时会在内存上申请一个称为栈的数据空间。数据在存储时是从内存的下层(大的地址编号)逐渐往上层(小的地址编号)累积,读出时则是按照从上往下的顺序进行的。

32位的x86系列的CPU中,进行1次栈的push或pop操作,即可处理32位(4字节)的数据。 64位的x86_64系列的CPU中,进行1次栈的push或pop操作,即可处理64位(8字节)的数据。 在数据入栈和出栈的时候,不需要指定“哪一个地址编号的内存进行push或pop”。这是因为对栈读写的内存地址是由SP寄存器(栈指针寄存器)进行管理的。数据入栈或出栈后,SP寄存器的值会自动更新(32位CPU下,push指令会使SP存储的地址-4字节,pop指令 4字节)。

寄存器数量有限,在实际的操作中,需要借助栈来完成一些计算任务。比如我们知道X0~X7这8个寄存器可以用来存储函数参数,如果函数参数多于8个就需要借助于栈来存储额外的参数。而对于函数参数是浮点数的情况,传参时则不再使用X0~X7寄存器,而是使用D0~D7寄存器。超出8个的参数仍然使用栈来传递。 在操作栈的时候,通常包括栈空间的开辟和回收。开辟和回收栈空间通常使用的是SUB和ADD指令和FP、SP寄存器。

通过上面叙述,我们已经知道如下结论: 1.栈是一块连续的内存空间 2.栈的增长方向是从高地址到低地址 3.栈底为高地址,栈顶为低地址,先入栈的数据地址高 4.FP(X29)寄存器为帧指针寄存器,用于指向栈底 5.SP寄存器为栈指针寄存器,用于指向栈顶 6.每次数据入栈或出栈都会更新SP寄存器的值

如下2张图所示,可以看出两个变量在栈内存中的布局。 图1中先后声明两个变量num1、num2。先声明的变量先入栈,后声明的变量后入栈。假设栈中只有num1和num2两个变量,那么0x222在栈底,0x333在栈顶。 通过图2可以看出从做左到右、从上到下内存地址是增加的。所以0x333的内存地址高于0x222的内存地址。即可证明栈的增长方向确实是高地址到低地址。

由图2也可以看出,小端模式下高位字节存储在高地址,低位字节存储在低地址。以0x333的内存布局(红色部分33 03 00 00)举例 ,0x333在内存中占4个字节,低位的33在第一字节,其内存地址是0x7ffee66a8238(十进制为140732764160568)。较高位的03在第二字节,其内存地址是0x7ffee66a823c(十进制为140732764160569)。其余两个高位字节为0,用0占位补齐。不难看出小端模式下确实是高字节位在高地址,低字节位在低地址。

图1

图2

0 人点赞