公众号

一文搞懂C++异常处理详细过程

大家好,我是轩辕。

看下面这段代码:

void exception_test(void* ptr, int number) {
	try {
		if (!ptr) {
			throw MemoryException(ptr);
		}
		if (number == 0) {
			throw DivisionException(number);
		}
		printf("test done\n");
	}
	catch (MemoryException& e) {
		printf("%s\n", e.GetMessage());
	}
	catch (DivisionException& e) {
		printf("%s\n", e.GetMessage());
	}
}

这个函数里面,对两个输入参数进行了检查,如果发现参数错误,就抛出异常。

那么现在有一个问题:程序运行的时候抛出的异常,如何知道该交给哪个catch块来处理呢?毕竟C++不像Java有反射啊?

我的知识星球上有位小伙伴就提了这么一个问题:

今天这篇文章,我们就从逆向的方式来探究这个问题的答案。

首先要明确一个事情:C++作为一门编程语言,有很多种编译器都支持,像我们Windows平台接触的VC++,还有Linux下的GCC-G++,还有clang等其他编译器。C++的语言规范里面只对异常处理的语法特性做了规定,但如何实现这个异常处理,它没有规定,各家编译器自行实现,只要最后表现出来的符合标准语法规定即可。

这里就以咱们逆向分析常见的Windows平台上的VC++来为例进行分析,但要记住,本文分析的内容,只限定与VC++,其他编译器并不适用。

在谈C++的异常处理实现之前,先给大家介绍一下Windows上的结构化异常处理机制,这个在之前的课程中给大家简要提过,这里我们再来复习一下。

在计算机中,有两种异常。一种是CPU异常,一种是软件异常。CPU异常一般指的是CPU执行指令过程中发生的异常情况,比如执行除法指令的时候,发现除数是0。比如访问内存的时候,发现地址异常等等,这些都是属于CPU异常。

软件异常,一般是指程序在运行过程中,发现错误,自己主动抛出异常。比如C++中的throw关键字抛出的异常,就属于这一类。

不管是CPU异常,还是软件异常,最终都会走到统一的异常派遣分发流程,这里面的过程非常繁琐复杂。总体来说,操作系统收到这些异常后,会通过一系列的流程检查,然后去寻找处理这些异常的函数,如果没有任何函数可以处理这些异常,程序弹个报错窗口,然后崩溃退出。

这里面结构化异常处理SEH就是一个重要的机制,多个异常处理函数的地址存放在栈中,通过单向链表的形式串接起来,然后通过FS寄存器指向的TEB中的一个字段进行定位。当异常发生的时候,操作系统库函数就沿着这个链表,依次寻找可以处理当前异常的函数。

而C++的异常处理,在VC++编译器中的实现,就与这个有很大关系。

来再一次看之前的代码:

#include <exception>
using namespace std;

// 内存异常
class MemoryException : public exception {
public:
    MemoryException(void* addr) {
        this->address = addr;
    }

    const char* GetMessage() {
        sprintf_s(message, sizeof(message), "bad address: %p", address);
        return message;
    }

private:
    void* address;
    char message[100];
};

// 除数异常
class DivisionException : public exception {
public:
    DivisionException(int divisor) {
        this->divisor = divisor;
    }

    const char* GetMessage() {
        sprintf_s(message, sizeof(message), "bad divisor: %d", divisor);
        return message;
    }

private:
    int divisor;
    char message[100];
};


void exception_test(void* ptr, int number) {
	printf("enter exception_test\n");
	try
	{
		if (!ptr) {
			throw MemoryException(ptr);
		}

		if (number == 0) {
			throw DivisionException(number);
		}
		printf("test done\n");
	}
	catch (MemoryException& e)
	{
		printf("%s\n", e.GetMessage());
	}
	catch (DivisionException& e)
	{
		printf("%s\n", e.GetMessage());
	}

	printf("leave exception_test\n");
}

int main() {

    exception_test(NULL, 0);
}

我定义了两个异常类,分别代表内存异常和除数异常。在exception_test函数中检查了这个函数的两个参数,当发现参数错误的时候,通过throw关键字,抛出了异常。

星球小伙伴的第一个问题:这里有两个catch块,当异常发生的时候,程序怎么知道该调用哪个catch块呢?毕竟C++并不像Java,具有反射这样的类型动态识别机制。

实际上,很多人不知道,C++其实有一个低配版的运行时类型识别能力,叫做RTTI,可以通过这个机制来获取类的一些信息。之所以称之为低配版,是因为不具备像Java那样能根据类型名称动态创建对象的能力。

C++标准提供了type_id关键字和type_info结构体,通过这两个东西可以获得类的运行时名字,比如下面的代码:

const type_info &ti = typeid(MemoryException);
cout << ti.name() << endl;

运行后输出如下:

VC++在进行异常分发的时候,就离不开这个东西的支持。

我们来看一下前面的代码在通过throw关键字抛出异常的地方,反汇编是什么样的:

可以看到这里调用MemoryException的构造函数,创建了一个内存异常的C++对象,然后调用_CxxThrowException函数抛出了异常。

我们在IDA里面可以看得更直观一些:

注意看_CxxThrowException函数的第二个参数,_TI2_AVMemoryException___TI2_AVDivisionException__,这俩是个啥?点过去看看:

它的类型是:_s__ThrowInfo结构体,这个结构体定义是这样的:

注意看最后这个成员,pCatchableTypeArray,p开头,这是一个指针,对比上面的数据,在_TI2_AVMemoryException__中它的值是0x0040E248,定位过去看看:

可以看到,这每个里面是有3个DWORD,第一个是元素数量0x00000002,后面两个成员又是一个指针,注意看名字,这里实际上登记的是MemoryException这个类的信息和它的父类exception类的信息。点第一个,也就是0040E1F4那个过去看看:

这里其实就是MemoryException这个类的RTTI信息了。

回到我们前面最开始throw抛出异常的地方,这里实际上就是把抛出的异常类型作为参数层层包装起来后传递了下去。

至于传到哪里去了呢,这里就是Windows异常处理更底层的地方了,这里面东西就更多了,先不用管,总之记住:抛出异常的地方,把类型作为参数传了下去,等到最后异常处理函数的地方,它会收到这个类型信息。

现在我们再来看看,异常处理的函数,来看一下在exception_test函数入口处,安装的结构化异常处理节点中,它里面设置的异常处理函数地址是什么?

通过FS寄存器的操作,这里构建在栈里面的结构化异常链条节点中,异常处理地址是__ehhandler这个一个地址,我们跳过去看看是个啥:

这里做了一点安全检查(__security_check_cookie之前课程介绍过,防止栈溢出攻击的检查,因为SEH的异常处理函数地址也在栈中,所以有这么个检查)之后,跳到了___CxxFrameHandler3这个函数里面去了。而这个函数,就是VC++上,为C++语言提供了异常处理函数的入口,当通过throw抛出异常后,程序就会来到这个函数里去处理异常。不过注意一点,不同版本的VC++这个函数名字可能有稍许的差异。

我们再去看看这个函数里面在干啥:

简单包装了一下,进入了另一个内部函数,再跟进去看看:

这里面逻辑就要复杂一些了,涉及到栈帧的一些展开处理,因为原来抛出异常的try块里面可能有C++的对象,抛出异常的时候没来得及析构,这里就要做这方面的工作。最后注意看第37那个函数调用,FindHandler,从名字就能看出来,它在寻找一个处理异常的Handler函数。进去看看,这里面逻辑也比较多,看关键部分:

这里面在调用一个循环,在遍历某个数组,然后调用__TypeMatch函数尝试进行匹配,如果匹配上,跳出循环,调用CatchIt,从名字就能猜的出来,这里就在寻找能够处理异常的catch块,找到后,就调用这个catch块。注意看__TypeMatch,它传了pThrowInfo参数,而这个参数前面我们说了,里面包含抛出异常的对象类型RTTI信息。而结合这个__TypeMatch函数名字,就是在做类型匹配。进去看看:

这个关键的strcmp函数就是在做类型的名称匹配!

至此,我们知道了我们抛出的C++异常,为什么能匹配到对应的catch块了,来总结一下:

  1. 在通过throw抛出异常的时候,通过_CxxThrowException函数把异常类的信息作为参数封装起来了,传给了操作系统底层。

  2. 操作系统底层经过一系列的异常分发派遣,结合SEH的机制,最后调用到安装在栈帧里面的SEH结构化异常处理节点中的函数,这个函数,编译器设置的是___CxxFrameHandler3

  3. 在这个函数中,它会拿到前面抛出异常的类型信息,然后和当前函数代码里面的各个catch块信息做匹配,找到类型相匹配的catch块,然后调用它。编译器在编译函数的时候,会把函数里的各个catch块信息保存到PE文件某个地方存起来。

  4. 因为抛出异常的时候,_s__ThrowInfo里面第四个成员里面的数组包含了子类和父类的信息,寻找Handler的时候,先找子类,子类没有的情况下,会找到父类。这也是为什么用父类也能够匹配到异常处理块的原因。

最后,关于C++的try...catch与VC++中的__try...__except其实还有很多不同的地方,C++里面这些数据结构的设计,栈帧的展开恢复以及Windows底层异常处理的分发流程,还有很多知识点没有讲到,如果对这方面感兴趣的话,推荐大家看张银奎的 《软件调试》 这本书的第11章和第24章。

感谢大家的阅读!