緩沖區(qū)溢出與注入分析

2018-02-24 15:41 更新

緩沖區(qū)溢出與注入分析

前言

雖然程序加載以及動態(tài)符號鏈接都已經(jīng)很理解了,但是這伙卻被進(jìn)程的內(nèi)存映像給”糾纏"住。看著看著就一發(fā)不可收拾——很有趣。

下面一起來探究“緩沖區(qū)溢出和注入”問題(主要是關(guān)心程序的內(nèi)存映像)。

進(jìn)程的內(nèi)存映像

永遠(yuǎn)的 Hello World,太熟悉了吧,

#include <stdio.h>
int main(void)
{
    printf("Hello World\n");
    return 0;
}

如果要用內(nèi)聯(lián)匯編(inline assembly)來寫呢?

 1  /* shellcode.c */
 2  void main()
 3  {
 4      __asm__ __volatile__("jmp forward;"
 5                   "backward:"
 6                           "popl   %esi;"
 7                           "movl   $4, %eax;"
 8                           "movl   $2, %ebx;"
 9                           "movl   %esi, %ecx;"
10                           "movl   $12, %edx;"
11                           "int    $0x80;"    /* system call 1 */
12                           "movl   $1, %eax;"
13                           "movl   $0, %ebx;"
14                           "int    $0x80;"    /* system call 2 */
15                   "forward:"
16                           "call   backward;"
17                           ".string \"Hello World\\n\";");
18  }

看起來很復(fù)雜,實際上就做了一個事情,往終端上寫了個 Hello World 。不過這個非常有意思。先簡單分析一下流程:

  • 第 4 行指令的作用是跳轉(zhuǎn)到第 15 行(即 forward 標(biāo)記處),接著執(zhí)行第 16 行。
  • 第 16 行調(diào)用 backward,跳轉(zhuǎn)到第 5 行,接著執(zhí)行 6 到 14 行。
  • 第 6 行到第 11 行負(fù)責(zé)在終端打印出 Hello World 字符串(等一下詳細(xì)介紹)。
  • 第 12 行到第 14 行退出程序(等一下詳細(xì)介紹)。

為了更好的理解上面的代碼和后續(xù)的分析,先來介紹幾個比較重要的內(nèi)容。

常用寄存器初識

X86 處理器平臺有三個常用寄存器:程序指令指針、程序堆棧指針與程序基指針:

寄存器 名稱 注釋
EIP 程序指令指針 通常指向下一條指令的位置
ESP 程序堆棧指針 通常指向當(dāng)前堆棧的當(dāng)前位置
EBP 程序基指針 通常指向函數(shù)使用的堆棧頂端

當(dāng)然,上面都是擴展的寄存器,用于 32 位系統(tǒng),對應(yīng)的 16 系統(tǒng)為 ip,spbp 。

call,ret 指令的作用分析

  • call 指令

    跳轉(zhuǎn)到某個位置,并在之前把下一條指令的地址(EIP)入棧(為了方便”程序“返回以后能夠接著執(zhí)行)。這樣的話就有:

  call backward   ==>   push eip
                        jmp backward
  • ret 指令

    通常 call 指令和 ret 是配合使用的,前者壓入跳轉(zhuǎn)前的下一條指令地址,后者彈出 call 指令壓入的那條指令,從而可以在函數(shù)調(diào)用結(jié)束以后接著執(zhí)行后面的指令。

  ret                    ==>   pop eip

通常在函數(shù)調(diào)用后,還需要恢復(fù) espebp,恢復(fù) esp 即恢復(fù)當(dāng)前棧指針,以便釋放調(diào)用函數(shù)時為存儲函數(shù)的局部變量而自動分配的空間;恢復(fù) ebp 是從棧中彈出一個數(shù)據(jù)項(通常函數(shù)調(diào)用過后的第一條語句就是 push ebp),從而恢復(fù)當(dāng)前的函數(shù)指針為函數(shù)調(diào)用者本身。這兩個動作可以通過一條 leave 指令完成。

這三個指令對我們后續(xù)的解釋會很有幫助。更多關(guān)于 Intel 的指令集,請參考:Intel 386 Manual, x86 Assembly Language FAQ:part1, part2, part3.

什么是系統(tǒng)調(diào)用(以 Linux 2.6.21 版本和 x86 平臺為例)

系統(tǒng)調(diào)用是用戶和內(nèi)核之間的接口,用戶如果想寫程序,很多時候直接調(diào)用了 C 庫,并沒有關(guān)心系統(tǒng)調(diào)用,而實際上 C 庫也是基于系統(tǒng)調(diào)用的。這樣應(yīng)用程序和內(nèi)核之間就可以通過系統(tǒng)調(diào)用聯(lián)系起來。它們分別處于操作系統(tǒng)的用戶空間和內(nèi)核空間(主要是內(nèi)存地址空間的隔離)。

用戶空間         應(yīng)用程序(Applications)
                        |      |
                        |     C庫(如glibc)
                        |      |
                       系統(tǒng)調(diào)用(System Calls,如sys_read, sys_write, sys_exit)
                            |
內(nèi)核空間              內(nèi)核(Kernel)

系統(tǒng)調(diào)用實際上也是一些函數(shù),它們被定義在 arch/i386/kernel/sys_i386.c (老的在 arch/i386/kernel/sys.c)文件中,并且通過一張系統(tǒng)調(diào)用表組織,該表在內(nèi)核啟動時就已經(jīng)加載了,這個表的入口在內(nèi)核源代碼的 arch/i386/kernel/syscall_table.S 里頭(老的在 arch/i386/kernel/entry.S)。這樣,如果想添加一個新的系統(tǒng)調(diào)用,修改上面兩個內(nèi)核中的文件,并重新編譯內(nèi)核就可以。當(dāng)然,如果要在應(yīng)用程序中使用它們,還得把它寫到 include/asm/unistd.h 中。

如果要在 C 語言中使用某個系統(tǒng)調(diào)用,需要包含頭文件 /usr/include/asm/unistd.h,里頭有各個系統(tǒng)調(diào)用的聲明以及系統(tǒng)調(diào)用號(對應(yīng)于調(diào)用表的入口,即在調(diào)用表中的索引,為方便查找調(diào)用表而設(shè)立的)。如果是自己定義的新系統(tǒng)調(diào)用,可能還要在開頭用宏 _syscall(type, name, type1, name1...)來聲明好參數(shù)。

如果要在匯編語言中使用,需要用到 int 0x80 調(diào)用,這個是系統(tǒng)調(diào)用的中斷入口。涉及到傳送參數(shù)的寄存器有這么幾個,eax 是系統(tǒng)調(diào)用號(可以到 /usr/include/asm-i386/unistd.h 或者直接到 arch/i386/kernel/syscall_table.S 查到),其他寄存器如 ebxecx,edxesi,edi 一次存放系統(tǒng)調(diào)用的參數(shù)。而系統(tǒng)調(diào)用的返回值存放在 eax 寄存器中。

下面我們就很容易解釋前面的 Shellcode.c 程序流程的 2,3 兩部分了。因為都用了 int 0x80 中斷,所以都用到了系統(tǒng)調(diào)用。

第 3 部分很簡單,用到的系統(tǒng)調(diào)用號是 1,通過查表(查 /usr/include/asm-i386/unistd.harch/i386/kernel/syscall_table.S)可以發(fā)現(xiàn)這里是 sys_exit 調(diào)用,再從 /usr/include/unistd.h 文件看這個系統(tǒng)調(diào)用的聲明,發(fā)現(xiàn)參數(shù) ebx 是程序退出狀態(tài)。

第 2 部分比較有趣,而且復(fù)雜一點。我們依次來看各個寄存器,首先根據(jù) eax 為 4 確定(同樣查表)系統(tǒng)調(diào)用為 sys_write,而查看它的聲明(從 /usr/include/unistd.h),我們找到了參數(shù)依次為文件描述符、字符串指針和字符串長度。

  • 第一個參數(shù)是 ebx,正好是 2,即標(biāo)準(zhǔn)錯誤輸出,默認(rèn)為終端。
  • 第二個參數(shù)是 ecx,而 ecx 的內(nèi)容來自 esi,esi 來自剛彈出棧的值(見第 6 行 popl %esi;),而之前剛好有 call 指令引起了最近一次壓棧操作,入棧的內(nèi)容剛好是 call 指令的下一條指令的地址,即 .string 所在行的地址,這樣 ecx 剛好引用了 Hello World\\n 字符串的地址。
  • 第三個參數(shù)是 edx,剛好是 12,即 Hello World\\n 字符串的長度(包括一個空字符)。這樣,Shellcode.c 的執(zhí)行流程就很清楚了,第 4,5,15,16 行指令的巧妙之處也就容易理解了(把 .string 存放在 call 指令之后,并用 popl 指令把 eip 彈出當(dāng)作字符串的入口)。

什么是 ELF 文件

這里的 ELF 不是“精靈”,而是 Executable and Linking Format 文件,是 Linux 下用來做目標(biāo)文件、可執(zhí)行文件和共享庫的一種文件格式,它有專門的標(biāo)準(zhǔn),例如:X86 ELF format and ABI,中文版

下面簡單描述 ELF 的格式。

ELF 文件主要有三種,分別是:

  • 可重定位的目標(biāo)文件,在編譯時用 gcc-c 參數(shù)時產(chǎn)生。
  • 可執(zhí)行文件,這類文件就是我們后面要討論的可以執(zhí)行的文件。
  • 共享庫,這里主要是動態(tài)共享庫,而靜態(tài)共享庫則是可重定位的目標(biāo)文件通過 ar 命令組織的。

ELF 文件的大體結(jié)構(gòu):

ELF Header               #程序頭,有該文件的Magic number(參考man magic),類型等
Program Header Table     #對可執(zhí)行文件和共享庫有效,它描述下面各個節(jié)(section)組成的段
Section1
Section2
Section3
.....
Program Section Table   #僅對可重定位目標(biāo)文件和靜態(tài)庫有效,用于描述各個Section的重定位信息等。

對于可執(zhí)行文件,文件最后的 Program Section Table (節(jié)區(qū)表)和一些非重定位的 Section,比如 .comment,.note.XXX.debug 等信息都可以刪除掉,不過如果用 stripobjcopy 等工具刪除掉以后,就不可恢復(fù)了。因為這些信息對程序的運行一般沒有任何用處。

ELF 文件的主要節(jié)區(qū)(section)有 .data.text,.bss.interp 等,而主要段(segment)有 LOAD,INTERP 等。它們之間(節(jié)區(qū)和段)的主要對應(yīng)關(guān)系如下:

Section 解釋 實例
.data 初始化的數(shù)據(jù) 比如 int a=10
.bss 未初始化的數(shù)據(jù) 比如 char sum[100]; 這個在程序執(zhí)行之前,內(nèi)核將初始化為 0
.text 程序代碼正文 即可執(zhí)行指令集
.interp 描述程序需要的解釋器(動態(tài)連接和裝載程序) 存有解釋器的全路徑,如 /lib/ld-linux.so

