C++逆向

关于Typora插入视频的方法:

Typora视频无法正常显示与mp4格式 - elmagnifico’s blog

1717728278001

直接下载一个格式工厂,然后转一下就行

因为MP4里面又有很多编码,可能Typora不支持

输出配置将 视频编码 改为 AVC(H264)即可,这样qq录屏下来的MP4就可以直接插入Typora了

1717728375010

结构体和类

结构体和类 都有构造函数,析构函数,和成员函数,两者的区别只有一个,结构体的访问控制默认为public,类的默认访问控制是private。

值得注意的是,在c++中所谓的private,public,protected都是c++编译器在编译的时候检查的,在实际编译的时候并没有多加什么,也就是说,加这些修饰是给编译器看的,看能否通过。编译成功后,程序在执行过程中不会对访问控制方面又再多检查和限制

结构体大小:

首先要解决的是对齐问题:

结构体对齐值编译器可以设置:

1716795528869

当然也可以手动设置

1
2
3
4
5
6
7
8
9
#pragma pack(push)    //保存push指令之前的对齐状态
#pragma pack(8) //设置对齐值为8字节
struct Vector2D
{
char x;
int y;
long long z;
}
#pragma pack(pop) //还原回push指令之后的对齐状态

计算结构体成员对齐 要满足:

1
struct member % min(结构体成员类型大小,设置的align) == 0

计算结构体总大小要满足:

1
struct size % min(其中结构体成员的最大类型大小,设置的align) ==0

我们举个例子:

1
2
3
4
5
6
7
8
9
10
#pragma pack(push)    //保存push指令之前的对齐状态
#pragma pack(8) //设置对齐值为8字节
struct Vector2D
{
char x;
int y;
char z;
long long k;
}
#pragma pack(pop) //还原回push指令之后的对齐状态

这个结构体多大呢?

首先

1
2
3
4
0x0		char x;
0x4 int y; //这里为什么从0x4开始呢?是因为要满足struct member开始的地址 % min(结构体成员类型大小,设置的align) == 0,如果从0x1开始,0x1 % min(4,8),不能被整除,所以从0x4开始
0x8 char z;
0x10 long long k;//这里为什么不从0x8+0x4即0xc开始呢?一样的道理,因为要满足struct member开始的地址 % min(结构体成员类型大小,设置的align) == 0,如果从0x1开始,0x1 % min(8,8),不能被整除,所以从0x10开始

所以按照这样,把所有结构体成员加起来,大小应该是0x4+0x4+0x4+0x8=0x14

最后一步:结构体的总大小:

因为要满足 struct size % min(其中结构体成员的最大类型大小,设置的align) ==0

所以为了整除 min(8,8),所以结构体总大小应该是0x18

我们放到编译器看看:

发现大小确实是0x18 (即24)

1716796733420

然后这是内存:

1716796779594

也和我们推测的一致

在IDA的操作

1716797857169

例如这样不太好看出具体赋值类型,可以按下k键,就可以转为有类型的,更符合我们写汇编的语法

1716797964178

如何区分数组和结构体?

首先是初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(
char x,
int y,
char z,
long long k,
int a,
int b,
int c,
int d)
{
Vector2D v;
v = { x,y,z,k };
int arr[4] = { a,b,c,d };
printf("%d", sizeof(v));
printf("%ll", v.k);
printf("%d", arr[3]);
return 0;
}

1.结构体成员类型可以不一致

1716798487571

2.数组用的是数组寻址公式,但是结构体不是

1716798765846

用头文件导入结构体:

之前我们学的都是自己 构造结构体,但是耗时,又麻烦,因此我们可以自己做

例如这个结构体:

1
2
3
4
5
6
7
8
9
10
#pragma pack(push)    //保存push指令之前的对齐状态
#pragma pack(8) //设置对齐值为8字节
struct Vector2D
{
char x;
int y;
char z;
long long k;
}
#pragma pack(pop) //还原回push指令之后的对齐状态

先放入一个头文件中

1716799011954

然后在IDA的左上角,可以载入C语言的头文件

1716799033102

选择我们刚刚那个文件后,这就是加载成功了!

1716799316578

如何添加我们加载进来的头文件结构体呢?

先Shift+F9,进入我们的结构体界面:

鼠标箭头设置在最末尾,然后按下 Insert 键 (在我的电脑上是和‘0’在一起)

1716799479223

然后弹出来的框填好

1716799578287

然后选中我们导入的结构体

1716799627873

双击加载

1716799683112

发现已经添加啦,然后可以按 ctrl 和 ‘+’ ,这样可以展开结构体

1716799722414

如何应用结构体?

首先找到结构体的首地址

双击进去查看,这里是var_1C,注意要按下k键转为 var

1716800350980

然后应用结构体

1716800388816

效果如下,非常好用

1716800415796

结构体作为返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Vector2D
{
char x;
int y;
char z;
long long k;
int j;
int o;
int p;
int w;
double q;
};
#pragma pack(pop) //还原回push指令之后的对齐状态


Vector2D Func(Vector2D v)
{
printf("%c %d %c %ll", v.x, v.y, v.z, v.k);
scanf("%d", &v.y);
return v;
}

当结构体里面成员很多的时候,就不会用寄存器来返回了,那编译器是如何做的呢?

我们可以看到

1
2
3
4
5
6
7
8
9
10
11
.text:00401201 83 C4 08                      add     esp, 8
.text:00401204 83 EC 30 sub esp, 30h
.text:00401207 B9 0C 00 00 00 mov ecx, 0Ch
.text:0040120C 8D B5 5C FF FF FF lea esi, [ebp+v]
.text:00401212 8B FC mov edi, esp
.text:00401214 F3 A5 rep movsd //这里相当于把所有成员push进栈
.text:00401216 8D 8D 2C FF FF FF lea ecx, [ebp+result] //这里额外传了一个ecx
.text:0040121C 51 push ecx ; result
.text:0040121D E8 DE FE FF FF call ?Func@@YA?AUVector2D@@U1@@Z ; Func(Vector2D)


在调用 Func 之前,我们注意到多push了一个 ecx,是一个局部变量

然后我们看看它是怎么返回的:

1
2
3
4
5
6
7
8
9
10
11
12
.text:00401136 83 C4 08                      add     esp, 8
.text:00401139 B9 0C 00 00 00 mov ecx, 0Ch
.text:0040113E 8D 75 0C lea esi, [ebp+v]
.text:00401141 8B 7D 08 mov edi, [ebp+result]
.text:00401144 F3 A5 rep movsd
.text:00401146 8B 45 08 mov eax, [ebp+result]
.text:00401149 5F pop edi
.text:0040114A 5E pop esi
.text:0040114B 5D pop ebp
.text:0040114C C3 retn


发现将结果全部拷贝到了 传进来的 result 这个局部变量,然后返给eax

就好像一个指针,这样能节省很多指令

识别类的构造,析构函数

识别构造函数

识别类的构造函数总结:

1.构造函数是这个对象在作用域内调用的第一个成员函数,根据this指针可以区分每一个对象

2.这个成员函数是通过this call调用的

3.这个函数返回this指针

上述条件缺一不可

编译器何时会为类提供默认构造函数?

1.本类和本类中定义的成员对象或者父类中存在虚函数

这是因为要初始化虚表,且这个工作理应该在构造函数中隐式完成,所以在没有定义构造函数的情况下,编译器会添加默认的构造函数,用于隐式完成虚表的初始化工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class Test
{
public:
virtual void Func()
{
cout << "this is func" << endl;
}
~Test()
{
cout << "this is xigou func" << endl;
}
};
int main()
{
Test t;
t.Func();
return 0;
}


1717157654475

这里可以看到,虽然,没有写构造函数,但是编译器还是为我们生成了一个默认的构造函数

1717157691972

2.父类或本类中定义的成员对象带有构造函数

在对象被定义时,因为对象本身为派生类,所以构造顺序是先构造父类,再构造自身,当父类中带有构造函数的时候,将会调用父类的构造函数,而这个调用过程需要在构造函数内完成,因此编译器添加了默认的构造函数来完成这个调用过程

在没有定义构造函数的情况下,当类没有虚函数,父类和成员对象也没有定义构造函数的时候,提供默认构造函数已经没有任何意义,只会降低执行的效率,因此编译器没有堆这类情况提供默认的构造函数

C语言中malloc函数和c++中的new区别很大,尤其是malloc不负责触发构造函数,它也不是运算符,无法进行运算符重载

析构函数出现的时机

  1. 局部对象:作用域结束前调用析构函数

  2. 堆对象:释放堆空间前调用析构函数

  3. 参数对象:退出函数前,调用参数对象的析构函数
    1717160569597
    例如这样,传入一个Test2类的参数,执行完这个函数后,会自动调用Test2的析构函数

  4. 返回对象:如无对象的引用定义,退出函数后,调用返回对象的析构函数,否则与对象引用的作用域一致

    也就是说,返回对象是一个类对象,如果后续没有调用这个类,则出去构造这个类的函数以后,就会调用析构函数,否则和该函数一样的作用域

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Test
    {
    public:
    Test2 GetTest2()
    {
    static Test2 t3;
    return t3;
    }
    };


    int main()
    {

    Test t;
    Test2=t.GetTest2();
    cout<<"6666"<<endl;
    return 0;
    }


    在上面代码中,如果对t.GetTest2();返回的变量没有引用的话,在还没调用 cout<<”6666”<<endl;这个语句的时候,就会去调用Test2类的析构函数

    1717161537069

    否则的话,就会

    1717161662683

  5. 全局对象:main()函数返回后调用析构函数

  6. 静态对象:main()函数返回后调用析构函数

虚函数:

(一) 虚函数

  • 识别面向对象的依据,因为虚表不可被优化。
  • 关键字:virtual

构造函数时不能变成虚函数的,但是析构可以

虚表大概情况

如何查看虚表?

一种方法是用交叉引用,找到一个函数,如果按下X,交叉引用后,发现有在rdata出现,则证明这是一个虚函数,而且追过去可以查看整个类的虚函数数组

1717555864062

第二种方法是:

找到一个类的构造函数或者析构函数,然后查看它的反汇编:
1717555963506

就可以看到虚函数数组的首地址

方法三:

虚表指针存在 this 指针的前四个字节

1717556295340

跟踪一下这个 this 指针

1717556323897

发现this指针的前四个字节就是虚表指针

具体操作是在构造函数:

1717556464546

在构造函数中,将虚表指针写在了this指针的前四个字节

有了虚函数,就会比虚函数多四个字节

析构函数会重新对虚表赋值

在析构函数中要还原虚表指针,层层递归,一直到最终父类被析构结束

例如下面这个代码:

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
#include <iostream>
#include <string>
using namespace std;
// Base class with a virtual function
class Father
{
public:
virtual void draw()
{
std::cout << "Drawing" << std::endl;
}

virtual ~Father()
{
cout << "father析构" << endl;
}
};


class Shape : public Father {
public:
virtual void draw() {
std::cout << "Drawing a Shape" << std::endl;
}

virtual void draw2()
{
std::cout << "Drawing a Shape2222" << std::endl;
}

virtual ~Shape()
{
cout << "Shape析构" << endl;
}
};

// Derived class Circle
class Circle : public Shape {
public:
Circle()
{
cout << "Circle构造函数" << endl;
}

virtual void Func()
{
cout << "Circle Func" << endl;
}

virtual ~Circle()
{
cout << "Circle析构" << endl;
}
};

int main() {
Circle c;
c.draw();
return 0;
}


Circle类算是孙子类,往上面还有父类,爷爷类,我们用IDA反汇编看看析构是怎么个情况

发现在Circle的析构函数首先将Circle的虚表赋值给了this指针

然后回调用Circle父类的析构函数

1717586309137

然后我们继续探

同样的,将父类的虚表赋值给this指针,然后调用父类的父类的析构函数

1717586406307

一直递归,直到最终的父类析构结束,一样的,这里也需要将自己的虚表赋值给this指针

1717586439883

如何快速定位一个类的构造函数和析构函数

由刚刚的特征我们可以知道的是,构造函数和析构函数必然会去操作虚表,所以我们可以查看虚表的引用去找构造和析构,这一招非常好用

1717557091671

可能不一定有构造函数,但是一定有析构函数

这样不直观的话,可以查看引用图:

1717586887526

1717586870152

这俩其中一个是构造,一个是析构,地址低的是构造,高的是析构

虚表在编译的时候就已经生成

  • 如果一个类至少有一个虚函数(virtual 修饰),那么就会有虚表。(继承过来的虚函数也算)

  • 虚表在全局数据区 (vs放到 rdata段)

  • 虚表指针在虚表首地址处理。

  • 所有表项都指向成员函数指针,如果虚表数组每一个元素不是指向一个函数指针,那么就不是虚表

  • 虚表不一定以0结尾。

值得注意的是,虚表的结尾不一定是0,具体分析具体判断

1717556085846

  • 判断:同上上下文特征,判断有几个函数指针。

  • 构造函数填充虚表指针

  • 析构函数回填虚表指针

    • 析构函数调用虚函数无多态性

为什么IDA没有PDB也能识别类名?

由于 C++ RTTI 新版本才有

1717587487600

例如这样,明明没有加载PDB,但是还是识别出了虚表类是Circle,这是因为

1717587532967

有个叫RTTI的东西

在编译器选项可以关闭:

1717587745578

把这个改为 否 即可

这样虚表就没有信息了,增大逆向难度

如果用到Try catch,这会强制开启RTTI,因为Try要去识别类型

虚表的使用

成员函数产生多态

1.是虚函数

2.使用指针或者使用引用

3.调用的时候 Call Vftable[index] (如果是直接调用函数,那么就不是虚函数)

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
#include <iostream>
using namespace std;
class Temp
{
public:
Temp()
{
cout << "Temp构造函数调用" << endl;
}


virtual void Func1()
{
cout << "Func1函数调用" << endl;
}

virtual void Func2()
{
cout << "Func2函数调用" << endl;
}

virtual void Func3()
{
cout << "Func3函数调用" << endl;
}

virtual void Func4()
{
cout << "Func4函数调用" << endl;
}

virtual ~Temp()
{
cout << "Temp 析构函数调用" << endl;
}

};
int main()
{
Temp* ptr;
Temp t;
ptr = &t;
ptr->Func1();
ptr->Func2();
ptr->Func3();
ptr->Func4();
}


反汇编以后长这样:

1717599089704

我们看看函数调用情况:

1717599123673

可以看到,如果不是虚函数,那么调用函数直接是call 函数名

但是如果是虚函数的函数,就会用间接调用

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
ptr->Temp::Func1(ptr);
.text:00412A84 8B 45 E8 mov eax, [ebp+ptr]
.text:00412A87 8B 10 mov edx, [eax]
.text:00412A89 8B F4 mov esi, esp
.text:00412A8B 8B 4D E8 mov ecx, [ebp+ptr]
.text:00412A8E 8B 02 mov eax, [edx]
.text:00412A90 FF D0 call eax

ptr->Temp::Func2(ptr);
.text:00412A99 8B 45 E8 mov eax, [ebp+ptr]
.text:00412A9C 8B 10 mov edx, [eax]
.text:00412A9E 8B F4 mov esi, esp
.text:00412AA0 8B 4D E8 mov ecx, [ebp+ptr]
.text:00412AA3 8B 42 04 mov eax, [edx+4]
.text:00412AA6 FF D0 call eax


ptr->Temp::Func3(ptr);
.text:00412AAF 8B 45 E8 mov eax, [ebp+ptr]
.text:00412AB2 8B 10 mov edx, [eax]
.text:00412AB4 8B F4 mov esi, esp
.text:00412AB6 8B 4D E8 mov ecx, [ebp+ptr]
.text:00412AB9 8B 42 08 mov eax, [edx+8]
.text:00412ABC FF D0 call eax


我们来仔细分析一下这个 间接 call 是如何用到虚表的

我们发现 call eax 是来自[edx + xx],然后溯源发现edx其实就是 this 指针

然后后面的 + xx 其实就是对应的虚表偏移,这样就能拿到正确的函数

继承

构造函数的顺序:

1.构造基类

2.构造成员对象

3.自己

注意注意,这是一个递归的操作

具体来说,在进行构造基类的构造时,如果基类还有基类,那么又进入基类的基类的构造

直到结束递归

析构的顺序

1.自己

2.构造成员对象

3.构造基类

实践一下:

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
#include <iostream>
using namespace std;

class A
{
public:
A()
{
cout << "This is A's gouzao" << endl;
}


~A()
{
cout << "This is A's xigou" << endl;
}
};


class B :public A
{
public:
B()
{
cout << "this is B's gouzao" << endl;
}

~B()
{
cout << "This is B's xigou" << endl;
}
};



class C : public B
{
public:
C()
{
cout << "this is C's gouzao" << endl;
}

~C()
{
cout << "This is C's xigou" << endl;
}
};


int main()
{
C c;
return 0;
}


这样我们发现,最终递归先调用的会是A的构造,然后是B的,最后是C的


析构函数之前说过就不再细说了

如果是虚函数,那么先基类的构造,再填充自己的虚表

1717729522928

这样,也给我们识别继承提供了依据

实践可以发现,先调用的基类构造,然后再调用成员构造,最后调用自己的构造

1717729714105

But,老版本的成员构造是在自己的构造之后,新版本才满足 基类构造 -> 成员构造 -> 自己的构造 这个顺序

成员函数和构造函数的区别就是,成员函数不填虚表

派生类的行为:

1.拷贝基类的虚表

2.覆盖(跟基类同名·,同参,同返回值)

3.新增加的虚函数,追加

另外虚函数会进行传递,就是A中的Func函数是虚函数,然后B继承A,C继承B,这个

继承的原理:

父类指针可以指向子类,并调用子类的函数,这是为什么呢?

这就涉及到继承的问题,具体来说是因为 构造函数中,虚表的覆盖

派生类的构造函数之前说过了,

构造函数的顺序:

1.构造基类

2.构造成员对象

3.自己

所以,最终子类的虚表是自己的,及时是父类指针指向子类,也可以去调用子类的虚函数,因为虚表已经被覆盖了

继承的特征:

找到虚表的地方,然后查看交叉引用,如果发现虚函数有交叉引用,就说明存在继承

1717770796986

1717770822088

实战:

如果调用了delete,那么就会产生析构代理

可以通过查看对虚表的引用,去迅速找到 构造和析构函数

多重继承

内存结构

A的内存结构

A::vftable
A::member

B的内存结构

B::vftable
B::member

AB的内存结构应该长这样

A::vftable
A::member
B::vftable
B::member
AB::member

但是要覆盖虚表,因此长这样

AB::vftable
A::member
AB::vftable
B::member
AB::member

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <iostream>
using namespace std;

class A
{
public:
A()
{
cout << "A's constructor" << endl;
}

~A()
{
cout << "A's destructor" << endl;
}

virtual void func1()
{
cout << "A's virtual func" << endl;
}

int m_a = 0;
};


class B
{
public:
B()
{
cout << "B's constructor" << endl;
}

~B()
{
cout << "B's destructor" << endl;
}

virtual void func2()
{
cout << "B's virtual func" << endl;
}

int m_b = 0;
};



class AB :public A,public B
{
public:
int m_AB = 0;

AB()
{
m_AB = 3;
cout << "this is AB constructor" << endl;
}

~AB()
{
cout << "this is AB destructor" << endl;
}

virtual void func2()
{
cout << "this is AB's func2" << endl;
}

virtual void func3()
{
cout << "this is AB's func3" << endl;
}

};


int main()
{
AB ab;
AB* ptr = &ab;
ptr->func1();
ptr->func2();
ptr->func3();
return 0;
}


1718249326959

观察内存结构也确实如此

另外如果AB这个类也有一个虚函数,那么覆盖虚表的时候,有必要在A表和B表都塞入AB的虚函数,例如func3吗?

1718250025681

事实上只有在拷贝A的虚表有
1718250088547

但是B没有,因为没必要拷贝两份

所以自己类新增加的函数,会加在第一个虚表里面

ptr->func3(); 这个代码对应的汇编

1718250417152

1
2
3
4
5
6
7
8
.text:00112D5A 8B 45 CC                      mov     eax, [ebp+ptr] //将this指针赋值给eax
.text:00112D5D 8B 10 mov edx, [eax] //将this指针存的值给edx,实际上就是虚表
.text:00112D5F 8B F4 mov esi, esp
.text:00112D61 8B 4D CC mov ecx, [ebp+ptr] //this call,传递this指针给ecx
.text:00112D64 8B 42 04 mov eax, [edx+4] //edx+4实际上在虚表就是func3
.text:00112D67 FF D0 call eax //调用func3


构造析构顺序

构造顺序:

1.构造第一个基类

2.构造第二个基类

……

3.构造成员对象

4.构造自己

析构顺序:即构造顺序反过来

特征:多次覆盖虚表

纯虚函数

在C++中,抽象类是一种不能直接实例化的类。它的主要目的是为派生类提供接口(即一组纯虚函数)。抽象类通常用于定义派生类必须实现的接口,从而确保所有派生类都有一致的接口实现。

纯虚函数会在虚表填一个 _purecall函数,避免未实现而调用,如果直接调用未实现的纯虚函数,那么将会报错

下面是一个包含纯虚函数的抽象类Shape,以及它的派生类CircleRectangle

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
64
65
66
67
68
#include <iostream>
using namespace std;

// 抽象类 Shape
class Shape {
public:
// 纯虚函数
virtual void draw() const = 0;
virtual double area() const = 0;

// 非纯虚函数,可以有定义
void info() const {
cout << "This is a shape." << endl;
}
};

// 派生类 Circle
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}

// 实现纯虚函数
void draw() const override {
cout << "Drawing Circle" << endl;
}

double area() const override {
return 3.14159 * radius * radius;
}
};

// 派生类 Rectangle
class Rectangle : public Shape {
private:
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}

// 实现纯虚函数
void draw() const override {
cout << "Drawing Rectangle" << endl;
}

double area() const override {
return width * height;
}
};

int main() {
// 不能实例化抽象类 Shape
// Shape shape; // 错误

Circle circle(5.0);
Rectangle rectangle(4.0, 6.0);

// 可以通过基类指针或引用使用派生类对象
Shape* shapes[] = { &circle, &rectangle };
for (Shape* shape : shapes) {
shape->draw();
cout << "Area: " << shape->area() << endl;
}

return 0;
}


特征:

1.有偏移表

2.构造顺序:

  1. 构造虚基类
  2. 一次构造基类
  3. 构造成员对象
  4. 构造自己

析构顺序相反

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
#include <iostream>
using namespace std;

int main(int argc)
{
try {
if (argc == 1)
throw 1;
}

catch (int e)
{
cout << "catch int " << e << endl;
}

catch (...)
{
cout << "catch all" << endl;
}

return 0;
}


一旦try内的语句有抛出(throw)异常,就会去搜索对应的catch块执行语句

值得注意的是,在C++中,当一个异常被抛出时,程序会在找到第一个匹配的catch块之后处理该异常,然后继续执行后续代码。并且,一旦异常被捕获并处理,程序不会再继续搜索其他的catch块来处理同一个异常。

事件查看器

介绍下事件查看器这个工具

1718600434405

可以用来查看具体是什么异常,但是貌似也可以用调试??

这里手动造一个异常

1718600575669

打开事件查看器,就可以发现具体的错误码和错误偏移,还有路径

1718600561100

值得注意的是,这个偏移量是ROV,相对于ImageBase的偏移

具体分析C++异常过程

![C++ RE 08](x86_C++逆向分析/C++ RE 08.png)

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;
int main()
{
try
{
throw 1;
}

catch (int e)
{
cout << "触发int异常\n" << endl;
}

catch (float e)
{
cout << "触发了float异常\n" << endl;
}

return 0;
}


当某个函数有try catch的时候,会在函数头注册一个异常回调函数:

例如下面的main函数开头:

1718710227685

1719061935153

分析一下这个代码是干啥的:

1
2
3
4
5
6
7
8
9
10
11
12
13
.text:00412640 55                            push    ebp
.text:00412641 8B EC mov ebp, esp
.text:00412643 6A FF push 0FFFFFFFFh
.text:00412645 68 00 77 41 00 push offset __ehhandler$_main
.text:0041264A 64 A1 00 00 00 00 mov eax, large fs:0
.text:00412650 50 push eax
.text:00412651 51 push ecx
.text:00412652 81 EC 08 01 00 00 sub esp, 108h
.text:00412658 53 push ebx
.text:00412659 56 push esi
.text:0041265A 57 push edi


push offset __ ehhandler$ _main,这是一个函数指针,放入栈中

mov eax, large fs:0 将异常链表存入eax

后续会有

1
2
3
4
.text:00412675 8D 45 F4                      lea     eax, [ebp-0Ch]
.text:00412678 64 A3 00 00 00 00 mov large fs:0, eax


这个就是将__ehhandler $ _main这个函数置为链表头,原来的作为Next指针存着

这样就实现了将函数_ _ehhandler$ _main 挂入异常链表

在含有try的函数结束之后

1
2
3
4
.text:008527A6 8B 4D F4                      mov     ecx, [ebp+var_C]
.text:008527A9 64 89 0D 00 00 00 00 mov large fs:0, ecx


会有一个注销异常链表的操作,这里就是拿出next,然后覆盖掉原来的异常链表,这样,就恢复了原来的异常链表(即注销)

之前的 push 0FFFFFFFFh,代表try还没开始,也就是说 [ebp-0x4] 位置代表着 trylevel,如果是0xffffffff则代表try块还未开始,如果是0,就代表进入try块

当结束try块以后,还是会将这个trylevel置为-1

1719062524688

如果有try嵌套:

1719062762747

反正也是赋值为非负的一个数

双击__enhandler__$main

在调用函数__CxxFrameHandler之前,会传递一个参数,这里就是eax

其实这就是FuncInfo函数信息表

1718710265704

FuncInfo结构

介绍一下FuncInfo

1
2
3
4
5
6
7
8
9
10
11
12
struct FuncInfo
{
magicNumber dd ;? //编译器生成的固定数字
maxState dd ? ;//最大栈展开数的下标值,也就是trylevel最大不能超过maxState,同时也是栈展开最大的次数
pUnwindMap dd ? ;//指向栈展开函数表的指针,指向UnwindMapEntry表结构
dwTryCount dd ? ;//一个函数里面的Try块的数量
pTryBlockMap dd ? ;//Try块的列表,指向TryBlockMapEntry表的结构
}

//64位程序下会额外多几个成员


其中pUnwindMap和pTryBlockMap分别指向 UnwindMapEntry 和 TryBlockMapEntry 结构

UnwindMapEntry要配合FuncInfo里面的maxState使用。

UnwindMapEntry的作用:栈展开的时候需要执行的函数由UnwindMapEntry表记录

TryBlockMapEntry的作用:这个结构用来判断异常产生在哪一个Try块

UnwindMapEntry结构

这个结构记录了需要执行函数

1
2
3
4
5
struct UnwindMapEntry
{
DWORD toState;//栈展开数下标值,即Trylevel,到时候看范围就知道
DWORD lpFuncAction;//展开执行的函数
}

由于栈展开存在多个对象,因此以数组的形式记录每个对象的析构信息

toState 项用来判断结构是否属于处于数组中,lpFuncAction用于记录析构函数所在的地址

TryBlockMapEntry结构

在这个结构体中可以知道对应的Try有几个Catch,并且能找到对应的Catch块

TryBlockMapEntry块成员长这样:

1
2
3
4
5
6
7
8
struct TryBlockMapEntry
{
DWORD tryLow; //try块的最小状态索引,用于范围检查(trylevel的最小索引)
DWORD tryHigh; //try块的最大状态索引,用于范围检查(trylevel的最大索引)
DWORD catchHigh; //catch块的最高状态索引,用于范围检查(trylevel的上限)
DWORD dwCatchCount; //catch块的个数
DOWRD pCatchHandlerArray; //catch块的描述,指向_msRttiDscr表结构
}

TryBlockMapEnrty 表结构用于判断异常产生在哪一个try块,tryLow,tryHigh 项用于检查产生的异常是否来源于try块中

1719197846739

最左边的TryLow才是真正的trylevel下标,另外一个TryHigh是用来描述范围的

_msRttiDscr 结构

这个结构用于描述try块中的某一个catch块的信息

1
2
3
4
5
6
7
struct _msRttiDscr
{
DWORD nFlag; //用于Catch块的匹配检查
DWORD pType; //catch块要捕捉的类型,指向TypeDescriptor表结构,如果是零,就代表所有类型,即catch all
DWORD dispCatchObjOffset; //用于定位异常对象在当前ESP中的偏移位置
DWORD CatchProc; //catch块的首地址,可以用来定位catch
}

具体来说:

nFlag标记用于检查catch块的类型匹配:

如果是 1 :常量 2:变量 4:未知 8:引用

异常的匹配信息存在pType所指向的结构

这个结构便是 TypeDescriptor

TypeDescriptor结构

这是一个记录 异常类型的结构:具体结构长这样:

1
2
3
4
5
6
struct TypeDescriptor
{
DWORD Hash; //类型名称的Hash数值
DWORD spare;//保留
DWORD name; //类型名称
}

有了这些信息之后,就可以通过与抛出异常时的信息进行对比,得到对应的表结构

再通过_msRttiDscr结构中的CatchProc得到catch块的首地址

关于throw

抛出异常的工作 由 throw 抛出,在源代码含有throw的函数体中可以找到 __CxxThrowException 这个函数,和之前 _CxxFrameHandler 类似,之前传进去的参数是 FuncInfo,这回是 ThrowInfo

1719065341932

这样可以通过参数,去获取抛出的对象(或者数值)

另外一个参数就是ThrowInfo

