逆向基礎(一)

wyzsk發表於2020-08-19
作者: reverse-engineering · 2014/04/28 17:06

from:http://yurichev.com/writings/RE_for_beginners-en.pdf

第一章


CPU簡介

CPU就是執行所有程式的工作單元。

詞彙表:

Instruction:CPU的原指令,例如:將資料在資料區與暫存器之間進行轉移操作,對資料進行操作,算術操作。原則上每種CPU會有自己獨特的一套指令構架(Instruction Set Architecture(ISA))。

Machine code: CPU的指令碼(機器碼),每條指令都會被譯成指令碼。

Assembly Language: 組合語言,助記碼和其他一些例如宏那樣的特性組成的便於程式設計師編寫的語言。

CPU register:CPU暫存器,每個CPU都有一些通用暫存器(General Purpose Registers(GPR))。X86有8個,x86-64(amd64)有16個,ARM有16個,最簡單去理解暫存器的方法就是,把暫存器想成一個不需要型別的臨時變數。想象你在用高階程式語言,並且只有8個32bit的變數。只用這些可以完成非常多的事情。

那麼機器碼跟程式語言有什麼區別那?對於人類來講,使用例如C/C++, Java, Python這樣程式語言會比較簡單,但是CPU更喜歡低階抽象的東西。但願有一天CPU也能直接來執行高階語言的語句,但那肯定會非常的複雜。相反人類使用匯編語言會感覺不很方便,因為它非常的低階。而且很難用它寫非常長的程式碼並不出現錯誤。有一種將高階語言轉換到組合語言的程式,它被叫做編譯器。

第二章


Hello,world!

讓我們用最著名的程式碼例子開始吧:

#!cpp
#include <stdio.h> 
int main() {
    printf("hello, world");
    return 0;
};

2.1 x86

2.1.1 MSVC-x86

在MSVC 2010中編譯一下:

#!bash    
cl 1.cpp /Fa1.asm

(/Fa 選項表示生產彙編列表檔案)

#!bash
CONST   SEGMENT
$SG3830 DB      'hello, world', 00H
CONST   ENDS
PUBLIC  _main
EXTRN   _printf:PROC
; Function compile flags: /Odtp
_TEXT   SEGMENT
_main   PROC

        push    ebp
        mov     ebp, esp
        push    OFFSET $SG3830
        call    _printf
        add     esp, 4
        xor     eax, eax
        pop     ebp
        ret     0

_main   ENDP 
_TEXT   ENDS

MSVC生成的是Intel彙編語法。Intel語法與AT&T語法的區別將在後面討論。

編譯器會把1.obj檔案連線成1.exe。

在我們的例子當中,檔案包含兩個部分:CONST(放資料)和_TEXT(放程式碼)。

字串“hello,world”在 C/C++ 型別為const char*,然而他沒有自己的名稱。

編譯器需要處理這個字串,就自己給他定義了一個$SG3830。

所以例子可以改寫為:

#!cpp
#include <stdio.h>
const char *$SG3830="hello, world";
int main() {
    printf($SG3830);
    return 0; 
};

我們回到彙編列表,正如我們看到的,字串是由0位元組結束的,這也是 C/C++的標準。

在程式碼部分,_TEXT,只有一個函式:main()。

函式main()與大多數函式一樣都有開始的程式碼與結束的程式碼。

函式當中的開始程式碼結束以後,呼叫了printf()函式:CALL _printf

在PUSH指令的幫助下,我們問候語字串的地址(或指向它的指標)在被呼叫之前存放在棧當中。

當printf()函式執行完返回到main()函式的時候,字串地址(或指向它的指標)仍然在堆疊中。

當我們都不再需要它的時候,堆疊指標(ESP暫存器)需要改變。

#!bash
ADD ESP, 4 

意思是ESP暫存器加4。

為什麼是4呢?由於是32位的程式碼,透過棧傳送地址剛好需要4個位元組。

在64位系統當中它是8位元組。

