异常处理学习
在学习DWARF Expression这个概念的时候,我们需要知道异常处理、栈展开等概念
异常处理
所谓的异常就是在应用程序正常执行过程中的发生的不正常的事件,如溢出,除数为0等不正常程序的之星,就会引发异常。
由CPU引发,而不是程序员自己定义的异常叫做硬件异常,例如用指针指向一个非法地址,就会引发异常,比如下面这个代码
可以看到q这个指针指向了一个不属于它访问权限的地址,引发了异常。
除了由CPU引发异常之外,还可以由代码自行定义异常发生。在C语言中我们用_try{...}_except(...){...}_finally{...}块定义异常,try块里面是需要测试的代码,catch有exception参数和一个代码块,参数这里定义我们需要捕获什么异常,代码块这里就是捕获到异常之后我们需要如何处理这个异常,finally通常是进行一些收尾操作。
而语法上可能大家会有一些误解,即__try、__except、__finally三个代码块的执行流程
_try块后面必须而且只能跟一个异常处理块,异常控制的流程如下:
1、如果try块里面被保护的代码如果没有异常发生,那么跳过except块继续执行
2、如果有异常发生,那么try会找到最近的一个异常处理块,进行结构化异常处理
3、如果在当前函数层找不到异常处理块,就向调用函数找,如果调用函数也没有异常处理结构,那么就继续在调用函数的调用函数上找,直到确认没有异常处理块为止
4、如果在被保护的代码块执行过程中或调用的任何例程中发生异常,则会计算 __except 表达式,这个表达式一共有三个值
- EXCEPTION_CONTINUE_EXECUTION (-1) 异常已消除。 从出现异常的点继续执行。
- EXCEPTION_CONTINUE_SEARCH (0) 无法识别异常。 继续向上搜索堆栈查找处理程序,首先是所在的 try-except 语句,然后是具有下一个最高优先级的处理程序。
- EXCEPTION_EXECUTE_HANDLER (1) 异常可识别。 通过执行 __except 复合语句将控制权转移到异常处理程序,然后在 __except 块后继续执行。
我们可以看到,上述流程中有一个问题,就是_finally块和_except块都是异常处理块,如果两个结构都有,它们的执行顺序是怎么样的呢
我们可以写一段代码查看他们的先后顺序
#include <stdio.h>
#include <stdlib.h>
int filter()
{
puts("异常处理");
return 1;
}
int main(int argc, char* argv[])
{
_try
{
_try
{
int a = 3.151413 / 8.90876;
}
_finally
{
puts("异常处理2.....");
}
_asm
{
xor edx,edx;
xor ecx,ecx;
mov eax,0x10;
idiv ecx;
}
puts("继续跑……");
}
_except(filter())
{
puts("异常处理……");
}
system("pause");
return 0;
}
可以看到这段程序里含有两个try块,一个finally块和一个except块,运行后可以得到结果可以看到程序捕获到了异常,在里层的try块最先被finally捕获,然后进行处理,然后跳到了外层的try块,被except处理。
其他情况下,如果说filter函数返回的是-1,那么程序会无限次的检测到外层的异常,从而无限次的执行filter函数。
栈展开(栈回溯)
栈展开是指当程序中所有的异常回调函数都不处理异常时,系统在终结程序之前会给发生异常的线程中所有注册的回调函数一个调用。
我们通常使用API函数RltUnwind进行栈展开,该函数的调用方式如下:
RltUnwind(VirtualTargetFrame, TargetPC, ExceptionRecord, ReturnValue)
VirtualTargetFrame:展开时,最后在SEH链上的停止于回调函数所对应的EXCEPTION_REGISTRATION的指针,即希望在哪个回调函数前展开调用停止,其对应的EXCEPTION_REGIESTRATION结构指针就作为这个参数使用
TargetPC:调用RltUnwind返回后应执行指令的地址。如果为0,则自然返回RltUnwind调用后的下一条指令。
ExceptionRecord:一个指向 EXCEPTION_RECORD 结构体的指针,该结构体保存了关于异常的信息。如果该参数不为 NULL,则表示需要保存异常信息,并且在恢复程序的执行状态之前,会将异常信息保存到该参数所指向的 EXCEPTION_RECORD 结构体中。在底层化的栈展开操作中,如果需要保存异常信息,就需要设置该参数为非 NULL 值。
ReturnValue:返回值,通常不使用。
下面是一个简单的实现代码:
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
void func3()
{
int a = 10, b = 0;
int c = a / b; // 故意制造除零错误
}
void func2()
{
func3();
}
void func1()
{
func2();
}
LONG filter(EXCEPTION_POINTERS* ep)
{
printf("发生异常,异常地址:%p\n", ep->ExceptionRecord->ExceptionAddress);
// 恢复程序的执行状态,跳转到异常处理程序中
return EXCEPTION_EXECUTE_HANDLER;
}
int main()
{
__try
{
func1();
}
__except(filter(GetExceptionInformation()))
{
// 异常处理程序
printf("进入异常处理程序\n");
// 恢复程序的执行状态,跳转到异常发生前的位置
RtlUnwind(NULL, (PVOID)0x12345678, NULL, NULL);
}
printf("程序继续运行\n");
return 0;
}
在这个示例代码中,我们定义了三个函数 func1、func2 和 func3,它们都是故意制造除零错误的。在 main 函数中,我们通过 __try 和 __except 语句来实现异常处理。当程序执行到 func3 函数时,会发生除零错误,此时异常会被抛出。
由于我们使用了底层化的栈展开操作,因此程序不会崩溃,而是会跳转到 filter 函数中执行异常处理程序。在异常处理程序中,我们打印了一条消息,表示进入了异常处理程序。然后我们使用 RtlUnwind 函数来恢复程序的执行状态,跳转到异常发生前的位置。最后,程序会继续执行并打印一条消息,表示程序继续运行。
RtlUnwind 函数的第一个参数是一个指向 CONTEXT 结构体的指针(即EXCEPTION_REGIESTRATION结构指针),该结构体保存了程序的执行状态。在本例中,我们将其设置为 NULL,表示不需要保存程序的执行状态。
第二个参数是一个指向恢复点的指针,该恢复点是在异常发生前程序的执行位置。在本例中,我们将其设置为一个任意的地址 0x12345678,表示恢复到异常发生前的位置。
第三个参数是一个指向 EXCEPTION_RECORD 结构体的指针,该结构体保存了关于异常的信息。在本例中,我们将其设置为 NULL,表示不需要保存异常信息。
第四个参数是一个指向目标函数的指针,该函数是在恢复程序执行状态之前要执行的特殊处理程序。在本例中,我们将其设置为 NULL,表示不需要执行特殊处理程序。
CFI(Call Frame Information)
在栈展开的过程中会调用相应的回调函数,从而导致栈帧(每个函数所使用的栈)变化很大,我们需要重点关注CFA,CFA就是上一级调用者的堆栈指针,它的值是在执行(不是执行完)当前函数(callee)的caller的call指令时的RSP值。
.eh_frame段:存储着跟函数入栈相关的关键数据。当函数执行入栈指令后,在该段会保存跟入栈指令一一对应的编码数据,根据这些编码数据,就能计算出当前函数栈大小和cpu的哪些寄存器入栈了,在栈中什么位置。
由于映射表.eh_frame的生成需要在asm汇编文件中写一个固定格式的长表。编译器不知道代码的确切大小,因此这会导致编码效率低下,表格也很难阅读,所以有了.CFI伪指令,以.cfi开头的指令都是伪指令,它们不会被编译成机器码出现在代码段中,而是被储存在.eh_frame块中。
上图详细说明了怎么样利用.eh_frame来进行栈回溯:
1、根据当前的PC在.eh_frame中找到对应的条目,根据条目提供的各种偏移计算其他信息。
2、首先根据CFA = rsp+4,把当前rsp+4得到CFA的值。再根据CFA的值计算出通用寄存器和返回地址在堆栈中的位置。
3、通用寄存器栈位置计算。例如:rbx = CFA-56。
4、返回地址ra的栈位置计算。ra = CFA-8。
5、根据ra的值,重复步骤1到4,就形成了完整的栈回溯。
补充:几个.cfi伪指令功能如下
(1).cfi_startproc
用在每个函数的入口处。
(2).cfi_endproc
.cfi_endproc用在函数的结束处,和.cfi_startproc对应。
(3).cfi_def_cfa_offset [offset]
用来修改修改CFA计算规则,基址寄存器不变,offset变化:
CFA = register + offset(new)
(4).cfi_def_cfa_register register
用来修改修改CFA计算规则,基址寄存器从rsp转移到新的register。
register = new register
(5).cfi_offset register, offset
寄存器register上一次值保存在CFA偏移offset的堆栈中:
*(CFA + offset) = register(pre_value)
(6).cfi_def_cfa register, offset
用来定义CFA的计算规则:
CFA = register + offset
默认基址寄存器register = rsp。
x86_64的register编号从0-15对应下表。rbp的register编号为6,rsp的register编号为7。
%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15
DWARF Expression
但是.cfi伪指令在恢复CFA上的表达能力是很有限的,为了解决这个问题,DWARF 3标准引入了DWARF Expression。它是一个支持一系列操作的基于栈的虚拟机。
DWARF 是一种补充的调试信息,在编译时构建了一张映射表 .eh_frame,对于每个机器指令,指定当时如何计算 CFA、返回地址 (return address, ra),以及寄存器值的内容地址,他们相对于 RSP 寄存器的偏移。
DWARF Expression支持的操作可以分为编码(入栈立即数),寄存器寻址(入栈 REG + OFFSET),栈操作(SWAP,POP等操作),算术运算,流程转移等
详情可参考https://dwarfstd.org/doc/DWARF5.pdf
里面的2.5
这里简单介绍几个常用操作
编码(入栈立即数)
对于一条push imm32指令而言,立即数imm32的编码可以使用两种方式:1. 标准补码编码;2. U/SLEB128编码[3]。LEB128编码包含ULEB128与SLEB128,分别编码无符号数与有符号数,它的编码与解码过程在WIKI百科上的描述很详细,我就不在此赘述了。
虚拟机中单位元素的长度与当前机器上地址的长度相等,比如在AMD64架构下,地址的长度是int64,那么虚拟机中单位元素就是int64。
- DW_OP_const1(2, 4, 8)u OP1(u_int8, u_int16, u_int32, u_int64)
这4条指令都包含操作数OP1,OP1使用补码编码,语义都是将OP1压入栈中。如果OP1的长度小于单位元素长度,使用0补齐高位。 - DW_OP_const1(2, 4, 8)s OP1(int8, int16, int32, int64)
与上一条指令基本相同,区别是这条指令压入的是有符号数,而上一条指令压入的是无符号数。 - DW_OP_constu OP1(ULEB128)
这条指令包含操作数OP1,OP1使用ULEB128编码,它向栈中压入OP1。 - DW_OP_consts OP1(SLEB128)
与上一条指令基本相同,区别是OP1使用SLEB128编码,压入的是有符号数。
寄存器寻址
- DW_OP_bregn OP1(SLEB128)
n的取值可以是0-31,代表着寄存器的编号。AMD64环境中寄存器编号如下图所示。这条指令向栈中压入 REG + OP1,REG是由n指定的。注意压入的仅仅是地址,而不存在解引用的过程,解引用操作需要使用DW_OP_deref指令。 - DW_OP_bregx OP1(LEB128), OP2(SLEB128)
这条指令与上一条基本相同,不同的是REG使用操作数OP1指定了,OP1是寄存器编号。这条指令向栈中压入 REG + OP2,REG由OP1指定。
栈操作
- DW_OP_drop
从栈中弹出栈顶元素 - DW_OP_pick OP1(u_int8)
复制一份栈中第OP1个元素压入栈顶。栈中元素的编号从0开始,0是栈顶。 - DW_OP_swap
交换栈顶两个元素 - DW_OP_deref
弹出栈顶元素作为地址,解引用这个地址,值压入栈顶。 - DW_OP_deref_size OP1(u_int8)
与上一条指令基本相同,不同的是上一条指令无法控制读取的长度,只能是栈的单位元素的长度,这条指令可以控制读取长度。操作数OP1指示读取长度,是字节数。如果小于栈单位元素长度,用0补齐高位;如果大于栈单位元素长度则会报错。
算数运算指令
- DW_OP_plus
弹出栈顶两个元素,相加,值压入栈顶。 - DW_OP_neg
弹出栈顶元素,取负,值压入栈顶。注意虚拟机中没有sub操作,因此使用DW_OP_neg, DW_OP_plus来表示减法操作,即弹出栈顶两个元素,用栈顶第二个元素减去第一个元素,值压入栈顶。 - DW_OP_mul
乘法 - D W_OP_mod
求模 - DW_OP_or, and, not, xor
与,或,非,异或 - DW_OP_shr, shl;ashr
逻辑右移,左移;算数右移
控制流转移指令
- DW_OP_le, ge, eq, lt, gt, ne
弹出栈顶两个元素,栈顶第二个元素记为O2,栈顶元素记为O1
比较两个操作数,O2 le, ge, eq, ... O1
如果该表达式成立,把1压入栈顶,否则把0压入栈顶。
比较是有符号形式的,le(less equal, <=), gt(great than, >)...
可以将无符号数的比较转为有符号数的比较,如比较无符号数U1,U2大小,可以转换为 U1 - U2 与 0 的有符号大小比较。 - DW_OP_bra OP1(int16_t)
操作数OP1使用补码编码,并且是2字节大小。弹出栈顶元素,如果它非0,则跳转到OP1处执行,OP1表示偏移地址,它是从OP1后面开始算的。比如 DW_OP_bra 10 的字节码是 0x28 0x0A 0x00 ,假设 0x28 是第0个字节,0x00是第2个字节,那么跳转的目标就是第12个字节 - DW_OP_skip OP1(int16_t)
与上一条指令基本相同,区别是这条指令是无条件跳转。
DWARF Expression 存在的局限性
- 不能在虚拟机内写入程序运行内存,而只能读取程序运行内存。也就是说,如果我们要写入内存,只能在退出虚拟机后写入。
- DWARF Expression并不能”恢复“所有寄存器。严格来说并不是不能”恢复“所有寄存器,而是恢复之后又会调用一些C++标准库函数导致恢复的值又被破坏掉了,最终我们在catch块中没办法得到恢复的值。RBX,R12-R15寄存器是AMD64 ABI定义的由被调用者保护的寄存器,因此它们的值一定可以在catch块中被接收到。
2023 CTF国赛初赛(CISCN) re- ez_byte
ida打开附件,可以查到字符串%100s,点进去看,发现还有一个yes,但是字符串里面没有搜索出来
汇编代码能看到,但是调用函数f5反编译之后也没有办法看见在哪
汇编代码往上查可以发现有一个cmp r13,r12,但是r12在流程上并没有进行操作,有可能是被隐藏起来了,finger恢复一下符号,发现了一个函数
一大堆if之后是一个异常处理函数,回调函数里会有一系列SSE指令集有关xmm寄存器操作,怀疑对r12的操作代码被隐藏在Dwarf调试信息里面,在异常被捕获后才会执行代码。
我们在linux系统里把调试信息dump下来
打印出来之后直接找对r12进行操作的字节码,整理出来是这样的
这段Dwarf expression代码是一个用于计算某个变量值的表达式。具体地,它使用了一系列DW_OP_xxx操作码,其中每个操作码都会对前面操作码的结果进行一些计算或转换。
下面是对每个操作码的解释:
:::info
-
DW_OP_constu: 2616514329260088143
DW_OP_constu: 1237891274917891239
DW_OP_constu: 1892739
这三个操作码将三个无符号整数值(分别是2616514329260088143、1237891274917891239和1892739)依次压入堆栈。
:::
:::info -
DW_OP_breg12 (r12): 0
DW_OP_plus
DW_OP_xor
DW_OP_xor
这四个操作码先从r13中读取一个值,并将它加上偏移量0,再从堆栈中弹出两个值,然后将它们相加。接着,它们的结果被执行两次异或操作。最后的结果将被压入堆栈。
:::
:::info -
DW_OP_constu: 8502251781212277489
DW_OP_constu: 1209847170981118947
DW_OP_constu: 8971237
这三个操作码将三个无符号整数值(分别是8502251781212277489、1209847170981118947和8971237)依次压入堆栈。
:::
:::info -
DW_OP_breg13 (r13): 0
DW_OP_plus
DW_OP_xor
DW_OP_xor
DW_OP_or
这五个操作码先从r13中读取一个值,并将它加上偏移量0,再从堆栈中弹出两个值,然后将它们相加。接着,它们的结果被执行两次异或操作,然后再执行一次按位或操作。最后的结果将被压入堆栈。
:::
:::info -
DW_OP_constu: 2451795628338718684
DW_OP_constu: 1098791727398412397
DW_OP_constu: 1512312
这三个操作码将三个无符号整数值(分别是2451795628338718684、1098791727398412397和1512312)依次压入堆栈。
:::
:::info -
DW_OP_breg14 (r14): 0
DW_OP_plus
DW_OP_xor
DW_OP_xor
DW_OP_or
这五个操作码先从r14中读取一个值,并将它加上偏移量0,再从堆栈中弹出两个值,然后将它们相加。接着,它们的结果被执行两次异或操作,然后再执行一次按位或操作。最后的结果将被压入堆栈。
:::
:::info -
DW_OP_constu: 8722213363631027234
DW_OP_constu: 1890878197237214971
DW_OP_constu: 9123704
这三个操作码将三个无符号整数值(分别是8722213363631027234、1890878197237214971和9123704)依次压入堆栈。
:::
:::info -
DW_OP_breg15 (r15): 0
DW_OP_plus
DW_OP_xor
DW_OP_xor
DW_OP_or
这五个操作码先从r15中读取一个值,并将它加上偏移量0,再从堆栈中弹出两个值,然后将它们相加。接着,它们的结果被执行两次异或操作,然后再执行一次按位或操作。最后的结果将被压入堆栈。
:::
分析代码,写出脚本
def decrypt():
r15 = (8722213363631027234 ^ 1890878197237214971) - 9123704
r14 = (2451795628338718684 ^ 1098791727398412397) - 1512312
r13 = (8502251781212277489 ^ 1209847170981118947) - 8971237
r12 = (2616514329260088143 ^ 1237891274917891239) - 1892739
print(hex(r12))
print(hex(r13))
print(hex(r14))
print(hex(r15))
import binascii
hexstring = "65363039656662352d653730652d346539342d616336392d6163333164393663"
print("flag{" + binascii.unhexlify(hexstring).decode(encoding="utf-8") + "3861}")
decrypt()