pwnable.tw刷题之dubblesort

2018-02-24 17:11:51 浏览数 (1)

前言

上一篇中我介绍了phttp://www.freebuf.com/articles/others-articles/134271.htmlwnable.tw中第三题calc的解题思路,在这篇里,我将和大家分享第四题dubblesort的分析过程。

该题在算法上难度不大,能看得懂汇编就基本上可以分析清楚,重点是如何在ASLR、NX等多重保护开启的情况下,利用题目中出现的漏洞来进行漏洞利用,并获取系统shell。该题为我们提供了一个在多重保护下栈溢出的思路,而且还有几个小的技巧值得我们学习。作为一名新手,在这题上我也是绞尽脑汁,最后还是在别人的提示下完成题目,在此将学到的知识分享给大家。

1、题目解析

题目给出了程序dubblesort和libc库文件,说明可能可以通过return to libc的方式进行漏洞利用。从题目来看,貌似是一道和排序(sort)相关的题目。下载并执行程序dubblesort,如下图可以看到,首先需要输入用户名,之后输入想要排序的数字的个数,再依次输入要排序的数字,最后程序会计算并输出排序结果。

使用pwntools的checksec功能对程序的执行保护进行检查,发现包括NX在内的大部分保护都开启了,这对我们来说并不是一个好消息。

2、算法分析

使用IDA对dubblesort程序进行分析,发现程序流程并不复杂。

2.1) main函数分析

如下图所示,在main函数中,首先生成canary并将其入栈,然后调用了timer()函数来计时,超时则会结束程序。在这之后,程序调用read函数来获取用户输入的用户名,缓冲区大小为64字节,也就是64/4=16个栈单元。紧接着,程序调用scanf函数接收用户输入的要排序的数字个数。在以上准备工作完成后,程序就进入while循环,依次接收要排序的数字,并将其保存在nums数组中,该数组是函数创建的局部变量,从下图中可以看出,其起始位置位于栈上esp 0x1c的位置。

2.2) sort函数分析

在接收完用户的所有输入后,程序调用sort函数对用户输入的所有数字按照从小到大的顺序进行排序。

上图为sort函数的代码,算法很简单,是一个典型的冒泡排序,重复count次,每次排序将当前最大的数放在数组的最后,在循环结束后,所有数就从小到大排列了。由于排序并不是本题的重点,因此在这里就不详细介绍了,感兴趣的朋友可以查阅冒泡排序相关知识。在这里我们只用记得,排序后的数字序列仍然保存在原先栈上开辟的这段空间内,只不过数值的顺序变了。

3、漏洞分析

3.1) 栈溢出和canary绕过

从上面的分析可以看出来,在输入待排序数的时候,程序并没有限制要排序数的个数。但是,由于待排序数组位于栈空间内,而当前栈空间的大小是有限的,这就可以导致栈溢出。循环为数组赋值的汇编代码如下:

从上图我们可以看出,待排序数组的起始位置为esp 0x1c。但是不要忘了,我们的main函数开启了canary保护,canary的位置在esp 0x7c的位置(如下图),该位置在esp 0x1c和ebp之间,这让栈溢出无所适从。

这时我们要考虑,有没有什么方法在输入数据时不改变栈上原来数据的内容?我尝试着输入非法字符,结果如下:

这里出现了一个奇怪的现象,当我在第五个数的位置输入“f”这个非法字符时,之后的所有输入自动结束,并且从该位置之后的数据被泄露出来。这个原因我思索了好久,最后发现,这是因为scanf函数接收的数据格式为无符号整型(%u),而程序在检测到stdin中的字符是“f”时,将其视为非法输入,于是本次的scanf函数执行失败,原栈上对应位置的数据也没有被改变。

在下一次循环执行到scanf时,程序又到stdin中取数据,这时,上次输入的“f”由于非法并没有被取走,它还在stdin中存在着,因此scanf的输入又失败了……至此往后的每次循环,scanf都去取stdin中的这个“f”,然后每次都失败,于是从第五个位置往后的所有栈上数据都不会被修改,且在程序最后被泄露出来。

这里可能有朋友要问了,在循环中明明有fflush,为什么无法清空stdin?我在网上查了相关内容,发现对于一些编译器,fflush会失效,不知道这里是不是这个原因。如果有朋友清楚这里的疑问,请一定要帮我解惑!

