最近遇到了一个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
:
关于常见的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 2 3
| ptr ------> +------------+ | 123 | +------------+
|
如果此时我们插入了过多的字符串的话,其中的is_large参数就会被修改为1,此时如果还要发生insert等过程的话,ptr所指向的位置的前4个字节就会被置为下一处分配的地址,并且原先的数据也会移动过去
1 2 3 4 5
| ptr -------> +----------------+ +------------------+ | ptr | | | 123 | +----------------+ +------------------+ | | +-----------------------+
|
总觉得这里有可以发生pwn的位置。。下次试试把