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

CPP 异常处理概述

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

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

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

    并继续在上层函数内寻找异常处理代码。如此递归向上回滚栈帧直到栈帧为空或找到可以处理当前异常的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)
  {

  }
}

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

  1. 清理栈上的 oops 对象,执行其析构函数;
  2. 回退栈帧,(恢复寄存器,至少恢复 rbprsp);

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

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

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

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

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.

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

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

// Memory layout: 
// +---------------------------+-----------------------------+---------------+
// | __cxa_exception                _Unwind_Exception        | thrown object |
// +---------------------------+-----------------------------+---------------+
struct _Unwind_Exception {
    uint64 exception_class; // GNUC++下, = 0x434C4E47432B2B00 ("CLNGC++\0")
    _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++ 异常对象的内存布局示意图。其中,thrown object 部分为用户自定义的异常信息,如本文例子中 ExceptionA 对象。__cxa_exception_Unwind_Exception分别是 libc++abi 和 libunwind 层定义的对象。创建一个 ExceptionA 异常处理对象需要如下两步:

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

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

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

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

异常处理帧

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

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

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

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

  1. 0x4070C8 处的 Address of exception handler
  2. 0x4070CC 开始的 Language-specific handler data (optional)

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

Language-specific handler data 解析

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

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

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;

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

llvm-project (LLVM 与 GNUC 实现的是同一套 Runtime)中,__gxx_personality_seh0 的实现在 libcxxabi\src\cxa_personality.cpp 文件里。如下:

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 的实现在 libunwind\src\Unwind-seh.cpp 中。它对 __gxx_personality_imp 做了一层封装,处理一些外部逻辑。接着进入 libcxxabi\src\cxa_personality.cpp:__gxx_personality_imp,它又是对 libcxxabi\src\cxa_personality.cpp:scan_eh_tab 函数的一层封装。

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

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

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++;classInfoOffset,其中 readULEB128 定义如下:

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 的读取,解析如下:

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

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,其中第一项解析结果如下:

对应 try 块:

对应 landingPad 起始地址:

对应的 actionEntryItem 的起始地址:

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

在最简单的情况下,判断当前 try 块对应的 catch块群 是否有能力处理某个特定类型的异常时,程序会遍历 actionEntry 链表,对每个 ttypeIndex ,找到对应的 typeinfo 类(记为 catchType),并判断 catchType能否捕捉到抛出的异常,即 catchType->can_catch(excpType, adjustedPtr),若能,则保存 ttypeIndex 到 results 结构体,并设置 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 的逻辑在 get_shim_type_info 函数中。 在本例中, ttypeIndex 简单对应 classInfo 表的下标,表中的每项编码方式为 ttypeEncoding = 0x9BclassInfo 是倒序存储的,表的第一项在最高地址处,第二项在第一项 -4 的地址处,以此类推。表格解析如下:

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

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

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

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

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);
}

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

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