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行調用的函數。在某些涉及到虛繼承的情況下,還會有第三種析構器。
那麼現在我們搞清楚了虛表的布局: