pwn专题入门分享系列记录3
对于这一题(出自iscc2017)我是又爱又恨,爱是因为它帮助我去学习了一直以来没去实践过的堆分配策略、double free漏洞等相关的经典知识,恨是因为个人水平有限被这道题整整虐了五天,每次觉得突破了一个关卡后面又等着一个坑,让人欲罢不能,最终也实在无奈又好奇地去求教一些大佬才得以解惑,也着实受益匪浅。下面分享一下这题的心路历程吧~
先看一下程序的功能,每次会输出包含4个选项的菜单,分别是1、create(创建内存堆块并保存输入数据)2、read(读取某堆块内存的数据)3、free(释放某堆块的内存数据)4、bye(退出):
看到free选项就感觉和堆分配,看了代码果不其然,对应功能的3个函数名称都带又“_chunk”,一般堆块结构才会使用这个术语:
接着就在代码里找可利用的漏洞了,一般这种题可能结合“溢出”,所以重点关注了一下相关处理函数,果然发现了一处:
此函数是用来读取list数组保存的堆块指针对应的内容,由用户指定要读取的堆块索引result,仔细看变量result的数据类型变化,一开始定义的是64位有符号的整型数,在判断是否为0xFFFFFFFF的时候转换成了32位无符号整型数(DWORD),然后与chunk_number比较、当作数组list索引的时候,又转化成了32位有符号整型数。这些转换存在潜在的漏洞可以让数组list的索引result为一个负数来向前(低地址)引用,当然,最终为这个漏洞创建利用条件的是真正发生整数溢出漏洞的函数read_int():
从这个函数的整体功能上看,就是把用户输入的十进制数字符串转换成数组的id索引(整型数),代码规定用户最多输入10位十进制数,这样可以计算得到最大值应该是0x2540BE3FF(10个9即9999999999),最高位“2”已超过了32位整数的范围,这样就存在让num(32位无符号整型)溢出的漏洞,返回到上一层去索引数组就可以构造“负索引”的条件了。于是,我们的exp可以先定义一个read_chunk函数,支持读取list数组的负索引数据:
def read_chunk(id):
target.sendline('2')
target.recvuntil('id: ')
if len(str(id & 0xffffffff)) == 10:
target.send(str(id & 0xffffffff))
else:
target.sendline(str(id & 0xffffffff))
return target.recv()
另外,继续看代码可以发现引用read_int函数的还有free_chunk函数,该函数引用list数组的时候也存在类似可以释放“负索引”元素的情况:
于是,也顺便定义了该函数,支持“负索引”:
def free_chunk(id):
target.sendline('3')
target.recvuntil('id: ')
if len(str(id & 0xffffffff)) == 10:
target.send(str(id & 0xffffffff))
else:
target.sendline(str(id & 0xffffffff))
接下来,考虑这个“负索引”漏洞有什么作用。索引的数组list是一个全局的64位指针数组,且在程序中指定其数组计数chunk_number也是一个全局的32位有符号整型变量:
这样,当list索引为负数时,list前面的数据就会被当作指针,从而可以访问到一些“特别”的地址做一些“特别”的事。比如,read_chunk的功能是读取指针数组里某指针指向的数据,如果在list前面找到一个“特别的”指针,就可以读取一些“特别”的数据,本人也确实找到了一个这样“特别”的指针,它位于list索引“-52”的地方,也就是地址0x0000000000601f00处,运行时这个地方保存着一个指向0x0000000000602000的指针(got表头地址),同时这个指针指向地址的0x10处保存着指向libc模块的“_dl_runtime_resolve”指针,而该指针值将会被read_chunk函数当作堆块内容的长度:
这样一来我们只需调用“read_chunk(-52)”即可读取从got表处开始的size为0x7f4d93e0ca70的数据,虽然实际上并不会真读取那么大size的数据,但已经足够泄漏我们需要的很多数据了,比如各种libc函数的运行地址,list保存的堆块地址等等。Ok,貌似不错的收获,然并卵,最后劫持程序好像都不需要泄漏这些信息。。。
接着继续分析,锁定free_chunk函数,观察堆块释放的过程:
如果是正常的情况下,堆块释放的逻辑是没什么问题的,但是因为索引可以传入负数,去释放原list之前的堆块“节点”,并且释放完之后还要将释放的“节点”之后的“节点队列”进行前移操作,这样就可能破坏内存数据结构,当然也可以“适当地”操作它,比如进行“free_chunk(-2)”:
由于索引“-2”即0x0000000000602090处数据初始化为0,堆块节点释放时便“free(NULL)”,实际上啥也没干,但是该节点之后的堆块指针队列还是会进行前移“一格”。同理,若进行“free_chunk(-6)”,0x0000000000602070后的指针队列前移,而且这样由于覆盖到了chunk_number(堆块数量计数),导致可以将该值修改成很大的数,这样就造成,list数组不仅可以传负数进行“下溢”,还可以进行“上溢”访问到堆块地址的范围,只是到时候只能进行释放操作而不会进行前移(大于0x2E),且不可进行分配操作(creak_chunk对chunk_number的值做了限制,超过0x2F将不再可分配,此处也有漏洞,详见下文),但这样实际上结合上面泄漏的堆块地址,也已经足够构造堆块内容去进行“任意地址”释放了,并且还可以不限释放次数进行所谓“double free”。到这里就有必要去学一学堆块分配策略的相关知识了,需要知道glibc是怎么分配/释放堆块的,以及一些常见的利用方式。这里就本题情况做个大概的介绍。
还是先做个小实验,代码如下:
def create_chunk(content):
target.sendline('1')
target.recvuntil('content: ')
target.send(content)
target.recvuntil('>')
#pause to debug
def debug(iq=False):
context.terminal = ['gnome-terminal','-x','sh','-c']
gdb.attach(proc.pidof(target)[0])
if iq == True:
target.interactive()
quit()
pause()
for i in range(0, 0x20):
create_chunk('a' * 16)
free_chunk(0x1F)
free_chunk(0x1E)
debug()
运行后弹出gdb进行调试,观察此时fastbin的堆块结构:
根据上面的实验代码,我们先释放的“chunk_0x1F”,再释放的“chunk_0x1E”,它们的chunk size都是0x20(最后三位“AMP”用作标志位,此时最低位“P”为1表示前堆块在使用中,fastbin此标志正常都为1),实际可用的buf size都是0x18(减去size头部),所以这两个chunk是属于同一个size管理队列,如上图所示main.arena.fastbinsY的第一个元素维护一个最近释放的空闲chunk(堆块)指针,在释放完“chunk_0x1E”的时候,该值本来是上一个释放的“chunk_0x1F”的地址“0x25663e0”,而后该值改为“chunk_0x1E”的地址“0x25663c0”,而为了维护该空闲队列将最新的空闲堆块“chunk_0x1E”的fd指针指向上一个空闲堆块“chunk_0x1F”的地址“0x25663e0”(原来的buf头部)。此为小堆块fastbin的释放机制,当然其更多的内部检查机制这里不继续深入。
而相信你也可以猜到,分配机制应该是以此为基础,类似的相反操作过程,比如下一步要分配一块相同size的堆块,堆管理器会优先从最新的空闲堆块“chunk_0x1E”摘取,将其中的fd指针替换main.arena.fastbinsY的第一个元素,然后将“chunk_0x1E”的buf地址(原存放fd的地址)返回给用户使用,到时就回到释放“chunk_0x1E”之前的状态了,最新空闲堆块指针又重新指向“chunk_0x1F”;同样,分配过程也是有内部检查机制,这里顺便提一下,比如此时将要分配到空闲堆块“chunk_0x1E”,则会去检查该chunk的chunk size是否为同样的0x21(或0x20貌似也行,“P”标志为0),要相同才能通过检查成功分配。
OK,这题关于堆分配/释放的基础知识就是这样了,再就是讲讲“double free”的原理吧。上面的实验代码先释放“chunk_0x1F”再释放“chunk_0x1E”,如果后面再释放一次“chunk_0x1F”会怎么样呢?很简单,到时候还是改两个地方,一个是“chunk_0x1F”的fd指针,将改成上一次最新空闲堆块指针(“chunk_0x1E”的地址“0x25663c0”),另一个就是最新空闲堆块指针(main_arena.fastbinsY[0])则修改成“chunk_0x1F”的地址0x25663e0”,这样的状态就比较有意思了,两个chunk的fd指针将会互相指向对方chunk地址,而最新空闲堆块指针又指向其中的一个“chunk_0x1F”:
这样就构成了一个所谓的交叉“回环”了,接着再思考一下继续进行连续分配相同size的内存会如何?首先第一次当然是分配到“chunk_0x1F”,但是此时如果给其buf写数据,则“fd指针部分”可以被改写,从而在下下次分配(第三次)的时候,改写的数据(比如’a’*8)将被当作是最新空闲堆块指针被赋值给main.arena.fastbinsY[0],于是就控制了第四次分配相同size时的目标chunk地址了(如下图改成“0x6161616161616161”,即8个’a’):
当然,前面提到,下一次分配任意地址成功的条件是目标chunk的size要同样为0x20才行。至此,堆分配的利用先介绍到这里,可能有点啰嗦了,但如果能帮到一些想入门的同学也值了。
下面继续回到题目上去,上面题目部分最后提到了可以将chunk_number通过list队列前移的方式修改成很大的数值,从而造成list“上溢”可以访问到堆块的地址范围,进而构造堆块数据对任意地址进行free操作。然后本人就以类似上面的实验一样方式去构造一个double free的回环,控制了下一次分配的目标chunk地址。那么很现实的一个问题来了,目标chunk地址要控制到哪里分配?由于要检查chunk size,我们希望的很多地址比如got表函数地址附近都找不到可以满足的“0x20”size。那么可以构造这个0x20来对某块感兴趣的区域附近进行分配么,答案是肯定的,并且最后发现也确实需要如此。如何构造呢?很简单,条件已经早已具备,就是利用free_chunk会前移list节点队列的特性,先让chunk_number达到0x21,然后往前移就构造出需要的size,于是便能在chunk_number附近成功分配一块内存并写0x10大小的任意数据:
之后本人就卡死在这里了,很尴尬,一方面chunk_number因为过大不能进行分配需要通过再次前移修改回0,这样控制的分配地址就更偏离了chunk_number的位置了(这点很关键!详见下文),另一方面本来是想通过将该区域的数据写出一个system地址,然后通过前移的方式覆盖到got表函数,但是又遇到多余的size 0x21会先覆盖导致程序崩溃的难题。后来也想过不在该区域分配而分配到栈上,因为栈的数据构造出一个0x21还是可以的,不过也有一些坑爹的问题浮现;或者思考能不能分配到fini程序退出函数用到的相关数据结构,来劫持eip,最后这些思路实际起来都夭折了。无奈了很久,只好虚心去托人请教pwn大佬,终破玄机。
下面还是直接指出漏洞利用的一些关键点吧。总的来说,本人之所以没有成功是因为忽略到了两个关键利用点。首先,本题要进行“double free”其实没必要像上面提到的先修改chunk_number成大值再通过构造堆块数据进行任意释放,程序代码里还有另外一个隐藏的漏洞被本人忽略了:
创建堆块的函数create_chunk一开始就限制了堆块队列的数量为0x30个,所以上面写的是“chunk_number <= 0x2F”;而相对的,释放堆块的函数也做了类似的限制,是在进行节点队列前移操作的时候:
注意到,这里是“0x2E”,正是本人没有细究的地方,理所当然地以为处理队列的边缘节点相差个“1”很“正常”。仔细想想,create的时候最边缘节点应该是list[0x30],而free的时候最边缘节点则是list[0x2F](或者list[0x2E],实际上释放该节点才是正常的临界值),也就是说把list队列创建满的时候再去释放最边缘节点list[0x30](或者list[0x2F]),此时并不会进行前移操作;而释放完之后再通过释放队列前面的节点让list[0x30](或者list[0x2F])的堆块指针进行前移,后面就又有机会再释放一次原来list[0x30](或者list[0x2F])指向的堆块了,从而就造成了list[0x30](或者list[0x2F])节点堆块的“double free”,简单方便!这样利用的double free便没有了本人实现的各种“后遗症”了。
#double free the hole chunk
for i in range(0, 0x30):
create_chunk('b' * 16)
free_chunk(0x30 - 1) #first free the list[0x2F] chunk
for i in range(0, 0x30):
free_chunk(0) #double free the old list[0x2F] chunk
然后是第二个没有想到的关键利用点,还是位于create_chunk函数:
上面提到构造分配的chunk位置位于关键的chunk_number附近,不仅是因为通过chunk_number可以构造处分配成功的“0x20”size,而且如果分配成功,并通过给buf赋值,覆盖到了特殊变量chunk_number的话,就可以多一次“任意地址”写buf地址的机会,因为程序写完buf后会将buf指针保存在当前堆块计数chunk_number所指向的list索引位置。所以本人千算万算,最终失算在这里,没有想到任意分配的堆块关键要改写的是这个值。可是改这个值有什么作用呢?别忘了,list数组可以向前索引的,只要将chunk_number改成一个负数,向前索引到got表函数的位置,就可以将buf的地址替换掉其中一个函数地址,然后buf除了用于覆盖chunk_number的8字节,还有另外8字节可用的空间,可以放置shellcode指令!哦,忘了说了,这个程序没有开启NX。。。那么下面的问题就容易了,8字节的指令空间怎么拿shell呢,简单,jump到其他堆块去执行实现排布好的shellcode呗~
create_chunk(asm('jmp [0x602208]') + 'y' + p64(-17 & 0xffffffff)) #over rewrite chunk_number to save new create chunk pointer => rewrite got free(-17)
free_chunk(-17) #call free to get eip and exec shellcode
target.interactive()
exp其他部分包括shellcode见附件吧,反正也不是自己写的,大牛写的exp个人还是比较拜服的~
目前没有反馈
表单载入中...