深入理解GOT表覆寫技術
0x00:前言
玩pwn的時候,有時要用到got表覆寫技術,本文在於分享對GOT表覆寫技術的理解,鋪墊性的基礎知識較多,目的在於讓初學者知其然,還要知其所以然!
0x01:ELF文件生成過程
//hello.c #include <stdio.h> int main(){ printf("Hello World!n"); return 0; } 執行指令:$gcc hello.c -o hello
注:gcc命令實際上是具體程序(如ccp、cc1、as等)的包裝命令,用戶通過gcc命令來使用具體的預處理程序ccp、編譯程序cc1和匯編程序as等。
預處理過程
主要處理源文件中以“#”開頭的預編譯指令,經過預編譯處理后,得到的是預處理文件(如,hello.i) ,它還是一個可讀的文本文件 。
$gcc –E hello.c –o hello.i
編譯過程
將預處理后得到的預處理文件(如 hello.i)進行詞法分析、語法分析、語義分析、優化后,生成匯編代碼文件。經過編譯后,得到的匯編代碼文件(如 hello.s)還是可讀的文本文件,CPU無法理解和執行它。
$gcc –S hello.i –o hello.s(或者$gcc –S hello.c –o hello.s)
匯編過程
匯編程序(匯編器)用來將匯編語言源程序轉換為機器指令序列(機器語言程序)。匯編結果是一個可重定位目標文件(如 hello.o),其中包含的是不可讀的二進制代碼,必須用相應的工具軟件來查看其內容。
$gcc –c hello.s –o hello.o (或者$gcc –c hello.c –o hello.o)
預處理、編譯和匯編三個過程針對一個模塊(一個.c文件)進行處理,得到對應的一個可重定位目標文件(一個.o文件)。
鏈接過程
將多個可重定位目標文件合並以生成可執行目標文件
鏈接過程指令較復雜,此處不詳細說明,具體可以參考《程序員的自我修養》第二章
0x02:目標文件格式概述
三類目標文件
1、可重定位目標文件 (Relocatable File; 后綴名為“.o”)
Linux下的.o(Windows下的.obj) 包含代碼和數據,可被用來鏈接成可執行文件或共享目標文件,靜態鏈接庫也可以歸為這一類 每個.o 文件由對應的.c文件生成 每個.o文件代碼和數據地址都從0開始
2、可執行目標文件(Executable File;一般沒有后綴名)
包含的代碼和數據可以被直接復制到內存並被執行
代碼和數據地址為虛擬地址空間中的地址
3、共享的目標文件 (Shared Object File;后綴名為“.so”)
鏈接器可使用.so文件跟其他.o文件和.so文件鏈接以生成新的.o文件 動態鏈接器將幾個.so文件與可執行文件結合,作為進程映像的一部分來運行 特殊的可重定位目標文件,能在裝入或運行時被裝入到內存並自動被鏈接,稱為共享庫文件 Windows 中稱其為 Dynamic Link Libraries (DLLs)
標准的幾種目標文件格式
DOS操作系統(最簡單) :COM格式,文件中僅包含代碼和數據,且被加載到固定位置
System V UNIX早期版本:COFF格式,文件中不僅包含代碼和數據,還包含重定位信息、調試信息、符號表等其他信息,由一組嚴格定義的數據結構序列組成
Windows: PE格式(COFF的變種),稱為可移植可執行(Portable Executable,簡稱PE)
Linux等類UNIX:ELF格式(COFF的變種),稱為可執行可鏈接(Executable and Linkable Format,簡稱ELF)
兩種視圖
鏈接視圖(被鏈接):可重定位目標文件 (Relocatable object files)
• 可被鏈接(合並)生成可執行文件或共享目標文件
• 包含代碼、數據(已初始化.data和未初始化.bss)
• 包含重定位信息(指出哪些符號引用處需要重定位)
• 文件擴展名為.o(相當於Windows中的 .obj文件)
執行視圖(被執行):可執行目標文件(Executable object files)
• 定義的所有變量和函數已有確定地址(虛擬地址空間中的地址)
• 符號引用處已被重定位,以指向所引用的定義符號
• 沒有文件擴展名或默認為a.out(相當於Windows中的 .exe文件)
• 可被CPU直接執行,指令地址和指令給出的操作數地址都是虛擬地址
0x03:ELF可重定位目標文件
.bss 節
C語言規定: 未初始化的全局變量和局部靜態變量的默認初始值為0 將未初始化變量(.bss節)與已初始化變量(.data節)分開的好處 (1).data節中存放具體的初始值,需要占磁盤空間 (2).bss節中無需存放初始值,只要說明.bss中的每個變量將來在執行時占用幾個字節即可,因此,.bss節實際上不占用磁盤空間,提高了磁盤空間利用率 所有未初始化的全局變量和局部靜態變量都被匯總到.bss節中,通過專門的“節頭表(Section header table)”來說明應該為.bss節預留多大的空間
0x04:ELF可執行目標文件
0x05:符號及符號表
鏈接操作的步驟
Step 1. 符號解析(Symbol resolution)
(1)確定程序中有定義和引用的符號 (包括變量和函數等)
(2)將定義的符號存放在一個符號表( symbol table)中.
符號表是一個結構數組
每個表項包含符號名、長度和位置等信息
(3)將每個符號的引用都與一個確定的符號定義建立關聯
Step 2. 重定位
(1)合並相同的節:將集合E中所有目標模塊中相同的節分別合並為一個新節;
(2)對定義符號進行重定位:確定新節中所有定義符號在虛擬地址空間中的絕對地址,完成這一步后,每條指令和每個全局或局部變量都可確定地址;
(3)對引用符號進行重定位:將可執行文件中符號引用處的地址修改為重定位后的地址信息.需要用到在.rel_data和.rel_text節中保存的重定位信息。
//main.c int buf[2] = {1, 2}; void swap(); int main() { swap(); return 0; } //swap.c extern int buf[]; int *bufp0 = &buf[0]; static int *bufp1; void swap() { int temp; bufp1 = &buf[1]; temp = *bufp0; *bufp0 = *bufp1; *bufp1 = temp; }
鏈接符號的類型
每個可重定位目標模塊m都有一個符號表,它包含了在m中定義和引用的符號。有三種鏈接器符號:
(1) Global symbols(模塊內部定義的全局符號)
由模塊m定義並能被其他模塊引用的符號。例如,非static C函數和非static的C全局變量(指不帶static的全局變量)。如,main.c 中的全局變量名buf
(2)External symbols(外部定義的全局符號)
由其他模塊定義並被模塊m引用的全局符號.如,main.c 中的函數名swap
(3)Local symbols(本模塊的局部符號)
僅由模塊m定義和引用的本地符號。例如,在模塊m中定義的帶static的C函數和全局變量.如,swap.c 中的static變量名bufp1 注:局部符號不是指程序中的局部變量(分配在棧中的臨時性變量)
目標文件中的符號表
.symtab 節記錄符號表信息,是一個結構數組,函數名在text節中,變量名在data節或bss節中
0x06:靜態鏈接和符號解析
靜態鏈接對象
多個可重定位目標模塊 + 靜態庫(標准庫、自定義庫)
(.o文件) (.a文件,其中包含多個.o模塊)
靜態庫 (.a archive files)
將所有相關的目標模塊(.o)打包為一個單獨的庫文件(.a),稱為靜態庫文件 ,也稱存檔文件(archive) 使用靜態庫,可增強鏈接器功能,使其能通過查找一個或多個庫文件中定義的符號來解析符號 在構建可執行文件時,只需指定庫文件名,鏈接器會自動到庫中尋找那些應用程序用到的目標模塊,並且只把用到的模塊從庫中拷貝出來 在gcc命令行中無需明顯指定C標准庫libc.a(默認庫)
自定義一個靜態庫文件
//program1.c #include <stdio.h> void Function(){ printf("This is the first test program!n"); } //program2.c #include <stdio.h> void Function2(){ printf("This is the second test program!n"); } //main.c void Function( ); int main() { Function(); return 0; } $ gcc -m32 –c program1.c program2.c $ ar rcs -m32 mylib.a program1.o program2.o //將program1.o和program2.o打包生成mylib.a $ gcc -m32 –c main.c $ gcc -m32 –static –o program main.o ./mylib.a $ ./program This is the first test program!
鏈接器中符號解析的全過程
三個重要集合:
E 將要被合並以組成可執行文件的所有目標文件集合
U 當前所有未解析的引用符號的集合
D 當前所有定義符號的集合
開始E、U、D為空,首先掃描main.o,把它加入E,同時把Function加入U,main加入D。接着掃描到mylib.a,將U中所有符號(本例中為Function)與mylib.a中所有目標模塊(program1.o和program2.o)依次匹配,發現在program1.o中定義了Function,故program1.o加入E,Function從U轉移到D。在program1.o中發現還有未解析符號printf,將其加到U。不斷在mylib.a的各模塊上進行迭代以匹配U中的符號,直到U、D都不再變化。此時U中只有一個未解析符號printf,而D中有main和Function。因為模塊program2.o沒有被加入E中,因而它被丟棄。接着,掃描默認的庫文件libc.a,發現其目標模塊printf.o定義了printf,於是printf也從U移到D,並將printf.o加入E,同時把它定義的所有符號加入D,而所有未解析符號加入U。處理完libc.a時,U一定是空的。 注:被鏈接模塊應按調用順序指定! 若命令為:$ gcc -m32–static –o myproc ./mylib.a main.o 首先,掃描mylib,因是靜態庫,應根據其中是否存在U中未解析符號對應的定義符號來確定哪個.o被加入E。因為開始U為空,故其中兩個.o模塊都不被加入E中而被丟棄。然后,掃描main.o,將Function加入U,直到最后它都不能被解析,因此,出現鏈接錯誤,因為它只能用mylib.a中符號來解析,而mylib中兩個.o模塊都已被丟棄!
0x07:可執行文件的加載
通過調用execve系統調用函數來調用加載器:
加載器(loader)根據可執行文件的程序(段)頭表中的信息,將可執行文件的代碼和數據從磁盤“拷貝”到存儲器中
加載后,將PC( EIP)設定指向Entry point ,最終執行main函數,以啟動程序執行
execve()函數的功能是在當前進程上下文中加載並運行一個新程序。
execve()函數的用法如下:
int execve(char *filename, char *argv[], *envp[]); filename是加載並運行的可執行文件名(如./hello),可帶參數列表argv和環境變量列表envp。若錯誤(如找不到指定文件filename),則返回-1,並將控制權交給調用程序; 若函數執行成功,則不返回,最終將控制權傳遞到可執行目標中的主函數main。
主函數main()的原型形式如下:
int main(int argc, char **argv, char **envp); 或者: int main(int argc, char *argv[], char *envp[]);
argc指定參數個數,參數列表中第一個總是命令名(可執行文件名)
hello程序的加載和運行過程
Step1:在shell命令行提示符后輸入命令:$./hello
Step2:shell命令行解釋器構造argv和envp
Step3:調用fork()函數,創建一個子進程,與父進程shell完全相同(只讀/共享),包括只讀代碼段、可讀寫數據段、堆以及用戶棧等。
Step4:調用execve()函數,在當前進程(新創建的子進程)的上下文中加載並運行hello程序。將hello中的.text節、.data節、.bss節等內容加載到當前進程的虛擬地址空間(僅修改當前進程上下文中關於存儲映像的一些數據結構,不從磁盤拷貝代碼、數據等內容)
Step5:調用hello程序的main()函數,hello程序開始在一個進程的上下文中運行。
0x08:共享庫和動態鏈接【划重點】
靜態庫有一些缺點
庫函數(如printf)被包含在每個運行進程的代碼段中,對於並發運行上百個進程的系統,造成極大的主存資源浪費; 庫函數(如printf)被合並在可執行目標中,磁盤上存放着數千個可執行文件,造成磁盤空間的極大浪費; 程序員需關注是否有函數庫的新版本出現,並須定期下載、重新編譯和鏈接,更新困難、使用不便;
解決方案: Shared Libraries (共享庫)
是一個目標文件,包含有代碼和數據
從程序中分離出來,磁盤和內存中都只有一個備份
可以動態地在裝入時或運行時被加載並鏈接
Window稱其為動態鏈接庫(Dynamic Link Libraries,.dll文件) Linux稱其為動態共享對象( Dynamic Shared Objects, .so文件)
自定義一個動態共享庫文件
//program3.c #include <stdio.h> void Function3(){ printf("This is the third test program!n"); } //program4.c #include <stdio.h> void Function4(){ printf("This is the fourth test program!n"); } //main.c void Function3(); int main(){ Function3(); return 0; } $ gcc -m32–c program3.c program4.c $ gcc -m32 -shared -fPIC -o mylib.so program3.o program4.o $ gcc -m32 -c main.c $ gcc -m32 -o program main.o ./mylib.so $ ./program This is the third test program!
地址無關代碼【划重點】
• 動態鏈接用到一個重要概念:
我們希望程序模塊中共享的指令部分在裝載時不需要因為裝載地址的改變而改變,所以實現的基本想法就是把指令中那些需要被修改的部分分離出來,跟數據部分放在一起,這樣指令部分就可以保持不變,而數據部分可以在每個進程中擁有一個副本。這種方案就叫做地址無關代碼(Position-Independent Code,PIC)。
GCC選項-fPIC指示生成PIC代碼
• 共享庫代碼是一種PIC
共享庫代碼的位置可以是不確定的
即使共享庫代碼的長度發生變化,也不影響調用它的程序
• 所有引用情況
模塊內的數據訪問。如模塊內的全局變量和靜態變量
模塊內的過程調用、跳轉。采用PC相對偏移尋址
模塊間的數據訪問。如外部變量的訪問
模塊間的過程調用、跳轉。
要實現動態鏈接,必須生成PIC代碼,要生成PIC代碼,主要解決第3和第4這兩個問題
源代碼:
static int a; static int b; extern void ext(); void bar() { a=1; b=2; } void foo() { bar(); ext(); }
(1)模塊內的函數調用或跳轉
調用或跳轉源與目的地都在同一個模塊,相對位置固定,只要用相對偏移尋址即可。
call的目標地址為:0x8048369 + 0xffffffdb(-0x25) = 0x8048344
注:
8048364: e8 db ff ff ff call 8048344 <bar>
該指令是一條近址相對位移調用指令
(2)模塊內的數據引用
注:任何一條指令與它需要訪問的模塊內部數據之間的相對位置是固定的,那么只要相對於當前指令加上固定的偏移量就可以訪問模塊內部的數據了。
變量a與引用a的指令之間的距離為常數,調用__get_pc后,call指令的返回地址被置ECX。若模塊被加載到0x9000000,則a的訪問地址為:
0x9000000+0x34c+0x118c(指令與.data間距離)+0x28(a在.data節中偏移)
(3)模塊間的數據訪問
ELF解決模塊間的數據訪問目標地址的做法是在數據段里面建立一個指向這些變量的指針數組,也稱為全局偏移表(Global Offset Table,Got),當代碼需要引用該全局變量時,可通過GOT中相對應的項間接引用。
模塊在編譯時可以確定GOT相對於當前指令的偏移,然后根據變量地址在GOT中的偏移就可得到變量的地址。比如,當指令要訪問變量b時,程序會先找到GOT,然后根據GOT中變量所對應的項找到變量的目標地址。
(4)模塊間的調用、跳轉
同理,我們可以使用類似於模塊間的數據訪問的方式,在GOT中加一個項(指針),用
於指向目標函數的首地址(如&ext),但是也要多用三條指令並額外多用一個寄存器(如EBX)。因此,可用“延遲綁定(lazy binding)”技術來優化動態鏈接性能:當函數第一次被用到時才進行綁定(符號查找、重定位等),這樣可以大大加快程序啟動速度。
ELF使用PLT(Procedure linkage Table, 過程鏈接表)的方法來實現。通常我們調用某個外部模塊的函數時,應該是通過GOT中相應的項進行間接跳轉。而PLT為了實現
延遲綁定,在這個過程中有增加了一層間接跳轉。調用函數並不直接通過GOT跳轉,而是通過一個叫作PLT項的結構來進行跳轉。每個外部函數在PLT中都有一個相應的項,比如bar()在PLT中的項的地址我們稱為bar@plt
。其中bar@plt
的實現如下:
bar@plt:
jmp *(bar@GOT) push n push moduleID jump _dl_runtime_resolve
第一條指令是通過一條GOT間接跳轉的指令。bar@GOT
表示GOT中保存的bar()這個函數相應的項。鏈接器在初始化階段沒有將bar()的地址填入到該項中,而是將上面代碼中第二條指令“push n”的地址填入到bar@GOT
中。顯然,第一條指令的效果是跳轉到第二條指令,第二條指令將一個數字n壓入堆棧中,該數字為bar這個符號引用在重定位表“.rel.plt”中的下標。接着將模塊的ID壓入堆棧中,然后調用_dl_runtime_resolve函數來完成符號解析和重定位工作。_dl_runtime_resolve在進行一系列工作以后將bar()的真正地址填入到bar@GOT
中。再次調用bar@plt
時,第一條jump指令能跳轉到真正的bar()函數中,bar()函數返回的時候會根據堆棧里保存的EIP直接返回到調用者,而不會在繼續執行bar@plt
中第二條指令開始的那段代碼。
ELF將GOT拆分為兩個表叫做“.got”和“.got.plt”:
.got 用來保存全局變量引用的地址; .got.plt 用來保存函數引用的地址,即對於外部函數的引用全部被分離出來放到了“.got.plt”中
注:Linux下,ELF可執行文件虛擬地址空間默認從地址0x08048000
開始分配
0x09 實踐部分
理解了何為GOT表和PLT之后,我們再通過pwnable.kr中的題目passcode來介紹一下GOT表覆蓋技術:
Mommy told me to make a passcode based login system.
My initial C code was compiled without any error!
Well, there was some compiler warning, but who cares about that?
ssh passcode@pwnable.kr -p2222 (pw:guest)
//源代碼passcode.c #include <stdio.h> #include <stdlib.h> void login(){ int passcode1; int passcode2; printf("enter passcode1 : "); scanf("%d", passcode1); fflush(stdin); // ha! mommy told me that 32bit is vulnerable to bruteforcing :) printf("enter passcode2 : "); scanf("%d", passcode2); printf("checking...n"); if(passcode1==338150 && passcode2==13371337){ printf("Login OK!n"); system("/bin/cat flag"); } else{ printf("Login Failed!n"); exit(0); } } void welcome(){ char name[100]; printf("enter you name : "); scanf("%100s", name); printf("Welcome %s!n", name); } int main(){ printf("Toddler's Secure Login System 1.0 beta.n"); welcome(); login(); // something after login... printf("Now I can safely trust you that you have credential :)n"); return 0; }
解題思路:
由於welcome()和login()函數調用棧的EBP相同,通過gdb調試后可以發現
輸入的變量沒有用取地址符號&,導致讀入數據的時候,scanf會把這個變量中的值當成存儲地址來存放數據,name值的最后4個字節是passcode1值,所以可以通過將passcode1的值改為fflush()的地址,scanf()之后會調用fflush()函數,覆蓋fflush()在GOT表中的內容,把system(“/bin/cat flag”)對應匯編代碼地址寫入fflush()中,當這個函數被調用時,就會直接執行system(“/bin/cat flag”)。
通過objdump -R passcode
命令查看GOT表可以發現fflush()位於0x0804a004
處,即將0x80485e3
(調用system的地址)覆寫位於0x0804a004的fflush()函數的GOT表。
看看got表:
//exp.py
#!/usr/bin/python from pwn import * p= process('./passcode') fflush_got = 0x0804a004 system_addr = 0x80485e3 payload = "A" * 96 + p32(fflush_got) + str(system_addr) #有點小問題,見后 p.send(payload) p.interactive()
GOT表覆寫原理以及自己的理解
scanf函數:把某個輸入的值輸入到某個內存里。如果我們可以控制這個內存,或者可以控制這個輸入,那么我們可能可以劫持進程流。
現在passcode1沒有加&號。scanf是默認從棧中讀取4個字節當作scanf的地址。
那么,如果我們可以控制棧呢?棧中讀取的4個字節是我們控制的。輸入的值是我們控制的。那么,scanf函數幫助我們控制了函數流程。
如果我們在scanf函數里,將原本是fflush函數地址的地方,寫入了system函數地址。
那么當程序執行fflush函數時,相當於執行了system函數。也就實現了我們所說的:GOT表的覆寫。
過程如下:
將passcode1的地址填充為fflush地址-->scanf("%d", fflush地址)-->send(system_addr
),該scanf使用system_addr覆蓋了fflush地址。下次執行printf
時其實執行的是system("/etc/cat flag")(printf隱含了fflush的調用!)
0x0A:參考資料
《程序員的自我修養》
《計算機系統基礎(一):程序的表示、轉換與鏈接》https://www.icourse163.org/course/NJU-1001625001
pwn題目分析
題目已經很清楚了,那么直觀的想法就是通過某種方式在scanf("%d", passcode1);
之前就為passcode1和passcode2精心准備好一個垃圾“隨機”值。
可是怎么辦呢?一旦進入login()就沒戲了,所以我盯上了welcome()。首先,welcome和login之間沒有多余的棧操作,因此二者的ebp應該是一致的。其次,在welcome有一個scanf("%100s", name);
,這是個很大的buffer,所以理論上來說,通過對name進行精心的布局,應該可以覆蓋到login()棧幀中passcode1和passcode2的值。這也正是我對“隨機”二字加了引號的原因。
那么passcode1和passcode2應該是什么值呢?從邏輯上來看,passcode1為338150(0x000582E6),passcode2為13371337(0x00cc07c9)即可。
然而且不論這兩個地址是否是可寫的,至少00字節的存在就因為截斷而打消念想了。另一方面,瀏覽一下反匯編代碼就會發現,實際上name的地址在ebp-0x70
的位置,而passcode1在ebp-0x10
的位置(兩個棧幀中ebp值相等),經過計算,name也覆蓋不到passcode2。
welcome:
login:
那么接下來怎么辦?
實際上,既然scanf是一個具有寫功能的函數,我們完全可以利用scanf來修改此后使用到的某個函數的got表項。例如,程序在scanf("%d", passcode1);
后立即使用了fflush函數,所以我們完全可以先找到fflush的got表項地址(程序沒有開PIE,無需leak),把passcode1布局為該地址,並在調用到scanf(“%d”, passcode1)時輸入程序代碼中調用system("/bin/cat flag");
處的地址即可。
前面說的passcode1和name是在一個棧空間的,計算一下,name和passcode1相差96個字節,所以name后4個字節正好可以覆蓋到passcode1。因此可以把passcode1的地址覆蓋成fflush的地址,然后利用scanf函數把system的地址覆寫過去。這樣等調用fflush的就調用成了system。(因為scanf函數會調用fflush。可以調用system函數的原因是,linux沒有對code段進行隨機化)
思路明確了,接下來就是找地址了。
objdump -R passcode找fflush地址:
system(“cat flag”)地址:
Pwn
程序NO PIE,所以地址就無需leak了,直接通過gdb找到的就是固定的。
關於got和plt表的關系以及linux的動態延遲加載,我就不詳細展開了,給一張圖吧: