我在网络上一个偶然的机会看到了一个关于Win7 64位的计算器存在一个Bug的信息。(http://marcoramilli.blogspot.com/2013/08/bug-in-wincalcexe.html)我在相同的环境中重新的操作了一遍,结果calc.exe崩溃了。我没有什么其他事情做,所以我对自己说为什么不对这个bug进行一次根本原因的分析呢?然后我开始记录我的分析最后成为了一篇文章,就是本文。
本文是我对于一个bug的分析文章,如果你发现了文中的错误或者有不清楚的地方可以发电子邮件给我,我会很高兴的。
通过如下步骤可以很轻松的重现Bug:
(1) 打开计算器calc.exe然后计算1/255
(2) 点击按钮”F-E”
(3) 程序崩溃
我将calc.exe挂在Windbg上进行上述操作,将会显示如下结果:
通过状态码来看,这是一个栈溢出错误,当指令尝试向一个属于栈的地址写数据时(实际上这个地址并不属于栈),发生了异常。这是因为栈已经耗尽了内存,不能够使用更多的空间了。
栈的提交和保留的内存大小可以通过Windbg的“!dh”命令计算PE的文件头来计算出来。数据值如下:
栈保留大小: 应用程序的使用的栈的总尺寸。
(译注:reserve 值指定虚拟内存中的总的堆栈分配。 对于 x86 和 x64 计算机,默认堆栈大小为 1 MB。 在 Itanium 芯片组上,默认大小为 4 MB。)
栈提交大小: 应用程序可以使用的直到保护页面(该页面来自保留内存)的内存空间。当应用程序能够访问当前这个保护页面页面后,当前页面下一个页面就成了保护页面。这个过程将一直持续到用掉了所有的栈保留尺寸,最后导致栈溢出异常,原因很简单因为应用程序尝试向栈的范围外写数据。
(译注:commit 取决于操作系统所作的解释。 在 Windows NT 和 Windows 2000 中,它指定一次分配的物理内存量。 提交的虚拟内存导致空间被保留在页面文件中。更高的 commit 值在应用程序需要更多堆栈空间时可节省时间,但会增加内存需求并有可能延长启动时间。 对于 x86 和 x64 计算机,默认提交值为 4 KB。 在 Itanium 芯片组上,默认值为 16 KB。 http://msdn.microsoft.com/zh-cn/library/8cxs58a6.aspx )
现在我们需要知道是什么导致了栈溢出异常,所以我们要做的就是检查调用堆栈。
产生问题的原因是一个递归调用,函数写了它的参数、返回地址和局部变量及其他函数的参数,不断的调用直至栈耗尽。但是这是为什么呢?这是本文将要回答的问题。
对于某些人来说,挖掘产生这类bug的根本原因,似乎看上去没有什么价值,但是对我来说,这是一件愉快的工作,这个bug是我在calc.exe上遇到的第一个bug,这对我来说是一个很好的进一步了解“逆向x64”机会。
我做的第一件事是分析calc!putnum函数,它的原型和参数是什么?
在我们的样例中,函数calc!putrat调用了函数putnum
在我印象中,这些参数从现在起将会通过RAX寄存器来访问,利用RSP寄存器来保存函数的局部变量。
在x64中,参数传递总是通过RCX、RDX、R8和R9这几个寄存器来实现的,其他的附加参数传递是将其压入栈中。
(译注:有关x64的寄存器构成,可以参照此文章“ http://www.pediy.com/kssd/pediy09/pediy09-602.htm ”)
在我们的样例中,调用函数将会把这些数据压入栈中,被调用函数将会把传到RCX、RDX和R8D的参数放在栈上。
如上图所示:
(1)calc!putrat的栈帧
(2)函数calc!putnum的返回地址(calc!putrat+0xb8)已经压栈了。
(3)被调用函数将参数压入栈中了。
完整的calc!putnum反汇编程序如下:http://pastebin.com/imDN8DUa
经过一些分析,如下是我所关心的putnum函数信息:
参数1:表现为一个指向栈地址的指针,它的值为“00000000`00000001”(最初被设置为dword,因此是int型的。)
参数2:也表现为一个指向栈地址的指针,事实上,参数2为指向指针的指针。
参数3:表现为int类型。基于此句:movdword ptr [rax+18h],r8d
参数2作为指向整形数组的指针,我们马上看其详细信息。函数返回一个指向unicode字符串的指针,这一字符串是由整形数组转换得到用于显示的。
我们主要对参数2感兴趣。
现在我们需要做的是找出递归调用,然后看看它是如何达到条件跳转和无条件跳转的。
cmpedi,edx: 在本样例中,当调用函数putnum时,并没有将EDX赋值给EDI,意味着EDI的值是NULL,EDX的值在比较之前始终是0,现在EDI就是问题所在了。我发现EDI的取值是0或者1,当触发bug时,EDI的值在比较前始终是1(函数putnum会每次都被调用)。EDI的值是函数calc!stripzeroesnum返回到EAX的值。
我提到的数组像如下这样:
数组的第三个元素(arr[2])包含一个负数值,我们给他加上前一个元素与当前值的差值。这会增加它的值,因为0被跳过了了。第三个元素值减去1(arr[2] -1)将是整个数组的长度。在我看来,负值是用来描述整数倒置存储的。
更多的关于calc!stripzeroesnum的反汇编信息如下:http://pastebin.com/jSt2Ufh0
因为这个函数对我们很重要,我也手动的反编译了这个函数。
函数的一部分是将数组的元素与NULL进行比较,如果为空,它将使得index自增,并设置返回值为true,然后进一步循环比较,直至找到一个不为NULL的元素。此外,它会调用一个函数calc!memmove,这个函数会从数组的开头删除值为0的元素,后面的元素一次向前补齐,因此数组的第一个元素不为NULL。(此数组用来保存需要转换的整形数据,然而,数据的第一个元素的位置在数组的第四个,数组的前三个是保留用的。)这个函数还使用EBX作为数组中元素个数的计数器,因此当访问数组中的下一个元素,EBX的值将自减。
PS:这个函数提供了一个“最大值”用来验证数组中的整形数据个数不可超过此最大值,这个“最大值”由EDX提供。这个数组中不但包含整形元素值,还包含了记录了需要处理的整形元素个数,例如数组中的第二个元素(arry[1])
(译注:数组中的前三个元素存储的并不是用于计算的整形数据,这三个元素是用来对第四个元素起至数组末尾的有效元素的辅助记录值,其中第二个值array[1],是记录数组的有效长度)。
如果代码中的total_int变量(译注:total_int记录了整形数组中的元素个数)大于MAX变量,那么数组将从total_int-MAX(译注:访问数组中后MAX个元素)开始访问,然后MAX将作为计数器。如果total_变量小于MAX变量,计数器将仍然用EBX,从第四个元素(arry[3])开始访问。
比较命令是:cmp dword ptr [rdx], 0
RCX被函数使用,RCX+0xC是整形数组的第一个元素。这些整形元素是算数运算的结果。这些整形数据在内存中以相反的顺序保存,因此在内存中最后一个元素最先列出。
calc!stripzeroesnum+0×19:mov ebx, dword ptr[rsi+4],EBX保存了当前的元素个数。元素的构成为“0.XXXXX”,其中X不为NULL。这是数组的第二个元素,它保存了有效的整数的个数(total_int),例如0.000005,此时EBX的值为1(译注:5为有效整数)。
PS:计算器的界面可以格式为“0.XXXX”(其中0和.号都会被算作字符)的数据最大长度为34个字符,显示格式为“X.XXXX”的数据最大长度为33个字符。
本文中,我们对函数calc!stripzeroesnum的两次调用感兴趣,条件将控制每一个执行流程。
第一次调用在:“calc!putnum+0×34”;
第二次调用在:“calc!putnum+0×203”;
主要的控制访问数组的访问条件是EBX和R8D的比较。
cmp ebx, r8d jgcalc!stripzeroesnum+0x26
我们在前面已经介绍了EBX的值(total_int)(译注:记录了有效的整数个数),R8D的值是调用函数的EDX值。因此我们研究这两个情况。
首先看“calc!putnum+0×34”:调用函数以EDX作为参数,并赋值为0×20+2。calc!g_maxout==0×20,加2的目的是为了对应数据显示的时候包含了小数点和额外的0(译注:即0.XXXXXX这种情况,0×20表示数据长度为32)。
其次“calc!putnum+203”:在此次调用中,对于数组的访问采用了位于RAX中的编号,比较从数组中的第一个元素开始。
需要结果大于要显示的长度时结束,为了实现此功能,对于数组的访问采用了一个编号,这个编号的值为最大值与有效整数值的差值。
sub ebx, r8d movsxd rax,ebx mov ebx, r8d lea rdx,[rcx+rax*4]
现在的问题是,为什么崩溃发生在点击“F-E”按钮时,而不是点击“=”按钮?
实际上,当数据很长的时候,点击“=”按钮会发生数据取有效位操作。例如计算结果为如下数据“0.009009009009009009009009009009…”,点击“=”按钮后,显示结果为“0.00900900900900901”。
函数putnum调用了一个函数(calc!addnum)就是干这件事的。奇怪的是这个函数进行取有效长度操作是为了应对用户点击“=”按钮的,当我们尝试点击“F-E”按钮进行科学计数法显示时,函数putnum并未调用calc!addnum。
回顾
前文提到,addnum函数是用来取有效位数操作的,从而实现降低数据长度和精度。有时候,在一个很正常的情况下,递归调用将会被执行,但是最大值大于total_int,因此不会到达calc!stropzeroesnum。
奇怪的是,当选择“F-E”模式,函数addnum将不会执行取有效位数操作,有时候会提高数据显示的精度。
注意我说过,那个数组会忽略小数点后面到第一个整数间的0,例如0.0016316168515,标红色的两个0将会被忽略。
这些在后面会被加到total_int上然后与calc!g_maxout这一全局变量相比。(全局变量的值位0×20)。
calc!putnum+0x186: mov eax,ecx sub eax,esi ;ESI此处是一个负值,因此实现eax的值增加 cmp eax,r8b jg calc!putnum+0x1b3
区别是当点击“=”按钮时,取有效位操作将会停止获取更高精度,然后在末尾的0也将被删除掉。然而当点击“F-E”按钮时在如下情况(1/255,1/111,1/999)时,addnum函数将会一直计算下去,从而total_int加上末尾0的总长度大于g_maxout,从而导致在putnum+0×203处再进行一次stripzeroesnum。这会以编号(total_int-MAX)对数组进行访问,而位于此编号的数据元素为NULL,然后被当成0,被忽略了。当递归调用使得stripzeroesnum+0×34将会得到一个忽略了0的数组。然而,total_int将会大于calc!g_maxout或者total_int+zeros_after_the_point大于calc!g_maxout。然后调用addnum,提高了精度使得数组变得更大。数组超过了最大值,并(total_int-MAX)将指针指向了一个NULL元素,将会返回1(干掉了0和在数组中的个数)然后再一次调用putnum函数,而这个函数又调用了addnum函数,知道栈溢出。
如本文所展示的那样,递归调用在某些情况下可能会导致重大的栈溢出bug。
[via packetstormsecurity 编译fubeerf]