ADD ESP, 4” 實際上等同於“POP register”。

一些編輯器(如Intel C++編譯器)在同樣的情況下可能會用 POP ECX代替ADD(例如這樣的模式可以在Oracle RDBMS程式碼中看到,因為它是由Intel C++編譯器編譯的),這條指令的效果基本相同,但是ECX的暫存器內容會被改寫。

Intel C++編譯器可能用POP ECX,因為這比ADD ESP, X需要的位元組數更短,(1位元組對應3位元組)。

在呼叫printf()之後,在C/C++程式碼之後執行return 0,return 0是main()函式的返回結果。

程式碼被編譯成指令 XOR EAX, EAX

XOR事實上就是異或,但是編譯器經常用它來代替 MOV EAX, 0 原因就是它需要的位元組更短(2位元組對應5位元組)。

有些編譯器用SUB EAX, EAX 就是EXA的值減去EAX,也就是返回0。

最後的指令RET 返回給呼叫者,他是C/C++程式碼吧控制返還給作業系統。

2.1.2 GCC-x86

現在我們嘗試同樣的C/C++程式碼在linux中的GCC 4.4.1編譯

#!bash
gcc 1.c -o 1

下一步,在IDA反彙編的幫助下,我們看看main()函式是如何被建立的。

(IDA,與MSVC一樣,也是顯示Intel語法)。

我也可以是GCC生成Intel語法的彙編程式碼,新增引數

#!bash
-S -masm=intel

彙編程式碼:

#!bash
main            proc near 

var_10          = dword ptr -10h

                push    ebp
                mov     ebp, esp
                and     esp, 0FFFFFFF0h
                sub     esp, 10h
                mov     eax, offset aHelloWorld ; "hello, world"
                mov     [esp+10h+var_10], eax
                call _printf
                mov eax, 0
                leave
                retn
main            endp

結果幾乎是相同的,“hello,world”字串地址(儲存在data段的)一開始儲存在EAX暫存器當中,然後儲存到棧當中。

同樣的在函式開始我們看到了

AND ESP, 0FFFFFFF0h

這條指令該指令對齊在16位元組邊界在ESP暫存器中的值。這導致堆疊對準的所有值。

SUB ESP,10H在棧上分配16個位元組。 這裡其實只需要4個位元組。

這是因為,分配堆疊的大小也被排列在一個16位元組的邊界。

該字串的地址(或這個字串指標),不使用PUSH​​指令,直接寫入到堆疊空間。 var_10,是一個區域性變數,也是printf()的引數。


然後呼叫printf()函式。

不像MSVC,當gcc編譯不開啟最佳化,它使用MOV EAX,0清空EAX,而不是更短的程式碼。

最後一條指令,LEAVE相當於MOV ESP,EBP和POP EBP兩條指令。

換句話說,這相當於指令將堆疊指標(ESP)恢復,EBP暫存器到其初始狀態。

這是必須的,因為我們在函式的開頭修改了這些暫存器的值(ESP和EBP)(執行MOV EBP,ESP/AND ESP...)。

2.1.3 GCC:AT&T 語法

我們來看一看在AT&T當中的彙編語法,這個語法在UNIX當中更普遍。

#!bash
gcc -S 1_1.c

我們將得到這個:

#!bash
.file   "1_1.c" 
.section    .rodata

.LC0:
        .string "hello, world"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushl   %ebp
        .cfi_def_cfa_offset 8
        .cfi_offset 5, -8
        movl    %esp, %ebp
        .cfi_def_cfa_register 5
        andl    $-16, %esp
        subl    $16, %esp
        movl    $.LC0, (%esp)
        call    printf
        movl    $0, %eax
        leave
        .cfi_restore 5
        .cfi_def_cfa 4, 4
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3"
        .section        .note.GNU-stack,"",@progbits 

有很多的宏(用點開始)。現在為了簡單起見,我們先不看這些。(除了 .string ,就像一個C字串編碼一個null結尾的字元序列)。然後,我們將看到這個:

#!bash
.LC0:
        .string "hello, world"
main:
        pushl   %ebp
        movl    %esp, %ebp
        andl    $-16, %esp
        subl    $16, %esp
        movl    $.LC0, (%esp)
        call    printf
        movl    $0, %eax
        leave
        ret

在Intel與AT&T語法當中比較重要的區別就是:

運算元寫在後面

在Intel語法中:<instruction> <destination operand> <source operand>
在AT&T語法中:<instruction> <source operand> <destination operand>

有一個理解它們的方法: 當你面對intel語法的時候,你可以想象把等號放到2個運算元中間,當面對AT&T語法的時候,你可以放一個右箭頭(→)到兩個運算元之間。

AT&T: 在暫存器名之前需要寫一個百分號(%)並且在數字前面需要美元符($)。方括號被圓括號替代。 AT&T: 一些用來表示資料形式的特殊的符號

l      long(32 bits)
w      word(16bits)
b      byte(8 bits)

讓我們回到上面的編譯結果:它和在IDA裡看到的是一樣的。只有一點不同:0FFFFFFF0h 被寫成了$-16,但這是一樣的,10進位制的16在16進位制裡表示為0x10。-0x10就等同於0xFFFFFFF0(這是針對於32位構架)。

外加返回值這裡用的MOV來設定為0,而不是用XOR。MOV僅僅是載入(load)了變數到暫存器。指令的名稱並不直觀。在其他的構架上,這條指令會被稱作例如”load”這樣的。

2.2 x86-64

2.2.1 MSVC-x86-64

讓我們來試試64-bit的MSVC:

#!bash
$SG2989 DB      ’hello, world’, 00H
main    PROC 
        sub     rsp, 40
        lea     rcx, OFFSET FLAT:$SG2923
        call    printf
        xor     eax, eax
        add     rsp, 40
        ret     0
main ENDP

在x86-64裡,所有被擴充套件到64位的暫存器都有R-字首。並且儘量不用棧來傳遞函式的引數了,大量使用暫存器來傳遞引數,非常類似於fastcall。

在win64裡,RCX,RDX,R8,R9暫存器被用來傳遞函式引數,如果還有更多就使用棧,在這裡我們可以看到printf()函式的引數沒用透過棧來傳遞,而是使用了rcx。 讓我們針對64位來看,作為64位暫存器會有R-字首,並且這些暫存器向下相容,32位的部分使用E-字首。

如下圖所示,這是RAX/EAX/AX/AL在64位x86相容cpu裡的情況  enter image description here

在main()函式會返回一個int型別的值,在64位的程式裡為了相容和移植性,還是用32位的,所以可以看到EAX(暫存器的低32位部分)在函式最後替代RAX被清空成0。

2.2.2 GCC-x86-64

這次試試GCC在64位的Linux裡:

#!bash
        .string "hello, world"
main:
        sub     rsp, 8
        mov     edi, OFFSET FLAT:.LC0 ; "hello, world"
        xor     eax, eax  ; number of vector registers passed
        call    printf
        xor     eax, eax
        add     rsp, 8
        ret

在Linux,*BSD和Mac OS X裡使用同一種方式來傳遞函式引數。頭6個引數使用RDI,RSI,RDX,RCX,R8,R9來傳遞的,剩下的要靠棧。

所以在這個程式裡,字串的指標被放到EDI(RDI的低32位部)。為什麼不是64位暫存器RDI那?

這是一個重點,在64位模式下,對低32位進行操作的時候,會清空高32位的內容。比如 MOV EAX,011223344h將會把值寫到RAX裡,並且清空RAX的高32位區域。 如果我們開啟編譯好的物件檔案(object file(.o)),我們會看到所有的指令:

Listing 2.8:GCC 4.4.6 x64

#!bash
.text:00000000004004D0                  main proc near
.text:00000000004004D0 48 83 EC 08           sub rsp, 8
.text:00000000004004D4 BF E8 05 40 00        mov edi, offset format ; "hello, world"
.text:00000000004004D9 31 C0                 xor eax, eax
.text:00000000004004DB E8 D8 FE FF FF        call _printf
.text:00000000004004E0 31 C0                 xor eax, eax
.text:00000000004004E2 48 83 C4 08           add rsp, 8
.text:00000000004004E6 C3                    retn
.text:00000000004004E6                  main endp

就像看到的那樣,在04004d4那行給edi寫字串指標的那句花了5個bytes。如果把這句換做給rdi寫指標,會花掉7個bytes.就是說GCC在試圖節省空間,為此資料段(data segment)中包含的字串不會被分配到高於4GB地址的空間上。

可以看到在printf()函式呼叫前eax被清空了,這樣做事因為要eax被用作傳遞向量暫存器(vector registers)的個數。

參考【21】 MichaelMatz/JanHubicka/AndreasJaeger/MarkMitchell. Systemvapplicationbinaryinterface.amdarchitecture processor supplement, . Also available as http://x86-64.org/documentation/abi.pdf.

2.3 ARM

根據作者自身對ARM處理器的經驗,選擇了2款在嵌入式開發流行的編譯器,Keil Release 6/2013和蘋果的Xcode 4.6.3 IDE(其中使用了LLVM-GCC4.2編譯器),這些可以為ARM相容處理器和系統晶片(System on Chip)(SOC))來進行編碼。比如ipod/iphone/ipad,windows8 rt,並且包括raspberry pi。

2.3.1 未進行程式碼最佳化的Keil編譯:ARM模式

讓我們在Keil裡編譯我們的例子

#!bash
armcc.exe –arm –c90 –O0 1.c

armcc編譯器可以生成intel語法的彙編程式列表,但是裡面有高階的ARM處理器相關的宏,對我們來講更希望看到的是IDA反彙編之後的結果。

Listing 2.9: Non-optimizing Keil + ARM mode + IDA
#!bash
.text:00000000                  main
.text:00000000 10 40 2D E9              STMFD SP!, {R4,LR}
.text:00000004 1E 0E 8F E2              ADR R0, aHelloWorld ; "hello, world"
.text:00000008 15 19 00 EB              BL __2printf
.text:0000000C 00 00 A0 E3              MOV R0, #0
.text:00000010 10 80 BD E8              LDMFD SP!, {R4,PC}

.text:000001EC 68 65 6C 6C+aHelloWorld  DCB "hello, world",0 ; DATA XREF: main+4

針對ARM處理器,我們需要預備一點知識,要知道ARM處理器至少有2種模式:ARM模式和thumb模式,在ARM模式下,所有的指令都被啟用並且都是32位的。在thumb模式下所有的指令都是16位的。Thumb模式比較需要注意,因為程式可能需要更為緊湊,或者當微處理器用的是16位記憶體地址時會執行的更快。但也存在缺陷,在thumb模式下可用的指令沒ARM下多,只有8個暫存器可以訪問,有時候ARM模式下一條指令就能解決的問題,thumb模式下需要多個指令來完成。

從ARMv7開始引入了thumb-2指令集。這是一個加強的thumb模式。擁有了更多的指令,通常會有誤解,感覺thumb-2是ARM和thumb的混合。Thumb-2加強了處理器的特性,並且媲美ARM模式。程式可能會混合使用2種模式。其中大量的ipod/iphone/ipad程式會使用thumb-2是因為Xcode將其作為了預設模式。

在例子中,我們可以發現所有指令都是4bytes的,因為我們編譯的時候選擇了ARM模式,而不是thumb模式。

最開始的指令是”STMFD SP!, {R4, LR}”,這條指令類似x86平臺的PUSH指令,會寫2個暫存器(R4和LR)的變數到棧裡。不過在armcc編譯器裡輸出的彙編列表裡會寫成”PUSH {R4, LR}”,但這並不準確,因為PUSH命令只在thumb模式下有,所以我建議大家注意用IDA來做反彙編工具。

