程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> C++虛函數逆向教程(1)

C++虛函數逆向教程(1)

編輯:關於C++

C++虛函數逆向教程(1):關於C++程序的逆向,網絡上已經有很多文章了,這些文章也或多或少的提到了虛函數。然而,這篇文章中,我想著重介紹一下,在代碼量比較大的程序中。

我們應該如何處理虛函數。這些程序裡,通常存在著數以千計的類,類型之間的關系也很復雜,因此在我看來,分享處理這些類的經驗是很有價值的。但在我介紹這些復雜的案例之前,我會先介紹一些簡單的栗子。如果你已經對虛函數的逆向有了一些了解,那麼可以直接去看本文的第二部分。(譯者注:截止本文翻譯結束前,作者尚未發布第二部分)
此外,注意以下幾點:
示例代碼編譯時沒有使用RTTI,也沒有使用異常機制
下文中的樣例在x86平台上測試
所有的二進制文件已經被strip了(剝離了符號)
大多數虛函數的實現細節是沒有特定標准的,因此不同編譯器對此的處理方法很可能不一致。因此,我們將著重討論GCC編譯器的行為
另外,我們的文件編譯時的命令行參數為g++ -m32 -fno-rtti -fnoexceptions -O1 file.cpp,輸出文件用strip處理過。
目標
大多數情況下,我們是沒辦法讓一個對虛函數的調用,變換為一個對非虛函數的調用的。這是因為,我們需要的信息在靜態編譯中是不全面的,只有在運行時才會存在。因此,這段文章的目標,是判斷哪些函數會在特定的情況下被調用。稍後我們會學習其他的技巧,來進一步縮小范圍。
基本功
假設你已經比較熟悉C++了,但對它的具體實現還不太熟悉,那麼就先來看一看編譯器是如何實現虛函數的。現在有這麼一段代碼:
// file reversing-1.cpp
#include
#include
struct Mammal {
Mammal() { std::cout "Mammal::Mammal\n"; }
virtual ~Mammal() { std::cout "Mammal::~Mammal\n"; };
virtual void run() = 0;
virtual void walk() = 0;
virtual void move() { walk(); }
};
struct Cat : Mammal {
Cat() { std::cout "Cat::Cat\n"; }
virtual ~Cat() { std::cout "Cat::~Cat\n"; }
virtual void run() { std::cout "Cat::run\n"; }
virtual void walk() { std::cout "Cat::walk\n"; }
};
struct Dog : Mammal {
Dog() { std::cout "Dog::Dog\n"; }
virtual ~Dog() { std::cout "Dog::~Dog\n"; }
virtual void run() { std::cout "Dog::run\n"; }
virtual void walk() { std::cout "Dog::walk\n"; }
};
然後還有這麼一段調用他們的代碼:
// file reversing-2.cpp
int main() {
Mammal *m;
if (rand() % 2) {
m = new Cat();
} else {
m = new Dog();
}
m->walk();
delete m;
}
很顯然,m是cat類還是dog類,取決於rand函數的輸出。這是無法被編譯器提前預測的,那麼編譯器是怎麼調用合適的walk函數呢?
由於我們把walk函數聲明為了虛函數,編譯器會在程序所處的內存空間中,插入一張含有函數指針的表,稱為“虛函數表”,也就是“虛表”(vtable);而在實例化類的時候,每個對象會多出一個稱作“虛指針”(vptr)的成員,這個虛指針指向正確的虛表,初始化這個虛指針的代碼會被添加到類的構造函數中。這樣,當編譯器需要調用虛函數的時候,就可以通過虛指針找到對應的虛表,從而找到合適的函數,進而調用他。這也意味著,具有同一個父類的子類,其虛表中函數的順序也應該是一致的。比如,在上面的例子中,Dog和Cat類都是Mammal類的子類,那麼Dog類的虛表中,第一項指向的是Dog::run,第二項指向的是Dog::walk,而Cat類的虛表中,第一項指向的是Cat::run,第二項則是Cat::walk。
通過在.rodata段中尋找指向函數的偏移量,我們可以在二進制文件中輕松地找到Mammal,Cat和Dog類的虛表,如下圖所示。

主函數這時候被編譯成了這個樣子:

可以看到,程序在實例化類的時候,為每個對象申請了4字節的內存空間,這和我們預期是相符的(因為每個類中沒有數據成員,而編譯器為我們添加了vptr)。我們也可以在第15行和第17行中,看到對虛函數的調用過程。在第15行中,編譯器先對指針解引用,從而獲得vptr;接下來計算vptr+12,也就是訪問虛表中的第四項,而第17行則是訪問虛表中的第二項。之後,程序調用虛表中對應項目指向的函數。

我們再看一下,三張虛表的第四項分別是sub_80487AA、sub_804877E和___cxa_pure_virtual。前兩個函數如上圖所示,分別是Dog和Cat類中對walk函數的實現,那麼最後一個函數一定是Mammal類中對應的實現了。這很正常,因為Mammal類中沒有定義walk的具體實現,而是聲明其為“純虛函數”,那麼就GCC就幫我們插入了一個默認的項目。那麼到這裡我們知道了,虛表1是屬於Mammal類的,而2和3分別是屬於Cats和Dogs類的。
但比較奇怪的是,每個vtable中含有五個項目,而在我們的程序中,每個類只有四個虛函數,他們分別是:
run
walk
move
析構器
實際上,多出來的項目是一個“額外的”析構器。這是因為,GCC會在不同的場景中,使用不同的析構函數。前者只是簡單的把實例對應的所有成員都清理掉,而後者則會同時要求回收為這個實例分配的內存,這也就是在第17行調用的函數。在某些涉及到虛繼承的情況下,還會有第三種析構器。
那麼現在我們搞清楚了虛表的布局:

| Offset | Pointer to  |
|--------+-------------|
|      0 | Destructor1 |
|      4 | Destructor2 |
|      8 | run         |
|     12 | walk        |
|     16 | move        |
值得注意的是,虛表的前兩個項目是空指針。這是新版本GCC的一個特征,當類具有純虛函數時,編譯器會將其析構器替換為空指針。
現在可以考慮給他們重命名,以方便閱讀:

注意,由於Cat和Dog類都沒有實現move方法,因此他們直接采用了Mammal類中的方法,虛表中的項目值也一樣。
結構體
為了研究方便,我們定義幾個結構體。剛才我們已經看到了Mammal、Cat和Dog類的唯一成員是他們的虛指針,因此做定義如下:

接下來就比較麻煩了:我們需要為每個虛表創建一個結構體。這是為了讓反編譯器能夠更清楚的向我們展示,如果m具有一個特定類型的話,哪個函數應該被調用。這樣,我們在閱讀代碼的時候就可以排除很多干擾了。那麼為了達成這個目的,我們需要把結構體中的每個項目命名為對應的函數名:

接下來,把類中虛指針的類型調整為指向對應虛表的指針。比如,Cat類的vptr成員,應該使用CatVtable*類型。此外,我還把虛表中每個項目的類型都改為了函數指針,比如Dog__run的類型是void (*)(Dog*),這樣就更容易識別了。
最後一步是回到原來的代碼中,為變量賦予適當的類型:

上面是把m設定為Cat*或Dog*,可以看到,現在的代碼比剛才簡潔很多:如果m是Dog類型的話,那麼第15行調用的就是Dog__walk,否則是Cat__walk。這個例子很簡單,但能夠說明我們大致的思路了。
我們也可以把m設置為Mammal*類型,但效果就不太好了:

假設m是Mammal*類型,那麼第15行就會調用一個純虛函數,這是不可能的,此外第17行的調用也會產生問題。因此我們可以推測,m一定不是Mammal*類型。
這種和源代碼不符的說法,可能聽上去比較奇怪。實際上,這是因為在編譯時,我們給m賦予的是一個編譯時的類型(靜態類型),但我們更關注它的動態類型(或者說是運行時的類型),因為這才是決定哪個虛函數被調用的關鍵。事實上,一個元素的動態類型,基本永遠不可能是一個抽象類。因此如果給出的虛表中,含有一個___cxa_pure_virtual函數,那這個類型可能並不是他的運行時類型,可以無視。實際剛才的例子中我們完全可以不給Mammal類的虛表創建一個結構體,因為這個結構體永遠都不會用到。
通過以上的分析,我們知道了,動態類型可能是Cat或者Dog,我們也知道了如何通過查看虛表的項目,來判斷哪個函數會被調用,這是C++虛函數逆向的第一步。下一步,我們會介紹如何處理更多、更復雜的二進制程序和繼承關系。
  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved