C++逆向分析

最近遇到了一个C++的pwn题,所以在这里对C++的程序进行深入学习一下:


C++逆向分析学习

C++的类

C++与C最大的区别就是【面向对象】这个概念。我们知道在C++中,class是一个常用的概念。那么在二进制程序中,class呈现的是什么样子的呢,我们这里用一个程序进行分析说明。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream>
#include <string>

using namespace std;

class Person
{

public:
Person(){
age = 0;
name = "";
};
~Person(){

};
virtual void setName(string name) = 0;
virtual void setAge(int age) = 0;
protected:
int age;
string name;

};

class Student :public Person{

public:
Student(){
age = 17;
}
void setName(string name){
this->name = name;
}
string getName(){
return this->name;
}
void setAge(int age){
if(age>30 || age < 6){
cout<<"not suitable for school!"<<endl;
return;
}
this->age = age;
}
int getAge(){
return this->age;
}
};

int main(){
Student *stu = new Student();
stu->setName("Link");
stu->setAge(18);
cout<<stu->getName()<<" with age "<<stu->getAge()<<endl;
return 0;
}

首先我们可以看到,类Person为虚类,因为其没有实现任何一个函数,而是通过其子类Student实现的。

关于属性分布

其实class本身只是struct的一种衍生方式,所以使用了虚拟函数的class本身的结构是这样的:

1
2
3
4
5
6
7
8
9
10
11
strcut class{
func* funcptr --------> +-----------------+
| func1 addresss |
+-----------------+
| func2 addresss |
+-----------------+
...
type a;
type b;
...
};

而当我们使用new函数进行申请对象的时候,比如如下

1
Person *p = new Person();

实际上是发生了这样的事情:

1
2
3
Person *p = (Person*)malloc(sizeof(Person));
// 调用了自己的初始化函数将其初始化,如果其为继承的类,那么此时会在进行一次父类函数的类似操作。
Person::Person(p);

而在Person里面,会发生如下的操作:

1
2
*p = func_ptr
其他属性进行初始化。

关于虚表(Virtual table)

首先复习一下什么叫做虚函数:

一个简单的复习

虚函数出现的目的是为了实现【多态】,也就是所谓的【父类指针指向子类函数】的功能,例如如下的函数

1
2
3
void GiveFood(Animal animal, Food food){
animal.eat(food);
}

这个eat函数,不同的动物表现的不一样:

  • 狗在吃的时候发出"Bow, Bow"
  • 猫吃饭的时候发出"Miao, Miao"
  • 鹦鹉在吃饭的时候支持我们输入数据

但是如果每个动物我们都用不同的函数去判断的话,那么整个过程就太冗长了。为了支持这个功能,C++提供了多态。也就是说对于所有的动物,他们共有的能力就是【吃饭】,那么为了能够调用这个【吃饭】的过程,我们可以不去考虑功能的细节,直接使用这个函数:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

#include <iostream>
#include <string>

using namespace std;

class Animal
{

public:
Animal(){
name = "";
};
~Animal(){

};
void eating(string food){
cout<<"the "<<food<<"test well...."<<endl;
};
protected:
string name;

};

class Dog :public Animal{

public:
void eating(string food){
cout<<"Bow, Bow!"<<endl;
}
};

class Cat :public Animal{

public:
void eating(string food){
cout<<"Miao~"<<endl;
}
};

class Parrot :public Animal{

public:
void eating(string food){
string talking;
cin>>talking;
cout<<"I say"<<talking<<endl;
}
};

void GiveFood(Animal *a, string food){
a->eating(food);
}

int main(){
Dog *doggy = new Dog();
Cat *catty = new Cat();
Parrot *parrot = new Parrot();
GiveFood(doggy, "meat");
GiveFood(catty, "fish");
GiveFood(parrot, "bug");
return 0;
}

运行结果为:

1
2
3
4
5
learnC++$ ./learnCpp_Poly.bin
Bow, Bow!
Miao~
Emm..
I sayEmm..

但是,如果我们不使用virtual关键字说明的话,此时得到的结果就是完全不一样的内容:

1
2
3
4
learnC++$ ./learnCpp_Poly.bin
the meattest well....
the fishtest well....
the bugtest well....

可以看到,这个时候调用的是Anima本身的eating函数,而不是相应的子类函数。

为什么会导致这样的情况

我们这次用一个比较段的程序来说明这个事情

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Person
{

public:
Person(){
age = 0;
name = "";
};
~Person(){

};
void setName(string name){
cout<<"this man call "<<name<<endl;
};
virtual void setAge(int age){
cout<<"person age is "<<age<<endl;
};
protected:
int age;
string name;

};

class Student :public Person{

public:
Student(){
age = 17;
}
void setName(string name){
this->name = name;
}
string getName(){
return this->name;
}
void setAge(int age){
if(age>30 || age < 6){
cout<<"not suitable for school!"<<endl;
return;
}
this->age = age;
}
int getAge(){
return this->age;
}
};

这个程序和我们最初的内容很像,但是区别在于,其中的setAge为虚函数,而setName不为虚函数。然后我们观察其二进制,可以发现一些有趣的现象:

C++为了能够支持重载,会将函数名字本身进行破坏后重组,所以这里函数的名字看起来怪怪的。这个数据段中放了在运行中的一些重要信息:

1
2
3
4
5
6
7
.rodata:0000000000401220                 public _ZTV7Student ; weak
.rodata:0000000000401220 ; `vtable for'Student
.rodata:0000000000401220 _ZTV7Student dq 0
.rodata:0000000000401228 dq offset _ZTI7Student ; `typeinfo for'Student
.rodata:0000000000401230 off_401230 dq offset _ZN7Student6setAgeEi
.rodata:0000000000401230 ; DATA XREF: Student::Student(void)+1Co
.rodata:0000000000401230 ; Student::setAge(int)

这段内容其实放的是Student类本身的【虚表】,也就是这个类中的虚类的基本信息

1
2
3
4
5
+---------------------------------+
|00401228 `typeinfo for Student | Student类本身的属性
+---------------------------------+
|00401230 Student6setAgeEi | Student类的函数:setAge
+---------------------------------+
1
2
3
4
5
.rodata:0000000000401270                 public _ZTI7Student ; weak
.rodata:0000000000401270 ; `typeinfo for'Student
.rodata:0000000000401270 _ZTI7Student dq offset unk_602270 ; DATA XREF: .rodata:0000000000401228o
.rodata:0000000000401278 dq offset _ZTS7Student ; "7Student"
.rodata:0000000000401280 dq offset _ZTI6Person ; `typeinfo for'Person

这一段则是介绍了Student这个类的基本属性。上面的typeinfo for Student所指向的地址就是这个里。这里记录了Student这个类的名字(_ZTS开头的表示的是字符串)以及其父类内容(ZTI表示的是对应的info)

观察了这么多,我们能够发现两个事情:

  • Person和Student的setAge都被放在这个表格中,分别叫做_ZN6Person6setAgeEi和 _ZN7Student6setAgeEi
  • 无论是Person还是Student的setName函数都不见了。

这个现象蛮有趣的,我们观察一下main函数的基本内容:

1
2
3
4
Student::setName(v10, &v9);
std::string::~string((std::string *)&v9);
std::allocator<char>::~allocator(&v8);
(**(void (__fastcall ***)(Student *, signed __int64))v10)(v10, 18LL);

对比源码后不难发现,最后的这个函数指针就是函数setAge。也就是说,当调用setAge的时候,程序的逻辑其实上是:

1
Stu对象调用setAge --> 找到其虚函数表 --> 第一个内容为setAge --> 调用对象的第一个函数表中内容。

而在调用setName的时候,由于此时没有使用virtual关键字,导致此时setName被认为是父类和子类两个完全不同的函数,因此直接采用与普通的函数相同的处理方法,直接在.text中查找对应的函数。

使用VTable的理由存在

为了实现【多态】,那么势必是要在【运行中才能确定对应调用的函数】。比如我们之前喂动物的例子,不运行起来,GiveFood永远不知道此时的Animal本身对应的对象是哪一个对象,因此就需要再运行的时候再对其进行绑定,为了实现这个运行时绑定的效果,使用虚表就变得方便了很多。

虚类的特征 – VTable中的函数

之前我们虽然提到过虚类,但是没有给出过虚类对象在内存中存在的某个特征。在虚类中,本来要实现的函数的位置上会有一个叫做purecall的函数进行填充,结构如下:

1
2
3
4
.rdata:00007FF6F848A500 ??_7Character@@6B@ dq offset _purecall  ; DATA XREF: sub_7FF6F8482A50:loc_7FF6F8482A91↑o
.rdata:00007FF6F848A500 ; sub_7FF6F8482B60+A↑o ...
.rdata:00007FF6F848A508 dq offset _purecall
.rdata:00007FF6F848A510 dq offset _purecall

这里就相当于说,Character对象有三个要实现的虚函数,这里先用purecall进行提前的填充。

How To Pwn

知道了上述的说法,我们就有了第一种在C++下可以进行pwn的手段。
首先,我们知道,用new申请的对象,首先要进行【获得当前vptr的过程】

1
2
3
4
5
            +------------+
obj_ptr --> | vptr | -------+
+------------+ | +---------+
+----->| func1 |
+---------+

也就是说,如果我们此时调用func1的时候,实际的调用过程是:

mov rax, QWORD PTR [rbp-0x18] ; 取出obj_ptr
mov rax, QWORD PTR [rax]      ; 取出vptr的地址
mov rax, QWORD PTR [rax]      ; 取出vptr中第一项,也就是func1的地址
call rax                      ; 跳转到func1上

此时obj_ptr指向的堆是这样的

1
2
3
4
5
+---------------+
| vtable address|----+ vtable
+---------------+ | +------------+
| attrib | +----->| func1 |
+---------------+ +------------+

那么,如果我们能够伪造一个vptr,然后将堆上的vptr addr改成我们指定的一个伪造的vptr,那么会变成:

1
2
3
4
5
6
+---------------+
| fake address |----+
+---------------+ | fake table
| attrib | | +-----------+ +-------------+
+---------------+ +--------->|&shellcode |----------->| shellcode |
+-----------+ +-------------+

这样我们就能够实现pwn了!

without NX

由于一般的堆都是在读入数据的时候申请的,因此此时我们直接将fake address填入堆地址,堆的地址可以通过各种方法泄露出来。这样的话我们同时能够得到多个可以利用的堆

with NX

这种时候就只能考虑到使用一般的堆溢出攻击了。。。。

关于传参的细节

在windows系统下,使用vs进行代码编译之后,发现了一个特征:
在x86环境下,传参使用压栈的方式,越往右的参数越先压栈

1
2
3
4
5
6
7
8
9
10
11
12
13
func(pn....p3,p2,p1)

+-------+ stack frame
| p1 |
+-------+ |
| p2 | |
+-------+ |
| p3 | |
+-------+ |
... |
| pn | V
+-------+
stack point

在x64环境下,传参的一部分在寄存器中进行传递,网上大部分查到的顺序是rdi,rsi,rcx,rdx,r8,r9,但是实际上,发现在windows下,使用vs编译后,传参的顺序变成了rcx,rdx,r8,r9

1
func(rcx,rdx,r8,r9,...)

关于常见的string类型

C++中的string类型使用的倒是很平常,但是其反汇编一般不好认。。。这里记录一下对string的第一感觉:
string类型再执行的时候,结构体本身如下:

1
2
3
4
5
6
00000000 String          struc ; (sizeof=0x10, mappedto_54)
00000000 ptr dd ?
00000004 size_ dd ?
00000008 all_size dd ?
0000000C is_large dd ?
00000010 String ends

简单介绍一下:

  • ptr: 指向string分配空间的指针
  • size_: 当前string中字符总大小
  • all_size: 当前分配的字节是否达到了某个大小(待修正)
  • is_large: 当前使用的字节空间超过了某个限度,此时需要重新分配空间

运行过程

ptr指向字符串的指针,然后这个指向的位置为一开始申请的大小,如果我们不断的insert新的数据的时候,系统会将当前ptr指向的堆块的前四个字节变成指针,然后这四个指针指向的位置为新申请的空间。如:

1
string a = "123";

此时空间结构如下:

1
2
3
ptr ------> +------------+
| 123 |
+------------+

如果此时我们插入了过多的字符串的话,其中的is_large参数就会被修改为1,此时如果还要发生insert等过程的话,ptr所指向的位置的前4个字节就会被置为下一处分配的地址,并且原先的数据也会移动过去

1
2
3
4
5
ptr -------> +----------------+         +------------------+
| ptr | | | 123 |
+----------------+ +------------------+
| |
+-----------------------+

总觉得这里有可以发生pwn的位置。。下次试试把