x86 反彙編/程式碼混淆
程式碼混淆是指使程式的彙編程式碼或機器程式碼更難以反彙編或反編譯的行為。術語“混淆”通常用來暗示故意增加難度,但許多其他做法會導致程式碼被混淆,而並非有意為之。軟體供應商可能會試圖混淆甚至加密程式碼,以防止逆向工程。有許多不同的混淆型別。請注意,許多程式碼最佳化(在上一章中討論過)具有使程式碼更難以閱讀的副作用,因此最佳化充當混淆。
混淆可以是很多東西
- 在執行時解密的加密程式碼。
- 在執行時解壓縮的壓縮程式碼。
- 包含加密部分和簡單解密器的可執行檔案。
- 以難以閱讀的順序排列的程式碼指令。
- 以非明顯方式使用的程式碼指令。
本章將嘗試考察一些常見的程式碼混淆方法,但不會深入研究破解混淆的方法。
最佳化編譯器會參與一個稱為交織的過程,以嘗試在流水線處理器中最大限度地提高並行性。這種技術基於兩個前提
- 某些指令可以按不同順序執行,並仍然保持正確的輸出
- 處理器可以同時執行某些任務對。
英特爾的NetBurst 架構將 x86 處理器分為兩個不同的部分:支援硬體和基本核心處理器。處理器的基本核心包含能夠以極快的速度執行某些計算的能力,但不包含你我熟悉的指令。處理器首先將程式碼指令轉換為稱為“微操作”的形式,然後由基本核心處理器處理。
處理器也可以分解為 4 個元件或模組,每個模組都能執行某些任務。由於每個模組都可以獨立執行,因此處理器核心可以同時處理最多 4 個獨立的任務,只要這些任務可以由所有 4 個模組執行
- 埠 0
- 雙速整數運算、浮點載入、記憶體儲存
- 埠 1
- 雙速整數運算、浮點運算
- 埠 2
- 記憶體讀取
- 埠 3
- 記憶體寫入(寫入地址匯流排)
因此,例如,處理器可以同時在埠 0 和埠 1 中執行 2 個整數運算指令,因此編譯器通常會竭盡全力將算術指令彼此靠近。如果時序恰到好處,那麼在一個指令週期內最多可以執行 4 個算術指令。
然而,請注意,寫入記憶體特別慢(需要埠 3 傳送地址,埠 0 寫入資料本身)。浮點數需要先載入到 FPU 才能進行運算,因此浮點載入和浮點運算指令不能在一個指令週期內對單個值進行運算。因此,載入浮點值、操作整數值然後操作浮點值的做法並不少見。
最佳化編譯器經常會使用不直觀的指令。有些指令可以執行它們未設計的功能,通常作為有用的副作用。有時,一條指令可以比其他專門指令更快地執行任務。
唯一知道一條指令比另一條指令快的方法是查閱處理器文件。但是,瞭解一些最常見的替換對反彙編人員非常有用。
以下是一些示例。第一個框中的程式碼比第二個框中的程式碼執行速度更快,但執行完全相同的任務。
示例 1
快速
xor eax, eax
慢速
mov eax, 0
示例 2
快速
shl eax, 3
慢速
push edx
push 8
mul dword [esp]
add esp, 4
pop edx ;# edx is not preserved by "mul"
有時可以進行這樣的轉換,以使分析更困難
示例 3
快速
push $next_instr
jmp $some_function
$next_instr:...
慢速
call $some_function
示例 4
快速
pop eax
jmp eax
慢速
retn
- lea
- lea 指令具有以下形式
lea dest, (XS:)[reg1 + reg2 * x]
其中 XS 是段暫存器(SS、DS、CS 等),reg1 是基地址,reg2 是可變偏移量,x 是乘法縮放因子。lea 實際上做的就是將第二個引數指向的記憶體地址載入到第一個引數中。請看下面的示例
mov eax, 1
lea ecx, [eax + 4]
那麼,ecx 的值是多少?答案是 ecx 的值為 (eax + 4),即 5。本質上,lea 用於對暫存器和位元組或更小的常量(-128 到 +127)進行加法和乘法。
現在,考慮
mov eax, 1
lea ecx, [eax+eax*2]
現在,ecx 等於 3。
區別在於 lea 速度很快(因為它只添加了暫存器和一個小的常量),而add 和mul 指令更通用,但速度更慢。lea 經常以這種方式用於算術運算,即使編譯器沒有積極最佳化程式碼。
- xor
- xor 指令對兩個運算元執行按位異或運算。然後考慮下面的示例
mov al, 0xAA
xor al, al
它做了什麼?讓我們看一下二進位制程式碼
10101010 ;10101010 = 0xAA
xor 10101010
--------
00000000
答案是“xor reg, reg”將暫存器設定為 0。更重要的是,“xor eax, eax”將 eax 設定為 0 的速度更快(並且生成的程式碼指令更小),而等效的“mov eax, 0”則更慢。
- mov edi, edi
- 在 64 位 x86 系統上,這條指令會清除 rdi 暫存器的最高 32 位。
- shl, shr
- 在二進位制運算中,左移等效於將運算元乘以 2。右移也等效於將運算元除以 2,但最低位會被丟棄。一般來說,左移 位會將運算元乘以 ,而右移 位等效於除以 。需要注意的是,結果是一個沒有小數部分的整數。例如
mov al, 31 ; 00011111
shr al, 1 ; 00001111 = 15, not 15.5
- xchg
- xchg 用於交換兩個暫存器的內容,或者一個暫存器和一個記憶體地址的內容。值得注意的是,xchg 的執行速度比移動指令更快。因此,當源中的值不再需要儲存時,可以使用 xchg 將值從源移動到目標。
例如,考慮以下程式碼:
mov ebx, eax
mov eax, 0
這裡,eax 中的值被儲存在 ebx 中,然後 eax 被載入為零值。我們可以使用 xchg 和 xor 來完成相同的操作:
xchg eax, ebx
xor eax, eax
您可能會驚訝地發現,第二個程式碼示例的執行速度明顯快於第一個程式碼示例。
混淆器
[edit | edit source]市面上有很多工具可以自動化程式碼混淆過程。這些產品會使用多種轉換方法將程式碼片段轉換為更難閱讀的形式,但不會影響程式本身的流程(雖然這些轉換可能會增加程式碼大小或執行時間)。
程式碼轉換
[edit | edit source]程式碼轉換是一種重排序程式碼的方式,使其執行完全相同的任務,但更難追蹤和反彙編。我們可以透過例子來最好地說明這種技術。假設我們有兩個函式,FunctionA 和 FunctionB。這兩個函式都由三個不同的部分組成,這些部分按順序執行。我們可以將此分解如下
FunctionA()
{
FuncAPart1();
FuncAPart2();
FuncAPart3();
}
FunctionB()
{
FuncBPart1();
FuncBPart2();
FuncBPart3();
}
我們有主程式,它執行這兩個函式
main()
{
FunctionA();
FunctionB();
}
現在,我們可以將這些程式碼段重新排列成更復雜的格式(組合語言)
main:
jmp FAP1
FBP3: call FuncBPart3
jmp end
FBP1: call FuncBPart1
jmp FBP2
FAP2: call FuncAPart2
jmp FAP3
FBP2: call FuncBPart2
jmp FBP3
FAP1: call FuncAPart1
jmp FAP2
FAP3: call FuncAPart3
jmp FBP1
end:
如您所見,這更難閱讀,儘管它完全保留了原始程式碼的程式流程。這段程式碼對人來說很難閱讀,但對於自動反彙編器(如 IDA Pro)來說卻很容易閱讀。
不透明謂詞
[edit | edit source]不透明謂詞是程式碼中無法在靜態分析期間評估的謂詞。這迫使攻擊者執行動態分析以瞭解該行的結果。通常,這與分支指令有關,該指令用於防止靜態分析理解所採取的程式碼路徑。
程式碼加密
[edit | edit source]程式碼可以像任何其他型別的資料一樣加密,但程式碼也可以用來加密和解密自身。加密程式無法直接反彙編。但是,這樣的程式也不能直接執行,因為加密後的操作碼無法被 CPU 正確解釋。因此,加密程式必須包含某種方法在執行之前解密自身。
最基本的方法是包含一個小的存根程式,該程式解密可執行檔案的其餘部分,然後將控制權傳遞給已解密的例程。
反彙編加密程式碼
[edit | edit source]要反彙編加密的可執行檔案,您必須首先確定程式碼是如何解密的。程式碼可以透過兩種主要方式之一進行解密
- 一次全部解密。整個程式碼部分在一次傳遞中被解密,並在執行期間保持解密狀態。使用偵錯程式,允許解密例程完全執行,然後將解密後的程式碼轉儲到檔案中以進行進一步分析。
- 按塊解密。程式碼被加密成單獨的塊,每個塊可能具有單獨的加密金鑰。塊可以在使用之前被解密,並在使用之後再次被重新加密。使用偵錯程式,您可以嘗試捕獲所有解密金鑰,然後使用這些金鑰來解密整個程式,或者您可以等待塊被解密,然後將塊單獨轉儲到一個單獨的檔案中以進行分析。