而程序在執(zhí)行以后,.data,.bss.text 等一些節(jié)區(qū)會被 Program header table 映射到 LOAD 段,.interp 則被映射到了 INTERP 段。

對于 ELF 文件的分析,建議使用 file,sizereadelf,objdumpstrip,objcopygdb,nm 等工具。

這里簡單地演示這幾個工具:

$ gcc -g -o shellcode shellcode.c  #如果要用gdb調(diào)試,編譯時加上-g是必須的
shellcode.c: In function ‘main’:
shellcode.c:3: warning: return type of ‘main’ is not ‘int’
f$ file shellcode  #file命令查看文件類型,想了解工作原理,可man magic,man file
shellcode: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),
dynamically linked (uses shared libs), not stripped
$ readelf -l shellcode  #列出ELF文件前面的program head table,后面是它描
                       #述了各個段(segment)和節(jié)區(qū)(section)的關(guān)系,即各個段包含哪些節(jié)區(qū)。
Elf file type is EXEC (Executable file)
Entry point 0x8048280
There are 7 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
  INTERP         0x000114 0x08048114 0x08048114 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD           0x000000 0x08048000 0x08048000 0x0044c 0x0044c R E 0x1000
  LOAD           0x00044c 0x0804944c 0x0804944c 0x00100 0x00104 RW  0x1000
  DYNAMIC        0x000460 0x08049460 0x08049460 0x000c8 0x000c8 RW  0x4
  NOTE           0x000128 0x08048128 0x08048128 0x00020 0x00020 R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x4

 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r
          .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
   03     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
   04     .dynamic
   05     .note.ABI-tag
   06
$ size shellcode   #可用size命令查看各個段(對應(yīng)后面將分析的進(jìn)程內(nèi)存映像)的大小
   text    data     bss     dec     hex filename
    815     256       4    1075     433 shellcode
$ strip -R .note.ABI-tag shellcode #可用strip來給可執(zhí)行文件“減肥”,刪除無用信息
$ size shellcode               #“減肥”后效果“明顯”,對于嵌入式系統(tǒng)應(yīng)該有很大的作用
   text    data     bss     dec     hex filename
    783     256       4    1043     413 shellcode
$ objdump -s -j .interp shellcode #這個主要工作是反編譯,不過用來查看各個節(jié)區(qū)也很厲害

shellcode:     file format elf32-i386

Contents of section .interp:
 8048114 2f6c6962 2f6c642d 6c696e75 782e736f  /lib/ld-linux.so
 8048124 2e3200                               .2.

補充:如果要刪除可執(zhí)行文件的 Program Section Table,可以用 A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux 一文的作者寫的 elf kicker 工具鏈中的 sstrip 工具。

程序執(zhí)行基本過程

在命令行下,敲入程序的名字或者是全路徑,然后按下回車就可以啟動程序,這個具體是怎么工作的呢?

首先要再認(rèn)識一下我們的命令行,命令行是內(nèi)核和用戶之間的接口,它本身也是一個程序。在 Linux 系統(tǒng)啟動以后會為每個終端用戶建立一個進(jìn)程執(zhí)行一個 Shell 解釋程序,這個程序解釋并執(zhí)行用戶輸入的命令,以實現(xiàn)用戶和內(nèi)核之間的接口。這類解釋程序有哪些呢?目前 Linux 下比較常用的有 /bin/bash 。那么該程序接收并執(zhí)行命令的過程是怎么樣的呢?

先簡單描述一下這個過程:

  • 讀取用戶由鍵盤輸入的命令行。
  • 分析命令,以命令名作為文件名,并將其它參數(shù)改為系統(tǒng)調(diào)用 execve 內(nèi)部處理所要求的形式。
  • 終端進(jìn)程調(diào)用 fork 建立一個子進(jìn)程。
  • 終端進(jìn)程本身用系統(tǒng)調(diào)用 wait4 來等待子進(jìn)程完成(如果是后臺命令,則不等待)。當(dāng)子進(jìn)程運行時調(diào)用 execve,子進(jìn)程根據(jù)文件名(即命令名)到目錄中查找有關(guān)文件(這是命令解釋程序構(gòu)成的文件),將它調(diào)入內(nèi)存,執(zhí)行這個程序(解釋這條命令)。
  • 如果命令末尾有 & 號(后臺命令符號),則終端進(jìn)程不用系統(tǒng)調(diào)用 wait4 等待,立即發(fā)提示符,讓用戶輸入下一個命令,轉(zhuǎn) 1)。如果命令末尾沒有 & 號,則終端進(jìn)程要一直等待,當(dāng)子進(jìn)程(即運行命令的進(jìn)程)完成處理后終止,向父進(jìn)程(終端進(jìn)程)報告,此時終端進(jìn)程醒來,在做必要的判別等工作后,終端進(jìn)程發(fā)提示符,讓用戶輸入新的命令,重復(fù)上述處理過程。

現(xiàn)在用 strace 來跟蹤一下程序執(zhí)行過程中用到的系統(tǒng)調(diào)用。

$ strace -f -o strace.out test
$ cat strace.out | grep \(.*\) | sed -e "s#[0-9]* \([a-zA-Z0-9_]*\)(.*).*#\1#g"
execve
brk
access
open
fstat64
mmap2
close
open
read
fstat64
mmap2
mmap2
mmap2
mmap2
close
mmap2
set_thread_area
mprotect
munmap
brk
brk
open
fstat64
mmap2
close
close
close
exit_group

相關(guān)的系統(tǒng)調(diào)用基本體現(xiàn)了上面的執(zhí)行過程,需要注意的是,里頭還涉及到內(nèi)存映射(mmap2)等。

下面再羅嗦一些比較有意思的內(nèi)容,參考《深入理解 Linux 內(nèi)核》的程序的執(zhí)行(P681)。

Linux 支持很多不同的可執(zhí)行文件格式,這些不同的格式是如何解釋的呢?平時我們在命令行下敲入一個命令就完了,也沒有去管這些細(xì)節(jié)。實際上 Linux 下有一個 struct linux_binfmt 結(jié)構(gòu)來管理不同的可執(zhí)行文件類型,這個結(jié)構(gòu)中有對應(yīng)的可執(zhí)行文件的處理函數(shù)。大概的過程如下:

  • 在用戶態(tài)執(zhí)行了 execve 后,引發(fā) int 0x80 中斷,進(jìn)入內(nèi)核態(tài),執(zhí)行內(nèi)核態(tài)的相應(yīng)函數(shù) do_sys_execve,該函數(shù)又調(diào)用 do_execve 函數(shù)。 do_execve 函數(shù)讀入可執(zhí)行文件,檢查權(quán)限,如果沒問題,繼續(xù)讀入可執(zhí)行文件需要的相關(guān)信息(struct linux_binprm 描述的)。

  • 接著執(zhí)行 search_binary_handler,根據(jù)可執(zhí)行文件的類型(由上一步的最后確定),在 linux_binfmt 結(jié)構(gòu)鏈表(formats,這個鏈表可以通過 register_binfmtunregister_binfmt 注冊和刪除某些可執(zhí)行文件的信息,因此注冊新的可執(zhí)行文件成為可能,后面再介紹)上查找,找到相應(yīng)的結(jié)構(gòu),然后執(zhí)行相應(yīng)的 load_binary 函數(shù)開始加載可執(zhí)行文件。在該鏈表的最后一個元素總是對解釋腳本(interpreted script)的可執(zhí)行文件格式進(jìn)行描述的一個對象。這種格式只定義了 load_binary 方法,其相應(yīng)的 load_script 函數(shù)檢查這種可執(zhí)行文件是否以兩個 #! 字符開始,如果是,這個函數(shù)就以另一個可執(zhí)行文件的路徑名作為參數(shù)解釋第一行的其余部分,并把腳本文件名作為參數(shù)傳遞以執(zhí)行這個腳本(實際上腳本程序把自身的內(nèi)容當(dāng)作一個參數(shù)傳遞給了解釋程序(如 /bin/bash),而這個解釋程序通常在腳本文件的開頭用 #! 標(biāo)記,如果沒有標(biāo)記,那么默認(rèn)解釋程序為當(dāng)前 SHELL)。

  • 對于 ELF 類型文件,其處理函數(shù)是 load_elf_binary,它先讀入 ELF 文件的頭部,根據(jù)頭部信息讀入各種數(shù)據(jù),再次掃描程序段描述表(Program Header Table),找到類型為 PT_LOAD 的段(即 .text,.data.bss 等節(jié)區(qū)),將其映射(elf_map)到內(nèi)存的固定地址上,如果沒有動態(tài)連接器的描述段,把返回的入口地址設(shè)置成應(yīng)用程序入口。完成這個功能的是 start_thread,它不啟動一個線程,而只是用來修改了 pt_regs 中保存的 PC 等寄存器的值,使其指向加載的應(yīng)用程序的入口。當(dāng)內(nèi)核操作結(jié)束,返回用戶態(tài)時接著就執(zhí)行應(yīng)用程序本身了。

  • 如果應(yīng)用程序使用了動態(tài)連接庫,內(nèi)核除了加載指定的可執(zhí)行文件外,還要把控制權(quán)交給動態(tài)連接器(ld-linux.so)以便處理動態(tài)連接的程序。內(nèi)核搜尋段表(Program Header Table),找到標(biāo)記為 PT_INTERP 段中所對應(yīng)的動態(tài)連接器的名稱,并使用 load_elf_interp 加載其映像,并把返回的入口地址設(shè)置成 load_elf_interp 的返回值,即動態(tài)鏈接器的入口。當(dāng) execve 系統(tǒng)調(diào)用退出時,動態(tài)連接器接著運行,它檢查應(yīng)用程序?qū)蚕礞溄訋斓囊蕾囆?,并在需要時對其加載,對程序的外部引用進(jìn)行重定位(具體過程見《進(jìn)程和進(jìn)程的基本操作》)。然后把控制權(quán)交給應(yīng)用程序,從 ELF 文件頭部中定義的程序進(jìn)入點(用 readelf -h 可以出看到,Entry point address 即是)開始執(zhí)行。(不過對于非 LIB_BIND_NOW 的共享庫裝載是在有外部引用請求時才執(zhí)行的)。

對于內(nèi)核態(tài)的函數(shù)調(diào)用過程,沒有辦法通過 strace(它只能跟蹤到系統(tǒng)調(diào)用層)來做的,因此要想跟蹤內(nèi)核中各個系統(tǒng)調(diào)用的執(zhí)行細(xì)節(jié),需要用其他工具。比如可以通過 Ftrace 來跟蹤內(nèi)核具體調(diào)用了哪些函數(shù)。當(dāng)然,也可以通過 ctags/cscope/LXR 等工具分析內(nèi)核的源代碼。

Linux 允許自己注冊我們自己定義的可執(zhí)行格式,主要接口是 /procy/sys/fs/binfmt_misc/register,可以往里頭寫入特定格式的字符串來實現(xiàn)。該字符串格式如下::name:type:offset:string:mask:interpreter:

  • name 新格式的標(biāo)示符
  • type 識別類型(M 表示魔數(shù),E 表示擴展)
  • offset 魔數(shù)(magic number,請參考 man magicman file)在文件中的啟始偏移量
  • string 以魔數(shù)或者以擴展名匹配的字節(jié)序列
  • mask 用來屏蔽掉 string 的一些位
  • interpreter 程序解釋器的完整路徑名

Linux 下程序的內(nèi)存映像

Linux 下是如何給進(jìn)程分配內(nèi)存(這里僅討論虛擬內(nèi)存的分配)的呢?可以從 /proc/<pid>/maps 文件中看到個大概。這里的 pid 是進(jìn)程號。

/proc 下有一個文件比較特殊,是 self,它鏈接到當(dāng)前進(jìn)程的進(jìn)程號,例如:

$ ls /proc/self -l
lrwxrwxrwx 1 root root 64 2000-01-10 18:26 /proc/self -> 11291/
$ ls /proc/self -l
lrwxrwxrwx 1 root root 64 2000-01-10 18:26 /proc/self -> 11292/

看到?jīng)]?每次都不一樣,這樣我們通過 cat /proc/self/maps 就可以看到 cat 程序執(zhí)行時的內(nèi)存映像了。

$ cat -n /proc/self/maps
     1  08048000-0804c000 r-xp 00000000 03:01 273716     /bin/cat
     2  0804c000-0804d000 rw-p 00003000 03:01 273716     /bin/cat
     3  0804d000-0806e000 rw-p 0804d000 00:00 0          [heap]
     4  b7b90000-b7d90000 r--p 00000000 03:01 87528      /usr/lib/locale/locale-archive
     5  b7d90000-b7d91000 rw-p b7d90000 00:00 0
     6  b7d91000-b7ecd000 r-xp 00000000 03:01 466875     /lib/libc-2.5.so
     7  b7ecd000-b7ece000 r--p 0013c000 03:01 466875     /lib/libc-2.5.so
     8  b7ece000-b7ed0000 rw-p 0013d000 03:01 466875     /lib/libc-2.5.so
     9  b7ed0000-b7ed4000 rw-p b7ed0000 00:00 0
    10  b7eeb000-b7f06000 r-xp 00000000 03:01 402817     /lib/ld-2.5.so
    11  b7f06000-b7f08000 rw-p 0001b000 03:01 402817     /lib/ld-2.5.so
    12  bfbe3000-bfbf8000 rw-p bfbe3000 00:00 0          [stack]
    13  ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]

編號是原文件里頭沒有的,為了說明方便,用 -n 參數(shù)加上去的。我們從中可以得到如下信息:

  • 第 1,2 行對應(yīng)的內(nèi)存區(qū)是我們的程序(包括指令,數(shù)據(jù)等)
  • 第 3 到 12 行對應(yīng)的內(nèi)存區(qū)是堆棧段,里頭也映像了程序引用的動態(tài)連接庫
  • 第 13 行是內(nèi)核空間

總結(jié)一下:

  • 前兩部分是用戶空間,可以從 0x000000000xbfffffff (在測試的 2.6.21.5-smp 上只到 bfbf8000),而內(nèi)核空間從 0xC00000000xffffffff,分別是 3G1G,所以對于每一個進(jìn)程來說,共占用 4G 的虛擬內(nèi)存空間
  • 從程序本身占用的內(nèi)存,到堆棧段(動態(tài)獲取內(nèi)存或者是函數(shù)運行過程中用來存儲局部變量、參數(shù)的空間,前者是 heap,后者是 stack),再到內(nèi)核空間,地址是從低到高的
  • 棧頂并非 0xC0000000 下的一個固定數(shù)值

結(jié)合相關(guān)資料,可以得到這么一個比較詳細(xì)的進(jìn)程內(nèi)存映像表(以 Linux 2.6.21.5-smp 為例):

地址 內(nèi)核空間 描述
0xC0000000
(program flie) 程序名 execve 的第一個參數(shù)
(environment) 環(huán)境變量 execve 的第三個參數(shù),main 的第三個參數(shù)
(arguments) 參數(shù) execve 的第二個參數(shù),main 的形參
(stack) 棧 自動變量以及每次函數(shù)調(diào)用時所需保存的信息都
存放在此,包括函數(shù)返回地址、調(diào)用者的
環(huán)境信息等,函數(shù)的參數(shù),局部變量都存放在此
(shared memory) 共享內(nèi)存 共享內(nèi)存的大概位置
...
...
(heap) 堆 主要在這里進(jìn)行動態(tài)存儲分配,比如 malloc,new 等。
...
.bss (uninitilized data) 沒有初始化的數(shù)據(jù)(全局變量哦)
.data (initilized global data) 已經(jīng)初始化的全局?jǐn)?shù)據(jù)(全局變量)
.text (Executable Instructions) 通常是可執(zhí)行指令
0x08048000
0x00000000 ...

光看沒有任何概念,我們用 gdb 來看看剛才那個簡單的程序。

$ gcc -g -o shellcode shellcode.c   #要用gdb調(diào)試,在編譯時需要加-g參數(shù)
$ gdb -q ./shellcode
(gdb) set args arg1 arg2 arg3 arg4  #為了測試,設(shè)置幾個參數(shù)
(gdb) l                             #瀏覽代碼
1 /* shellcode.c */
2 void main()
3 {
4     __asm__ __volatile__("jmp forward;"
5     "backward:"
6        "popl   %esi;"
7        "movl   $4, %eax;"
8        "movl   $2, %ebx;"
9        "movl   %esi, %ecx;"
10               "movl   $12, %edx;"
(gdb) break 4               #在匯編入口設(shè)置一個斷點,讓程序運行后停到這里
Breakpoint 1 at 0x8048332: file shellcode.c, line 4.
(gdb) r                     #運行程序
Starting program: /mnt/hda8/Temp/c/program/shellcode arg1 arg2 arg3 arg4

Breakpoint 1, main () at shellcode.c:4
4     __asm__ __volatile__("jmp forward;"
(gdb) print $esp            #打印當(dāng)前堆棧指針值,用于查找整個棧的棧頂
$1 = (void *) 0xbffe1584
(gdb) x/100s $esp+4000      #改變后面的4000,不斷往更大的空間找
(gdb) x/1s 0xbffe1fd9       #在 0xbffe1fd9 找到了程序名,這里是該次運行時的棧頂
0xbffe1fd9:      "/mnt/hda8/Temp/c/program/shellcode"
(gdb) x/10s 0xbffe17b7      #其他環(huán)境變量信息
0xbffe17b7:      "CPLUS_INCLUDE_PATH=/usr/lib/qt/include"
0xbffe17de:      "MANPATH=/usr/local/man:/usr/man:/usr/X11R6/man:/usr/lib/java/man:/usr/share/texmf/man"
0xbffe1834:      "HOSTNAME=falcon.lzu.edu.cn"
0xbffe184f:      "TERM=xterm"
0xbffe185a:      "SSH_CLIENT=219.246.50.235 3099 22"
0xbffe187c:      "QTDIR=/usr/lib/qt"
0xbffe188e:      "SSH_TTY=/dev/pts/0"
0xbffe18a1:      "USER=falcon"
...
(gdb) x/5s 0xbffe1780    #一些傳遞給main函數(shù)的參數(shù),包括文件名和其他參數(shù)
0xbffe1780:      "/mnt/hda8/Temp/c/program/shellcode"
0xbffe17a3:      "arg1"
0xbffe17a8:      "arg2"
0xbffe17ad:      "arg3"
0xbffe17b2:      "arg4"
(gdb) print init  #打印init函數(shù)的地址,這個是/usr/lib/crti.o里頭的函數(shù),做一些初始化操作
$2 = {<text variable, no debug info>} 0xb7e73d00 <init>
(gdb) print fini   #也在/usr/lib/crti.o中定義,在程序結(jié)束時做一些處理工作
$3 = {<text variable, no debug info>} 0xb7f4a380 <fini>
(gdb) print _start #在/usr/lib/crt1.o,這個才是程序的入口,必須的,ld會檢查這個
$4 = {<text variable, no debug info>} 0x8048280 <__libc_start_main@plt+20>
(gdb) print main   #這里是我們的main函數(shù)
$5 = {void ()} 0x8048324 <main>

補充:在進(jìn)程的內(nèi)存映像中可能看到諸如 init,fini_start 等函數(shù)(或者是入口),這些東西并不是我們自己寫的?。繛槭裁磿艿轿覀兊拇a里頭呢?實際上這些東西是鏈接的時候 gcc 默認(rèn)給連接進(jìn)去的,主要用來做一些進(jìn)程的初始化和終止的動作。更多相關(guān)的細(xì)節(jié)可以參考資料如何獲取當(dāng)前進(jìn)程之靜態(tài)影像文件和"The Linux Kernel Primer", P234, Figure 4.11,如果想了解鏈接(ld)的具體過程,可以看看本節(jié)參考《Unix環(huán)境高級編程編程》第7章 "UnIx進(jìn)程的環(huán)境", P127和P13,ELF: From The Programmer's Perspective,GNU-ld 連接腳本 Linker Scripts

上面的操作對堆棧的操作比較少,下面我們用一個例子來演示棧在內(nèi)存中的情況。

棧在內(nèi)存中的組織

這一節(jié)主要介紹一個函數(shù)被調(diào)用時,參數(shù)是如何傳遞的,局部變量是如何存儲的,它們對應(yīng)的棧的位置和變化情況,從而加深對棧的理解。在操作時發(fā)現(xiàn)和參考資料的結(jié)果不太一樣(參考資料中沒有 ediesi 相關(guān)信息,再第二部分的一個小程序里頭也沒有),可能是 gcc 版本的問題或者是它對不同源代碼的處理不同。我的版本是 4.1.2 (可以通過 gcc --version 查看)。

先來一段簡單的程序,這個程序除了做一個加法操作外,還復(fù)制了一些字符串。

/* testshellcode.c */
#include <stdio.h>      /* printf */
#include <string.h>     /* memset, memcpy */

#define BUF_SIZE 8

#ifndef STR_SRC
# define STR_SRC "AAAAAAA"
#endif

int func(int a, int b, int c)
{
    int sum = 0;
    char buffer[BUF_SIZE];

    sum = a + b + c;

    memset(buffer, '\0', BUF_SIZE);
    memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);

    return sum;
}

int main()
{
    int sum;

    sum = func(1, 2, 3);

    printf("sum = %d\n", sum);

    return 0;
}

上面這個代碼沒有什么問題,編譯執(zhí)行一下:

$ make testshellcode
cc     testshellcode.c   -o testshellcode
$ ./testshellcode
sum = 6

下面調(diào)試一下,看看在調(diào)用 func 后的棧的內(nèi)容。

$ gcc -g -o testshellcode testshellcode.c  #為了調(diào)試,需要在編譯時加-g選項
$ gdb -q ./testshellcode   #啟動gdb調(diào)試
...
(gdb) set logging on    #如果要記錄調(diào)試過程中的信息,可以把日志記錄功能打開
Copying output to gdb.txt.
(gdb) l main            #列出源代碼
20
21              return sum;
22      }
23
24      int main()
25      {
26              int sum;
27
28              sum = func(1, 2, 3);
29
(gdb) break 28   #在調(diào)用func函數(shù)之前讓程序停一下,以便記錄當(dāng)時的ebp(基指針)
Breakpoint 1 at 0x80483ac: file testshellcode.c, line 28.
(gdb) break func #設(shè)置斷點在函數(shù)入口,以便逐步記錄棧信息
Breakpoint 2 at 0x804835c: file testshellcode.c, line 13.
(gdb) disassemble main   #反編譯main函數(shù),以便記錄調(diào)用func后的下一條指令地址
Dump of assembler code for function main:
0x0804839b <main+0>:    lea    0x4(%esp),%ecx
0x0804839f <main+4>:    and    $0xfffffff0,%esp
0x080483a2 <main+7>:    pushl  0xfffffffc(%ecx)
0x080483a5 <main+10>:   push   %ebp
0x080483a6 <main+11>:   mov    %esp,%ebp
0x080483a8 <main+13>:   push   %ecx
0x080483a9 <main+14>:   sub    $0x14,%esp
0x080483ac <main+17>:   push   $0x3
0x080483ae <main+19>:   push   $0x2
0x080483b0 <main+21>:   push   $0x1
0x080483b2 <main+23>:   call   0x8048354 <func>
0x080483b7 <main+28>:   add    $0xc,%esp
0x080483ba <main+31>:   mov    %eax,0xfffffff8(%ebp)
0x080483bd <main+34>:   sub    $0x8,%esp
0x080483c0 <main+37>:   pushl  0xfffffff8(%ebp)
0x080483c3 <main+40>:   push   $0x80484c0
0x080483c8 <main+45>:   call   0x80482a0 <printf@plt>
0x080483cd <main+50>:   add    $0x10,%esp
0x080483d0 <main+53>:   mov    $0x0,%eax
0x080483d5 <main+58>:   mov    0xfffffffc(%ebp),%ecx
0x080483d8 <main+61>:   leave
0x080483d9 <main+62>:   lea    0xfffffffc(%ecx),%esp
0x080483dc <main+65>:   ret
End of assembler dump.
(gdb) r        #運行程序
Starting program: /mnt/hda8/Temp/c/program/testshellcode

Breakpoint 1, main () at testshellcode.c:28
28              sum = func(1, 2, 3);
(gdb) print $ebp  #打印調(diào)用func函數(shù)之前的基地址,即Previous frame pointer。
$1 = (void *) 0xbf84fdd8
(gdb) n           #執(zhí)行call指令并跳轉(zhuǎn)到func函數(shù)的入口

Breakpoint 2, func (a=1, b=2, c=3) at testshellcode.c:13
13              int sum = 0;
(gdb) n
16              sum = a + b + c;
(gdb) x/11x $esp  #打印當(dāng)前棧的內(nèi)容,可以看出,地址從低到高,注意標(biāo)記有藍(lán)色和紅色的值
                 #它們分別是前一個?;刂?ebp)和call調(diào)用之后的下一條指令的指針(eip)
0xbf84fd94:     0x00000000      0x00000000      0x080482e0      0x00000000
0xbf84fda4:     0xb7f2bce0      0x00000000      0xbf84fdd8      0x080483b7
0xbf84fdb4:     0x00000001      0x00000002      0x00000003
(gdb) n       #執(zhí)行sum = a + b + c,后,比較棧內(nèi)容第一行,第4列,由0變?yōu)?
18              memset(buffer, '\0', BUF_SIZE);
(gdb) x/11x $esp
0xbf84fd94:     0x00000000      0x00000000      0x080482e0      0x00000006
0xbf84fda4:     0xb7f2bce0      0x00000000      0xbf84fdd8      0x080483b7
0xbf84fdb4:     0x00000001      0x00000002      0x00000003
(gdb) n
19              memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
(gdb) x/11x $esp #緩沖區(qū)初始化以后變成了0
0xbf84fd94:     0x00000000      0x00000000      0x00000000      0x00000006
0xbf84fda4:     0xb7f2bce0      0x00000000      0xbf84fdd8      0x080483b7
0xbf84fdb4:     0x00000001      0x00000002      0x00000003
(gdb) n
21              return sum;
(gdb) x/11x $esp #進(jìn)行copy以后,這兩列的值變了,大小剛好是7個字節(jié),最后一個字節(jié)為'\0'
0xbf84fd94:     0x00000000      0x41414141      0x00414141      0x00000006
0xbf84fda4:     0xb7f2bce0      0x00000000      0xbf84fdd8      0x080483b7
0xbf84fdb4:     0x00000001      0x00000002      0x00000003
(gdb) c
Continuing.
sum = 6

Program exited normally.
(gdb) quit

從上面的操作過程,我們可以得出大概的棧分布(func 函數(shù)結(jié)束之前)如下:

地址 值(hex) 符號或者寄存器 注釋
低地址 棧頂方向
0xbf84fd98 0x41414141 buf[0] 可以看出little endian(小端,重要的數(shù)據(jù)在前面)
0xbf84fd9c 0x00414141 buf[1]
0xbf84fda0 0x00000006 sum 可見這上面都是func函數(shù)里頭的局部變量
0xbf84fda4 0xb7f2bce0 esi 源索引指針,可以通過產(chǎn)生中間代碼查看,貌似沒什么作用
0xbf84fda8 0x00000000 edi 目的索引指針
0xbf84fdac 0xbf84fdd8 ebp 調(diào)用func之前的棧的基地址,以便調(diào)用函數(shù)結(jié)束之后恢復(fù)
0xbf84fdb0 0x080483b7 eip 調(diào)用func之前的指令指針,以便調(diào)用函數(shù)結(jié)束之后繼續(xù)執(zhí)行
0xbf84fdb4 0x00000001 a 第一個參數(shù)
0xbf84fdb8 0x00000002 b 第二個參數(shù)
0xbf84fdbc 0x00000003 c 第三個參數(shù),可見參數(shù)是從最后一個開始壓棧的
高地址 棧底方向

先說明一下 ediesi 的由來(在上面的調(diào)試過程中我們并沒有看到),是通過產(chǎn)生中間匯編代碼分析得出的。

$ gcc -S testshellcode.c

在產(chǎn)生的 testShellcode.s 代碼里頭的 func 部分看到 push ebp 之后就 pushediesi 。但是搜索了一下代碼,發(fā)現(xiàn)就這個函數(shù)里頭引用了這兩個寄存器,所以保存它們沒什么用,刪除以后編譯產(chǎn)生目標(biāo)代碼后證明是沒用的。

$ cat testshellcode.s
...
func:
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %edi
        pushl   %esi
...
        popl    %esi
        popl    %edi
        popl    %ebp
...

下面就不管這兩部分(ediesi)了,主要來分析和函數(shù)相關(guān)的這幾部分在棧內(nèi)的分布:

  • 函數(shù)局部變量,在靠近棧頂一端
  • 調(diào)用函數(shù)之前的棧的基地址(ebp,Previous Frame Pointer),在中間靠近棧頂方向
  • 調(diào)用函數(shù)指令的下一條指令地址 ` (eip`),在中間靠近棧底的方向
  • 函數(shù)參數(shù),在靠近棧底的一端,最后一個參數(shù)最先入棧

到這里,函數(shù)調(diào)用時的相關(guān)內(nèi)容在棧內(nèi)的分布就比較清楚了,在具體分析緩沖區(qū)溢出問題之前,我們再來看一個和函數(shù)關(guān)系很大的問題,即函數(shù)返回值的存儲問題:函數(shù)的返回值存放在寄存器 eax 中。

先來看這段代碼:

/**
 * test_return.c -- the return of a function is stored in register eax
 */

#include <stdio.h>

int func()
{
        __asm__ ("movl $1, %eax");
}

int main()
{
        printf("the return of func: %d\n", func());

        return 0;
}

編譯運行后,可以看到返回值為 1,剛好是我們在 func 函數(shù)中 moveax 中的“立即數(shù)” 1,因此很容易理解返回值存儲在 eax 中的事實,如果還有疑慮,可以再看看匯編代碼。在函數(shù)返回之后,eax 中的值當(dāng)作了 printf 的參數(shù)壓入了棧中,而在源代碼中我們正是把 func 的結(jié)果作為 printf 的第二個參數(shù)的。

$ make test_return
cc     test_return.c   -o test_return
$ ./test_return
the return of func: 1
$ gcc -S test_return.c
$ cat test_return.s
...
        call    func
        subl    $8, %esp
        pushl   %eax      #printf的第二個參數(shù),把func的返回值壓入了棧底
        pushl   $.LC0     #printf的第一個參數(shù)the return of func: %d\n
        call    printf
...

對于系統(tǒng)調(diào)用,返回值也存儲在 eax 寄存器中。

緩沖區(qū)溢出

實例分析:字符串復(fù)制

先來看一段簡短的代碼。

/* testshellcode.c */
#include <stdio.h>      /* printf */
#include <string.h>     /* memset, memcpy */

#define BUF_SIZE 8

#ifdef STR1
# define STR_SRC "AAAAAAA\0\1\0\0\0"
#endif

#ifndef STR_SRC
# define STR_SRC "AAAAAAA"
#endif

int func(int a, int b, int c)
{
        int sum = 0;
        char buffer[BUF_SIZE];

        sum = a + b + c;

        memset(buffer, '\0', BUF_SIZE);
        memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);

        return sum;
}

int main()
{
        int sum;

        sum = func(1, 2, 3);

        printf("sum = %d\n", sum);

        return 0;
}

編譯一下看看結(jié)果:

$ gcc -DSTR1 -o testshellcode testshellcode.c  #通過-D定義宏STR1,從而采用第一個STR_SRC的值
$ ./testshellcode
sum = 1

不知道你有沒有發(fā)現(xiàn)異常呢?上面用紅色標(biāo)記的地方,本來 sum1+2+3 即 6,但是實際返回的竟然是 1 。到底是什么原因呢?大家應(yīng)該有所了解了,因為我們在復(fù)制字符串 AAAAAAA\\0\\1\\0\\0\\0buf 的時候超出 buf 本來的大小。 buf 本來的大小是 BUF_SIZE,8 個字節(jié),而我們要復(fù)制的內(nèi)容是 12 個字節(jié),所以超出了四個字節(jié)。根據(jù)第一小節(jié)的分析,我們用棧的變化情況來表示一下這個復(fù)制過程(即執(zhí)行 memcpy 的過程)。

memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);

(低地址)
復(fù)制之前     ====> 復(fù)制之后
0x00000000       0x41414141      #char buf[8]
0x00000000       0x00414141
0x00000006       0x00000001      #int sum
(高地址)

下面通過 gdb 調(diào)

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號