CPP 异常处理机制初探

admin 2023年2月14日22:51:55评论47 views字数 12212阅读40分42秒阅读模式

近期各大CTF中出现过不少C++异常处理机制相关的赛题。本文将介绍GNUC++异常处理的基本机制、可执行文件中的异常处理帧结构、以及对特定于语言的处理程序数据数据的解析过程。__gxx_personality_seh0

CPP 异常处理概述

c++中,异常处理的实现主要是要处理两件事:

  1. 根据抛出的异常找到合适的异常处理代码(即,捕获对应类型异常的 块)。catch

  2. 当抛出异常的函数无法处理被抛出的异常时(如下面的函数),需要合理清理当前栈帧上的对象,回退栈帧到上一层函数(清理栈上对象可能需要返回到对应函数内执行一些 块)。doThrowcleanup

    并继续在上层函数内寻找异常处理代码。如此递归向上回滚栈帧直到栈帧为空或找到可以处理当前异常的块。catch

以下面的程序为例:

// g++ -std=c++11 test.cc -o test.exe
#include <iostream>
#include <exception>
#include <cstring>
using namespace std;

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(str_, a);
}
char *str_;
};

Strobj doThrow(bool doth) {
int a = 1, b = 2;
Strobj oops("123456");
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)
{

}
}

当程序在函数内抛出异常时,C++ Runtime 会检测函数内是否存在能处理该异常的异常处理代码(即,能捕捉对应异常类型的块),如果不存在,函数将不再正常运行,而是返回上一级,此时需要:doThrowdoThrowcatch

  1. 清理栈上的 对象,执行其析构函数;oops

  2. 回退栈帧,(恢复寄存器,至少恢复 、);rbprsp

代码编译过程较为复杂,我们可以通过逆向最终编译生成的文件理解其实现。

CPP 异常处理机制初探

如上图,程序在 处抛出异常,进入 C++ Runtime 代码。Runtime 判断当前函数不存在对应的 块(所以栈帧应该回滚到上层),但存在一个需要执行的 块(抛出异常时,栈上存在一个存活的对象,在栈帧回滚时需要做析构);此时,Runtime 会首先进入 块,在 块的末尾,通过 来重启异常处理。__cxa_throwcatchcleanupcleanupcleanup_Unwind_Resume

_Unwind_Resume 之后,栈帧回滚到函数。如下图,在 C++ 中对应同一个块的连续的多个块编译后组成一个块群。从相应块中抛出的异常,在Runtime确信这个异常能被这个块群中的某个块处理的情况下,首先进入块群的起始地址,再根据抛出的异常的类编号进行分发。类编号在从异常返回到块时存储在寄存器中。此外,rax 指向被抛出的异常对象。maintrycatchcatchtrycatchcatchcatchcatchrdx

CPP 异常处理机制初探

CPP 异常处理机制初探

在此例中,Runtime 接着搜索 函数中所在的块对应的的异常处理块们( 块),找到了能处理的 块地址()以及相应的类编号(在本例中,编号为3)。在分发后,跳转到了 处。main0x4015A0: call doThrowtrycatchExceptionAcatch0x4016300x4016D0

CPP 异常处理机制初探

Itanium C++ ABI 异常处理框架

本节参考 Itanium C++ ABI: Exception Handling ($Revision: 1.22 $)

GNUC++ 的实现的是 Itanium C++ ABI 这一套接口。Mingw++ 大概用的是 GNUC++ 这一套 Runtime ,异常处理流程与 Itanium C++ ABI 描述一致,但编译后的二进制文件格式又部分采用了 VC 的设计(逆向编译后的二进制文件观察到的,说法不一定正确)。

Itanium C++ ABI 中,异常处理由通用的(指适用于支持多种上层语言的)异常处理库 libunwind 和 建立在libunwind 之上的专注于处理 C++ 异常处理逻辑的 libc++ 异常处理模块 两部分组成。其中,libc++ 提供了一个针对特定编译实现的 `personality` 函数,它能解析特定的异常处理相关的数据结构,告诉 libunwind 某个函数是否包含某个特定的 `catch` 块或者 在回滚栈前是否需要先进入某些 `cleanup` 块清理栈上对象。而 libunwind 提供了异常处理框架的实现,并在某些时刻调用 函数获取决策信息。personality

