CTF系列-Reverse Engineering逆向工程基礎I
Reverse是CTF競賽中算是相對困難度高的一個類別,基於其比較偏向電腦底層的部分,因此相對不會像之前的Web與Crypto類別那麼直覺,除了二進位檔案的靜態與動態分析,組合語言(assembly)也會是其中一個重點學習的部分,因此這裡也會先介紹一些需要知道的組合語言知識。歡迎各位來訊說明或補充筆者不足的部分喔!🤗
Basic Assembly
組合語言會大量出現在Reverse的情境當中,實際生活中的逆向工程也會需要讀組合語言來判斷程式流程,因此這裡會先介紹組合語言所需要具備的基礎能力。組合語言是為了解決指令集極不易讀的特性而產生,以類似人類語言的方式來描述指令集。事實上組合語言出現得比C/C++等高階語言更早,因此利用組合語言編寫程式也是可行的。
x86 vs. ARM
x86事實上泛指一群Intel公司發行向下相容的指令集處理器,包含Intel 8086、80186、80286、80386、80486,原先的縮寫是80x86,後來基於方便因此直接寫成x86。它是複雜指令集電腦(CISC)的代表,而後續又在Intel公司的推行下出現了從32bit擴充到64bit的AMD64,也可稱為x86-64。目前一般電腦都是採用此種處理器。
而ARM則可以被認為是相對於x86的另一種處理器,因為他所屬集合的是IBM在1974年提出的簡化指令集電腦(RISC),目的在於簡化與減少指令。基於RISC所消耗的資源較少,效率又較高,因此通常行動裝置,包含iOS與Android都使用此類處理器。近年來因為CISC與RISC的相互競爭,兩者在指令集架構上已經非常接近。
此外,CISC與RISC在處理上還有一個非常重要的不同,在於RISC能夠完全使用暫存器(register,稍後說明)來傳遞參數,但CISC僅能將資料儲存在堆疊(stack)上傳遞。
Intel vs. AT&T syntax
這兩者都是x86/AMD64之下的語法風格,讀者能夠以自己容易瞭解的方向來選擇,筆者本人較常使用Intel風格。Intel風格是跟著第一代的Intel處理器8086產生,主要是Intel公司認為使用機器碼對人類的可讀性極低,其風格主要有16進位使用h
結尾、間接位址使用[]
表示等。
而AT&T公司則是由貝爾實驗室而來,基於作為C與Linux的發源地,他們決定不採用Intel風格,自創一個AT&T風格。這種風格在Linux系統中廣為使用,包含gdb、objdump等工具都預設採用AT&T風格,其風格主要有暫存器前有%
符號、16進位使用0x
開頭、間接位址使用()
表示等。詳細較重要差異可參考以下表格:
Intel | AT&T | |
---|---|---|
運算元(Operant)順序 | 目標運算元在前 | 來源運算元在前 |
Register | 不變 | % 字首 |
立即數 | 不變 | $ 字首 |
16進位立即數 | 字尾加上h |
字首加上0x |
記憶體長度存取 | BYTE PTR、WORD PTR、DWORD PTR、QWORD PTR開頭分別代表位元組(8bit,char)、字(16bit,short)、雙字(32bit,int)、四字(64bit,long) | b、w、l、q結尾分別代表位元組(8bit,char)、字(16bit,short)、雙字(32bit,int)、四字(64bit,long) |
變數取值 | [var] |
var |
變數取位址 | var |
$var |
以下是一個相同檔案hello.c
利用兩種語法風格產生的main
函數組合語言,可比較其中差異:
1 | //Intel |
1 | //AT&T |
Registers
通用暫存器根據位元數的不同,也會有不同的變化,如下表所示:
其中不同位元下所占用的位置如下所示,在此以RAX作為範例:
一些比較特別的暫存器如下,這些方法是由Intel所定義的暫存器傳統:
rax
:預設存放函式或數學運算的回傳值- stack operation
rsp
:stack pointer,指向堆疊的頂端(top)rbp
:base pointer,指向堆疊的底部(bottom)rip
:instruction pointer,會存放接下來程式要跳轉的位址
- string operation
rsi
:source,通常用於字串處理,存放來源字串的位址rdi
:destination,通常用於字串處理,存放目的地字串的位址rcx
:counter,loop或其他函數的計數器
Statements
組合語言有一些自己特定的指令來對暫存器進行處理,以下會逐一說明。
MOV (move)
MOV是最常用於組合語言的指令之一,用於變數賦值,如下所示,後方為轉換成較易理解的形式:
1 | mov eax, ecx ; eax = ecx |
mov
已被證明理論上是圖靈完備的,因此可以完全以mov
完成一個程式。
P.S. 但很少人會做那麼無聊的事情,又很難讀XD
ADD (add)
ADD是相加指令,會將相同長度的運算元進行相加操作,如下所示:
1 | mov eax, 0x100 ; eax = 0x100 |
SUB (subtract)
SUB與ADD相反,作為相減指令,會將相同長度的運算元進行相減操作,如下所示:
1 | mov eax, 0x300 ; eax = 0x300 |
MUL (multiplicate) & DIV (divide)
MUL是乘法指令,DIV則是除法指令,這兩項指令所產生的回傳值預設會存回到rax
暫存器。
1 | mov eax, 0x900 ; eax = 0x900 |
JMP (jump) & LOOP (loop)
JMP是跳躍指令,跳躍分為無條件跳躍與條件跳躍,無條件跳躍為無論暫存器為何值一律跳躍,而條件跳躍則會利用cmp等指令來確定暫存器的值後,再根據條件決定要跳躍到何處。JMP本身是無條件跳躍指令,單獨遇到JMP會直接跳躍到指定的位置。如下所示:
1 | jmp 0x400136 ; call 0x400136 |
我們也能使用JMP創造LOOP迴圈的功能,但需要使用其他方式退出,否則僅使用JMP將會製造無窮迴圈。
而LOOP指令則是正規用來創建迴圈的指令,其中前面提到cx
將會是其之計數器,先將cx
設為迴圈執行的次數後,每執行一次迴圈,一開始cx
即會減一,並與0
進行cmp
,若cx
為零則不跳躍,否則跳到指定的地方。需要注意的是,若一開始將cx
的值設為0
,則減一後發生溢位(overflow)會成為0xFFFFFFFF
,會使迴圈不依照原始的狀況跑,必須特別留意。使用情況如下:
1 | mov ax, 0x0 ; ax = 0x0 |
至於有條件跳躍指令可就多了,依照不同的暫存器比較會有不同的結果,以下表格列出了所有可能的JMP相關指令:
Stack
Stack是記憶體中一段確定的區域(這部分在pwn會介紹較多,這裡先簡單說明),堆疊有分為高位址(high memory address)與低位址(low memory address),在程式執行時堆疊的資料會由高位址往低位址堆疊,由上往下堆的型態與一般認定的堆疊概念不太相同,如下所示:
而stack中最常使用到的指令便是push
與pop
了,push
會將資料推進堆疊,pop
則會將stack最上層的資料從堆疊中移出。而負責控管他們的暫存器有三個,以64位元而言有rsp
、rbp
及rip
,前面有提過rsp
會指向目前堆疊的頂端,rbp
會指向底端,而rip
則會指向目前執行到程式的位址,並在外部函數準備跳回原本函數時,將存在堆疊上的位址套用到自己身上,完成跳回繼續執行的動作(pwn部分解釋)。
1 | endbr64 |
組合語言在函式的開始通常會有這樣初始化的動作,會將rsp
與rbp
都先還原到同一個位置,接著rsp
持續上長,rbp
則留在原處,框出目前正在處理的stack區域。
統整
組合語言根據不同的需求還有許多不同的相關指令,但因為不太會出現在CTF中,就不在這裡多介紹,想要參考更多的讀者可以根據需求尋找不同功能的指令來進行使用。想要直接讀懂組合語言需要長久的練習與精熟度,因此這個部分請務必好好弄懂!
二進位檔案(Binary)
二進位檔案通常會被稱為binary,通指包含ASCII及擴充ASCII字元中編寫資料或程式指令(instructions)的檔案,這裡以Linux系統內最常見的.elf
檔案,搭配預設編譯器gcc
指令來進行說明。一般而言編譯的過程包含六大步驟:**原始碼(source code)、預處理器(preprocessor)、編譯器(compiler)、組譯器(assembler)、連結器(linker)以及可執行檔(executable)**。以下以一個實際的案例來實作:
1 | //hello.c |
將其利用gcc
指令編譯,指令如下,其中-save-temps
會將編譯過程中的中間檔案保存,--verbose
能夠看到詳細資訊:
1 | $ gcc hello.c -o hello -save-temps --verbose |
Preprocessor
其中可以看到,hello.c
先被preprocessor處理成hello.i
,我們來看看hello.i
的內容:
1 | # 1 "hello.c" |
這個步驟可以由單一指令完成:
1 | gcc -E hello.c -o hello.i |
我們可以看到,原先的hello.c
運用到的函式都先被還原成最原始的定義式,包含#include
的下游內容也會被載入到程式中,形成一個相對冗長的原始碼型態,目的是在接下來的處理中處理器能夠更輕易的存取到需要的外部檔案與各種定義。
Compiler
接著我們看看編譯(compile)的階段,對應輸出檔案是hello.s
。產生易讀檔案指令如下:
1 | gcc -S hello.c -o hello.s -masm=intel -fno-asynchronous-unwind-tables |
將組合語言的內容印出看看:
1 | .file "hello.c" |
可以發現原本的C語言已經消失,變成了組合語言。從main
函數的地方可以明顯的看出,除了一些額外的小設定之外,程式先去LC0
的地方取出"Hello World!"
字串,再從呼叫外部.PLT表(pwn中清楚說明)中的puts()
函數輸出"Hello World!"
,利用直接讀組合語言的方式便能輕鬆的釐清這類簡單程式的邏輯,這在之後的Reverse題目中是非常重要的技能。
Assembler
接著進入到了目的碼的部分,hello.s
被組譯器組譯成了hello.o
,看看裡面寫了些什麼:
1 | gcc -c hello.c -o hello.o |
1 | ��UH��H�=��]�Hello, World!GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0GNU�zRx |
此時的hello.s
已經被處理成了一個可重定位檔案(Relocatable File)hello.o
,因此已經十分接近可執行檔了。我們利用objdump
看看裡面的程式型態:
1 | $ objdump -M intel -d hello.o |
因為還沒進行連結(link),因此main
函數中的位址仍舊是相對的,程式自動利用0x000000
作為base address,呈現相對位址的型態,同時外界的GOT表、PLT表等都尚未進行連結,因此在呼叫puts()
時會被設定為下一行的位址0x14
。因為資訊不完整的緣故,現在的hello.o
是沒有辦法跑起來的。
Executable
終於進行到了最後一步,我們要將hello.o
進行外部連結,產生最後的可執行檔。-static
會強制程式使用靜態連結,即不採用外界表直接將程式內容寫進可執行檔,可以完整看到所有呼叫內容的組合語言(但會變得很冗長)。
1 | gcc hello.o -o hello -static |
最終便產生可執行程式hello
,利用objdump
觀察一下main
函數的部分:
1 | 0000000000001149 <main>: |
可以發現main
函數已經被連結到正確的位址0x1149
,也做好了函數的所有呼叫連結,所以程式能夠正常運行了。以上就是所有產生可執行檔中間的內容~
Disassemble
反組譯是逆向工程中最簡單的部分,此過程為將可執行檔轉換成組合語言的過程,大部分的工具都能做到這件事,包含gdb、objdump等,主要是因為組合語言轉換成可執行檔的過程僅是將組合語言翻譯成機器碼,故此部分的逆向可以輕易轉回,如下所示:
1 | $ objdump -M intel -d hello |
然而,組合語言過程依舊有資訊遺失,因為反編譯時必須確定哪些機器碼是作為程式部分、哪些做為資料部分,才能精準反組譯,若是不小心將資料同樣反組譯,出現的組合語言就不會正確。現今組合語言的架構將程式與資料的界線弄得相當模糊,因此也不能確保反組譯組合語言輸出的內容必定正確,包含JMP LABEL的資訊是非常容易在反組譯過程遺失的。不過我們可以透過以下兩種演算法來還原程式流程,線性掃描與遞迴下降。
線性掃描簡而言之就是暴力搜索,根據機器碼的位置從頭到尾進行反組譯,但只要中間插入了其他資料,這種演算法就會失效。
遞迴下降則是透過程式的特徵(pattern)來進行判斷,如條件指令就可能會出現兩個分支,普通指令則會直接執行下一行等,遞迴下降會使用這種方法來判斷自己反組譯的內容是否正確,普遍而言正確率較線性掃描高,一般工具都會使用這種演算法來反組譯。
Decompile
反編譯的部分難度較反組譯高上許多,許多語言包含C/C++甚至沒有對應的反組譯器,從上面也可以看到,編譯的過程中程式出現了重大的改變,因此許多資訊在編譯過程中是會遺失的,也因此反編譯的難度極高。當然,也有某些程式語言如Java、C#等,可以透過特定的工具完整還原原始程式,但基本上大部分的高階語言仍然無法完全反編譯。這裡先簡單介紹一些工具,後續會陸續介紹這些工具的使用方法。
dnSpy
這個工具是專門用來逆向C#的可執行檔.NET
的工具,基於C#的特性,dnSpy基本上在有完整.dll
檔的情況下可以完整逆出程式邏輯與資料(但有些時候會有資訊遺漏),遇到.NET
的問題通常是使用這個來逆向處理。使用介面如下:
Java Decompiler
Java的下游檔案.class
與.jar
也可以直接透過逆向工具來完整逆向,一般使用線上工具即可,也有本地的軟體能夠使用,以下列出幾個好用的工具:
- Java-decompiler:本地工具
- Online Tool:線上工具
IDA Pro
IDA Pro是目前市面上數一數二強大的分析工具,可以對於x86、ARM等多個架構進行PE、ELF檔案的逆向靜態分析與動態偵錯,其內部使用Hex-Rays Decompiler,可以針對組合語言產生C語言偽原始碼,基本上雖然不精確,但對於分析而言是一個非常大的幫助,包含釐清程式的函式功能等。以下是使用的介面示意圖:
Ghidra
Ghidra的性質與IDA Pro類似,使用Java開發,基本上與IDA Pro相去無幾,且為開源程式,雖然plugin比IDA Pro少一些,但基本的反組譯、反編譯功能也不輸IDA Pro,詳細的使用介面如下:
gdb
gdb是一款強大的GNU命令列偵錯器,用於逆向工程的動態分析,並且支援Python的程式使用,擴充外掛也同樣易用,根據不同的需求,常用的外掛有gef、peda與pwndbg,讀者可以根據自己的需求選擇使用。使用介面如下(以pwndbg為例):
radare2
radare2是進行逆向工程的好工具,最大好處是支援命令列功能,可以在Linux CMD中運行,與IDA Pro能進行的功能已相當接近,但需要注意的是,這項工具並沒有反編譯功能。使用介面如下:
Ollydbg & x64dbg
這兩個工具是專門用於Windows逆向的偵錯工具,其中Ollydbg支援32bit,x64dbg則擴充支援32bit與64bit。這兩個工具的擴充性同樣很高,主要是針對Windows逆向的題目較能夠處理,但其中並沒有反編譯的功能。以下是使用介面(以x64dbg為例):