受到基友的耳濡目染,最近开始入坑CTF。接受他的建议,先在pwnable.kr和pwnable.tw两个平台上玩玩题。其中pwnable.kr建立较早,上面的题目难度从易到难,相邻题目的难度跃动不大,但是涉及知识面较广,网上的writeup也非常多,非常适合新手练习;pwnable.tw建立较晚,题目难度相对于前者较大,适合进阶。
我是两个平台交替着来,这个玩不下去了就换另一个。前几天做到了pwnable.tw的第三题,着实让我这个刚入坑的菜鸡绞尽脑汁。此题的漏洞比较有意思,难度对于刚入坑的新手小白来说也可以接受,在此分享我的解题思路。
00 题目解析
题目如下图所示:
由题目可知,这是一道关于计算器的题目。pwnable.tw上每题的flag文件都在/home/xxx/flag,其中xxx是题目的名字。我们先用题中所给的命令连接一下目标服务器:
在出现欢迎信息后,我输入了8*9,目标程序就返回了计算结果72。可见,这个目标程序确实具备计算功能。我们的目的是获取服务器上flag文件的内容,看来只能通过挖掘目标程序的弱点或漏洞来想办法显示flag。
点击黄色的calc,便可将目标程序下载到本地。接下来,我们要对目标程序进行分析,看看是否存在漏洞可以在服务器上显示出flag文件的内容。
01 算法分析
▌主函数分析
首先对程序进行静态分析。将程序丢到IDA中,发现主函数流程较简单,一个定时器,一个欢迎信息的输出,以及一个calc函数。这个calc应该就是程序的核心函数了。主函数汇编代码如下图所示:
▌calc 函数分析
我们进入calc函数,重点分析该函数执行流程。函数一开始就将canary压入栈内,作为对栈溢出攻击的第一层保护(下图中large gs:14h就是canary的值,被压入栈中ebp-0xc的位置)。
canary(金丝雀)是一种简单高效的保护栈内数据不被改写的方式,该方法就是在栈的尾部插入一个随机值(因为函数返回地址常在当前栈的尾部),当函数返回之时检测canary的值是否经过了改变,以此来判断栈溢出攻击是否发生。然而对于本题存在的漏洞来说,这种方式还不足以保护函数的返回地址被攻击者篡改,原因在下文我会介绍到。
压入canary过程如下图所示:
接下来calc函数调用_bzero将一段长度为1024字节的数据expr清0,并调用get_expr函数接收用户输入的运算表达式,若表达式格式合法,则将其放到expr中去。
也就是说,expr即为运算表达式所在字符串。如下图所示:
其中get_expr函数过滤了非法字符,只留下数字和“+,-,×,/,%”这五个运算符。该函数的具体流程在这里就不赘述了,感兴趣的朋友请自行分析。
接下来,init_pool函数的流程比较简单,它在当前栈上分配了一段100个字(400字节)的空间,将其内容清0。
那这段空间具体是做什么的?
我们在下文将会介绍,请大家记住它,因为它将是我们漏洞溢出的关键。
万事俱备,只欠东风。该输入的输入的,该分配的分配了,那谁来解释并处理我们输入的运算表达式呢?这个核心的工作就交由parse_expr函数来处理。init_pool和parse_expr的调用如下:
用IDA的F5功能可以更直观地看出calc函数的调用过程:
▌parse_expr 函数分析
该函数主要分为两个步骤:解析运算表达式、计算运算结果。下面我们来逐步分析一下parse_expr函数。
该函数共有两个参数,参数一为用户输入的运算表达式的地址,参数二为上文提到的init_pool函数分配的一段地址空间。函数开始时同样使用了canary的方式保护栈空间,之后又分配了100字节的空间给一个数组operator[100],这个数组的作用是保存所有的操作符。
那么函数究竟如何处理用户输入的运算表达式呢?
大家可以先自己思考一下,如果让我们自己写一个计算器,大概的流程应该是什么?首先,我们肯定需要将操作数和运算符分离开,然后再通过运算符来对运算符两边的操作数进行相应的计算。
但是,计算机不像人一样,当看到“100”就能马上识别出来这是数123,而是首先把它当作字符串处理,一个字符一个字符地读取,先读取“1”,再读取“2”,再读取“3”,直到读取到一个不是数字的字符,才把字符串“123”当成数123。这个函数的流程也是如此。
如下图所示,首先,函数进入一个大循环,来对运算表达式的每个字符进行分析和处理。若当前字符的ascii码值减去48(数字0的ascii码值)大于9,则将其识别为运算符;若小于或等于9,则将当前字符当作数字处理(数字0~9的ascii码值为48~57)。
疑问
ascii码小于48的字符可不止运算符,大于48的字符也不止数字啊,难道可以任意输入吗?大家还记得上文中提到的get_expr函数吗,就是它在用户输入的时候将不合法的字符全都过滤掉了,因此这时不会有其它非法的字符存在。
我查了ascii码表,“+,-,×,/,%”几个运算符的ascii码值都比48小啊,减去48是负数,肯定也小于9啊,为什么会大于9?因为这时定义的差值变量是无符号整型(unsigned int)的,作为一个无符号的数,它的值将远远大于9。
如果当前字符为数字,那么循环中什么也不做,只把seq+1,进入下一个循环。如果当前字符为运算符,函数要做的第一件事情并不是解析运算符,而是将运算符前面的字符串转化为整数保存起来。
保存在哪里呢?
就保存在传入parse_expr函数的参数initpool里面。
由上图我们可以看出,函数首先取操作数左边的字符串,若字符串为“0”,也就是说,用户输入的操作数为0,那么报错并直接退出当前运算过程。这是该题的一个小bug,因为从数学上来说,0除了作为除数,还是可以参与运算的。但是毕竟这只是一道pwn题,因此我们就忽略了这个小bug吧。
接下来,函数将操作符左边的操作数转换为int型数值,并将数值保存在initpool中。但是在这里我们要注意一个问题,从函数逻辑来看,操作数是从initpool[1]的位置开始保存的。
那么initpool[0]是干什么用的呢?
从count=(*initpool)++这一条语句来看,initpool[0]应该是保存当前运算数个数的。
它相当于一个指针,每次函数要保存操作数进去的时候,先判断initpool[0]当前的值是多少,若值为0(第一次保存操作数时),那么就将当前操作数保存在initpool[0+1]的位置上,若值为5(当前已经保存了5个操作数,类似1+2+3+4+5这种情况),就将当前操作数保存在initpool[6]的位置上。也就是说,initpool可以理解为一个带头部的数组,其头部(initpool[0])保存着当前数组中操作数的个数,而从initpool[1]往后依次保存着各个操作数。
由此可见,这个程序两个最重要的数据结构为initpool[]和operator[],它们分别保存了操作数和操作符。
接下来为了保证输入表达式的正确,函数对当前操作符的后一个字符进行了判断,若后一个字符也是操作符(类似5+×7这种情况),则视当前表达式非法,退出此次运算。
下面就进入到parse_expr函数的关键部分:
如图。首先判断operator数组中operator[seqopr]这个元素的值是否为0。operator数组保存了所有的操作符,而operator[seqopr]则保存了当前所解析操作符的上一个操作符。比如“7+9-5”这样一个运算表达式,当我们处理到“-”时,operator[seqopr]保存的就是“+”。
若operator[seqopr]为空,也就是说,当前处理的操作符为表达式的第一个操作符,那么函数就进入else,将当前操作符保存在operator[seqopr],也就是operator[0]。若当前操作符不是表达式的第一个操作符,那么就进入if条件。该if条件的作用,就是保存当前操作符至operator数组中,并进行之前操作符所对应的那部分运算。这里可能比较难理解,举个例子,比如表达式:
1+3-2
当处理运算符“+”时,由于这是该表达式的第一个运算符,函数只是将其左值“1”保存至initpool[1],并将“+”保存至operator[0],然后继续循环。当处理到运算符“-”时,initpool中已经有两个值,“1”和“3”,operator中也保存了一个值“+”,也就是说,此时运算场景为:
initpool[0]=2,initpool[1]=1,initpool[2]=3operator[0]=”+”
它的含义是:两个操作数1和3进行加法运算。
这时函数会首先对”1+3”这部分进行运算,然后将“-”运算符放在operator[1]中,等待着下一次运算。
下一次运算什么时候开始呢?
别忘了表达式可是一个字符串,它的结尾是一个“0×0”,当循环处理到“0×0”的时候,就开始了“-”这部分的运算。
那么就有同学可能会问,“1+3”的结果保存在哪儿呢?答案在eval函数中:
eval函数将计算“1+3”的结果,并将结果“4”保存在之前数值”1”所在的位置,也就是initpool[initpool[0]-1]=initpool[1]中。这样一来,当parse_expr函数处理到最后一个字符“0×0”的时候,当前运算场景如下:
initpool[0]=2,initpool[1]=4,initpool[2]=2operator[0]=”+”,operator[1]=”-“
此时函数会通过eval进行“4-2”的运算,并将运算结果仍然保存在initpool[1]中。也就是说,每次进入eval函数时,initpool永远只有三个有效元素,即下标initpool[0](在eval函数中总等于2),左操作数initpool[1]和右操作数initpool[2],并将运算结果放在initpool[initpool[0]-1]=initpool[1]中。这符合一次运算的必备条件,即:
一个运算符和两个操作数
经过parse_expr函数的多次运算,最终会将计算结果输出给用户:
上图中,ebp+var_5A0的位置为initpool[0],ebp+var_59C的位置为initpool[1],因此最后输出的结果应为:
initpool[1+initpool[0]-1]=initpool[initpool[0]]
这时候,漏洞就出现了(敲黑板!)。
▌漏洞分析
在上面的分析中我们可以知道,虽然eval函数看似每次都将运算结果放在initpool[1]中,但是实际上这个下标“1”是由initpool[0]-1得到的。由于正常的运算中initpool[0]总是等于2,因此我们总能将运算结果放到initpool[1]中,并最终将initpool[1]的值作为整个运算表达式的运算结果返回给用户。可是实际上,我们返回的是initpool[initpool[0]]的值。若我们能改变initpool[0]的值为任意值,那么我们就有可能泄露栈上的某个位置的值,甚至能通过运算改变该位置的值。
我们知道,initpool[0]的初始值为0,那么initpool[0]的值最开始是从哪里改变的呢?看下面这段代码:
这段代码的含义是:若运算符左边的操作数存在,那么就将操作数放到initpool[initpool[0]+1]的位置,并将initpool[0]的值+1。
如果当前操作符左边的操作数不存在呢?
也就是说,表达式的第一个字符就是运算符而不是操作数呢?这样的话,initpool[0]的值在解析下一个操作符之前就还是0,而不是1,当第一次进入eval函数时,我们的运算场景就出现了一个不符合运算条件的情况:
一个运算符和仅有的一个操作数
比如我们输入“+300”这样一个畸形的运算表达式,当函数处理到最后一个字符“0×0”,这时的运算场景如下:
initpool[0]=1,initpool[1]=300
operator[0]=”+”
eval函数中,由于initpool[*initpool – 1] = initpool[*initpool – 1] +initpool[*initpool],所以initpool[0]=initpool[0]+initpool[1]=301,最后initpool[0]自减1,因此,输出给用户的最终值为initpool[300]。这样就泄露了栈上ebp-5A0h+300=ebp-1140位置里的值。结果如下图所示:
若我们输入形如“+300-20”,“+300+1000”,则会对栈上的值进行计算再输出:
上图得知,initpool[300]的值本来为0,经过计算后输出了-20。
那么我们究竟有没有对initpool[300]这个位置的数修改成功呢?
我们可以做如下实验:
可以看出,没有修改成功。。。大失所望。。。但是不知道大家是否记得,calc函数中每次运算的循环周期都会对initpool和表达式缓冲区s进行清0(如下图所示),是不是因为这个原因呢?如果是这样,我们就找一块不在它们里面的栈空间来计算。
由于ebp-5A0h到ebp-0Ch这段栈空间都被initpool和s覆盖,每次循环都会被清0,因此我们找到ebp-0Ch这个4字节栈单元来测试。该空间为initpool[357]。测试结果如下:
从图中可以看出,修改成功了!我们成功地将initpool[357]这个4字节栈单元内的值覆盖为另一个计算过的值!这是一个振奋人心的消息,因为函数的返回地址就在它的后面,也属于可被修改的栈单元!
回到我们开始的问题,为什么canary不足以保证栈上数据被篡改?因为canary的位置在函数返回地址之后,而该题的漏洞允许攻击者绕过canary直接篡改返回值,因此canary的值不变,也就不会给攻击者进行栈溢出带来麻烦。
我们尝试着修改原返回值地址里的值,将其替换成其它值。我们首先要知道函数的返回地址在栈的位置,摸清该位置与initpool的起始位置的距离,这样才能通过initpool来修改返回地址。
从上图可以看出,当前的栈空间比较清楚明了,initpool距离当前栈的起始位置为5A0h=1440字节,也就是1440/4=360个栈单元,而众所周知,返回地址是在当前ebp位置的前一个位置入栈,也就是说,返回地址距离initpool的地址为361个栈单元即initpool[360]。当前栈空间如下图:
我们可以通过输入“+361”来泄露返回地址:
可以看到,在输入+361后,程序返回134517913,即0×08049499。查看IDA,在main函数中,调用calc函数的下一条汇编指令为mov指令,它的地址即为0×08049499:
而当我们输入“+361-999”的时候,该返回地址就被修改了:
这样一来,我们的思路就很清晰了:通过不断地输入畸形运算表达式来修改栈空间内函数返回地址及其之后的值,最终实现栈溢出攻击。
▌漏洞利用
由于目标系统开启了NX,无法直接在栈上执行shellcode,而且使用objdump命令可知,该程序是完全静态链接的(下图),因此我们首先考虑的就是使用ROP技术来想办法调用execve(“/bin/sh”)来启动Linux shell,再通过cat命令查看flag的内容。
若想调用execve(“/bin/sh”),则需要构造一个ROP链来创建场景。我个人一直认为ROP是安全领域里的一项十分有艺术性的技术,它的思路很巧妙,也能激发攻守双方的头脑风暴。
我们知道,在制作shellcode时,通常使用int 80h来调用某个系统函数,而int 80h这条指令,往往是通过eax寄存器的值来判断调用哪个系统函数,且通过ebx、ecx、edx等寄存器来存放要调用的系统函数的参数。
在本题的场景中,execve函数的系统调用号为11,也就是说,我们在调用int 80h之前,需要将eax的值置为11。同时,execve函数共有三个参数,其中在这里只有第一个参数“/bin/sh”有用,而另外两个参数可为0。这样一来,我们就需要构建ROP链,将寄存器场景变为:
eax=11
ebx=“/bin/sh”字符串的地址
ecx=0
edx=0
ROP链是由若干条ROP“小部件”组成的,其中每个“小部件”都是一个以“ret”指令结尾的汇编指令片段,而这些ROP链的位置都不在栈上,而在程序的可执行的段内(如.text段)。比如“pop eax; ret”就是一个“小部件”,它的功能是将当前栈顶的数值弹出并放入eax中,并返回到栈顶内的值指向的地址去继续执行程序。
只要我们将每个“小部件”的地址从函数返回值处开始依次存入栈中,程序就会依次跳到每个“小部件”上执行相应的代码,此时栈空间内的每个单元的数据就相当于程序的指明灯,告诉程序该去哪里执行,而不会在栈上执行任何代码。
我使用ROPgadget这个工具来生成ROP小部件,从而构建ROP链。为了将eax的值置为11,我找到了“pop eax; ret”(地址为0x0805c34b)这个小部件,通过将栈上值11弹出并存入eax来修改eax的值;而后,为了将edx置为0,我找到了“pop edx; ret”(地址为0x080701aa)这个小部件,原理相同。
最后,我通过“pop ecx; pop ebx; ret”(地址为0x080701d1)这个小部件将ecx和ebx的值置为0和“/bin/sh”字符串的地址。我们要构建的ROP链在栈上的情况如下:
分析清楚了要构造的场景,剩下的就靠我们通过输入的畸形表达式来计算并设置initpool的361~370这十个栈单元。对于每一个栈单元,我们首先获取其内的值,而后计算该值与目标值的差,最后相减即可。比如我们要将362位置上的值变为11,首先输入“+362”得到当前362栈单元的值135184896,然后计算135184896-11=135184885,最后输入“+362-135184885”将栈内值修改为11。
其中唯一比较麻烦的是“/bin/sh”字符串地址的获取。它是一个栈上的地址,而我们目前暂时无法知道栈的基址。但是别忘了,在当前栈内的某个空间保存这一个栈的地址,那就是当前ebp所指向栈的基址内的值,这个值是main函数的ebp值,也就是main函数的栈基址。那么我们只要知道main函数基址与calc函数基址的关系就可通过main函数基址计算出“/bin/sh”字符串的地址。由下图可以看出,main函数的栈空间大小由main函数的基址决定,大小值为:
main_stack_size=main_ebp&0xFFFFFF0 – 16
目前可知“/bin/sh”字符串的地址(369)与返回地址(361)之间的距离为8,而main函数栈基址与返回值之间的距离为:
dd_mainebp_ret=main_stack_size/4 + 1
也就推得“/bin/sh”字符串的地址为:
addr_binsh=main_ebp+(8-d_mainebp_ret)*4
现在我们就可以编写POC来对服务器上的目标程序开展攻击了,我的POC如下:
最终就可以获取目标服务器shell,并用cat命令显示出flag啦
flag我就打码了,小伙伴们快去寻觅吧!
限时特惠:本站每日持续更新海量各大内部网赚创业教程,会员可以下载全站资源点击查看详情
站长微信:11082411