banner
言心吾

言心吾のBlog

吾言为心声

PWN筆記之基礎

匯編基礎#

掌握程度:看懂匯編,分析 gadget,單步調試理解寄存器狀態

參考文章:x86_64 匯編之一:AT&T 匯編語法_x86_64 匯編 at&t-CSDN 博客

寄存器#

常用的 x86 CPU 寄存器有 8 個:EAXEBXECXEDXEDIESIEBPESP

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)#

image-20240331212445677

匯編語言指令#

常用的匯編指令:movjejmpcalladdsubincdecandor

數據傳送指令#

指令名稱示例備註
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 lablezf =1 時跳轉到標號為 label 的位置
JNE條件轉移指令JNE lablezf=0 時跳轉到標號為 label 的位置

linux 和 windows 下匯編的區別#

linuxwindows 下的匯編語法是不同的,其實兩種語法的不同和系統不同沒有絕對的關係,一般在 linux 上會使用 gcc/g++ 編譯器,而在 windows 上會使用微軟的 cl 也就是 MSBUILD,所以產生不同的代碼是因為編譯器不同,gcc 下採用的是 AT&T 的匯編語法格式,MSBUILD 採用的是 Intel 匯編語法格式。

差異IntelAT&T
引用寄存器名字eax%eax
赋值操作数顺序mov dest, srcmovl src, dest
寄存器、立即數指令前綴mov ebx, 0xd00dmovl $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的,又是另一個樣子…

調用約定就是為了解決上述問題,它對函數調用的細節作出了規定,這樣的話,每個人都遵守一個約定,當我們想調用別人編寫的代碼時,就不需要做啥修改了。

函數調用堆棧#

  1. 函數調用:當一個函數被調用時,程序將在調用堆棧上為其分配一個新的堆棧幀。 堆棧幀中包含函數的參數、局部變量、返回地址等信息。
  2. 參數傳遞:在函數調用過程中,參數通過壓堆操作傳遞給被調用的函數。 這些參數存儲在堆棧幀中,供函數內部使用。
  3. 執行函數:被調用的函數開始執行,使用堆棧幀中的參數和局部變量。 函數的執行過程可能涉及複雜的邏輯和計算。
  4. 返回值處理:當函數執行完畢後,程序將返回到調用該函數的代碼位置。 這個位置由堆棧幀中的返回地址指定。 如果函數有返回值,該值將被推送到調用者的堆棧幀中。
  5. 堆棧幀銷毀:當函數調用完成後,其對應的堆棧幀將從調用堆棧中彈出並銷毀,釋放所佔用的內存資源。

具體的函數調用流程#

  • 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 ,逐步分析堆棧幀變化:

函數調用過程中:

  1. 調用函數:
    • rip 壓入堆棧中,作為返回地址。(call)
  2. 被調用函數:
    • rbp 壓入堆棧中,作為當前堆棧幀的基址。
    • rsp 的值賦給 rbp,使 rbp 指向當前堆棧幀的底部。
    • 為局部變量和臨時數據分配堆棧空間,將 rsp 減去相應的大小。
    • 使用 rsp 作為基址指針來訪問函數參數和局部變量。

函數返回時:leave;ret;

  1. 被調用函數:
    • 將堆棧中分配的局部變量和臨時數據彈出。
    • rsp 恢復到函數調用時的值。
  2. 調用函數:
    • 從堆棧中彈出返回地址。
    • rip 更新為返回地址。

堆棧幀變化示意圖:

+----------------------------+
| main 函數堆棧幀               |
+----------------------------+
| 返回地址                    |
| rbp (main 函數的基址指針)   |
+----------------------------+
| funB 即調用函數堆棧幀         |
+----------------------------+
| 返回地址                   |
| rbp (funB 函數的基址指針)   |
+----------------------------+
| funA 即被調用函數堆棧幀       |
+----------------------------+
| rbp (funA 函數的基址指針)   |
| 局部變量                    |
+----------------------------+

如何傳參#

函數返回值給 RAX

x86-64 函數的調用約定為:

  1. 從左至右參數依次傳遞給RDI,RSI,RDX,RCX,R8,R9

  2. 如果一個函數的參數 > 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 headersStart of section headers可以定位段表和節表的在文件中的位置。

image

節表(Section Header Table)#

使用readelf -S命令讀取二進制 ELF 文件的節信息(sections)。程序 test 中共有 31 個節。匯編語言是按照節來編寫程序的,例如.text 節、.data 節。匯編代碼與機器代碼是一一對應的,匯編程序被轉換成二進制代碼時保留了節的信息。

readelf -S test

image

段表(Program Header Table)#

ELF 程序執行時(加載進入內存時),裝載器(Loader)根據程序的段表創建進程的內存鏡像(Image)。使用readelf -l命令讀取二進制 ELF 文件的段信息(segments)。程序 test 共有 13 個段,段的數量大於節的數量,因此存在多個節映射到同一個段的情況。

根據節的權限:可讀可寫的節被映射入一個段,只讀的節被映射入一個段,等等。

readelf -l test

image

image

鏈接視圖 / 執行視圖#

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表的基本結構如下圖所示:

image

其中,.got.plt的前三項存放著特殊的地址引用:

  • GOT[0]:保存.dynamic段的地址,動態鏈接器利用該地址提取動態鏈接相關的信息;
  • GOT[1]:保存本模塊的 ID;
  • GOT[2]:存放了指向動態鏈接器_dl_runtime_resolve函數的地址,該函數用來解析共享庫函數的實際符號地址。

過程鏈接表 PLT#

為了實現延遲綁定,當調用外部模塊的函數時,程序並不會直接通過 GOT 跳轉,而是通過存儲在 PLT 表中的特定表項進行跳轉。對於所有的外部函數,在 PLT 表中都會有一個相應的項,其中每個表項都保存了 16 字節的代碼,用於調用一個具體的函數。過程鏈接表的通用結構如下:

image

過程鏈接表中除了包含編譯器為調用的外部函數單獨創建的 PLT 表項外,還有一個特殊的表項,對應於 PLT [0],它用於跳轉到動態鏈接器,進行實際的符號解析和重定位工作:

image

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 這個值和之前的一致,如果不一致,則不會往下運行,從而避免了緩衝區溢出攻擊。

繞過方法:

  1. 修改 canary。
  2. 泄露 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 數據段的保護的技術。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。