偶尔分析C++的模块, 遇到触发异常操作, 但是不知道它SEH到底干啥了, 所以研究了下MSVC下的c++异常处理到底是怎么回事, 没理解透彻, 但是逆向应该是够用了, 如果有不对之处, 还望指正.

C++ try catch

这是一段示例的测试代码:

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
#include <iostream>
#include <exception>
#include <cstring>
using namespace std;
#pragma warning("disable":4996)

struct ExceptionA : public exception
{
ExceptionA(int a, int b) :a(a), b(b) {}
int a, b;
};

struct ExceptionB : public exception
{
ExceptionB(int a, int b) {}
};

struct ExceptionC : public exception
{
ExceptionC(int a, int b) {}
};

class Strobj {
public:
Strobj() = delete;
Strobj(char* a) {
int len = strlen(a);
str_ = new char[len + 1];
strcpy_s(str_, len+1, a);
}
char* str_ = NULL;
~Strobj() {
if (str_) {
delete str_;
}
}
};

Strobj doThrow(bool doth) {
int a = 1, b = 2;
char str[] = "123456";
Strobj oops(str);
if (doth)
throw ExceptionA(a, b);
return oops;
}

int main()
{
try
{
Strobj a = doThrow(true);
std::cout << a.str_ << std::endl;
}
catch (ExceptionC& e)
{
std::cout << "ExceptionC caught" << std::endl;
}
catch (ExceptionB& e)
{
std::cout << "ExceptionB caught" << std::endl;
}
catch (ExceptionA& e)
{
std::cout << "ExceptionA caught" << std::endl;
}
catch (std::exception& e)
{

}
}

这段代码很简单, 就是申请一个对象后触发异常.
正常情况下, 在触发异常后, 它会调用析构函数释放str_, 然后调用异常模块输出 “ExceptionA caught”.

这里简要介绍一下SEH, SEH就是异常捕获流程, 当程序发生异常的时候, 会跳转到最近(指最近的try catch)异常捕获函数, 它可以获取触发异常时的寄存器数据, 通过寄存器就知道触发异常时的原因, 如果能处理, 就修改异常时的rip值来跳转到后续的正常指令流, 如果不能处理就交给下一个SEH handler.

所以如果要知道触发异常后它怎么操作, 就看它注册的seh handler就行. 不过呢, msvc的C++ 对SEH做了封装, 异常由 _CxxThrowException 抛出. 处理会由__GSHandlerCheck_EH4进行简单操作后调用__CxxFrameHandler4进行处理

调试

我们在调试器里对析构函数下断点, 触发断点后, 查看栈回溯, 得到如下:

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
test.exe!`doThrow'::`1'::dtor$0()
vcruntime140_1d.dll!00007ff990de1030()
vcruntime140_1d.dll!00007ff990de4307()
vcruntime140_1d.dll!00007ff990de66ab()
vcruntime140_1d.dll!00007ff990de2cd2()
vcruntime140_1d.dll!00007ff990de2f5a()
vcruntime140_1d.dll!00007ff990de6dfb()
test.exe!__GSHandlerCheck_EH4(_EXCEPTION_RECORD * ExceptionRecord=0x000000473ff0de60, void * EstablisherFrame=0x000000473ff0f9d0, _CONTEXT * ContextRecord=0x000000473ff0d760, _DISPATCHER_CONTEXT * DispatcherContext=0x000000473ff0dcd0) 行 73
在 D:\a\_work\1\s\src\vctools\crt\vcstartup\src\gs\amd64\gshandlereh4.cpp(73)
ntdll.dll!00007ff9c5bb242f()
ntdll.dll!00007ff9c5b40939()
vcruntime140_1d.dll!00007ff990de6a7f()
vcruntime140_1d.dll!00007ff990de1c1e()
vcruntime140_1d.dll!00007ff990de218b()
vcruntime140_1d.dll!00007ff990de2ec5()
vcruntime140_1d.dll!00007ff990de2f5a()
vcruntime140_1d.dll!00007ff990de6dfb()
test.exe!__GSHandlerCheck_EH4(_EXCEPTION_RECORD * ExceptionRecord=0x000000473ff0eff0, void * EstablisherFrame=0x000000473ff0fbc0, _CONTEXT * ContextRecord=0x000000473ff0eb00, _DISPATCHER_CONTEXT * DispatcherContext=0x000000473ff0e980) 行 73
在 D:\a\_work\1\s\src\vctools\crt\vcstartup\src\gs\amd64\gshandlereh4.cpp(73)
ntdll.dll!00007ff9c5bb23af()
ntdll.dll!00007ff9c5b614b4()
ntdll.dll!00007ff9c5bb0ebe()
KernelBase.dll!00007ff9c341cf19()
vcruntime140d.dll!00007ff96105bbf1()
test.exe!doThrow(bool doth=true) 行 45
在 D:\Projects\test\test\main.cpp(45)
test.exe!main() 行 52
在 D:\Projects\test\test\main.cpp(52)

可以看到, 它先调用的__GSHandlerCheck_EH4两次, 再调用了````doThrow’::`1’::dtor$0```操作.

而且析构函数是先于catch模块调用的. C++没有finally关键词, 也是因为有析构函数, 就不需要finally了

修改

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
89
90
91
92
93
94
95
96
97
98
99
#include <iostream>
#include <exception>
#include <cstring>
using namespace std;
#pragma warning("disable":4996)

struct ExceptionA : public exception
{
ExceptionA(int a, int b) :a(a), b(b) {}
int a, b;
};

struct ExceptionB : public exception
{
ExceptionB(int a, int b) {}
};

struct ExceptionC : public exception
{
ExceptionC(int a, int b) {}
};

class Strobj {
static int index;
public:
Strobj() {
index += 1;
char a[] = "abcdefghijklmnopq";
char* s = &a[index];
int len = strlen(s);
str_ = new char[len + 1];
strcpy_s(str_, len + 1, s);
}
Strobj(char* a) {
int len = strlen(a);
str_ = new char[len + 1];
strcpy_s(str_, len+1, a);
}
char* str_ = NULL;
~Strobj() {
if (str_) {
delete str_;
str_ = NULL;
}
}
};

int Strobj::index = 0;

Strobj doThrow(bool doth) {
int a = 1, b = 2;
char str[] = "123456";
Strobj oops(str);
Strobj oops2(&str[1]);
Strobj oops3(&str[2]);
Strobj* myObjectPtr = new Strobj;
auto myObjectPtr2 = std::make_unique<Strobj>();
auto myObjectPtr3 = std::make_shared<Strobj>();
if (doth)
throw ExceptionA(a, b);
std::cout << oops2.str_ << std::endl;
std::cout << oops3.str_ << std::endl;
std::cout << myObjectPtr->str_ << std::endl;
std::cout << myObjectPtr2->str_ << std::endl;
std::cout << myObjectPtr3->str_ << std::endl;

delete myObjectPtr;
return oops;
}

int main()
{
try
{
Strobj a = doThrow(true);
std::cout << a.str_ << std::endl;
}
catch (ExceptionC& e)
{
std::cout << "ExceptionC caught" << std::endl;
}
catch (ExceptionB& e)
{
std::cout << "ExceptionB caught" << std::endl;
}
catch (ExceptionA& e)
{
std::cout << "ExceptionA caught" << std::endl;
}
catch (std::exception& e)
{
std::cout << "ExceptionA1 caught" << std::endl;

}
catch (...) {
std::cout << "ExceptionA2 caught" << std::endl;

}
}

这次新增了两个Strobj对象, 新增了, release编译后, 使用ida看看有什么不一样.

选择doThrow函数, 按’X’查看引用:

1712127361714

跟进.pdata的引用:

1712127336162

跟进 stru_14002DFAC:

1712127420884

可以看到多个析构函数.

再次调试

开启调试, 分别对所有偏移的函数下断点

1
2
3
4
5
6
7
8
??1?$shared_ptr@VStrobj@@@std@@QEAA@XZ
_doThrow____1___dtor$10
??1?$unique_ptr@VStrobj@@U?$default_delete@VStrobj@@@std@@@std@@QEAA@XZ
_doThrow____1___dtor$7
_doThrow____1___dtor$3
??1Strobj@@QEAA@XZ
??1Strobj@@QEAA@XZ
_doThrow____1___dtor$0

继续运行, 看看触发情况:

第一个命中的是最后的一个shared_ptr的析构函数, 释放的是myObjectPtr3

1712127520943

第二个命中的是unique_ptr的析构函数, 释放的是myObjectPtr2

1712127599116

第三个命中的直接是Strobj的析构函数, 释放的是oops3,

1712127849317

第四个是oops2

最后是oops

1712127884472

从断点命中情况来看, 它命中了stru_14002DFAC里的部分析构函数

1
2
3
4
5
??1?$shared_ptr@VStrobj@@@std@@QEAA@XZ
??1?$unique_ptr@VStrobj@@U?$default_delete@VStrobj@@@std@@@std@@QEAA@XZ
??1Strobj@@QEAA@XZ
??1Strobj@@QEAA@XZ
_doThrow____1___dtor$0

但是, 这里有一个问题, 那就是myObjectPtr并没有得到被的释放!!

我们用debug调试, 记录myObjectPtr->str_的地址, 当命中catch时, 查看该值, 发现没有被重置, 再次说明它并没有被释放

因为它是new申请的, 需要我们手动释放, 而它又是个局部变量, 我们没法在catch里正常释放它, 导致它的内存丢失了.

再来看main.

1712128404597

1712128467028

可以看到 main的.pdata里对应的stru_14002E010里, 有main的异常处理的代码块的rva. 这里因为有4个catch, 所以有4个异常处理块.

所以我们也可以发现, 在try catch操作中, 存在catch的函数, 它的stru指向里就有catch的操作模块, 也会给出异常的类型.

对于包含在其中的其它调用子函数, 如果存在自动释放的对象, 也会自动调用析构函数, 而且析构函数是比catch先调用的. 但是对于不会自动释放的对象, 就需要我们注意它的释放情况了.

Debug的版本会略有不同. MSVC版本不一样也可能不同

msvc举例(win11)

iassam.dll里

1712112112479

触发异常, 理论上应该处理 v12, v9, v11, 跟踪其struc后:

1712112164618

1712112155063

可以看到有三个释放操作的rva. 所以它触发异常后, 应该就会调用这些析构操作.

上层函数ChangePassword::onSyncRequest:

1712112295271

1712113662570

可以看到, 下半截是catch相关的操作, 上半截是析构相关的操作. 因此这个函数有try catch.

而调试对栈回溯里

1
2
3
4
5
6
00 00000097`ea97f020 00007ff9`e6a76ad2     iassam!ChangePassword::doChangePassword+0x9c
01 00000097`ea97f090 00007ff9`e6a759dc iassam!ChangePassword::onSyncRequest+0xb2
02 00000097`ea97f0e0 00007ff9`e6a75948 iassam!IASTL::IASRequestHandlerSync::onAsyncRequest+0x7c
03 00000097`ea97f110 00007ff9`e846311c iassam!IASTL::IASRequestHandler::OnRequest+0xa8
04 00000097`ea97f140 00007ff9`e8462e3f iaspolcy!Pipeline::executeNext+0x154

对这些位置下断点, 在ChangePassword::doChangePassword里面触发异常, 也可以看到最后返回的是IASTL::IASRequestHandlerSync::onAsyncRequest+0x7C 即 7ff9e6a759dc的位置.

旧版本msvc举例(server 2016)

1712114532284

1712114562160

这里只有个rva stru_1800321D0, 跟入:

1712114602198

stru_180033B7C,stru_180033B9C 就是上一个图里的下半截, 分别是析构函数, 和catch相关的函数.

导览图方法

1712115551300

在目标函数里直接ida看导览图, 就可以看到ida识别的异常处理流.

析构操作可能会以这样的形式存在:

1712115649867

其它

https://www.openrce.org/articles/full_view/21 这篇文章更详细地介绍了具体的情况, 我没有完全照着来, 有兴趣可以自己看一下.