例外處理學習

2023-07-19 18:01:02

在學習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操作碼,其中每個操作碼都會對前面操作碼的結果進行一些計算或轉換。
下面是對每個操作碼的解釋:

  1. DW_OP_constu: 2616514329260088143
    DW_OP_constu: 1237891274917891239
    DW_OP_constu: 1892739
    這三個操作碼將三個無符號整數值(分別是2616514329260088143、1237891274917891239和1892739)依次壓入堆疊。

  2. DW_OP_breg12 (r12): 0
    DW_OP_plus
    DW_OP_xor
    DW_OP_xor
    這四個操作碼先從r13中讀取一個值,並將它加上偏移量0,再從堆疊中彈出兩個值,然後將它們相加。接著,它們的結果被執行兩次互斥或操作。最後的結果將被壓入堆疊。

  3. DW_OP_constu: 8502251781212277489
    DW_OP_constu: 1209847170981118947
    DW_OP_constu: 8971237
    這三個操作碼將三個無符號整數值(分別是8502251781212277489、1209847170981118947和8971237)依次壓入堆疊。

  4. DW_OP_breg13 (r13): 0
    DW_OP_plus
    DW_OP_xor
    DW_OP_xor
    DW_OP_or
    這五個操作碼先從r13中讀取一個值,並將它加上偏移量0,再從堆疊中彈出兩個值,然後將它們相加。接著,它們的結果被執行兩次互斥或操作,然後再執行一次按位元或操作。最後的結果將被壓入堆疊。

  5. DW_OP_constu: 2451795628338718684
    DW_OP_constu: 1098791727398412397
    DW_OP_constu: 1512312
    這三個操作碼將三個無符號整數值(分別是2451795628338718684、1098791727398412397和1512312)依次壓入堆疊。

  6. DW_OP_breg14 (r14): 0
    DW_OP_plus
    DW_OP_xor
    DW_OP_xor
    DW_OP_or
    這五個操作碼先從r14中讀取一個值,並將它加上偏移量0,再從堆疊中彈出兩個值,然後將它們相加。接著,它們的結果被執行兩次互斥或操作,然後再執行一次按位元或操作。最後的結果將被壓入堆疊

  7. DW_OP_constu: 8722213363631027234
    DW_OP_constu: 1890878197237214971
    DW_OP_constu: 9123704
    這三個操作碼將三個無符號整數值(分別是8722213363631027234、1890878197237214971和9123704)依次壓入堆疊。

  8. 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()
flag{e609efb5-e70e-4e94-ac69-ac31d96c3861}