Itanium C++ ABI 中,对 libunwind 实现的异常处理流程描述如下:

The Unwind Process

The standard ABI exception handling / unwind process begins with the raising of an exception. This call specifies an exception object and an exception class.
The runtime framework then starts a two-phase process:

  • In the search phase, the framework repeatedly calls the personality routine, with the _UA_SEARCH_PHASE flag, first for the current PC and register state, and then unwinding a frame to a new PC at each step, until the personality routine reports either success (a handler found in thequeried frame) or failure (no handler) in all frames.
    It does not actually restore the unwound state,and the personality routine must access the state through the API. If the search phase reports failure, e.g. because no handler was found, it will call terminate() rather than commence phase 2.

  • If the search phase reports success, the framework restarts in the cleanup phase.
    Again, it repeatedly calls the personality routine, with the _UA_CLEANUP_PHASE flag, first for the current PC and register state, and then unwinding a frame to a new PC at each step, until it gets to the frame with an identified handler.
    At that point, it restores the register state, and controlis transferred to the user landing pad code.

简单来说,异常处理流程就是向上搜索栈帧,找到相应异常处理函数,然后跳转过去的流程。它分为两个阶段,阶段一是只搜索栈帧,寻找是否存在能当前异常的处理代码,如果不存在,就调用 函数结束程序的运行。如果找到了,进入阶段二。在阶段二,开始真正回滚栈帧,调用 块清理栈上局部对象, 直到回滚到存在相应异常处理代码的那个函数,跳转到对应的块。catchterminatecleanupcatch

GNUC++ 异常对象的数据结构

// Memory layout: 
// +---------------------------+-----------------------------+---------------+
// | __cxa_exception _Unwind_Exception | thrown object |
// +---------------------------+-----------------------------+---------------+
struct _Unwind_Exception {
uint64 exception_class; // GNUC++下, = 0x434C4E47432B2B00 ("CLNGC++")
_Unwind_Exception_Cleanup_Fn exception_cleanup;
uint64 private_1;
uint64 private_2;
};
struct __cxa_exception {
std::type_info * exceptionType;
void (*exceptionDestructor) (void *);
unexpected_handler unexpectedHandler;
terminate_handler terminateHandler;
__cxa_exception * nextException;
int handlerCount;
int handlerSwitchValue;
const char * actionRecord;
const char * languageSpecificData;
void * catchTemp;
void * adjustedPtr;
_Unwind_Exception unwindHeader;
};

上面是一个 C++ 异常对象的内存布局示意图。其中, 部分为用户自定义的异常信息,如本文例子中 对象。和分别是 libc++abi 和 libunwind 层定义的对象。创建一个 异常处理对象需要如下两步:thrown objectExceptionA__cxa_exception_Unwind_ExceptionExceptionA

CPP 异常处理机制初探

第一步先申请 大小为 的内存空间(记为 ),然后在前 大小的空间上初始化 对象,最后 。cxa_allocate_exceptionsizeof(__cxa_exception) + sizeof(ExceptionA)bufbufsizeof(__cxa_exception)__cxa_exceptionreturn buf + sizeof(__cxa_exception)

第二步调用的构造函数,在 后 大小的空间上初始化 实例。ExceptionAbufsizeof(ExceptionA)ExceptionA

通过这样的内存布局,在知道、两个对象中任意一个对象地址的情况下,可以仅通过加减运算得到另外两个对象的地址。__cxa_exception_Unwind_Exception

注:上面的内存布局是简化版本的,实际上长度是可变的(视成员值而定),通过或对象得到的标准做法是读取的成员变量 ,而 的值在 C++ Runtime 代码中计算得到。_Unwind_Exceptionexception_class__cxa_exception_Unwind_Exceptionthrown object__cxa_exceptionadjustedPtradjustedPtr

异常处理帧

这一部分内容与平台相关,比如Windows下的MSVC、Mingw-g++实现的是同一套格式。本节将对 Windows 下的 EXE 格式中的异常处理帧作一个简单的介绍。Linux平台下 ELF 文件的异常处理相关数据结构可以移步:Linux Standard Base Core Specification, Generic Part-Exception Frames

MSVC++或者Mingw-g++编译的 EXE 文件中一般会存在 段,并且在段内有一个 表。通过 结构,我们可以找到每个函数对应的 结构体对象。这个结构存储着对应函数异常处理相关的信息,包括函数中存在哪些块,在这些块中抛出异常后回滚栈帧需要调用的块们和可能可以处理异常的块们,以及函数序言中对栈做了哪些操作(回滚恢复到上层栈帧所需])等。.pdataRuntime_FunctionRUNTIME_FUNCTIONUNWIND_INFOtrytrycleanupcatch

CPP 异常处理机制初探

UNWIND_INFO 结构体可以参考 struct-unwind_info 。 下面是本文例子中 函数的 的部分结构体:mainUNWIND_INFO

CPP 异常处理机制初探

其中,最关键的是 从 开始的 结构体,它有两个成员:0x4070C8Exception Handler

  1. 0x4070C8 处的 和 Address of exception handler

  2. 0x4070CC 开始的 Language-specific handler data (optional)

我们看到本例中异常处理采用的 函数是 ,这与GNUC++的实现一致。这是因为上图中的二进制文件由 mingw-g++ 编译,而 mingw-g++实现的是 GNUC++ 那一套ABI。而 的具体结构还不得而知。personality__gxx_personality_seh0Language-specific handler data

Language-specific handler data 解析

在第二节中,我们提到 Runtime 中负责解析 异常处理相关的数据结构 的函数正是 函数。可以通过阅读 函数的实现来帮助我们解析这里的 。personality__gxx_personality_seh0Language-specific handler data

在此之前,我们先来看看指向 的指针是如何被传递给 函数的,方便我们在 函数的实现中找到对应的解析代码。首先是 函数的声明:Language-specific handler datapersonalitypersonalitypersonality

typedef EXCEPTION_DISPOSITION (*PEXCEPTION_ROUTINE) (
IN PEXCEPTION_RECORD ExceptionRecord,
IN ULONG64 EstablisherFrame,
IN OUT PCONTEXT ContextRecord,
IN OUT PDISPATCHER_CONTEXT DispatcherContext
);

第四个参数是 结构体,这个结构体是这样的:DispatcherContext

typedef struct _DISPATCHER_CONTEXT {
ULONG64 ControlPc;
ULONG64 ImageBase;
PRUNTIME_FUNCTION FunctionEntry;
ULONG64 EstablisherFrame;
ULONG64 TargetIp;
PCONTEXT ContextRecord;
PEXCEPTION_ROUTINE LanguageHandler;
PVOID HandlerData;
} DISPATCHER_CONTEXT, *PDISPATCHER_CONTEXT;

其中, 指针正好指向 (这里同样参考微软文档Language-specific handler)。HandlerDatalanguage-specific handler data

在 (LLVM 与 GNUC 实现的是同一套 Runtime)中, 的实现在 libcxxabisrccxa_personality.cpp 文件里。如下:llvm-project__gxx_personality_seh0

extern "C" _LIBCXXABI_FUNC_VIS EXCEPTION_DISPOSITION
__gxx_personality_seh0(PEXCEPTION_RECORD ms_exc, void *this_frame,
PCONTEXT ms_orig_context, PDISPATCHER_CONTEXT ms_disp)
{
return _GCC_specific_handler(ms_exc, this_frame, ms_orig_context, ms_disp,
__gxx_personality_imp);
}

其中,_GCC_specific_handler 的实现在 libunwindsrcUnwind-seh.cpp 中。它对 做了一层封装,处理一些外部逻辑。接着进入 libcxxabisrccxa_personality.cpp:__gxx_personality_imp,它又是对 libcxxabisrccxa_personality.cpp:scan_eh_tab 函数的一层封装。__gxx_personality_imp

scan_eh_tab 负责真正解析异常处理数据结构,是关键函数(下文给出一个解析的例子,建议打开上面的链接对着源代码看)。在 605 行, 首先通过 _Unwind_GetLanguageSpecificData 取出了指向 的指针,并赋值给变量,本例中 。lsdascan_eh_tabLanguage-specific handler datalsdalsda = (uint8_t*)0x4070CC

首先,执行 与 获取 和 ,前者的值为 0xFF,对应 。故而 (实现如下)直接返回 0。uint8_t lpStartEncoding = *lsda++;const uint8_t* lpStart = (const uint8_t*)readEncodedPointer(&lsda, lpStartEncoding);lpStartEncodinglpStartDW_EH_PE_omitreadEncodedPointer

static
uintptr_t
readEncodedPointer(const uint8_t** data, uint8_t encoding)
{
uintptr_t result = 0;
if (encoding == DW_EH_PE_omit)
return result;
const uint8_t* p = *data;
// first get value
switch (encoding & 0x0F)
{
case DW_EH_PE_absptr:
result = readPointerHelper<uintptr_t>(p);
break;
case DW_EH_PE_uleb128:
result = readULEB128(&p);
break;
case DW_EH_PE_sleb128:
result = static_cast<uintptr_t>(readSLEB128(&p));
break;
case DW_EH_PE_udata2:
result = readPointerHelper<uint16_t>(p);
break;
case DW_EH_PE_udata4:
result = readPointerHelper<uint32_t>(p);
break;
case DW_EH_PE_udata8:
result = readPointerHelper<uint64_t>(p);
break;
case DW_EH_PE_sdata2:
result = readPointerHelper<int16_t>(p);
break;
case DW_EH_PE_sdata4:
result = readPointerHelper<int32_t>(p);
break;
case DW_EH_PE_sdata8:
result = readPointerHelper<int64_t>(p);
break;
default:
// not supported
abort();
break;
}
// then add relative offset
switch (encoding & 0x70)
{
case DW_EH_PE_absptr:
// do nothing
break;
case DW_EH_PE_pcrel:
if (result)
result += (uintptr_t)(*data);
break;
case DW_EH_PE_textrel:
case DW_EH_PE_datarel:
case DW_EH_PE_funcrel:
case DW_EH_PE_aligned:
default:
// not supported
abort();
break;
}
// then apply indirection
if (result && (encoding & DW_EH_PE_indirect))
result = *((uintptr_t*)result);
*data = p;
return result;
}

紧接着读取 : 与 ,其中 定义如下:ttypeEncodinguint8_t ttypeEncoding = *lsda++;classInfoOffsetreadULEB128

static
uintptr_t
readULEB128(const uint8_t** data)
{
uintptr_t result = 0;
uintptr_t shift = 0;
unsigned char byte;
const uint8_t *p = *data;
do
{
byte = *p++;
result |= static_cast<uintptr_t>(byte & 0x7F) << shift;
shift += 7;
} while (byte & 0x80);
*data = p;
return result;
}

直到 660 行完成 的读取,解析如下:lsda header

CPP 异常处理机制初探

接着,开始解析 ,这个表中的每项对应函数内的一个 块。每一项由 、、、 四个成员组成,前三项的编码格式由 指定,最后一项固定为 ,解析代码如下:callSiteTabletrystartlengthlandingPadactionEntrycallSiteEncodingULEB128

uintptr_t start = readEncodedPointer(&callSitePtr, callSiteEncoding);
uintptr_t length = readEncodedPointer(&callSitePtr, callSiteEncoding);
uintptr_t landingPad = readEncodedPointer(&callSitePtr, callSiteEncoding);
uintptr_t actionEntry = readULEB128(&callSitePtr);

本例中,callSiteEncoding 方式也是 ULEB128,其中第一项解析结果如下:

CPP 异常处理机制初探

对应 try 块:

CPP 异常处理机制初探

对应 landingPad 起始地址:

CPP 异常处理机制初探

对应的 actionEntryItem 的起始地址:

CPP 异常处理机制初探

actionEntry 以单向链表结构存储。每个 actionEntry 有 ttypeIndex 与 actionOffset 两个成员,均是 格式的。actionOffset 指示下一个 actionEntry 相对当前地址的偏移。比如 0x40710F 开始的 actionEntry 链是 0x40710F -> 0x40710D -> 0x40710B -> 0x407109。每个大于 0 的 ttypeIndex 则通过 classInfo 表对应到一个类的 typeinfo 对象。SLEB128

在最简单的情况下,判断当前 块对应的 块群 是否有能力处理某个特定类型的异常时,程序会遍历 actionEntry 链表,对每个 ttypeIndex ,找到对应的 typeinfo 类(记为 ),并判断 能否捕捉到抛出的异常,即 ,若能,则保存 ttypeIndex 到 results 结构体,并设置 指示找到了异常处理函数。trycatchcatchTypecatchTypecatchType->can_catch(excpType, adjustedPtr)results.reason = _URC_HANDLER_FOUND

static
intptr_t
readSLEB128(const uint8_t** data)
{
uintptr_t result = 0;
uintptr_t shift = 0;
unsigned char byte;
const uint8_t *p = *data;
do
{
byte = *p++;
result |= static_cast<uintptr_t>(byte & 0x7F) << shift;
shift += 7;
} while (byte & 0x80);
*data = p;
if ((byte & 0x40) && (shift < (sizeof(result) << 3)))
result |= static_cast<uintptr_t>(~0) << shift;
return static_cast<intptr_t>(result);
}

通过 ttypeIndex 查到对应 typeinfo 的逻辑在 函数中。 在本例中, ttypeIndex 简单对应 表的下标,表中的每项编码方式为 。 是倒序存储的,表的第一项在最高地址处,第二项在第一项 -4 的地址处,以此类推。表格解析如下:get_shim_type_infoclassInfottypeEncoding = 0x9BclassInfo

CPP 异常处理机制初探

例如,ttypeIndex = 4 对应表格第四项,即 0x407114 地址处的四字节编码地址。解码后地址值为 0x404028,存储着 的地址。std::ExceptiontypeInfo

CPP 异常处理机制初探

至此,我们已经可以通过解析 LSDA 获得每个函数的每个 块区域的 地址、对应catch块群地址、catch块群能解析的异常类型、以及每个块能处理的异常类型对应的 ttypeIndex。trycatch

最后,通过逆向与调试可以知道,在从返回到 块群起始地址时, Runtime 至少要准备好 rax、rdx 两个寄存器的值,分别设置为:被抛出的异常对象的成员的内存地址、捕获异常的块的编号(即上文中所说的类编号)。而 这个类编号,其实就是对应的 ttypeIndex 的值(见下文解释)。throwcatchunwind_exceptioncatch

在 函数中,如果 的返回值指示返回原因为 ,且当前在第二阶段,则设置对象中函数返回值寄存器组中 编号为0的寄存器(x86 架构下是 rax)的值为异常对象地址;编号为1的寄存器(x86架构下为 rdx)的值为 ttypeIndex 值。__gxx_personality_impscan_eh_tab_URC_HANDLER_FOUNDcontextunwind_exception

static _Unwind_Reason_Code 
__gxx_personality_imp{
......
// In other cases we need to scan LSDA.
scan_eh_tab(results, actions, native_exception, unwind_exception, context);
if (results.reason == _URC_CONTINUE_UNWIND ||
results.reason == _URC_FATAL_PHASE1_ERROR)
return results.reason;

if (actions & _UA_SEARCH_PHASE)
{
......
}
assert(actions & _UA_CLEANUP_PHASE);
assert(results.reason == _URC_HANDLER_FOUND);
set_registers(unwind_exception, context, results);
return _URC_INSTALL_CONTEXT;
}

static
void
set_registers(_Unwind_Exception* unwind_exception, _Unwind_Context* context,
const scan_results& results)
{
#if defined(__USING_SJLJ_EXCEPTIONS__)
#define __builtin_eh_return_data_regno(regno) regno
#endif
_Unwind_SetGR(context, __builtin_eh_return_data_regno(0),
reinterpret_cast<uintptr_t>(unwind_exception));
_Unwind_SetGR(context, __builtin_eh_return_data_regno(1),
static_cast<uintptr_t>(results.ttypeIndex));
_Unwind_SetIP(context, results.landingPad);
}

这里 对象中的虚拟寄存器值会在 最终返回到 块等用户代码时恢复到机器寄存器上。contextcxa_throwcatch

来源先知社区的【1235466189519487师傅

注:如有侵权请联系删除

CPP 异常处理机制初探

 

如需进群进行技术交流,请扫该二维码

CPP 异常处理机制初探


原文始发于微信公众号(衡阳信安):CPP 异常处理机制初探

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年2月14日22:51:55
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   CPP 异常处理机制初探https://cn-sec.com/archives/1551565.html

发表评论

匿名网友 填写信息