如何逆向C++虛函數?
目前網上有很多關於逆向C++的文章,但是涉及到虛函數的逆向只佔很小的一部分。不過,在這裡我想花一些時間來寫逆向虛函數的內容。這其中涉及到很多類以及繼承函數,所以我認為可以提供一些逆向的技巧。如果你已經熟悉虛函數的逆向過程,那麼直接進入第2部分。這部分主要是提及一些準備工作。
另外需要提及以下幾點:
代碼沒有經過RTTI(RTTI將在稍後討論)編譯。n使用32位x86平台n二進位文件已被抽離n大多數虛函數的實現細節不規範,不同編譯器的情況也是不同的。出於這個原因,我們將專註於GCC編譯器。n
所以一般來說,我們需要分析的二進位文件是經過g++ -m32 -fno-rtti -fnoexceptions -O1 file.cpp編譯的,然後使用strip進行抽離。
0x01目標
在大多數情況下,我們不能指望「devirtualize」虛函數調用。直到運行時才能取得所需的信息。相反,我們工作的目標是,以確定哪些函數可能會在特定點調用。在後面的部分,我們將重點縮小可能性。
0x02基礎內容
假設已經熟悉C ++,但也許不知道如何實現它。所以我們先通過查看編譯器如何實現虛函數來開始。假設我們有以下類:
#include <cstdlib>n#include <iostream>nstruct Mammal {n Mammal() { std::cout << "Mammal::Mammaln"; }n virtual ~Mammal() { std::cout << "Mammal::~Mammaln"; };n virtual void run() = 0;n virtual void walk() = 0;n virtual void move() { walk(); }n};nstruct Cat : Mammal {n Cat() { std::cout << "Cat::Catn"; }n virtual ~Cat() { std::cout << "Cat::~Catn"; }n virtual void run() { std::cout << "Cat::runn"; }n virtual void walk() { std::cout << "Cat::walkn"; }n};nstruct Dog : Mammal {n Dog() { std::cout << "Dog::Dogn"; }n virtual ~Dog() { std::cout << "Dog::~Dogn"; }n virtual void run() { std::cout << "Dog::runn"; }n virtual void walk() { std::cout << "Dog::walkn"; }n};n
使用下面代碼:
int main() {n Mammal *m;n if (rand() % 2) {n m = new Cat();n } else {n m = new Dog();n }n m->walk();n delete m;n}n
m是cat或dog取決於rand的輸出。當然編譯器無法提前知道這些,所以它是如何調用正確的函數得?答案是,每種類型都有一個虛函數,編譯器會將虛函數表插入到生成的二進位文件中。這種類型的每個實例會提供一個額外的成員稱為vptr,指向該對象正確的虛表。代碼初始化這個指針的正確的值,將被添加到構造函數中。
然後,當編譯器需要調用一個虛擬函數,會先訪問正確的虛表,找到該對象的虛函數並調用。這意味著,在虛表中的條目必須以相同的順序應對每個相關類型(每個類的run可能是在索引1中,每一個walk在索引2,等等)。
所以我們希望找到二進位文件的三個虛表Mammal,cat和dog。我們可以使用.rodata找到相鄰的函數:
再看一下主函數:
我們可以看到,在這兩個分支都分配了4個位元組大小的空間。這是有意義的,因為在結構體中的唯一數據是由編譯器加入的vptr。在15和17行,可以看到存在虛函數的調用,編譯器間接調用(獲取vptr),並加上12訪問在vtable中的第4項。在第17行中,得到虛表中的第2項。然後程序從表中檢索的虛函數指針並調用它。
再看一下虛表,第4項是sub_80487AA,sub_804877E和___cxa_pure_virtual。如果我們看一下這兩個「sub_」函數的內容,可以發現他們定義walk為Dog和Cat(如圖所示)。通過消除該___cxa_pure_virtual函數必須屬於虛函數表的Mammal。這是有道理的,因為Mammal沒有定義walk,而這些「pure_virtual」是由GCC插入,當這些函數是虛函數時。這樣,表1必須是Mammal對象,2是用於Cat和表3是Dog。
比較奇怪的是,每個虛表中存在5個項目,但是卻只有4個虛函數:
1. runn2. walkn3. moven4. the destructorsn
附加條目是一個「額外」的析構函數。在這裡,GCC將插入多個析構函數。其中的第一個將簡單地銷毀對象的成員。第二個將刪除已為對象分配內存(第17行)。在某些情況下,可以是在某些虛擬繼承情況中使用的第3版。
在完成對「sub_」函數的內容回首,我們發現虛函數表的布局如下:
| Offset | Pointer to |n|--------+-------------|n| 0 | Destructor1 |n| 4 | Destructor2 |n| 8 | run |n| 12 | walk |n| 16 | move |n
但是,請注意,在Mammal 表頭兩項都是零。這是GCC的較新版本緣故。編譯器使用具有純虛函數的類與空指針代替析構函數項(即,抽象類)。
有了這一切之後,我們重命名一下。最後是這樣:
請注意,由於Cat和Dog都沒有實現move,它們都是繼承Mammal,因此vtables中的move項目是相同的。
0x03結構體
下面開始定義一些有用的結構體。我們已經看到,唯一的成員Mammal,Cat和Dog的結構將是他們的vptrs。因此,我們可以快速地定義這些:
下一步是有點複雜。我們要為每個虛表創建一個結構體。這裡的目標是獲得反編譯器輸出並展示實際上是哪個函數被調用的。然後,我們可以通過這些可能性檢查所有的選項。
為了實現這一目標,結構體的成員將具有相應的功能,指向的內容如下:
我們需要設置的vptr類型的每個結構是對應的Vtable類型。例如,該類型的vptr為Cat,應該是CatVtable*。此外,我已經設置每個虛表項的類型是一個函數指針。這將有助於IDA正確顯示。所以類型Dog__run應該是void (*) (Dog*)(因為這是Dog__run簽名)。
如果我們回到主函數的反編譯代碼,我們現在可以重命名局部變數m,並設置其類型為Cat*或Dog*。後來我們看到:
現在,我們可以很容易地看到被調用的可能函數。如果m是Cat,那麼第15行會調用Cat__walk,如果是Dog然後它會調用Dog__walk。顯然,這是一個簡單的例子,但是這就是一般的思路。
我們還可以設置類型m是Mammal*的,但如果我們按如下的方式做可能會有一些問題:
注意,如果真正的類型m是Mammal然後在第15行調用將是一個純虛函數。這本不應該發生。還有在17行這顯然會造成一個空指針調用的問題。因此,我們可以得出這樣的結論m不能是Mammal。
這似乎很奇怪,因為m實際上是聲明為Mammal*。然而,該類型是編譯時類型(靜態類型)。我們感興趣的是動態類型(或運行時類型)m,因為這將決定哪個虛函數將被調用。事實上,動態類型的對象可以根本不可能是抽象類型。所以如果一個給定的虛函數表中包含的一個___cxa_pure_virtual函數,可以忽略它。我們可以沒有創建虛表結構,Mammal,因為它永遠不會被使用(但我希望看到為何有用)。
註:本文參考來源於alschwalm
推薦閱讀:
※一種基於SDR實現的被動GSM嗅探
※乾貨 | 如何利用csi.exe繞過Windows Device Guard?
TAG:技术分析 |