C++中的函數名稱粉碎機制和它的逆向應用


1.名稱粉碎機制的由來

在C語言的語法中,函數名稱是一個函數的唯一標識,如果一個文件內含有兩個名稱相同的函數,編譯器就會報“函數已有主體”的錯誤;在多個文件鏈接時,如果發現有兩個名稱相同的函數,鏈接器就會報“符號重定義”的錯誤。

具有多態特性的C++支持函數的重載,函數不再以函數名稱作為唯一標識。只要滿足構成重載的條件,兩個(或多個)功能不同的函數可以有相同的函數名稱。這樣一來,函數的調用者會獲得多態性帶來的極大方便(雖然函數的編寫者的工作量沒有改變,所有的同名函數仍需要一個一個地去編寫)。構成函數重載的條件是:
1.作用域相同
2.函數名稱相同
3.參數不同(類型,個數,順序)
(另外:返回值類型、調用約定類型並不作為參考)

為了支持函數重載這一新特性,編譯器的開發者們大多選擇使用名稱粉碎機制,即把函數的原有名稱和參數類型、個數、順序等信息融合成一個新的函數名稱。這個新的名稱就是此函數的唯一標識。有了它,之后的工作就可以繼續沿用C語言的套路(在編譯、連接過程中,若發現新名稱存在重復現象,仍會發出“函數已有主體”或“符號重定義”信息)。

值得一提的是,C++標准中只是說明了函數重載的定義,但並沒有提出“名稱粉碎機制”這種概念。由於名稱粉碎機制的直觀、高效、易於兼容以前的C版本的特點,所以各編譯器作者不約而同地選擇用名稱粉碎機制來實現函數重載。雖然各編譯器的思路是一致的,但是由於沒有統一的標准,所以各編譯器的名稱粉碎結果也自然是五花八門。下面我們來觀察微軟VC編譯器的名稱粉碎細節。

2.微軟VC編譯器的名稱粉碎細節

在一般情況下,我們是看不到名稱粉碎機制的細節的(因為我們沒有必要知道編譯器內部的操作)。為了看到這些細節,我們必須進入編譯生成的obj文件中探索。例如這次定義兩個名為test_add的函數,分別用於計算整形數據相加之和和雙精度浮點型數據相加之和:

//文件 test.cpp
#include "stdafx.h"

int test_add(int n1, int n2)
{
  return n1 + n2;
}

double test_add(double d1, double d2)
{
  return d1 + d2;
}

在頭文件中聲明這兩個函數:

//文件test.h
#pragma once

int test_add(int n1, int n2);
double test_add(double d1, double d2);

在main函數中調用test_add函數分別求整形和浮點數的和:

//文件main.cpp
#include "stdafx.h"
#include "test.h"

int _tmain(int argc, _TCHAR* argv[])
{
  int nNum = test_add(8, 9);
  double dNum = test_add(8.8, 9.9);
  
  return 0;
}

此時程序可以正常編譯編譯運行。來到工程目錄下,使用WinHex打開test.cpp所生成的obj文件(名稱為test.obj)。使用文本搜索功能搜索“test_add”,可以在文件快結束的地方發現被粉碎后的新函數名稱,如圖所示:

除了在obj文件中探索,其實還可以通過人為制造編譯錯誤的方法,快速地讓編譯器告訴我們粉碎后的新的函數名稱。在原有代碼的基礎上,注釋掉test.cpp文件中關於兩個test_add函數的定義部分:

#include "stdafx.h"

/*
int test_add(int n1, int n2)
{
  return n1 + n2;
}

double test_add(double d1, double d2)
{
  return d1 + d2;
}*/

這樣一來,雖然編譯過程不會報錯,但是在鏈接的時候,因為main函數中需要執行test_add函數的代碼卻無法找到它的定義,就會發生錯誤:

1>main.obj : error LNK2019: 無法解析的外部符號 "int __cdecl test_add(int,int)" (?test_add@@YAHHH@Z),該符號在函數 _wmain 中被引用
1>main.obj : error LNK2019: 無法解析的外部符號 "double __cdecl test_add(double,double)" (?test_add@@YANNN@Z),該符號在函數 _wmain 中被引用

錯誤信息向我們揭示了:兩個add_test函數因為參數的不同,被名稱粉碎機制賦予了新的函數名稱,分別是?test_add@@YAHHH@Z?test_add@@YANNN@Z。通過這個人為制造錯誤的方法,我們可以繼續測試不同類型的參數會對名稱粉碎造成什么樣的影響。在微軟的名稱粉碎機制中,除了函數的參數類型之外,函數的調用約定、返回值類型和作用域都被整合到了粉碎后的新名稱之中。

此外,微軟還為我們提供了一個“反名稱粉碎”工具undname,用於快速地把粉碎后的函數名稱還原成本來的樣子。打開VS2012工具命令提示(位於 開始菜單->Microsoft Visual Studio 2012->Visual Studio Tools),輸入undname即可打開這個工具。我們可以利用它來直接翻譯粉碎后的名稱,如圖所示,函數的返回值類型,調用約定,參數的類型、個數、順序都被翻譯出來了。

3.文件粉碎機制的逆向應用

舉個具體例子,一個正在合作同一個項目的程序員,在完成了自己負責的那一部分功能后,因為想保護自己的源碼,所以只共享了編譯后生成的obj文件和一份配套的文檔,文檔里說明了怎么去調用此obj里的函數。

現在有了undname這個工具,配合之前對obj文件的文本搜索經驗,我們就可以嘗試探索並調用obj文件里的所有可用函數,而不是被局限於文檔的說明。

假設一個程序員寫了一個cpp文件,並且只在文檔里說明了test_open函數,卻隱藏了test_hiden函數的說明:

注:代碼中英語“隱藏”拼寫錯誤,應該是hidden,而非hiden。

//文件:test.cpp (作者:編程合作者)
//在文檔內提供次函數說明
int test_open(int n)
{
  return n + 10;
}

//文檔里沒有提到此函數
int test_hiden(int n)
{
  return n * 10;
}

編譯之后,obj文件和test_open函數的調用說明(test_open函數的調用說明也可以是頭文件的形式)被共享出去。主函數的編寫者拿到obj文件之后,雖然他不知道test_open函數的源碼,但是按照調用說明還是可以調用這個函數:

//文件:main.cpp (作者:主函數編寫者)
#include <stdio.h>

int test_open(int n);    //obj文件內的函數的聲明;也可以是包含一個頭文件的形式。

int main(int argc, char* argv[])
{
  int nNum = test_open(5);

  printf("%d", nNum);

  return 0;
}

現在只要編譯此main.cpp文件,就可以拿到main.obj文件。然后把main.obj文件和編程合作者提供的test.obj文件鏈接,我們就會得到有功能的main.exe文件了。main.exe的執行結果為15,符合test_open函數的功能,整個過程如下圖所示:

現在我們用WinHex來打開編程合作者發來的test.obj文件,嘗試探索一些他在文檔里沒有說明的信息(即test_hiden函數)。如圖所示,按照之前的經驗,可以在文件末尾發現被名稱粉碎的函數信息。

對於我們感興趣的test_hiden函數,復制它的信息,然后用undname工具還原一下本來的面貌,如圖所示:

原來,這是一個C調用約定的函數,它的返回值是int,需要1個int型的參數。知道了這些信息,我們就可以嘗試在main函數里去調用它。修改main函數代碼(test_hiden的函數聲明里:形參名字可以任意寫,甚至可以不寫,編譯器在乎的只是形參的類型;C調用約定是默認調用約定,所以不用寫):

#include <stdio.h>

int test_open(int n);
int test_hiden(int nSecret);

int main(int argc, char* argv[])
{
  int nNum = test_open(5);

  printf("Function that was documented: %d\r\n", nNum);

  nNum = test_hiden(5);
  printf("Function that was hiden: %d\r\n", nNum);

  return 0;
}

這樣,我們就調用了這個沒有被文檔說明的函數:

對於C語法下的帶有static修飾符的靜態函數,這種方法還是無能為力的,因為static函數的信息不會出現在obj文件中。

對於c++語法下類(Class)里面的private/protected函數(包括帶有static修飾符的靜態函數),雖然我們能通過obj文件和undname工具還原它們的函數信息,但由於它們的private/protected屬性,還是無法從外部對它們進行調用。如果函數是public屬性,那么無論他是普通函數,還是static修飾的靜態函數,都可以用本文的方法還原函數信息然后調用。

關於static修飾符的題外話:
同樣是用static修飾函數,C語法下(文件中的靜態函數)和C++語法下(類中的靜態函數)有很大的區別。C語法下(文件中的靜態函數)目的是把函數封裝在它所在的文件中,讓其他文件看不到這個函數,相當於給它添加了C++中的private屬性。C++語法下(類中的靜態函數)目的是讓類的使用者在無需實例化類為對象的情況下,可以順利使用類中的函數。
C++語法中,類中的靜態屬性和靜態函數,分別是為了替代C語法中的全局變量和全局函數。在C語法中,如果不用static修飾一個函數,那么它的作用域就是整個工程,它就是全局函數。C++為什么要替代C語法中的全局變量和全局函數?一是因為他們作用域為全局,難以管理。二是因為在程序員合作編寫項目時,隨着軟件代碼量的增加,出現了全局變量、全局函數的重名問題。
在一些其他流行的高級編程語言中,全局變量和全局函數是直接被禁止使用的。C++為了兼容C的語法,所以一直保留着全局變量和全局函數的用法。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM