匯編基礎#
掌握程度:看懂匯編,分析 gadget,單步調試理解寄存器狀態
寄存器#
常用的 x86 CPU
寄存器有 8 個:EAX
、EBX
、ECX
、EDX
、EDI
、ESI
、EBP
、ESP
CPU 優先讀寫寄存器,再通過寄存器、快取跟內存來交換數據,達到緩衝的目的,因為可以通過名稱訪問寄存器,這樣訪問速度是最快的,因此也被稱為零級快取
存取速度從高到低分別是: 寄存器 > 1級快取 > 2級快取 > 3級快取 > 內存 > 硬盤
通用寄存器及用途#
上面提到的大 8 個寄存器都有其特定的用途,我們以32位 CPU
為例簡單說明下這些寄存器的作用,整理如下表:
寄存器 | 含義 | 用途 | 包含寄存器 |
---|---|---|---|
EAX | 累加 (Accumulator) 寄存器 | 常用於乘、除法和函數返回值 | AX(AH、AL) |
EBX | 基址 (Base) 寄存器 | 常做內存數據的指針,或者說常以它為基址來訪問內存 | BX(BH、BL) |
ECX | 計數器 (Counter) 寄存器 | 常做字符串和循環操作中的計數器 | CX(CH、CL) |
EDX | 數據 (Data) 寄存器 | 常用於乘、除法和 I/O 指針 | DX(DH、DL) |
ESI | 來源索引 (Source Index) 寄存器 | 常做內存數據指針和源字符串指針 | SI |
EDI | 目的索引 (Destination Index) 寄存器 | 常做內存數據指針和目的字符串指針 | DI |
ESP | 堆棧指針 (Stack Point) 寄存器 | 只做堆棧的栈頂指針;不能用於算術運算與數據傳送 | SP |
EBP | 基址指針 (Base Point) 寄存器 | 只做堆棧指針,可以訪問堆棧內任意地址,經常用於中轉 ESP 中的數據,也常以它為基址來訪問堆棧;不能用於算術運算與數據傳送 | BP |
在上面的圖標中每個常用寄存器後面還有其他的名字,rax,eax,ax,ah,al 其實是表示同一個寄存器,只是包含不同的範圍
下面為 64 位寄存器的對照關係:
|63..32|31..16|15-8|7-0|
|AH. |AL.|
|AX......|
|EAX............|
|RAX...................|
指令指針寄存器#
指令指針寄存器(RIP
)包含下一條將要被執行的指令的邏輯地址。
通常情況下,每取出一條指令後,RIP 會自增指向下一條指令。在 x86_64 中 RIP 的自增也即偏移 8 字節。
但是 RIP 並不總是自增,也有例外,例如call
指令和ret
指令。call
指令會將當前 RIP 的內容壓入堆棧中,將程序的執行權交給目標函數;ret
指令則執行出堆操作,將之前壓入堆的 8 個字節的 RIP 地址彈出,重新放入 RIP。
標誌寄存器(EFLAGS)#
匯編語言指令#
常用的匯編指令:mov
、je
、jmp
、call
、add
、sub
、inc
、dec
、and
、or
數據傳送指令#
指令 | 名稱 | 示例 | 備註 |
---|---|---|---|
MOV | 傳送指令 | MOV dest, src | 將數據從 src 移動到 dest |
PUSH | 進棧指令 | PUSH src | 把源操作數 src 壓入堆棧 |
POP | 出棧指令 | POP desc | 從栈頂彈出字數據到 dest |
算術運算指令#
指令 | 名稱 | 示例 | 備註 |
---|---|---|---|
ADD | 加法指令 | ADD dest, src | 在 dest 基礎上加 src |
SUB | 減法指令 | SUB dest, src | 在 dest 基礎上減 src |
INC | 加 1 指令 | INC dest | 在 dest 基礎上加 1 |
DEC | 減 1 指令 | DEC dest | 在 dest 基礎上減 1 |
邏輯運算指令#
指令 | 名稱 | 示例 | 備註 |
---|---|---|---|
NOT | 取反運算指令 | NOT dest | 把操作數 dest 按位取反 |
AND | 與運算指令 | AND dest, src | 把 dest 和 src 進行與運算之後送回 dest |
OR | 或運算指令 | OR dest, src | 把 dest 和 src 進行或運算之後送回 dest |
XOR | 異或運算 | XOR dest, src | 把 dest 和 src 進行異或運算之後送回 dest |
循環控制指令#
指令 | 名稱 | 示例 | 備註 |
---|---|---|---|
LOOP | 計數循環指令 | LOOP label | 使 ECX 的值減 1,當 ECX 的值不為 0 的時候跳轉至 label,否則執行 LOOP 之後的語句 |
轉移指令#
指令 | 名稱 | 示例 | 備註 |
---|---|---|---|
JMP | 無條件轉移指令 | JMP lable | 無條件地轉移到標號為 label 的位置 |
CALL | 程序調用指令 | CALL labal | 直接調用 label |
JE | 條件轉移指令 | JE lable | zf =1 時跳轉到標號為 label 的位置 |
JNE | 條件轉移指令 | JNE lable | zf=0 時跳轉到標號為 label 的位置 |
linux 和 windows 下匯編的區別#
linux
和 windows
下的匯編語法是不同的,其實兩種語法的不同和系統不同沒有絕對的關係,一般在 linux
上會使用 gcc/g++
編譯器,而在 windows
上會使用微軟的 cl
也就是 MSBUILD
,所以產生不同的代碼是因為編譯器不同,gcc
下採用的是 AT&T 的匯編語法格式,MSBUILD
採用的是 Intel 匯編語法格式。
差異 | Intel | AT&T |
---|---|---|
引用寄存器名字 | eax | %eax |
赋值操作数顺序 | mov dest, src | movl src, dest |
寄存器、立即數指令前綴 | mov ebx, 0xd00d | movl $0xd00d, %ebx |
寄存器間接尋址 | [eax] | (%eax) |
數據類型大小 | 操作碼後加後綴字母,“l” 32 位,“w” 16 位,“b” 8 位(mov dx, word ptr [eax]) | 操作數前面加 dword ptr, word ptr,byte ptr 的格式 (movb % bl % al) |
尋址方式#
直接尋址
內存尋址:[ ]
溢出(有無符號 & 上下溢出)#
- 存儲位數不夠
- 溢出到符號位
整數溢出配合別的漏洞使用
個人認為,有符號位數進位代表溢出
LINUX 文件基礎#
保護層級:0-3
0 - 內核
3 - 用戶
虛擬內存:物理內存經過 MMU 轉換後的地址 系統給每個用戶進程分配一段虛擬內存空間
大端序小端序#
大端序:數據高位 -> 計算機地址低位(更符合人類閱讀習慣)
小端序:數據低位 -> 計算機地址低位(反直覺,但更符合存儲邏輯、運算規律)
計算機輸出字符串:低地址到高地址
linux 數據存儲格式為小端序,arm 架構為大端序
以字符串形式輸入數字時,要注意格式,linux 從低到高讀入數據,可用 pwntools 進行轉換
文件描述符#
每個文件描述符與一個打開的文件相對應
- 0
- 1
- 2
stdin->buf->stdout
例如:
read(0,buf,size)
write(1,buf,size)
堆棧(stack)#
阉割版數組,只能在一頭操作
數據結構:後進先出(LIFO),與函數調用順序相同
函數開始執行:main->funA->funB
函數完成順序:funB->funA->main
基本操作:push 壓入,pop 彈出
函數調用指令 call,返回指令 ret
操作系統為每個程序都設置了一個堆棧,程序每個獨立的函數都有獨立的堆棧幀
linux 中的堆棧由高地址(堆棧頂)向低地址(堆棧底)生長
很多算法如 DFS 都是利用堆棧,以遞歸形式實現
調用約定 (Calling Convention)#
什麼是調用約定#
函數的調用過程中有兩個參與者,一個是調用方 caller,另一個是被調用方 callee。
調用約定規定了 caller 和 callee 之間如何相互配合來實現函數調用,具體包括的內容如下:
- 函數的參數存放在哪的問題。是放在寄存器中?還是放在堆棧中?放在哪個寄存器中?放在堆棧中的哪個位置?
- 函數的參數按何種順序傳遞的問題。是從左到右將參數入堆棧,還是從右到左將參數入堆棧?
- 返回值如何傳遞給 caller 的問題。是放在寄存器裡面,還是放在其他地方?
- 等等
那麼,為什麼需要調用約定呢?
舉個例子,如果我們用匯編語言編寫代碼沒有一個統一的規範來遵守的話。那么A
習慣將參數放在堆棧中,B
習慣將參數放在寄存器中,C
习惯 …,每個人編寫的代碼都按照自己的想法來。這樣,當 A
嘗試調用其他人的代碼時,就不得不遵循其他人的習慣,比如說調用B
的,那麼A
需要將參數放入 B 規定好的寄存器中;調用C
的,又是另一個樣子…
調用約定就是為了解決上述問題,它對函數調用的細節作出了規定,這樣的話,每個人都遵守一個約定,當我們想調用別人編寫的代碼時,就不需要做啥修改了。
函數調用堆棧#
- 函數調用:當一個函數被調用時,程序將在調用堆棧上為其分配一個新的堆棧幀。 堆棧幀中包含函數的參數、局部變量、返回地址等信息。
- 參數傳遞:在函數調用過程中,參數通過壓堆操作傳遞給被調用的函數。 這些參數存儲在堆棧幀中,供函數內部使用。
- 執行函數:被調用的函數開始執行,使用堆棧幀中的參數和局部變量。 函數的執行過程可能涉及複雜的邏輯和計算。
- 返回值處理:當函數執行完畢後,程序將返回到調用該函數的代碼位置。 這個位置由堆棧幀中的返回地址指定。 如果函數有返回值,該值將被推送到調用者的堆棧幀中。
- 堆棧幀銷毀:當函數調用完成後,其對應的堆棧幀將從調用堆棧中彈出並銷毀,釋放所佔用的內存資源。
具體的函數調用流程#
- pop
pop rax 的作用:
mov rax [rsp];
// 堆棧頂數據彈出到寄存器
add rsp 8;
// 堆棧頂指針下移一個單位
- push
push rax 的作用:
sub rsp 8;
// 堆棧上移一個單位
mov [rsp] rax;
// 將一個寄存器的值放在堆棧頂
- jmp
立即跳轉,不涉及函數調用,用於循環,if-else
如 call 1234h 的作用:
mov rip 1234h;
- call
函數調用,需要保存返回地址
如 call 1234h 的作用:
push rip;
mov rip 1234h;
- ret
pop rip
實例:main call funB , funB call funA ,逐步分析堆棧幀變化:
函數調用過程中:
- 調用函數:
- 將
rip
壓入堆棧中,作為返回地址。(call)
- 將
- 被調用函數:
- 將
rbp
壓入堆棧中,作為當前堆棧幀的基址。 - 將
rsp
的值賦給rbp
,使rbp
指向當前堆棧幀的底部。 - 為局部變量和臨時數據分配堆棧空間,將
rsp
減去相應的大小。 - 使用
rsp
作為基址指針來訪問函數參數和局部變量。
- 將
函數返回時:leave;ret;
- 被調用函數:
- 將堆棧中分配的局部變量和臨時數據彈出。
- 將
rsp
恢復到函數調用時的值。
- 調用函數:
- 從堆棧中彈出返回地址。
- 將
rip
更新為返回地址。
堆棧幀變化示意圖:
+----------------------------+
| main 函數堆棧幀 |
+----------------------------+
| 返回地址 |
| rbp (main 函數的基址指針) |
+----------------------------+
| funB 即調用函數堆棧幀 |
+----------------------------+
| 返回地址 |
| rbp (funB 函數的基址指針) |
+----------------------------+
| funA 即被調用函數堆棧幀 |
+----------------------------+
| rbp (funA 函數的基址指針) |
| 局部變量 |
+----------------------------+
如何傳參#
函數返回值給 RAX
x86-64 函數的調用約定為:
-
從左至右參數依次傳遞給
RDI
,RSI
,RDX
,RCX
,R8
,R9
-
如果一個函數的參數 > 6 個,則從右至左壓入堆棧中傳遞
系統調用#
syscall 指令#
用於調用系統函數,調用時指明系統調用號(可網上查詢 64 位 Linux 系統調用表)
系統調用號存在 RAX 寄存器,然後布置好參數,執行 syscall 即可
示例:調用 read (0,buf,size)
mov rax 0;
mov rdi 0;
mov rsi buf;
mov rdx size;
syscall;
ELF 文件結構#
ELF 文件格式#
ELF (Executable and Linkable Format) 為 linux 中二進制可執行文件格式。
ELF 文件頭(ELF Header)#
readelf -h 命令可以讀取 ELF 文件的文件頭,ELF 頭包括了程序的入口點(Entry Point Address)、段信息和節信息。從 ELF 頭的Start of program headers和Start of section headers可以定位段表和節表的在文件中的位置。
節表(Section Header Table)#
使用readelf -S命令讀取二進制 ELF 文件的節信息(sections)。程序 test 中共有 31 個節。匯編語言是按照節來編寫程序的,例如.text 節、.data 節。匯編代碼與機器代碼是一一對應的,匯編程序被轉換成二進制代碼時保留了節的信息。
readelf -S test
段表(Program Header Table)#
ELF 程序執行時(加載進入內存時),裝載器(Loader)根據程序的段表創建進程的內存鏡像(Image)。使用readelf -l命令讀取二進制 ELF 文件的段信息(segments)。程序 test 共有 13 個段,段的數量大於節的數量,因此存在多個節映射到同一個段的情況。
根據節的權限:可讀可寫的節被映射入一個段,只讀的節被映射入一個段,等等。
readelf -l test
鏈接視圖 / 執行視圖#
Segment 和 Section 是從不同角度來劃分同一個 ELF 文件。這個在 ELF 中被稱為不同的視圖(View),
從 Section 的角度來看 ELF 文件就是鏈接視圖(Linking View)。
從 Segment 的角度來看就是執行視圖(Execution View)。
當我們在談到ELF 裝載時,段專門指 Segment;而在其他情況下,段指的是 Section。
libc#
glibc:GNU C Library ,glibc 本身是 GNU 旗下的 C 標準庫,後來逐漸成為了 Linux 的標準 c 庫
其後綴為 libc.so,本質也是 ELF 文件,可以單獨執行,通常 pwn 題接觸到的動態鏈接庫就是 libc.so 文件
linux 基本所有程序都依賴 libc,所以 libc 中的函數至關重要
延遲綁定機制#
靜態編譯與動態編譯#
動態編譯的可執行文件需要附帶一個的動態鏈接庫,在執行時,需要調用其對應動態鏈接庫中的命令。所以其優點一方面是縮小了執行文件本身的體積,另一方面是加快了編譯速度,節省了系統資源。缺點一是哪怕是很簡單的程序,只用了鏈接庫中的一兩條命令,也需要附帶一個相對龐大的鏈接庫;二是如果其他計算機上沒有安裝對應的運行庫,則用動態編譯的可執行文件就不能運行。
靜態編譯就是編譯器在編譯可執行文件的時候,將可執行文件需要調用的對應動態鏈接庫 (.so) 中的部分提取出來,鏈接到可執行文件中去,使可執行文件在運行的時候不依賴於動態鏈接庫。所以其優缺點與動態編譯的可執行文件正好互補。
延遲綁定(Lazy Binding)#
使用延遲綁定是基於這樣一個前提:在動態鏈接下,程序加載的模塊中包含了大量的函數調用。
延遲綁定通過將函數地址的綁定推遲到第一次調用這個函數時,從而避免動態鏈接器在加載時處理大量函數引用的重定位。
延遲綁定的實現使用了兩個特殊的數據結構:全局偏移表(Global Offset Table,GOT)和過程鏈接表(Procedure Linkage Table,PLT)。
全局偏移表 GOT#
在庫函數第一次調用後,程序才將其地址保存在 got 表中。
全局偏移表在 ELF 文件中以獨立的節區存在,共包含兩類,對應的節區名為.got
和.got.plt
,其中,.got
存放所有對於外部變量引用的地址;.got.plt
保存所有對於外部函數引用的地址,對於延遲綁定主要使用.got.plt
表。.got.plt
表的基本結構如下圖所示:
其中,.got.plt
的前三項存放著特殊的地址引用:
- GOT[0]:保存
.dynamic
段的地址,動態鏈接器利用該地址提取動態鏈接相關的信息; - GOT[1]:保存本模塊的 ID;
- GOT[2]:存放了指向動態鏈接器
_dl_runtime_resolve
函數的地址,該函數用來解析共享庫函數的實際符號地址。
過程鏈接表 PLT#
為了實現延遲綁定,當調用外部模塊的函數時,程序並不會直接通過 GOT 跳轉,而是通過存儲在 PLT 表中的特定表項進行跳轉。對於所有的外部函數,在 PLT 表中都會有一個相應的項,其中每個表項都保存了 16 字節的代碼,用於調用一個具體的函數。過程鏈接表的通用結構如下:
過程鏈接表中除了包含編譯器為調用的外部函數單獨創建的 PLT 表項外,還有一個特殊的表項,對應於 PLT [0],它用於跳轉到動態鏈接器,進行實際的符號解析和重定位工作:
PLT 與 GOT#
無論第幾次調用外部函數,程序真正調用的是 PLT 表,PLT 表其實是一段段匯編指令構成。
那麼這裡可能會有疑問:為什麼要存在 PLT,存在過度,而不是直接到達 GOT 呢?
這就比如說,你是一個有很多親戚的人,你每周都需要拜訪這些親戚,所以你將這些親戚的地址都記在了一個本子上,等你要去拜訪的時候就翻出來查找,那麼這個本子就是一個 PLT 表,它裡面每一個地址都會跳轉到對應的 GOT 表地址(你的親戚家)
假如有一天,你覺得每天跑來跑去好麻煩,於是把你所有親戚全都接到了你家裡住,每周只要到對應的房間去拜訪就可以了,那個本子也就沒用了,你扔掉了它。這時,就是在沒有 PLT 表的情況下,直接把 GOT 表弄來了。
你覺得是一個登記本佔地面積小,還是一屋子親戚佔地面積小呢。
這就是 PLT 表存在的原因之一,為了更高效的利用內存。
另一個原因就是可以增加安全性。
LINUX 安全防護機制#
gcc 安全編譯選項詳解(NX (DEP)、RELRO、PIE (ASLR)、CANARY、FORTIFY)_gcc pie-CSDN 博客
CANARY#
Canary 是一種針對堆棧溢出攻擊的防護手段,其基本原理是從內存中fs: 0x28處複製一個開頭字節為 \x00 的長度為 8 字節的隨機數 canary ,該隨機數會在創建堆棧幀時緊跟著 rbp 入堆棧(緊挨 ebp 的上個位置)。當攻擊者想通過緩衝區溢出覆蓋 ebp 或者 ebp 下方的返回地址時,一定會覆蓋掉 canary 的值;當程序結束時,程序會檢查 CANARY 這個值和之前的一致,如果不一致,則不會往下運行,從而避免了緩衝區溢出攻擊。
繞過方法:
- 修改 canary。
- 泄露 canary。
canary bypass#
- 格式化字符串繞過 canary
- 通過格式化字符串讀取 canary 的值
- Canary 爆破(針對有 fork 函數的程序)
- fork 作用相當於自我複製,每一次複製出來的程序,內存佈局都是一樣的,當然 canary 的值也一樣,那我們就可以逐位爆破,如果程序崩潰了就說明這一位不對,如果程序正常就可以接著跑下一位,直到跑出正確的 canary
- Stack smashing(故意觸發 canary_ssp leak)
- 劫持__stack_chk_fail
- 修改 got 表中__stack_chk_fail 函數的地址,在堆棧溢出後執行該函數,但由於該函數的地址被修改,所以程序會跳轉到我們想要執行的地址
NX#
堆棧上的數據沒有執行權限(not executable),開啟後,程序中的堆、堆棧、bss 段等可寫段就不能執行。
繞過方法:
用 mprotect 函數改寫段權限,且 nx 保護對於 rop 或劫持 got 表利用方式不影響。
PIE 和 ASLR#
ASLR 是什麼?#
ASLR 是 Linux 操作系統的功能選項,作用於程序(ELF)裝入內存運行時。是一種針對緩衝區溢出的安全保護技術,通過對加載地址的隨機化,防止攻擊者直接定位攻擊代碼位置,到達阻止溢出攻擊的一種技術。
開啟、關閉 ASLR#
查看當前系統 ASLR 的打開情況:
sudo cat /proc/sys/kernel/randomize_va_space
ASLR 有三個安全等級:
- 0: ASLR 關閉
- 1:隨機化堆基地址(stack)、共享庫(.so\libraries)、mmap 基地址
- 2:在1基礎上,增加隨機化堆基地址(chunk)
PIE 是什麼?#
PIE 是 gcc 編譯器的功能選項,作用於程序(ELF)編譯過程中。是一個針對代碼段( .text )、數據段( .data )、未初始化全局變量段( .bss )等固定地址的一個防護技術,如果程序開啟了 PIE 保護的話,在每次加載程序時都變換加載地址,從而不能通過 ROPgadget 等一些工具來幫助解題。
開啟 PIE#
在使用 gcc 編譯時加入參數-fPIE
。
PIE 開啟後會隨機化代碼段( .text )、初始化數據段( .data )、未初始化數據段( .bss )的加載地址。
PIE bypass#
程序的加載地址一般都是以内存頁位單位的,所以程序的基地址最後三個數字一定是 0,這也就是說那些地址已知的最後三個數就是實際地址的最後三個數。知道這一點後我們就有了繞過 PIE 的思路,雖然我並不知道完整的地址,但我知道最後三個數,那麼我們可以利用堆上已有的地址,只修改它們最後兩個字節(最後四個數)即可。
所以繞過 PIE 的核心思想就是partial writing(部分寫地址)
RELRO#
ReLocation Read-Only,堆棧地址隨機化,是一種用於加強對 binary 數據段的保護的技術。