题目到此,好像这条路走不通了。我曾经查阅资料,尝试使用其它方式绕过canary,但都证明了不可行。那有没有什么字符可以既让scanf认为它是合法字符,同时又不会修改栈上的数据呢?在多次尝试和不断查阅资料后,我发现“ ”和“-”可以达到此目的!因为这两个符号可以定义正数和负数,所以会被识别为合法字符。比如输入“ 4”会被识别为4,而“-4”则会将其转为正数输出(%u的原因)。测试如下图:

如上图所示,4294967293即为-3的无符号值,它们的十六进制是一样的(0xFFFFFFFD)。那么我们只输入一个“ ”或者“-”就可以达到我们的目的。如下图:

从图中可以看到,在第4个数的位置我输入了“ ”,它并未改变栈上数据,且不会影响之后的输入。

至此,我们可以解决canary绕过的问题了。canary距离待输入数据的起始位置为(esp 0x7c)-(esp 0x1c) 4=100字节,100/4=25个栈空间。也就是说,当我们要输入第25个数据时输入“ ”或者“-”就可以保持canary不变,从而绕过函数最后的canary检查,实现栈上任意位置的写入。

3.2) libc地址的泄露

那么我们要往栈上写入什么数据呢?前文提到,题目给了libc库文件libc.so.6,这就暗示我们可以通过ret2libc的方式来进行栈溢出。该方法的利用方式是,修改栈上函数返回值地址,将其变为libc库中某函数的地址(如system函数),从而达到获取系统shell等目的。

我们可以修改栈上的main函数返回值为libc中的system函数地址,并在参数对应的位置写入“/bin/sh”字符串的地址,从而使程序跳转到system函数,并执行shell。所期望的溢出后的栈空间如下图:

从图中可以看出,我们要溢出的数据总共35个栈空间,其中第25个栈空间的canary通过输入“ ”保持其值不变;第33个栈空间写入system函数的地址;第34个栈空间是system函数的返回地址,由于我们无需考虑system返回后的工作,次数据可任意填写;第35个栈空间需写入“/bin/sh”字符串的地址。现在的主要问题是,如何获取system函数和“/bin/sh”字符串的地址?

首先我们通过gdb调试发现,在ebp 4(main函数返回地址)的位置存放了一个libc中的函数地址__libc_start_main(main函数执行完后返回至该函数),可通过多次执行程序泄露该位置数据来判断libc地址是否随机,即目标系统是否开启ASLR。由上图可知,ebp 4的位置是从nums[0]开始的第33个栈空间,因此我们通过多次输入来泄露该位置的值,过程如下:

从上图可以看出,两次执行程序后,第33个位置的内容改变了,分别为4149671479=0xF756F637和4150175287=0xF75EA637,说明系统的libc基址改变了,开启了ASLR。

我们知道,在ASLR开启的情况下,堆栈地址和libc的地址都是随机的,那么我们如何获取libc中函数的地址呢?通过在输入数字时输入“ ”来泄露栈上数据的方法开上去可行,但每次泄露后程序就结束了,下次再执行程序时libc的地址又改变了,无法通过这种泄露来获取当前进程空间的libc地址并进行利用。因此我们要通过其它的手段来在程序执行的过程中泄露libc地址。

经过研究,我发现在输入用户名后程序的返回有点奇怪:

上图可看到,我们输入的用户名是mike,但程序返回的输出并不正常,不仅在mike后换行,而且后面还跟着几个不可见字符,这是为什么?

再来到程序接收用户名输入和输出的过程:

我们发现,程序在用printf函数输出欢迎字符串“Hello….”的时候格式为%s,大家知道,printf在做格式化输出字符串时,是以0×00(null)为结尾来判断字符串结束,可是在我们输入用户名name的时候,程序是用read来接收的,它并不会自动为我们输入的字符串补0。当我们输入“mike”这4个字符并敲回车后,真正传给程序的是“miken”这样一个5字节的字符串。

程序在接收这个字符串后将这五个字符保存在栈上的esp 0x3c的位置,但这五个字符之后是否跟着0×00就不得而知了。根据上面的输出我们大致可以猜到,“Hello mike”之后的换行应该是我们输入的回车(“n”)导致的,而下一行一开始的几个不可见字符,应该是栈上紧跟着换行符后面的数据。也就是说,我们通过输入,无意中泄露了栈上的数据!

这是一个好消息,因为我们可能可以在覆写栈上数据之前泄露出libc的地址。那么name之后的64字节地址空间中是否含有libc中的地址呢?

通过gdb调试,我们发现在name后的第7个栈单元保存着一个疑似libc中的地址0xf7fb1000:

那么此时的libc基址是多少呢?该地址又是否是libc上的地址呢?我首先通过 info sharedlibrary命令来获取libc的地址:

从图中可以看出,libc.so.6的地址空间为0xf7e16750到0xf7f4204d,好像并不包括我们上面可泄露的地址0xf7fb1000。我又用vmmap命令(info proc mappings命令亦可)查看libc在内存中的加载情况:

可以看出,libc的地址范围变为0xf7dff000到0xf7fb2000,和info sharedlibrary命令获取的libc地址不同(这里libc-2.23.so和libc.so.6是同一个文件,libc.so.6是libc-2.23.so链接)。这个问题同样困扰了我好久,通过查阅资料,我找到了两个命令的不同之处:info sharedlibrary显示的地址范围为libc-2.23.so文件的.text段在内存中的地址范围,而vmmap显示的为libc-2.23.so文件加载到内存中的全部地址空间。在这里可以这样验证:

首先通过hexdump命令验证了0xf7dff000确实为libc-2.23.so加载在内存中的起始地址(可清楚地看到ELF头部标志)。之后通过readelf -S命令查看libc-2.23.so文件的.text段偏移(0×17750),将其加上起始地址0xf7dff000即为0xf7e16750。验证成功。这个小实验和本题关系不大,但是能告诉大家如何在gdb调试时更加清楚地查看libc基址。

回到问题开始,0xf7fb1000这个地址确实在libc加载在内存中的地址范围内(0xf7dff000到0xf7fb2000),它的偏移是0xf7fb1000-0xf7dff000=0x1b2000,那么我们就可以泄露这个地址并减去它相对于libc基地址的偏移来动态获取libc的基址。可是事情并没有这么简单,在我写好exp后,怎么执行都无法获取shell,最后发现是这个偏移出了问题。因为我自己的libc库和目标系统的libc库不一样,偏移也就不同!那么真正的偏移是多少呢?

我们再用readelf命令来看看0x1b2000这个偏移在我的libc中的位置:

从上图可以看出,该偏移是.got.plt节相对于libc基址的偏移,那么我们再来看看题目中给的目标系统的libc文件的节情况:

可以看出,.got.plt节的偏移为0x1b0000,并不等于我们之前得到的0x1b2000。而system函数的偏移和“/bin/sh”字符串在libc中的偏移我们可以通过readelf -s命令和二进制编辑器HxD得到:

这样一来,我们就可以得到libc基址、system函数地址以及“/bin/sh”字符串的地址:

addr_libc=addr_leak-0x1b0000

addr_system=addr_libc 0x3a940

addr_shell=addr_libc 0x158e8b

4、漏洞分析

有了以上的分析,漏洞利用的实现就简单多了。

首先我们要泄露name后第6个栈单元上的数据(.got.plt节地址)。由分析可知,该单元距离name的初始地址为24字节,因此我们至少要发送24字节的冗余数据。经测试后发现,该栈单元的数据的第一个字节(即.got.plt节地址的最后一个字节,因为小端序)总为0×00,因此若要泄露该数据,需要多发送一个字节覆盖掉0×00,否则printf会将0×00之后的数据截断。可以发送’A'*24 ’n’来泄露出该数据的后三个字节,再加上’x00′即可。

之后就可以根据泄露的地址推算出system函数和“/bin/sh”字符串在内存中的地址。需要注意的是,程序在执行过程中会将所有数据排序,因此我们需要在输入数据时注意数据的大小,这并不难,具体做法是将canary之前的数据都置0,canary和返回地址之间(包括返回地址)的数据都写入system函数的地址(canary随机数大部分时间都小于system地址,除非人品不好),而最后两个栈单元都写入“/bin/sh”字符串的地址即可。配置好的栈结构如下:

后话

经过一段时间的学习,我深刻地意识到自己根基不牢且知识量匮乏,目前接触的这些皮毛只是二进制世界里极小的一部分,仍有太多好玩的东西等待着我们去学习和挖掘。

由于pwnable.tw要求不允许在公开渠道公布高分题目的解题思路,虽然这只是一道200分的题,但是POC和flag我就不留啦,祝大家解题愉快!

0 人点赞