這指令開始會減少SP的值,已加大棧空間,並且將R4和LR寫入分配好的棧裡。

這條指令(類似於PUSH的STMFD)允許一次壓入好幾個值,非常實用。有一點跟x86上的PUSH不同的地方也很贊,就是這條指令不像x86的PUSH只能對sp操作,而是可以指定操作任意的暫存器。

“ADR R0, aHelloWorld”這條指令將PC暫存器的值與”hello, world”字串的地址偏移相加放入R0,為什麼說要PC參與這個操作那?這是因為程式碼是PIC(position-independet code)的,這段程式碼可以獨立在記憶體執行,而不需要更改記憶體地址。ADR這條指令中,指令中字串地址和字串被放置的位置是不同的。但變化是相對的,這要看系統是如何安排字串放置的位置了。這也就說明了,為何每次獲取記憶體中字串的絕對地址,都要把這個指令裡的地址加上PC暫存器裡的值了。

BL __2print”這條指令用於呼叫printf()函式,這是來說下這條指令時如何工作的:

將BL指令(0xC)後面的地址寫入LR暫存器;
然後把printf()函式的入口地址寫入PC暫存器,進入printf()函式。

當printf()函式完成之後,函式會透過LR暫存器儲存的地址,來進行返回操作。

函式返回地址的存放位置也正是“純”RISC處理器(例如:ARM)和CISC處理器(例如x86)的區別。

另外,一個32位地址或者偏移不能被編碼到BL指令裡,因為BL指令只有24bits來存放地址,所有的ARM模式下的指令都是4bytes(32bits),所以一條指令裡不能放滿4bytes的地址,這也就意味著最後2bits總會被設定成0,總的來說也就是有26bits的偏移(包括了最後2個bit一直被設為0)會被編碼進去。這也夠去訪問大約±32M的了。

下面我們來看“MOV R0, #0“這條語句,這條語句就是把0寫到了R0暫存器裡,這是因為C函式返回了0,返回值當然是放在R0裡的。

最後一條指令是”LDMFD SP!, R4,PC“,這條指令的作用跟開始的那條STMFD正好相反,這條指令將棧上的值儲存到R4和PC暫存器裡,並且增加SP棧暫存器的值。這非常類似x86平臺裡的POP指令。最前面那條STMFD指令成對儲存了R4,和LR暫存器,LDMFD的時候將當時這兩個值儲存到了R4和PC裡完成了函式的返回。

我前面也說過,函式的返回地址會儲存到LD暫存器裡。在函式的最開始會把他儲存到棧裡,這是因為main()函式里還需要呼叫printf()函式,這個時候就會影響LD暫存器。在函式的最後就會將LD拿出棧放入PC暫存器裡,完成函式的返回操作。最後C/C++程式的main()函式會返回到類似系統載入器上或者CRT裡面。

彙編程式碼裡的DCB關鍵字用來定義ASCII字串陣列,就像x86彙編裡的DB關鍵字。

2.3.2未進行程式碼最佳化的Keil編譯: thumb模式

讓我們用下面的指令講例程用Keil的thumb模式來編譯一下。

#!bash
armcc.exe –thumb –c90 –O0 1.c

我們可以在IDA裡得到下面這樣的程式碼: Listing 2.10:Non-optimizing Keil + thumb mode + IDA

#!bash
.text:00000000          main
.text:00000000 10 B5                    PUSH {R4,LR}
.text:00000002 C0 A0                    ADR R0, aHelloWorld ; "hello, world"
.text:00000004 06 F0 2E F9              BL __2printf
.text:00000008 00 20                    MOVS R0, #0
.text:0000000A 10 BD                    POP {R4,PC}

.text:00000304 68 65 6C 6C+aHelloWorld  DCB "hello, world",0 ; DATA XREF: main+2

我們首先就能注意到指令都是2bytes(16bits)的了,這正是thumb模式的特徵,BL指令作為特例是2個16bits來構成的。只用16bits沒可能載入printf()函式的入口地址到PC暫存器。所以前面的16bits用來載入函式偏移的高10bits位,後面的16bits用來載入函式偏移的低11bits位,正如我說過的,所有的thumb模式下的指令都是2bytes(16bits)。但是這樣的話thumb指令就沒法使用更大的地址。就像上面那樣,最後一個bits的地址將會在編碼指令的時候省略。總的來講,BL在thumb模式下可以訪問自身地址大於±2M大的周邊的地址。

至於其他指令:PUSH和POP,它們跟上面講到的STMFD跟LDMFD很類似,但這裡不需要指定SP暫存器,ADR指令也跟上面的工作方式相同。MOVS指令將函式的返回值0寫到了R0裡,最後函式返回。

2.3.3開啟程式碼最佳化的Xcode(LLVM)編譯: ARM模式

Xcode 4.6.3不開啟程式碼最佳化的情況下,會產生非常多冗餘的程式碼,所以我們學習一個儘量小的版本。

開啟-O3編譯選項

#!bash
Listing2.11:Optimizing Xcode(LLVM)+ARM mode
__text:000028C4         _hello_world
__text:000028C4 80 40 2D E9                     STMFD   SP!, {R7,LR}
__text:000028C8 86 06 01 E3                     MOV     R0, #0x1686
__text:000028CC 0D 70 A0 E1                     MOV     R7, SP
__text:000028D0 00 00 40 E3                     MOVT    R0, #0
__text:000028D4 00 00 8F E0                     ADD     R0, PC, R0
__text:000028D8 C3 05 00 EB                     BL      _puts
__text:000028DC 00 00 A0 E3                     MOV     R0, #0
__text:000028E0 80 80 BD E8                     LDMFD   SP!, {R7,PC}

__cstring:00003F62 48 65 6C 6C+aHelloWorld_0    DCB "Hello world!",0

STMFD和LDMFD對我們來說已經非常熟悉了。

MOV指令就是將0x1686寫入R0暫存器裡。這個值也正是字串”Hello world!”的指標偏移。

R7暫存器裡放入了棧地址,我們繼續。

MOVT R0, #0指令時將R0的高16bits寫入0。這是因為普通情況下MOV這條指令在ARM模式下,只對低16bits進行操作。需要記住的是所有在ARM模式下的指令都被限定在32bits內。當然這個限制並不影響2個暫存器直接的操作。這也是MOVT這種寫高16bits指令存在的意義。其實這樣寫的程式碼會感覺有點多餘,因為”MOVS R0,#0x1686”這條指令也能把高16位清0。或許這就是相對於人腦來說編譯器的不足。

ADD R0,PC,R0“指令把R0暫存器的值與PC暫存器的值進行相加並且儲存到R0暫存器裡面,用來計算”Hello world!”這個字串的絕對地址。上面已經介紹過了,這是因為程式碼是PIC(Position-independent code)的,所以這裡需要這麼做。

BL指令用來呼叫printf()的替代函式puts()函式。

GCC將printf()函式替換成了puts()。因為printf()函式只有一個引數的時候跟puts()函式是類似的。

printf()函式的字串引數裡存在特殊控制符(例如 ”%s”,”\n” ,需要注意的是,程式裡字串裡沒有“\n”,因為在puts()函式里這是不需要的)的時候,兩個函式的功效就會不同。

為什麼編譯器會替換printf()到puts()那?因為puts()函式更快。

puts()函式效率更快是因為它只是做了字串的標準輸出(stdout)並不用比較%符號。

下面,我們可以看到非常熟悉的”MOV R0, #0”指令,用來將R0暫存器設為0。

2.3.4 開啟程式碼最佳化的Xcode(LLVM)編譯thumb-2模式

在預設情況下,Xcode4.6.3會生成如下的thumb-2程式碼

Listing 2.12:Optimizing Xcode(LLVM)+ths-

相關文章