C++异常处理源码与安全性分析

版权声明:本文为CSDN博主「ashimida@」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lidan113lidan/article/details/121865210

更多内容可关注微信公众号  

  C++异常处理需要DWARF的支持, 其业界实际标准是 IA-64 C++ABI[1],本文主要描述整个异常处理的流程以及在libgcc中的实现. 关于基于DWARF的栈回溯可参考 [2], 关于C++异常处理其他分析可参考[3-8]。


一、异常处理代码举例

  先以一个简单的C++程序为例:

1 #include <stdio.h>
2 #include <stdlib.h>
3
4 class x {
5         public:
6         x(void) {
7             printf("x:x() called\n");
8         }
9         ~x(void) {
10            printf("x:~x() called\n");
11        }
12 };
13
14 void test() {
15         x a;
16         throw "test";
17 }
18
19 int main()
20 {
21         try {
22                 test();
23                 throw 1;
24         }
25         catch(int x) {                        //loc1
26                 printf("Int: %d\n", x);
27                 return 0;
28         }
29         catch(const char * s) {               //loc2
30                 printf("String: %s\n", s);
31                 return 0;
32         }
33         return 0;
34 }

  编译与输出:

tangyuan@ubuntu:~/compiler_test/gcc_test/aarch64/test_exception$ aarch64-linux-gnu-g++ -static main.cc -O0 -o main
tangyuan@ubuntu:~/compiler_test/gcc_test/aarch64/test_exception$ ./main
x:x() called
x:~x() called
String: test1

   在此函数中 main函数调用了test, test函数在创建了类实例a后主动抛出了异常, 最终结果是test的父函数main捕获到了此异常, 在异常处理之前实例a的析构函数先被执行.


二、术语定义

  按照IA-64 C++ ABI的描述, 在抛出异常后的处理可以分为两个阶段: 1) 异常处理handler的搜索 2)逐级cleanup直到执行到handler, 这里先给出一些术语的定义:

   1. 异常处理中的handler代码片段:

      是最终捕获并处理异常的这段代码片段,如对于上面的代码来说:

      * throw 1;  的handler就是loc1的这一段代码片段

      * throw "test"; 的handler就是loc2的这一段代码片段

       一个包含多个catch的try/catch语句, 其多个catch case会在同一个handler代码片段中,运行时异常处理需为其传入参数来区分具体case。

   2. 异常处理中的cleanup代码片段:

      一个函数或block中可能有收尾工作要做,如test函数在返回前需要执行类实例a的析构, 这些为block做收尾工作的代码片段称为cleanup, test函数的cleanup代码片段中需要析构类实例a; (一个函数中如果存在类实例定义又存在函数调用的话,此函数中通常都会存在一段cleanup代码以确保子函数中抛出异常时此cleanup代码可被用来执行类实例的析构函数)。

   3. landing_pad: 

       landing_pad指的是一个cleanup或handler,或二者结合的一段代码片段:

       * 函数中的任何一地址最多只能对应此函数内的一个landing_pad:

          - 如果函数某地址处抛出了异常(可能是直接throw或子函数throw导致的)且此地址(在当前函数内)不需要执行cleanup/handler,则其landing_pad为空

          - 如果函数某地址处抛出了异常且此地址同时需要cleanup和handler, 则其landing_pad中会先执行cleanup再执行handler.

       * 函数中不同地址可能对应不同的landing_pad:

         - 如函数中若存在多个try { ... } [catch {..}], 那么不同try中的地址都有自己的landing_pad

   4. DWARF/.eh_frame:

       DWARF是一种调试文件格式, .eh_frame是加载到内存的节区,其中保存了代码中所有函数的CFI(Call Frame Infomation)信息,栈回溯中需要依赖此节区的内容(细节见[2]).

       注: 异常处理虽然是C++的标准, 但C编译输出的二进制中也可以存在.eh_frame段. C中虽然无法使用异常处理,但异常处理的栈回溯可以正常经过C代码(此时C代码编译时需开启-fexceptions支持)

   5. 异常处理过程中的两个阶段:

       在C++中可以通过throw抛出异常,异常的处理要经过两个阶段:

       1) phase1(search phase):  phase1要从throw语句所在函数开始逐级的unwind, 直到在某一级栈帧中找到了可以处理此throw抛出的异常的handler为止.

       2) phase2(cleanup phase): phase2 要再次从throw语句所在的函数开始逐级的unwind, 并依次执行每一级栈帧中的cleanup函数(若有),直到执行到handler为止(这里注意区分cleanup phase和cleanup函数)

        实际上两段式异常处理不是必须的,但这样会带来一定的好处[1]:

    A two-phase exception-handling model is not strictly necessary to implement C++ language semantics, but it does provide some benefits. For example, the first phase allows an exception-handling mechanism to dismiss an exception before stack unwinding begins, which allows resumptive exception handling (correcting the exceptional condition and resuming execution at the point where it was raised). While C++ does not support resumptive exception handling, other languages do, and the two-phase model allows C++ to coexist with those languages on the stack.

  6. LSDA(language-specifi data area): 

      异常相关一段数据(格式见四), 一个try/throw/catch 中是否有cleanup需要执行,是否有某类异常的handler等信息都记录在LSDA中.

  7. personality routine: 

      在异常处理phase1/phase2均会调用的栈回溯回调函数,phase1/2中每unwind到一个栈帧时都会调用此函数,随着传入参数的不同此函数的作用也不同: 

      * 在phase1: 此函数负责回溯每一级栈帧,直到在某个栈帧对应函数中找到当前抛出的异常类型的handler为止

      * 在phase2: 此函数同样回溯每一级栈帧,并逐级调用每一级中的cleanup函数,直到执行到handler函数为止

      personality routine的指针记录在CIE中(见[2]), 最常用的personality routine是 __gxx_personality_v0,本文的后续分析也基于此函数.

   需要注意的是: C++异常与windows的SEH异常不同,C++中只能支持主动触发的异常(也就是通过throw抛出的异常), 而windows SEH可以捕获如除零异常等。


三、异常处理的流程和关键函数

  以上面的测试代码为例,其异常处理的流程如图:

     test函数中 throw "test"; 的整个异常处理流程为:

  test: throw "test"; => _Unwind_RaiseException => test:cleanup => test:_Unwind_Resume => main:handler => main:函数返回

    其中涉及到的关键函数/代码片段描述如下:

    1. _Unwind_RaiseException:

        test中的throw函数最终调用调用到libgcc的_Unwind_RaiseException, 此函数的作用:

        * 首先执行phase1, 逐级栈回溯并通过personality routine搜索到此异常类型最终的handler(即main:handler)

        * 之后调用_Unwind_RaiseException_Phase2开始执行phase2, phase2回溯到一个landing_pad即返回,这里第一个找到的是test:cleanup(此信息记录在LSDA中), 说明test函数有收尾工作需要做, 此函数返回.

        * _Unwind_RaiseException跳转到test:cleanup执行test函数的收尾工作

    2. test:cleanup:

        test:cleanup的代码是编译器生成的,其作用是执行test中的收尾工作,在这里则是调用x:~x()这个析构函数; 收尾工作完成后则需要再次调用_Unwind_Resume继续phase2.

    3) _Unwind_Resume:

         _Unwind_Resume中重新调用_Unwind_RaiseException_Phase2继续phase2 (1=>2=>3 的过程中通过指针传递了异常相关的全局数据)

        * phase2还是从test函数开始栈回溯,但由于调用_Unwind_Resume的代码并不属于try块之内,test在此PC位置没有cleanup代码片段, phase2继续栈回溯到其父函数main;

        * main函数调用test时是在一个try块中的, 此时phase2找到了此try块的handler, 并跳转到main:handler

    4. main:handler:

        main:handler执行此异常的处理(这里是一个printf), 执行完毕后main函数正常退出其try/catch块继续执行,直到main函数返回(_Unwind_xxx在跳转前会修复栈帧,确保跳转到main:handler时的上下文(callee-saved reg)和main函数中导致抛出异常位置的上下文相同(即main函数调用test函数的位置)。


三、异常处理源码分析

1. __cxa_allocate_exception

    抛出异常前需要先调用__cxa_allocate_exception函数分配一个全局结构体用于在栈回溯中传递信息,其定义如下:

//./libstdc++-v3/libsupc++/eh_alloc.cc
/*
  分配大小为thrown_size + __cxa_refcounted_exception的空间
  ---------------------------   <== 分配 thrown_size + exception 
  __cxa_refcounted_exception
  ---------------------------   <== 函数返回的是指向这里的指针
  thrown_size
  ---------------------------   
*/
extern "C" void *
__cxxabiv1::__cxa_allocate_exception(std::size_t thrown_size) _GLIBCXX_NOTHROW
{
  void *ret;

  thrown_size += sizeof (__cxa_refcounted_exception);
  ret = malloc (thrown_size);

  if (!ret)
    ret = emergency_pool.allocate (thrown_size);

  if (!ret)
    std::terminate ();

  memset (ret, 0, sizeof (__cxa_refcounted_exception));

  return (void *)((char *)ret + sizeof (__cxa_refcounted_exception));
}

2. _Unwind_RaiseException

    throw语句最终调用_Unwind_RaiseException抛出异常,源码如下:

//./gcc/config/aarch64/aach64.h, 指定此属性的函数在pro/epilogue中会push/pop栈帧寄存器x29
#define LIBGCC2_UNWIND_ATTRIBUTE __attribute__((optimize ("no-omit-frame-pointer")))

//./libgcc/unwind.inc
/*  
    此函数最终实现异常抛出(throw), 其:
   * 首选执行phase1 search, 通过栈回溯确定当前异常类型最终的handler
   * 开始phase2 cleanup: 
      - 栈回溯找到一个landing_pad后则停止了, 设置上下文为栈回溯时landing_pad所在函数的上下文,并跳转到landing_pad
    landing_pad中如果需要继续栈回溯,则需在最后一条语句中调用 _Unwind_Resume.
  参数exc会在整个异常期间作为全局变量传递
 */
_Unwind_Reason_Code LIBGCC2_UNWIND_ATTRIBUTE _Unwind_RaiseException(struct _Unwind_Exception *exc)
{
  struct _Unwind_Context this_context, cur_context;
  _Unwind_Reason_Code code;
  unsigned long frames;

  /* 
     此函数基于_Unwind_RaiseException的DWARF信息, 将_Unwind_RaiseException入口时各个已保存到栈中的寄存器的值初始化到this_context中(细节可参考[2]),
     需要注意的是由于当前函数中调用了_builtin_eh_return(见后), 故和_Unwind_Backtrace不同的是uw_init_context除了初始化callee-saved寄存器外,还同时
     初始化了x0-x3, x0-x3的内存位置后续可用来为_builtin_eh_return传递参数
  */
  uw_init_context (&this_context);

  /* 此上下文初始化一次即可,这里先复制一份用于phase1 search 的栈回溯 */
  cur_context = this_context;

  /* phase 1: search, 其作用是逐级栈回溯直到找到可以处理当前异常的那个handler */
  while (1)
  {
      _Unwind_FrameState fs;
    
      /* 根据cur_context->ra, 计算当前函数caller各个寄存器的回溯规则(细节参考[2]), 结果记录到fs中*/
      code = uw_frame_state_for (&cur_context, &fs);

      if (code == _URC_END_OF_STACK) return _URC_END_OF_STACK;    /* 如果已经到栈底了则直接返回 */
      if (code != _URC_NO_REASON) return _URC_FATAL_PHASE1_ERROR; /* 出错则返回error */

      /* 为context->ra所在函数(caller)执行personality routine, 查看其中是否有异常的handler(第一次遍历是从_Unwind_RaiseException的父函数开始的) */
      if (fs.personality)
      {
        /* personality routine的地址记录在CIE中,这里以__gxx_personality_v0函数为例, 此时传入参数_UA_SEARCH_PHASE代表这是phase1, search; */
        code = (*fs.personality) (1, _UA_SEARCH_PHASE, exc->exception_class, exc, &cur_context);

        if (code == _URC_HANDLER_FOUND) break;    /* 找到handler则直接break; 发生错误则返回error; 否则继续循环遍历下一个栈帧 */
        else if (code != _URC_CONTINUE_UNWIND)
            return _URC_FATAL_PHASE1_ERROR;
      }
      /* context->ra所在函数中未找到handler则将fs信息更新到cur_context中,此后cur_context记录当前栈帧函数的caller函数的信息, 再次循环则从caller的caller中查找handler */
      uw_update_context (&cur_context, &fs);     
  }

  /* 执行到这里说明异常handler已经找到, handler和context->ra在同一函数, context中记录其caller指向到context->ra时的各个寄存器信息 */
  exc->private_1 = 0;
  /* 将找到handler时的context->CFA记录到exc->praivate_2中, phase2栈回溯时再次遍历到此栈帧时则可以直接执行的handler并结束处理 
     context记录的是handler的calee函数入口的寄存器信息,这也是context->ra所在函数执行到context->ra时的寄存器信息
  */
  exc->private_2 = uw_identify_context (&cur_context);    

  /* 这里开始执行phase2 cleanup, 此过程也需要从最后一级栈帧开始一边unwind一边执行cleanup函数, 此时可以复用前面已经初始化的上下文 */
  cur_context = this_context;

  /* 此函数执行phase2, 其通过栈回溯找到某级函数的一个landing_pad(cleanup/handler),并将landding_pad和参数保存到cur_context->ra/reg[0/1]中 */
  code = _Unwind_RaiseException_Phase2 (exc, &cur_context, &frames);

  /* 返回_URC_INSTALL_CONTEXT代表上下文已经设置好, 后续真正执行landing_pad, 未返回此值代表出错 */
  if (code != _URC_INSTALL_CONTEXT)  return code;

/* 这里调用的是 uw_install_context,为了便于分析直接将此宏展开
   uw_install_context的作用是恢复到this_context时的上下文(this_context记录的是callee入口时寄存器信息,同时也是caller调用callee前的寄存器信息), 并跳转到caller中的landing_pad代码执行,因此landing_pad执行时和之前执行到this_context->ra时拥有相同的寄存器上下文. 
   caller中landing_pad执行完毕后可以直接通过ret正常返回父函数, 也可以调用_Unwind_Resume继续处理异常,这取决于当前的landing_pad是cleanup函数还是handler.
   这里要恢复的寄存器上下文包括:
   * 当前的sp指向caller函数的栈顶
   * 所有callee-saved寄存器必须和caller调用callee时一致(callee可能是throw函数,也可能是任意一个内部调用了throw抛出异常的函数)
     注意:
     1) 这里不是和caller入口时一致,而是和caller调用callee时一致,因为callee保存的是此时的状态
     2) 跳转到landing_pad时必须保证所有callee-saved硬件寄存器全部恢复, 因为如A=>B=>C, 函数B中使用了x19,C中使用了x20, 那么函数B返回时会修复x19但不会修复x20,函数B自身的epilogue无法保证所有callee-saved寄存器全被修复。
   * 所有callee-used寄存器不必恢复,因为函数调用中这些寄存器被破坏是正常的,任何调用子函数的代码在子函数返回后都不应该期待这些寄存器还拥有原始值(但恢复应该也是没有错的).
   * caller执行landing_pad时的状态和其调用callee时的状态一致

   libgcc在_Unwind_RaiseException返回时是利用编译器为其生成的一系列pop指令恢复硬件寄存器的,这与libunwind实现不同, 后者是通过汇编代码主动将所有context中的寄存器pop到硬件寄存器中的.在libgcc中:
   * 由于 _Unwind_RaiseException => uw_init_context => __builtin_unwind_init 会导致所有callee-saved寄存器入栈, 故修改callee-saved寄存器实际上只需要修改当前函数栈上这些内存中的值即可, 当前函数_Unwind_RaiseException返回时会自动将这些内存值同步到硬件寄存器中.
   * 同时由于_Unwind_RaiseException => uw_install_context =>  __builtin_eh_return 会导致x0-x3寄存器入栈, 故修改当前函数栈上的x0-x3内存值同样可以在函数返回时将其同步到硬件寄存器中.
*/
//  uw_install_context (&this_context, &cur_context, frames);

  /*
     将this_context中的寄存器值全部写入到current中的寄存器指针指向的内存单元中(current指向的内存即为epilouge中pop寄存器的内存单元),
     landing_pad属于this_context的caller函数,但caller函数的运行时环境是记录在this_context中的.
  */
  long offset = uw_install_context_1 ((this_context), (cur_context));   
  
  void *handler = uw_frob_return_addr ((this_context), (cur_context));        /* 获取返回地址,实际上就是landing_pad的地址 */

  _Unwind_DebugHook ((cur_context)->cfa, handler);    

  _Unwind_Frames_Extra (frames);                                              /* 为Intel CET修复SCS栈帧 */
  
  /* 异常返回, 此函数和正常函数返回一样在返回前都需要执行epilogue(也就是pop各个寄存器的代码), 区别在于:
     * 正常函数返回是 ret;
     * 此函数返回代码类似 sp = sp + offset; goto handler;
     故此函数最终恢复到landing_pad时的函数栈, 并跳转到landing_pad的代码执行
  */
  __builtin_eh_return (offset, handler);           
}

3. _Unwind_RaiseException_Phase2

//./libgcc/unwind.inc
/*
    此函数负责phase2的cleanup, 此函数并非完成整个phase2,而是在发现一个landing_pad(cleanup/handler)时就返回,返回前需要为此landing_pad修改context上下文. 
    整个phase2 是通过: _Unwind_RaiseException => [func:cleanup;/function:handler => _Unwind_Resume]* 循环完成的
*/
static _Unwind_Reason_Code _Unwind_RaiseException_Phase2(struct _Unwind_Exception *exc,
                  struct _Unwind_Context *context, unsigned long *frames_p)
{
  _Unwind_Reason_Code code;
  unsigned long frames = 1;

  while (1)
  {
      _Unwind_FrameState fs;
      int match_handler;

      /* 通过栈回溯获取执行到context->ra时的上下文, 结果暂时保存在fs中*/
      code = uw_frame_state_for (context, &fs);

      /* 若当前栈帧就是phase1中找到handler的那个栈帧(context->cfa == exc->private_2), 
         则后面调用personality routine时添加flag _UA_HANDLER_FRAME,代表本次直接执行handler即可 */
      match_handler = (uw_identify_context (context) == exc->private_2 ? _UA_HANDLER_FRAME : 0);

      if (code != _URC_NO_REASON) return _URC_FATAL_PHASE2_ERROR;

      if (fs.personality)        
      {
         /* phase2 同样调用personality routine, 此时personality routine需要找到一个cleanup函数(或最后的handler),
           并将需要传递给cleanup/handler的参数写入 context->reg[0/1], context->ra中 */
         code = (*fs.personality) (1, _UA_CLEANUP_PHASE | match_handler, exc->exception_class, exc, context);

         if (code == _URC_INSTALL_CONTEXT) break;     /* 代表已经为一个landing_pad设置好了context,此函数直接返回 */

         if (code != _URC_CONTINUE_UNWIND) return _URC_FATAL_PHASE2_ERROR;    /* 出错则返回错误 */
      }

      gcc_assert (!match_handler);         /* 执行到handler所在栈帧后则栈回溯就结束了*/

      uw_update_context (context, &fs);    /* 当前栈帧中没有cleanup函数/handler需要执行,继续栈回溯上一帧 */
     
      _Unwind_Frames_Increment (context, frames);     /* 记录一共回溯了多少个栈帧,在Intel CET中需要根据其来更新影子栈SP */
  }

  *frames_p = frames;
  return code;
}

4. __gxx_personality_v0

//./libstdc++-v3/libsupc++/eh_personallity.cc
/*
   phase1/phase2均会执行此personality routine, 在两个阶段其作用不同:
   * phase1中负责确定context->ra上下文中是否有当前异常类型的handler, 如果有将其记录到ue_header全局结构体中(handler与context->ra属于同一函数),没有则返回继续回溯.
   * phase2中负责确定context->ra上下文中是否有需要执行的cleanup代码或是否已经回溯到了handler所在栈帧(的子函数), 如果是则更新当前context中 reg[0/1]/ra寄存器并返回, 没有则返回继续回溯.
*/
extern "C" _Unwind_Reason_Code __gxx_personality_v0 (int version, _Unwind_Action actions,
              _Unwind_Exception_Class exception_class,  struct _Unwind_Exception *ue_header, struct _Unwind_Context *context)
{
  ......
  const unsigned char *language_specific_data;   /* 指向当前函数LSDA的指针 */
  const unsigned char *action_record;            /* action_record = 0, 代表此时找到的landing_pad是一个纯cleanup函数, 非0则可能是handler和cleanup的混合,也可能是单独的handler */
  _Unwind_Ptr landing_pad;                       /* context所在函数的landing_pad地址(若有),这是运行时获取的一个可执行代码地址 */
  int handler_switch_value;                      /* 一个整形值,来自LSDA的解析结果,此值最终会传递给landing_pad,以帮助landing_pad确定当前应该执行哪个catch分支 */
  ......

  /* 如果当前context是handler所在栈帧,则直接从phase1 personality routine保存的结果中获取handler相关信息,并写入context 即可 */
  if (actions == (_UA_CLEANUP_PHASE | _UA_HANDLER_FRAME) && !foreign_exception)
    {
      /* 恢复phase1 personality routine存入的handler信息 */
      restore_caught_exception(ue_header, handler_switch_value, language_specific_data, landing_pad);
      found_type = (landing_pad == 0 ? found_terminate : found_handler);
      goto install_context;        /* 将handler信息写入context */
    }

  /* 如果当前context不是handler所在栈帧, 则不论phase1还是phase2都需要获取当前函数的LSDA信息:
     * 在phase1中是用来确认当前函数是否有handler
     * 在phase2中是用来确认当前函数是否有cleanup函数
    这里pass了LSDA解析相关代码
  */
  language_specific_data = (const unsigned char *) _Unwind_GetLanguageSpecificData (context);

  if (! language_specific_data) CONTINUE_UNWINDING;    /* 当前函数没有LSDA则直接继续上一个栈帧(return _URC_CONTINUE_UNWIND) */

  p = parse_lsda_header (context, language_specific_data, &info);    /* 解析LSDA头 => info */
  ......

  /* 获取当前栈帧对应的函数的返回地址, 在fs初始化时会初始化context->lsda, context->lsda/ra记录的都是当前context caller的信息  */
  ip = _Unwind_GetIP (context);    
  landing_pad = 0;
  action_record = 0;
  handler_switch_value = 0;
  .......
  /* 查找当前函数返回地址(context->ra)所在的代码位置是否有landing_pad(cleanup/handler) */
  while (p < info.action_table)
  {
      _Unwind_Ptr cs_start, cs_len, cs_lp;
      _uleb128_t cs_action;
      p = read_encoded_value (0, info.call_site_encoding, p, &cs_start);
      .......

      if (cs_lp) landing_pad = info.LPStart + cs_lp;            /* 如果有landing_pad则记录其地址 */
      /*
        action = 0,代表landing_pad是context->ra所在位置需要的一个cleanup代码片段
        action !=0,代表landing_pad是context->ra所在位置的一个handler代码片段
      */
      if (cs_action)    action_record = info.action_table + cs_action - 1;
      goto found_something;
    }
  }
  .......

  /* 这里判断找到的是cleanup函数还是handler,如果是handler还需确定当前抛出的异常类型对应catch 的handler_switch_value */
found_something:
  if (landing_pad == 0)  found_type = found_nothing;        /* 若context->ra所在函数未找到landing_pad则返回遍历下一个栈帧 */
  else if (action_record == 0)
      found_type = found_cleanup;                           /* action_record=0代表这是一个纯cleanup函数 */
  else                                   /* 否则是一个[cleanup +] handler(在landing_pad的最开始通常先执行cleanup,然后再执行handler的代码片段),此时需要根据LSDA信息
                                            确定本次throw的异常对应的编码(handler_switch_value),此编码会传递给handler, handler通过其来判断应该执行哪个catch case */
  {
      bool saw_handler = false;
      while (1)
      {
          p = read_sleb128 (p, &ar_filter);
          .......

          if (saw_handler)
          {  
              handler_switch_value = ar_filter;            /* 确定 handler_switch_value */
              found_type = found_handler;    
          }
          else
              found_type = (saw_cleanup ? found_cleanup : found_nothing);
      }
  }

do_something:
  if (found_type == found_nothing)  CONTINUE_UNWINDING;    /* 若最终什么也没发现,则继续unwind */
  if (actions & _UA_SEARCH_PHASE)                          /* 在phase1 search阶段, 只关注handler, 发现纯cleanup则继续unwind */
  {
      if (found_type == found_cleanup) CONTINUE_UNWINDING; /* search阶段发现cleanup则继续unwind (return _URC_CONTINUE_UNWIND) */
      ......
     
      save_caught_exception(ue_header, context, thrown_ptr,     /* 在phase1若发现了最终的handler,则保存到exc中以供后续phase2使用 */
                handler_switch_value, language_specific_data, landing_pad, action_record);
      
      return _URC_HANDLER_FOUND;                           /* 不论如何,phase1 到此即返回了 */
  }


install_context:            /* 若当前处于 phase2, 则发现landing_pad(cleanup/handler)时会走到install_context流程 */

  .......
  /* install_context主要是在context->reg[]中设置r0/r1参数分别为: 异常头_Unwind_Exception的指针和handler_switch_value
    并设置landing pad为返回地址(context->ra)并返回, 后续uw_install_context函数会将此上下文设置到硬件寄存器中并跳转到context->ra执行.
  */
  _Unwind_SetGR (context, __builtin_eh_return_data_regno (0), __builtin_extend_pointer (ue_header));
  _Unwind_SetGR (context, __builtin_eh_return_data_regno (1), handler_switch_value);
  _Unwind_SetIP (context, landing_pad);
  .......
  return _URC_INSTALL_CONTEXT;
}

5. uw_install_context_1

/*
   此函数负责将landing_pad所在栈帧的上下文(target) install到当前上下文(current)上, 此install操作只是对current->reg[i]或current->reg[i]指向的内存内容的修改,其并没有修改任何硬件寄存器.
   uw_install_context_1的install只是将target中寄存器的值全部写入到_Unwind_RaiseException栈上各个寄存器对应的内存中,最终是_Unwind_RaiseException自身的epilogue将这些内存值恢复到硬件寄存器的.
   
   这里需要注意的是:
   1) target中记录的是landing_pad所在函数子函数的上下文, 因为只有callee上下文中才会记录caller调用到callee时各个寄存器的状态, caller的上下文中记录的是caller的caller执行到caller时的寄存器状态.
   2) 子函数(callee)中记录的caller上下文通常不会因为子函数执行到某条语句而改变(见附录举例),.cfi指令理论上恢复的是某寄存器上一个保存在栈中的值,但通常编译器的用法是用其来恢复当前函数caller在调用callee时的寄存器状态. 也就是通常来说在一个函数不同位置其CFA或寄存器值只是获取方法不同, 而真正获取到的都应该是caller调用callee时的CFA和register.
*/
static long uw_install_context_1 (struct _Unwind_Context *current, struct _Unwind_Context *target)
{
  long i;
  _Unwind_SpTmp sp_slot;

  /* 
     遍历所有target时的寄存器状态,将其记录到current中, 后续current返回时即可将这些寄存器值写入到硬件寄存器中
     (libgcc中通过uw_install_context_1 caller的epilogue将 current->regs 写入到硬件寄存器,
     这里只负责将target->regs中寄存器的值复制到 current->regs指向的内存中)
  */
  for (i = 0; i < __LIBGCC_DWARF_FRAME_REGISTERS__; ++i)
  {

      /* c为最后一级栈帧中某寄存器在context中的指针(如若当前uw_install_context_1的caller是_Unwind_RaiseException,
         那么*c是_Unwind_RaiseException栈帧中的一个地址).
         t为当前要执行landing_pad的函数子函数context中某寄存器的指针.
      */
      void *c = (void *) (_Unwind_Internal_Ptr) current->reg[i];
      void *t = (void *) (_Unwind_Internal_Ptr)target->reg[i];
 
      /* current->reg[x]中必须记录的是一个指针,此地址在epilogue的会用来恢复其对应硬件寄存器的值
         current->reg[x]中存的若直接是寄存器值,那么epilogue默认无法将其写入硬件寄存器.
        而target->reg[x]是指针还是值就无所谓了,最终都是将其值写入到current对应的内存中.
      */
      gcc_assert (current->by_value[i] == 0);

      if (target->by_value[i] && c)
      {
         ......
         memcpy (c, &t, sizeof (_Unwind_Word));         /* 若target->reg[x]中记录的是寄存器的值,那么这个值直接复制到 current->reg[x]指向的内存中即可 */
      }
      else if (t && c && t != c)
         memcpy (c, t, dwarf_reg_size_table[i]);        /* 若target->reg[x]也是一个指针,则将其指向内存中的值复制到current->reg[x]指向的内存中即可 */
  }
  ......
  return target->cfa- current->cfa + target->args_size; /* 此函数返回的是target与current两个CFA的差值,后续用来修正sp */
}

6. _Unwind_Resume

//./libgcc/unwind.inc
/*
   _Unwind_Resume的作用是基于当前栈帧继续做栈回溯直到找到下一个landing_pad后跳转执行.
   需要注意的是, 如前面_Unwind_RaiseException 最终跳转到函数 A的landing_pad, 函数A需要resume则会再次调用_Unwind_Resume,
   此时_Unwind_Resume的caller是函数A, 在此过程中函数A在栈回溯中被遍历了两次:
  * 第一次来自_Unwind_RaiseException,其发现函数A中有cleanup/handler需要执行
  * 第二次来自_Unwind_Resume,其发现函数A中没有需要执行的landing_pad
  两次遍历结果不同是因为二者栈回溯时的context->ra 不同:
  * _Unwind_RaiseException回溯的通常是try {...} 中的地址, 在LSDA中会找到其cleanup/handler函数
  * _Unwind_Resume 回溯的是非try {...}中的地址, 故其不存在cleanup/handler
*/
void LIBGCC2_UNWIND_ATTRIBUTE _Unwind_Resume (struct _Unwind_Exception *exc)
{
  struct _Unwind_Context this_context, cur_context;
  _Unwind_Reason_Code code;
  unsigned long frames;

  /* 将当前callee-saved/x0-x3硬件寄存器的值初始化到 this_context上下文中 */
  uw_init_context (&this_context);
    
  cur_context = this_context;            /* 若执行uw_install_context,这里需要一个上下文备份 */

  if (exc->private_1 == 0)               /* 此值在_Unwind_RaiseException中设置为0 */
    code = _Unwind_RaiseException_Phase2 (exc, &cur_context, &frames);        /* 继续栈回溯查找landing_pad */
  else
    code = _Unwind_ForcedUnwind_Phase2 (exc, &cur_context, &frames);

  gcc_assert (code == _URC_INSTALL_CONTEXT);

  uw_install_context (&this_context, &cur_context, frames);        /* 恢复一个landing_pad所在函数的上下文, 并跳转到landing_pad执行 */
}

四、异常处理的安全性分析

   注: 这里的内容引自[9],笔者未亲自试验,若有错误之处还请指正.

   GCC编译的所有支持异常处理的二进制中都存在.eh_frame段, .eh_frame非调试段,是无法strip掉的. 且包含异常处理就意味着运行时一定存在DWARF的代码解释器(也就是如_Unwind_RaiseException等函数),  DWARF的bytecode实际上是另类的一种指令集,理论上是可以完成图灵完备的计算的, 同样也可以基于对这些bytecode的修改完成如对syscall、库函数的调用或ROP。 DWARF本质上应该是DOA(Date Oriented Attack) 攻击的一种。利用DWARF可以在不修改二进制可执行段和数据段的情况下完成木马程序的植入, 在[9]发表时(2011)尚无已知有效的检测方式(理论上如果只是木马注入应该是可以做检测的,但这会很麻烦).

   DWARF自身的语义可以:

  • 读取任意内存
  • 利用内存和和寄存器的值执行任意计算
  • 控制部分寄存器并影响程序的控制流

   DWARF自身的限制是没法直接写寄存器和内存(注意这里说的是没法直接写,但配合已有代码理论上总是可以完成写操作的),在GCC4.5.2中只支持64byte的栈. 作者[9]同时也提供可一个修改ELF中DWARF的工具katana,配合dwarfscript脚本可以方便的修改ELF中的DWARF内容.

   作者同时举例了DWARF的以下原语:

  1) 修改CFA:

DW_CFA_offset r16 1                   //CFA = r16 + 1
改为
DW_CFA_offset r16 6                  //CFA = r16 + 6 

     通过修改DWARF可以很容易修改CFA的计算方式, 在知道某个栈帧大小的情况下可以通过改变一个偏移来绕过某一级别的异常处理, 即修改CFA可导致异常处理执行不同的landing_pad(但不能做到任意地址跳转)

     * 修改CFA会直接导致context->ra(也就是返回地址)的修改(ra通常是根据CFA计算的,见下)返回地址是用来决定在哪个函数中查找landing_pad用的, 故修改CFA可导致将控制流重定位到其他landing_pad

//通常ra是通过*(CFA - x) 来获取的
00000100 0000000000000024 00000064 FDE cie=000000a0 pc=00000000004007c0..00000000004008a4
   LOC           CFA      x19   x20   x29   ra    
00000000004007c0 sp+0     u     u     u     u          
00000000004007c4 sp+48    u     u     c-48  c-40  
00000000004007cc sp+48    c-32  c-24  c-48  c-40  
00000000004008a0 sp+0     u     u     u     u  

     * 但context->ra的修改并不会影响最终的函数返回, 在异常处理中其作用只是用来确定landing_pad, 而landing_pad返回到父函数的地址来自程序栈,这个值是DWARF修改不了的.

  2) 修改寄存器:

     通过修改DWARF可以轻易修改寄存器的值,如:

DW_CFA_val_expression r16      //r16 = 0x600DF00D     //r16=0x600DF00D
begin EXPRESSION
DW_OP_constu 0x600DF00D
end EXPRESSION

     此修改会直接被带入到landing_pad, 可惜大多数情况下landing_pad应该不会直接使用寄存器的原始值(除了参数x0-x3), 但如果landing_pad被修改为任意地址时(见后)此原语就很有用了(注: DWARF虽然不能修改任意内存,但应该还是可以做到栈溢出的任意1字节写的, 如作者提到的DW_CFA_offset_extened原语配合DW_CFA_val_expression原语等).

     修改DWARF虽然可以控制流,但通常只能将控制流重定向到某一个catch块中(或cleanup代码), 这个catch块甚至可以任何函数中的一个catch块,但其缺点是控制流无法走出catch块, 因为DWARF指令始终还是走正常的异常处理流程的,正常异常处理总是要跳转到某个landing_pad。

  为了走出catch块,作者还提出了修改LSDA数据, LSDA数据记录在.gcc_except_table中,如图:

    修改LSDA则代表可以修改landing_pad为任意地址,这就解决了DWARF无法走出catch块的问题. 一个backdoor的运行逻辑如下:

    1) 程序正常执行直到有异常抛出(throw)

    2) 修改LSDA和.eh_frame后可以让异常跳转到任意地址执行(同时也可以控制全部callee-saved寄存器,x0-x3,其他寄存器则取决于当前函数保存了什么)

    此过程中不需要修改程序中原有的任何代码段与数据段。

    在此基础上,由于运行时.eh_frame段的指针是可以修改的, 故运行时也也可以实现动态的插入DWARF指令(旧版本gcc中.eh_frame和.gcc_except_table是可写的)来实现任意代码执行


参考资料:

[1] C++ ABI for Itanium: Exception Handling

[2] AARCH64平台的栈回溯_ashimida-CSDN博客

[3] C++ exception handling ABI | MaskRay

[4] Unwinding a Bug - How C++ Exceptions Work - shorne in japan

[5] c++ 异常处理(1) - twoon - 博客园

[6] c++ 异常处理(2) - twoon - 博客园

[7] C++异常机制的实现方式和开销分析

[8] https://monkeywritescode.blogspot.com/p/c-exceptions-under-hood.html

[9] https://cs.dartmouth.edu/~sergey/battleaxe/hackito_2011_oakley_bratus.pdf

[10] Katana

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值