2021浙江省赛pwn2复盘
这题在比赛是没有做出来的,属于赛后复盘,但是感觉这题不该在比赛做不出来,因为赛后花了两小时就出了。
静态分析
首先checksec发现保护全开了,ida打开,主函数是一个while 1 循环且没有return,如果要栈溢出得在其它函数。首先ida分析有部分不到位,有一个很明显的值赋值指针,然后后面还有对指针之后的元素读取一个int型变量,很明显栈布局是这样的。
1 | char buf[] |
然后我们去混淆一下,去除那个指针刚好是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 | pop_rdi_ret=0x0000000000026b72#libc中寻找的 |
可以看到后面输出了很多东西,但是因为栈不存在内存页对齐的说法,所以最后一个字节改大之后能输出多少东西全凭运气。我们可以在输入之后用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 | p.recvuntil(b'\x7f') |
这里我关了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 |
|
可以看出来,返回地址后面紧跟着的就是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 | offset=stack_addr+8+0x100-((stack_addr+8)|0xff) |
劫持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 | payload=(b'e'*0xb8+p64(code_base+0x130a)).ljust(0xf8,b'e')+p64(pop_rdi_ret) |
但是发现执行着突然退出了,而当时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 | payload=b'e'*0xb8+p64(code_base+0x130a) |
能稳定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 | from pwn import * |