每一个throw都对应一个ThrowInfo和一个拷贝的对象。里面包含着对应的信息,包括抛出对象的类型(ThrowInfo),里面放了什么(从拷贝对象可知)

下面是通过ThrowInfo和拷贝对象识别值和类型的过程


1719152317760

但是为什么会有两个RTTI,这是表示CMyException *的类型和void *类型的异常都可以被接收

ThrowInfo结构

1
2
3
4
5
6
7
struct ThrowInfo
{
DWORD nFlag; //抛出异常类型标记
DWORD pDestructor; //异常对象的析构函数地址
DWORD pForwardCompat;//未知
DOWRD pCatchTableTypeArray //catch块类型表,指向CatchTableTyoeArray表结构
}

nFlag为1的时候,表示抛出常量类型的异常; 2 表示抛出变量类型的异常

由于在try块中产生的异常被处理后就不会再返回try块了。因此pDestructor的作用就是记录try块里面的异常对象的析构函数地址,当异常处理完成以后调用异常对象的析构函数

抛出异常所对应的catch块的类型的信息被记录在pCatchTableTypeArray所指向的CatchTableTyoeArray表结构

ThrowInfo 结构体用于描述异常的类型、析构函数等信息,帮助运行时了解如何处理和清理异常对象。

CatchTableTypeArray结构

1
2
3
4
5
struct CatchTableTyoeArray
{
DWORD dwCount; //CatchTableType 数组包含的元素个数
DWORD ppCatchTableType; //catch块的类型信息,类型为CatchTableType**
}

ppCatchTableType是一个指向数组的指针,dwCount用来描述数组中元素的个数

CatchTableType中含有含有处理异常时的所需相关信息

CatchTableType结构

CatchTableType中含有含有处理异常时的所需相关信息

1
2
3
4
5
6
7
8
struct CatchTableType
{
DWORD flag;
DWORD pTypeInfo;//指向异常类型的结构,指向TypeDescriptor表结构
DWORD thisDisplacement; //基类信息
DWORD sizeorOffset; //类的大小
DWORD pCopyFunction; //复制构造函数的指针
}

flag用于标记异常对象属于哪一种类型,例如指针,引用,对象等,标记值所代表的含义为:

1:简单类型复制 2:已被捕获 4:有虚表基类复制 8:指针和类型引用复制

当异常类型为对象的时候,由于对象存在基类等相关信息,因此需要将他们也记录下来,thisDisplacement保存了记录基类信息结构的首地址

PMD结构:

1
2
3
4
5
6
struct PMD
{
DWORD dwOffsetToThis; //基类偏移
DWORD dwOffsetToVBase; //虚基类偏移
DWORD dwOffsetToVbTable; //基类虚表偏移
}

注意注意:

如果Try内有定义对象并且Throw了,那么就要进行析构,Try里面全部对象都要被析构

还原代码的逻辑:

1719198058819

进入一个函数首先看看有没有调用__CxxFrameHandler,和有没有对fs:[0]这个地址进行操作,这个是有异常的标志。

然后一顿操作,把FuncInfo解析出来,有maxState个UnwindMapEntry结构,里面有存析构函数(如果存在析构,具体执行顺序看下标),然后还有dwTryCount个TryBlockMapEntry结构,里面存着Catch块的具体地址(_msRttiDscr 结构)

catch可能不在IDA反编译出来的函数,所以看到try我们需要去自己找对应的catch

解决了这些结构体,就可以看汇编还原代码了。

不要把Catch当成一个函数,而是要把它当成代码块

1719218558733

看见trylevel为0,就可以匹配到对应的catch块了

Lambda表达式

在C++中,lambda表达式是一种匿名函数,它可以在需要函数的地方定义和使用。这种表达式可以捕获所在作用域中的变量,并且可以用来简化代码,提高可读性和灵活性。(我的理解是首先可以减小占用的空间,而且可以无脑直接用函数里面定义的参数)下面是lambda表达式的几个主要用途和它们与普通函数调用的区别:

主要用途

  1. 内联定义简单函数:避免为了定义小函数而额外创建一个函数。
  2. 回调函数:常用于事件处理和异步编程,例如在GUI编程或网络编程中。
  3. 函数对象:可以用作标准库算法(如std::sortstd::for_each)的参数。
  4. 函数式编程:允许更自然地使用函数作为数据处理的第一类对象。

基本用法

lambda表达式的基本语法如下:

1
2
3
4
5
[capture](parameters) -> return_type {
// function body
};


其中:

  • capture:捕获列表,用于捕获外部变量。

1.按值捕获

1
2
3
4
5
6
7
8
int x = 10;
auto lambda = [x]() {
return x + 5;
};
x = 20; // 修改x的值
std::cout << lambda() << std::endl; // 输出15,因为lambda捕获的是x的副本


2.按引用捕获

1
2
3
4
5
6
7
8
int x = 10;
auto lambda = [&x]() {
return x + 5;
};
x = 20; // 修改x的值
std::cout << lambda() << std::endl; // 输出25,因为lambda捕获的是x的引用


3.捕获所有外部变量

可以使用[=]按值捕获所有外部变量,或使用[&]按引用捕获所有外部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int a = 1, b = 2, c = 3;

// 按值捕获所有变量
auto lambdaByValue = [=]() {
return a + b + c;
};
std::cout << lambdaByValue() << std::endl; // 输出6

// 按引用捕获所有变量
auto lambdaByRef = [&]() {
a = 10;
b = 20;
c = 30;
};
lambdaByRef();
std::cout << a << " " << b << " " << c << std::endl; // 输出10 20 30


  1. 混合捕获

可以混合使用按值捕获和按引用捕获,具体说明要捕获的变量及其捕获方式。

1
2
3
4
5
6
7
8
9
10
int a = 1, b = 2, c = 3;
auto lambda = [=, &b, &c]() {
// a按值捕获,b和c按引用捕获
return a + b + c;
};
b = 20;
c = 30;
std::cout << lambda() << std::endl; // 输出54,因为a按值捕获为1,而b和c按引用捕获


5.隐式捕获和显式捕获

可以同时使用隐式捕获和显式捕获来控制捕获的变量。

1
2
3
4
5
6
7
8
9
10
int a = 1, b = 2, c = 3;
auto lambda = [&, a]() {
// a按值捕获,其它变量按引用捕获
return a + b + c;
};
b = 20;
c = 30;
std::cout << lambda() << std::endl; // 输出54,因为a按值捕获为1,而b和c按引用捕获


6.捕获this指针

在类的成员函数中,lambda表达式可以捕获this指针,从而访问类的成员变量和成员函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass {
public:
int value = 42;
auto getValueLambda() {
return [this]() {
return value;
};
}
};
MyClass obj;
auto lambda = obj.getValueLambda();
std::cout << lambda() << std::endl; // 输出42


  • parameters:参数列表,类似于普通函数的参数列表。

  • return_type:返回类型,可选。如果编译器能够推断返回类型,可以省略。

  • function body:函数体,包含lambda的实现代码。

实际逆向

在实际逆向的时候,Lambda实际上是由构造一个类,加函数体实现的

1719240599239

1
2
3
4
5
6
7
8
int n1 = 10, n2 = 20;

auto lambda1 = [=]()
{
return n1 + n2;
};


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.text:00411ACF C7 45 F4 0A 00 00 00          mov     [ebp+var_C], 0Ah
.text:00411AD6 C7 45 E8 14 00 00 00 mov [ebp+var_18], 14h
.text:00411ADD 8D 45 E8 lea eax, [ebp+var_18]
.text:00411AE0 50 push eax
.text:00411AE1 8D 4D F4 lea ecx, [ebp+var_C]
.text:00411AE4 51 push ecx
.text:00411AE5 8D 4D D8 lea ecx, [ebp+lambda_class1]
.text:00411AE8 E8 43 FD FF FF call lambda1_constructor ; 构造函数,构造出一个类
.text:00411AE8
.text:00411AED 8B F4 mov esi, esp
.text:00411AEF 68 3C 10 41 00 push offset sub_41103C
.text:00411AF4 8D 4D D8 lea ecx, [ebp+lambda_class1]
.text:00411AF7 E8 14 FE FF FF call lambda1_func
.text:00411AF7


看到类构造+函数调用的,用的是同一个ecx,可以考虑还原为lambda表达式

运算符重载:

没有还原依据

函数模板:

函数模板只在编译阶段有效,本质上是编译器对使用者的方便性设计,实际上不同类型的模板实际上是同的函数,这就需要我们根据可读性进行还原模板。(如果调用模板函数少,不如当成普通函数得了

正是因为这个特性,所以模板函数是无法做成Sig文件给IDA识别的,那么就会导致一个大问题,就是STL函数,本质上也是模板,那么IDA识别STL函数其实是一个难题,会导致可能逆向了很久,最后发现逆的是一个STL函数,那确实令人崩溃

令人悲伤的是:STL函数逆向靠经验

但是可以用字符串比对法,查看错误日志(就是报错的字符串),IDA ctrl+F12去找即可

可以看一下源码对照:

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
#include <iostream>
#include <vector>
#include <list>
using namespace std;
int main()
{
cout << "6666666" << endl;

int n;
cin >> n;

vector<int> int_vector;

int_vector.push_back(1);
int_vector.push_back(2);
int_vector.push_back(3);
int_vector.push_back(4);
int_vector.push_back(5);


list<int> int_list;
int_list.push_back(1);
int_list.push_back(2);
int_list.push_back(3);
int_list.push_back(4);
int_list.push_back(5);

return 0;


}


1719243093253

发现除了cout,cin这些是有符号的,其他都没有符号。

原因是:

coutcin 这样的流对象,通常是通过具体的函数实现的,并且这些函数在标准库中有固定的位置和符号名,容易被识别。这个模板参数是固定的,所以可以动态链接,有了符号信息,进而被IDA识别

vectorlist 这样的容器是模板类,它们在编译时会实例化生成具体的代码,这些代码在不同的使用场景下可能有所不同,没有固定的位置和符号名,增加了识别难度。这个模板参数不固定,什么变量类型都可以,因此不可以动态链接,只能是静态链接,导致不能被IDA识别。