ARM架构中栈空间变化研究与实践

0x00 概述

    通过阅读本文您将能够了解到如下几个方面:

  1. 函数调用过程中发生的事情。
  2. 函数栈帧空间的布局与变化。
  3. 局部变量作用域理解。
  4. 函数调用堆栈的一个实现原理。
  5. ARM栈空间操作和X86、X64的异同。

    本文将通过IDA实际调试一个函数调用过程为读者展示栈空间的操作过程。

0x01 ARM堆栈空间变化

    Thumb指令集就是ARM指令集的一个子集,其指令长度为2,ARM指令长度为4字节,所以很多指令不能用。例如函数序言部分(函数开头部分,一般为堆栈开辟指令)的寄存器保存 ,ARM中一般使用STMFD,LDMFD指令进行保存与恢复, thumb则通过push、pop指令。下面分析使用了Thumb指令集代码(只是随意选择了一个SO的一个任意函数)。

在函数的其中一个调用函数指令处设置断点,汇编如下所示:

.text:76564B0A MOV     R0, SP                   ; memcpy dest 将其余参数拷贝到SP开始的位置
.text:76564B0C ADD     R3, PC ; dword_766235D8
.text:76564B0E LDR     R5, [R3]
.text:76564B10 STR     R1, [SP,#0xD0+var_68]
.text:76564B12 MOVS    R2, #0x44                ;memcpy size
.text:76564B14 LDR     R4, [R5]
.text:76564B16 ADD     R1, SP, #0xD0+fileList+4 ; memcpy src
.text:76564B18 BLX     memcpy                   ;把后续参数依次拷贝到sp+0起始地址处
.text:76564B1C LDR     R2, [SP,#0xD0+s]		    ; 第三个参数,r2寄存器
.text:76564B1E LDR     R3, [SP,#0xD0+fileList]	; 第四个参数,r3寄存器
.text:76564B20 LDR     R1, [SP,#0xD0+var_68]	; 第二个参数,r1寄存器
.text:76564B22 MOVS    R0, R5					; 第一个参数,this指针,r0寄存器
.text:76564B24 LDR     R4, [R4,#0x40]
.text:76564B26 BLX     R4                       ;调用了一个函数,在该位置设置断点进行调试
.text:76564B28 STR     R0, [SP,#0xD0+var_6C]

通过IDA在HEX界面查看这些指令对应的二进制,发现长度都为2个字节,所以该汇编是Thumb指令集。正如上述所述,其实可从汇编使用的指令也能够推测出汇编对应的指令集

从上述汇编及其注释中您可以很容易发现参数的传递方式:

1、使用R0-R3寄存器依次作为第1到第4个参数进行保存。

2、参数个数超过的部分通过压栈保存,压栈顺序从参数列表的右往左压栈。

3、所以一般第五个参数位于SP+0,第六个位于SP+4.

在执行到BLX R4,R4存储了被调函数的起始地址(题外话:上述代码是通过访问类的this指针对应的虚函数表获取对应函数地址,如果你需要静态分析可以尝试找到该类的构造函数,构造函数中首条指令就是将当前层次类的虚函数表赋值给类的第一个成员虚函数指针,可以留言询问具体情况)。

此时,我们观察寄存器和栈空间情况如下所示:

当前函数栈空间

SP开始的位置依次存储了超过四个参数以外的参数值或者索引。

函数跳转前寄存器状态

寄存器R0-R4依次存储了第1-4参数值或者索引

 先从理论上分析通过BLX跳转到被函数时发生的操作。

按照ARM中BLX调用函数可分解两个步骤:

  1. 将返回地址也就是当前BLX下一条指令地址赋值给LR寄存器,以便被调函数可以将LR寄存器赋值给PC实现返回主调函数。
  2. 跳转到被调函数的起始地址取指令运行。

所以当进入被调函数时,栈空间是没有变化的,仅仅是LR将会设置为返回时的下一条指令地址。

从当前函数可以看到,下一条函数地址为0x76564B28,又因为当前函数为Thumb指令集,所以跳转地址+1。则理论上进入被调函数时,LR值将被设置为0x76564B29。

下面从实际中观察情况,IDA按F7进入函数,并查看查看当前情况:

进入函数后的寄存器状态

发现所有寄存器值和之前值均没有发生变化,除了LR变成了0x76564B29,和前文分析的是一致的。在这个小过程中没有对栈空间的操作,所以SP寄存器和栈空间是没有发生变化的,就不再重复贴图。

函数开头部分的汇编指令很重要,一般在进入函数后,函数序言部分(也就是开始部分)会做两件事情:

  1. 保存一些寄存器的值到栈。这些寄存器会在函数体中间代码使用,所以需要备份,直到函数结束时会还原。其保存方式分为PUSH类操作(push STMFD,另一种为先通过对sp进行sub指令操作开辟空间,通过STR存入这些寄存器的值。一般LR寄存器会保存于新函数栈的栈底(本文所示的函数有点例外)。
  2. 通过SUB指令一次性开辟栈空间,用于存储局部变量和下一个被调函数的参数空间(若有并参数个数大于四个)。
  3. SP作为栈顶指针,在函数运行过程中将不再变化,直到函数结束回收栈空间。

一般开辟栈空间结束都都在序言部分以SP的SUB指令为结束,后续开始函数功能。下面通过具体代码调试来分析。

进入函数后,函数开头的指令如下所示:

.text:765650BE SUB     SP, SP, #0x10    ;开辟了0x10空间
.text:765650C0 PUSH    {R4-R7,LR}       ;保存寄存器值
.text:765650C2 SUB     SP, SP, #0x54    ;开辟栈空间,为局部变量和下一个被调函数的参数空间   
.text:765650C4 LDR     R5, [R0,#0x18]
.text:765650C6 STR     R3, [SP,#0x78+a4]
.text:765650C8 LDR     R3, [SP,#0x78+a15]
.text:765650CA STR     R1, [SP,#0x78+a2]  ;通过STR保存寄存器到上述通过SUB开辟的栈空间
.text:765650CC STR     R2, [SP,#0x78+a3]
.text:765650CE ADD     R1, SP, #0x78+src 

开头序言部分和前文所述是一致的。经过前三条指令操作后栈空间如下所示:

执行序言后堆栈情况

可以看出寄存器中的值被压入栈中保存,包括返回地址LR寄存器。也能了解到栈空间是向低地址进行扩展的。

在之后的函数主体将使用不变的SP指针进行偏移访问当前栈空间、设置和访问局部变量。

函数结束时:

1、返回值或者返回值引用赋值给R0寄存器。

2、回收当前函数的栈空间。

栈空间的回收,只要将SP设置成上层函数调用该函数时的位置,但是如何知道当前栈空间的大小以回收。

一个简单的做法就是通过序言部分函数堆栈的开辟,反向进行回收,而实际上ARM汇编中回收的方式也是这样的:

.text:76565132                 ADD     SP, SP, #0x54
.text:76565134                 MOVS    R0, R5
.text:76565136                 POP     {R4-R7}
.text:76565138                 POP     {R3}    ;LR pop给了R3
.text:7656513A                 ADD     SP, SP, #0x10
.text:7656513C                 BX      R3   ;R3就是返回吓一跳指令,直接B跳转

.text:765650BE                 SUB     SP, SP, #0x10
.text:765650C0                 PUSH    {R4-R7,LR}
.text:765650C2                 SUB     SP, SP, #0x54   ; a5
序言与结尾堆栈操作对比

这样操作后,返回原主调函数sp被修正为之前函数的值,通过栈空间回收后的SP,对主调函数变量的偏移将没有任何影响。

0x02 局部变量

从上述过程很容易理解局部变量的作用域仅在当前函数下。因为进入函数时会开辟新栈,使用新的sp通过相对偏移进行局部变量访问。当被调函数结束后,栈空间回收,回到主调函数时sp修复为主调函数的栈顶指针,对于主调函数的局部变量使用SP的偏移是不会变的。所以在不同函数下SP对栈空间内存的访问仅仅只能获取当前函数的局部变量。

0x03 函数调用堆栈

通过栈空间和函数序言部分能够得到函数调用堆栈,以及每个函数帧中变量的值,大致做法如下:

  1. 通过函数序言部分能够获取当前栈空间的范围,栈顶和栈底。
  2. 通过序言指令也能得到LR存储的位置,一般都在当前栈空间的底部。
  3. 得到LR存储位置,获得上层函数的返回地址,计算该地址所在的函数空间得到上层函数名称。
  4. 通过序言模拟释放当前栈空间,找到上层函数的栈顶sp。
  5. 通过得到的LR找到的上层函数,并找到函数开头的序言部分,解析堆栈开辟指令,按照上述方式解析栈空间布局和返回地址。
  6. 循环如此,直到找到对应的调用层次。

例如您可以在上述《执行序言后堆栈情况》图中可以看到上层函数名称一样,在IDA中只要分析栈空间范围,并根据序言指令分析出LR存储位置,就可以找到上层调用的函数。

0x04 ARM栈空间变化思考

ARM架构中栈开辟就在序言阶段,这其实也是有意义的,因为ARM汇编中局部变量和参数的访问都是通过sp进行访问。若在函数运行过程中开辟或者PUSH、POP操作,没有固定的哨兵(sp)进行位置偏移,会使得访问的代码看起来特别混乱。ARM汇编采用的和X64栈空间操作类似,都采用不变的sp,而X86则通过ebp栈底指针进行访问,使得esp可以随意变动,并且ebp+负数则是局部变量 ,ebp+正数则是参数。然而发现在ARM汇编中也有类似X86的ebp指针。有的函数中会用R11寄存器指向当前栈空间底部第一个数据,达到了分割不同函数栈的作用。间接通过r11来访问变量与参数,这样使得表面上和X86相似的访问方式,实质上和x64本质一样,sp始终不变。

由于工作间紧,本文写的比较粗略和不当之处。若有疑问请各位不吝指正和赐教,谢谢。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值