在上一篇《ARM汇编入门指南1》文章里我们用一个 helloworld
的例子带着大家对ARM汇编有了一个最基本的认知,大致了解了一些ARM汇编入门的最基础的知识点:例如ARM64架构下的寄存器,内存布局,伪指令和几个常用的汇编指令等,在介绍新的知识点之前,我们先来回顾一下这几个汇编的基础知识:
在函数开始的时候使用 SUB
指令操作 sp
寄存器可以申请新的栈内存空间,在函数结束的时候使用 ADD
指令操作 sp
寄存器来释放之前申请的栈内存空间。
使用 ST
指令将寄存器的值保存到栈内存上(寄存器->栈),使用 LD
指令将栈内存上的值保存到寄存器中(栈->寄存器)。
使用 BL
指令来调用函数进行跳转。
其实上一篇的重点是介绍在ARM汇编中如何 调用函数 这个重要的知识点,因为大部分我们去分析汇编代码的时候,重点关注的都是函数之间的调用情况,例如一个函数内调用了哪些函数,一个函数被哪些函数调用过等这些关键的信息来从汇编的层面来分析和理解程序的逻辑。
在这一篇文章里,我们将接着上文继续来学习另一个重要的知识点,那就是如何在ARM汇编中去做 对象实例化 ,说直白一点就是用ARM汇编去实现创建一个 c++
的类( class
) 的实例化对象( object
)。
在上一篇 helloworld
中,我们故弄玄虚的使用了倒叙的手法,先给大概来看汇编代码,再通过一行行的解释来引出其对应的C++代码,这其实就是代码逆向的过程。在这一篇文章里面,我们将采用正序的手法,按照正常人的逻辑,先给大家看C++代码是怎样写的,让大家先能理解代码逻辑,然后使用编译器将其编译后看看它所对应的反汇编代码。
我们先来看我们这个例子里面的C++代码里面的这个 类的对象 长什么样的:
object.h
#ifndef H_Object
#define H_Object
class Object
{
public:
Object(long id, int type);
int GetType();
private:
int type_;
public:
long id_;
};
#endif
object.cpp
#include "object.h"
Object::Object(long id, int type) : id_(id), type_(type) {
}
int Object::GetType() {
return type_;
}
我们的 对象 很简单,就是一个名为 Object
的C++类,它有两个成员变量:id
和 type
,并且有1个成员方法:GetType()
。
我们先来看看在栈上 实例化对象 的ARM汇编是怎样的,我们的C++代码大概是这样的:
#include "object.h"
int main() {
Object object(1, 2);
return 0;
}
其实我们只是将上一篇 helloworld
的 main
函数里面修改了一行,将调用 printf
函数修改成了调用 Object
的构造器函数,用来在栈上面实例化了一个新的 Object
对象,它的 id
等于 1, type
等于 2。
接下来我们还是使用 Android NDK 自带的 clang
编译将其编译成二进制,然后使用 objdump
反汇编获得它的ARM汇编代码,在本例中我们使用的也和上文一样都是使用 ARM64
的架构来进行学习,这里我们的 main
函数反汇编后的代码量明显比我们上文的 helloworld
的要长很多,不过其实大部分和 helloworld
的汇编代码差不多,在经过了上一篇一行一行的汇编代码学习后,我们应该已经有足够的基础来一段一段的学习汇编代码了,下面我们先来看函数开始和和函数结束的这两段汇编代码:
// 函数开始
708: d10103ff sub sp, sp, #0x40 // 申请栈内存
70c: a9037bfd stp x29, x30, [sp,#48] // 备份sp和lr寄存器
710: 9100c3fd add x29, sp, #0x30 // 新的sp
// ...
// 函数结尾
760: a9437bfd ldp x29, x30, [sp,#48] // 还原sp和lr寄存器
764: 910103ff add sp, sp, #0x40 // 退还栈内存
768: d65f03c0 ret
这两段代码和上文 helloworld
的逻辑是一模一样的,也是在函数一开始的位置先使用 SUB
汇编指令申请一块新的栈空间,然后使用 STP
汇编指令将 x29(sp)
和 x30(lr)
寄存器的值先备份到栈内存上,并且在函数结束的时候使用 LDP
汇编指令将栈上备份的值还原到 x29(sp)
和 x30(lr)
寄存器中,如果看过上一篇文章的同学对于这两段汇编代码就很熟悉了,如果有不理解的可以先去读上一篇文章这一部分的详细讲解再回来继续阅读。
接下来的汇编代码就和我们之前的 helloworld
完全不一样的,并且它也是在函数开始和函数结束遥相呼应的:
// ...
714: d53bd048 mrs x8, tpidr_el0 // 读取tpidr_el0作为canary值
718: f9401509 ldr x9, [x8,#40]
71c: f81f83a9 stur x9, [x29,#-8] // 放canary金丝雀在栈上面做保护
// ...
744: f9401509 ldr x9, [x8,#40]
748: f85f83a0 ldur x0, [x29,#-8]
74c: eb000129 subs x9, x9, x0 // 检查栈上面的canary金丝雀是否被修改
750: f90003e9 str x9, [sp]
754: 540000c1 b.ne 76c <main+0x64>
758: 14000001 b 75c <main+0x54>
// ...
76c: 97ffffb1 bl 630 <__stack_chk_fail@plt> // 金丝雀死了修改则认为栈溢出
这两段汇编代码其实并不是我们写的C++代码,而是 clang
编译器在 SafeStack
阶段在汇编代码中插入的栈溢出保护的代码,这是一种常见的 canary
金丝雀的栈保护机制,它通过在栈底保存一个数据,在这里是 TLS
里面的一个值 ( tpidr_el0, #40
),然后在函数退出的时候,检查这个栈底的数据是否有被修改过,如果被修改了则认为栈溢出,跳转到 __stack_chk_fail()
函数中。
在这个栈保护的机制里面,使用了 MRS
汇编指令,它的作用是从 CPSR
程序状态寄存器中读取数据,而 MSR
汇编指令则是向 CPSR
寄存器中写入数据。这是通用性的解释,但其实根据ARM的文档,AArch64
状态下其实是没有等同于arm32的 CPSR
(Current Program State Register)寄存器的,而是取而代之使用叫做 Process State
的机制来实现类似的功能,包括 NZCV
条件码标志位等。
其中这里使用的是 MRS
指令去读取 tpidr_el0
的值,它按照ARM文档的解释是指:Provide locations to store the IDs of software threads and processes for OS management purposes.
按此处汇编上下文的意思,推测是获取当前线程的 pthread
结构体内的某个值作为 canary
的值来保护栈,但未查到相同文档说明该值代表什么意思,但具体这个值是什么并不影响对于栈保护内存机制的理解,从理论来说它可以是一个随机数,只要确保进函数时和出函数时是一致的即可实现保护效果。
另外还有两个不认识的汇编指令是 SUBS
和 B.NE
,其中 SUBS
和 SUB
一样,都是表示减法,但是增加了 S
的后缀,表示则计算后会去更新 NVCV
条件码标志位,然后 B.NE
和 B
也类似,也是跳转指令,只是增加了 .NE
或者 .EQ
后缀,则表示是条件跳转,如果不相等或者相等才跳转,类似于我们代码里面写得 if (x != y) goto A; else goto B
类似的逻辑。这里写得是指如果 栈上面现在的canary
的值不等于之前保存的 canary
值,则跳转到 __stack_chk_fail()
函数里面去表示栈溢出了。
在栈上面实例化一个 class
的对象,其实就是调用一下这个 class
对象的构造函数,和调用一个普通的函数类似,例如在上文的 helloworld
中调用 printf
函数一样,也是先准备好调用函数的参数,然后使用 BL
汇编指令跳转到这个函数地址执行,最后通过寄存器接收函数的返回值即可。
// ...
720: 2a1f03ea mov w10, wzr
724: b90017ea str w10, [sp,#20]
728: 320003ea orr w10, wzr, #0x1
72c: 2a0a03e1 mov w1, w10 // 参数1:id = 1
730: 910063e0 add x0, sp, #0x18 // 参数0:this=栈上的内存地址
734: 321f03e2 orr w2, wzr, #0x2 // 参数2:type = 2
738: f90007e8 str x8, [sp,#8]
73c: 97ffffe7 bl 6d8 <_ZN6ObjectC1Eli> // invoke function: Object::Object(id, type)
740: f94007e8 ldr x8, [sp,#8] // 返回值
// ...
这一段汇编对应的就是我们写得C++里面的 Object object(1, 2)
这一句调用构造函数,我们首先准备三个寄存器用于调用 Object::Object(long id, int type)
构造函数的传参:
x0
寄存器,用于存放一个指向栈内存的地址。
x1
寄存器放构造器的第一个参数 id
,值为 1
。其中 orr
汇编指令是 逻辑或
的操作。
x2
寄存器放构造器的第二个参数 type
, 值为 2
。
x8
寄存器准备接收构造器函数的返回值(这个构造器函数不返回任何值)。
这里很奇怪的地方就是,明明在C++的 Object::Object(long id, int type)
构造函数就2个参数,但为什么在汇编代码中,我们需要传递三个参数进去,这其实是 C++
的 class
语法引起的,在调用每个 class
的成员函数时,其实都隐式的将第0个参数 this
指针传递到成员函数中进行函数调用的,即真正在汇编看来的函数签名是这样的:
Object::Object(void* this, long id, long type);
在类似像 python
这种其他的语言,其实在书写代码的时候,都要求显式的将 this
或者 self
参数写出来,只是在像 java
或者 c++
这种语言中它们是通过隐式传递的。
下面我们来看 Object
类的构造函数里面干了些什么:
00000000000006d8 <_ZN6ObjectC1Eli>:
6d8: d10083ff sub sp, sp, #0x20
6dc: f9000fe0 str x0, [sp,#24] // 接收并保存this参数到栈内存
6e0: f9000be1 str x1, [sp,#16] // 接收并保存id参数到栈内存
6e4: b9000fe2 str w2, [sp,#12] // 接收并保存long参数到栈内存
6e8: f9400fe0 ldr x0, [sp,#24] // 加载this参数的值到x0寄存器
6ec: aa0003e1 mov x1, x0
6f0: f9400be8 ldr x8, [sp,#16]
6f4: f9000028 str x8, [x1] // 将id参数写入内存块,偏移为object基地址+0
6f8: b9400fe2 ldr w2, [sp,#12]
6fc: b9000802 str w2, [x0,#8] // 将type参数写入内存块,偏移为object基地址+8
700: 910083ff add sp, sp, #0x20
704: d65f03c0 ret
同样的我们先忽略掉进入函数和退出函数对于栈内存的扩大和缩小,直接看核心的逻辑:我们已经得知构造器一共接收了三个参数:this
, id
和 type
,它们都是通过寄存器传值的,分别在 x0
, x1
, x2
寄存器中,它们的值分别是sp,#18
, 1
和 2
, 因此 Object
的构造器首先把这3个参数都先从寄存器中取出来,将它们存到栈内存上,然后将 this
参数的值即一个栈上的地址加载到 x0
寄存器中,这个地址其实就是用于保存当前的 Object
对象的内存起始地址,而指向这个地址的指针其实就是这个 Object
对象的指针,在这里,这个 Object
对象的地址在栈上的 sp,#18
这个的内存地址。
然后做的事情就是把传进来的参数的值:id
, type
再从栈内存中加载到寄存器中,并将其写入到 Object
对象的内存块里面:
sp,#18
内存地址,即 Object
对象的内存基地址。
sp,#18
内存地址的8个字节, 保存了 id
参数的值,即数字 1
。
sp,#18+8
内存地址的8个字节,保存了 type
参数的值,即数字 2
。
到此我们就已经成功在栈上面实例化了一个 Object
对象,下面还是按照老规矩,我们挂上调试器来观察一下现在栈上的内存布局和寄存器的变化是这样的,更进一步的加深理解刚才学习的这些汇编代码执行完了之后的情况。需要注意的是,下面的表的从上到下的顺序是按照内存地址从小到大的顺序,但是因为这里我们观察的是栈内存,因为栈的生长方向是从高地址向低地址,因此在按照这个表从上到下的顺序是从栈顶到栈底的顺序:
address | memory | note |
---|---|---|
0x7ffffff410 | 00 00 00 00 00 00 00 00 | 栈顶,新的 sp ,或原始sp-0x40 指向的内存地址 |
0x7ffffff418 | 10 00 2f be 7f 00 00 00 | 保存着 tpidr_el0 的值 |
0x7ffffff420 | 00 00 00 00 00 00 00 00 | |
0x7ffffff428 | 02 00 00 00 00 00 00 00 | Object对象在内存中的基地址, type 成员变量的值:2 |
0x7ffffff430 | 01 00 00 00 00 00 00 00 | Object对象的 id 成员变量的值:1 |
0x7ffffff438 | 38 07 7e 0a a5 dd d8 6d | 栈溢出保护的 canary 金丝雀的值 |
0x7ffffff440 | 50 f4 ff ff 7f 00 00 00 | 保存着 x29(fp) 寄存器的值 |
0x7ffffff448 | b8 b8 1e bd 7f 00 00 00 | 保存着 x30(lr) 寄存器的值 |
0x7ffffff450 | 栈底,原始sp 指向的内存地址 |
在我们学会了在栈上实例化一个对象后,我们将进一步学习如何使用 c++
的 new
关键字来在堆内存上实例化一个对象,并获得一个指向这个堆内存的指针,这也是在 c++
代码中比较常见的创建对象实例的方法。我们稍微修改一下我们的 c++
代码:
#include <stdio.h>
#include "object.h"
int main() {
Object* obj = new Object(1, 2);
return 0;
}
其实也只是简单的修改了一行代码,将之前的 Object object(1, 2);
一行修改成了 Object* obj = new Object(1, 2)
。我们来看使用 new
关键字在堆上面实例化一个对象和直接在栈上实例化一个对象在汇编层面的区别是什么,这里我们反汇编以后会发现汇编的代码量又大大增加了,包括了一些 new
, delete
, 甚至 try-catch
,exception
相关的汇编代码,这里我们将直接忽略这些超纲的汇编代码,重点关注在堆上面实例化对象和在栈上实例化对象的主要区别,至于那些超纲的那些知识点我们将在后面的章节进行学习。
这里我们先摘抄出 main
函数汇编代码的主要区别:
// ...
64e4:b27c03e0 orrx0, xzr, #0x10 // x0 = 0x10,malloc函数的size参数
64e8:b24003e1 orrx1, xzr, #0x1// x1 = 1, 构造函数的id参数
64ec:321f03e2 orrw2, wzr, #0x2 // x2 = 2,构造函数的type参数
64f0:b81fc3bf sturwzr, [x29,#-4]
64f4:b90013e2 strw2, [sp,#16]
64f8:f90007e1 strx1, [sp,#8]
64fc:94000013 bl6548 <_Znwm> // 调用operatornew[](size=0x10)函数分配堆内存
6500:f90003e0 strx0, [sp] // 将malloc返回的地址作为构造函数的this参数
6504:f94007e1 ldrx1, [sp,#8]
6508:b94013e2 ldrw2, [sp,#16]
650c:97ffffe7 bl64a8 <_ZN6ObjectC1Eli> // 调用Object构造器
// ...
首先在堆上面去实例化一个对象和在栈上面去实例化一个对象基本是类似的,都是去调用它的构造函数:Object::Object(long id, int type)
来实现对象的实例化的,但最主要的区别是在调用构造函数的时候第一个 this
参数的传参值,当我们在栈上面实例化对象的时候,这个 this
参数直接传的是一个指向栈上的内存地址,这块内存因为在栈上面,因此在函数退出的时候是直接随着整个栈内存缩小而自动回收的,因此不需要调用内存回收的函数,而当我们在堆上面实例化对象的时候,这个 this
参数的值是先通过调用 operatornew
这个方法(其实它内部是调用 malloc
函数)在堆内存上申请一块内存(大小为 0x10
),然后将这个堆内存的地址作为 this
参数的值传入给构造函数的。
其实 Object
对象在堆内存上的内存布局和之前在堆上面实例化的对象的内存布局是一模一样的,只是内存的基地址一个在栈内存上面,一个在堆内存上面,Object
对象在内存上都是一个 0x10
大小的内存块,其中前面的 8 bytes
用来存放成员变量 id
, 后面的 8 bytes
用来保存成员变量 type
的值。
address | memory | note |
---|---|---|
0x7ffffff428 | 02 00 00 00 00 00 00 00 | Object对象在内存中的基地址, type 成员变量的值:2 |
0x7ffffff430 | 01 00 00 00 00 00 00 00 | Object对象的 id 成员变量的值:1 |
在我们实例化了一个类的对象后,对于一些 public
的成员变量,我们可以在外部直接访问到它们,例如我们修改一下我们的 c++
代码,增加获取 Object
的 id
成员变量的打印语句:
#include <stdio.h>
#include "object.h"
int main() {
Object* obj = new Object(1, 2);
printf("id=%lu", obj->id_); // 访问Object的公开成员变量:id
return 0;
}
涉及到的汇编代码如下:
// ...
6548:9400001b bl65b4 <_Znwm> // 调用new函数申请栈内存
654c:f9000be0 strx0, [sp,#16] // 返回值为内存地址,将其保存在栈上面
// ...
6558:97ffffe4 bl64e8 <_ZN6ObjectC1Eli> // 将该内存地址作为Object的this参数传入构造函数
// ...
656c:f85f03a9 ldurx9, [x29,#-16] // 从栈上面读取Object的内存基地址
6570:f9400521 ldrx1, [x9,#8] // 做一个0x08的偏移计算,读取Object的成员变量 `id_` 的内存地址上的值
// ...
6580:97ffff48 bl62a0 <printf@plt> // 将 `id_` 的值作为参数传递给 `printf` 函数打印
其实我们从之前的 Object
对象在实例化之后在内存中的布局就可以得知,要想访问到 Object
对象的成员变量的值,其实都可以通过 Object
对象的内存基地址增加一个 offset
偏移量计算得到其成员变量的内存地址,然后读取该内存地址上的值,即得到了这个成员变量的具体值。这样我们要访问 Object
对象的 id_
成员变量的值时,只需要先获取到 Object
指针指向的内存地址,将其作为 Object
实例对象在内存里的基地址,然后我们通过在该基地址上增加一个 0x08
的偏移量,即得到了 id_
成员变量的内存地址,再使用 LD
汇编指令读取内存里面的值到寄存器即可。
在写 c++
代码创建了一个类的实例对象后,除了访问它的成员变量,最多的操作其实是调用它的成员函数,例如在我们的 Object
对象中就提供一个 GetType()
成员函数来访问其私有的成员变量 type
,我们再稍微修改一下我们的 c++
代码:
#include <stdio.h>
#include "object.h"
int main() {
Object* obj = new Object(1, 2);
printf("type=%d", obj->GetType()); // 调用Object对象的成员函数
return 0;
}
当看到汇编代码的时候,其实我们就会发现调用一个对象的成员函数这件事情我们早就已经做过了,代码是如此的似曾相识:
// ...
6584:f85f03a9 ldurx9, [x29,#-16] // 从栈上面读取Object的内存基地址
658c:aa0903e0 movx0, x9 // 将Object的内存基地址加载到x0寄存器作为第0个参数
6590:97ffffe2 bl6518 <_ZN6Object7GetTypeEv> // 调用 Object::GetType() 函数
6598:b9000be0 strw0, [sp,#8] // 获取函数的返回值
// ...
这段代码其实和我们之前调用 Object
的构造函数:Object::Object(long id, int type)
的逻辑是一样的,也是将 Object
的指针(内存地址)作为第0个参数,然后通过 BL
指令跳转调用这个成员函数即可。也就是说调用一个类的成员函数,和调用一个普通的函数本质上来说是没有什么区别的,唯一的区别是调用成员函数的第0个参数都是 this
指针,在汇编中都是传入对象在内存中的基地址。
NOTE: 需要注意的是,这里所说的的成员函数的调用情况并不包括虚函数,虚函数会稍微有些区别,这个会在今后在讲类的继承这一节中一起提到。
在这一篇文章中我们分别通过在栈内存上和在堆内存上实例化一个 c++
类(class
) 的实例对象( object
),并访问了对象的成员变量,以及调用了对象的成员函数等几个很实际的例子中,学习到了几个新的汇编知识点,包括 canary
栈内存保护机制,和mrs
,subs
, b.ne
,orr
等汇编指令,对象在内存中的布局等。在后面的章节里我们将学习更多相关知识。