2.2 修改邻接变量
2.2.1 修改邻接变量的原理
通过上一节,我们已经知道了函数调用的细节和栈中数据的分布情况。如图2.1.8所示,函数的局部变量在栈中一个挨着一个排列。如果这些局部变量中有数组之类的缓冲区,并且程序中存在数组越界的缺陷,那么越界的数组元素就有可能破坏栈中相邻变量的值,甚至破坏栈帧中所保存的EBP值、返回地址等重要数据。
题外话:大多数情况下,局部变量在栈中的分布是相邻的,但也有可能出于编译优化等需要而有所例外。具体情况我们需要在动态调试中具体对待,这里出于讲述基本原理的目的,可以暂时认为局部变量在栈中是紧挨在一起的。
我们将用一个非常简单的例子来说明破坏栈内局部变量对程序的安全性有何种影响。
#include <stdio.h> #define PASSWORD "1234567" int verify_password (char *password) { int authenticated; char buffer[8];// add local buffto be overflowed authenticated=strcmp(password,PASSWORD); strcpy(buffer,password);//over flowed here! return authenticated; } main() { int valid_flag=0; char password[1024]; while(1) { printf("please input password: "); scanf("%s",password); valid_flag = verify_password(password); if(valid_flag) { printf("incorrect password!\n\n"); } else { printf("Congratulation! You have passed the verification!\n"); break; } } }
上述代码是第1章最后一节中Crack实验的验证程序修改而来的。请尤其注意以下两处修改:
(1)verify_password()函数中的局部变量char buffer[8]的声明位置。
(2)字符串比较之后的strcpy(buffer,password)。
这两处修改实际上对程序的密码验证功能并没有额外作用,这里加上它们只是为了人为制造一个栈溢出漏洞。
按照前面对系统栈工作原理的了解,我们不难想象出这段代码执行到int verify_password(char *password)时的栈帧状态如图2.2.1所示。
题外话:这里只是给出了字符数组的缓冲区与局部变量authenticated在栈中的一种分布形式。出于编译优化等目的,变量在栈中的存储顺序可能会有变化,需要在动态调试时具体问题具体分析。
可以看到,在verify_password 函数的栈帧中,局部变量int authenticated恰好位于缓冲区char buffer[8]的“下方”。
图2.2.1 栈帧布局
authenticated为int类型,在内存中是一个DWORD,占4个字节。所以,如果能够让buffer数组越界,buffer[8]、buffer[9]、buffer[10]、buffer[11]将写入相邻的变量authenticated中。
观察一下源代码不难发现,authenticated变量的值来源于strcmp函数的返回值,之后会返回给main函数作为密码验证成功与否的标志变量:当authenticated为0时,表示验证成功;反之,验证不成功。
如果我们输入的密码超过了7个字符(注意:字符串截断符NULL将占用一个字节),则越界字符的ASCII码会修改掉authenticated的值。如果这段溢出数据恰好把authenticated改为0,则程序流程将被改变。本节实验要做的就是研究怎样用非法的超长密码去修改buffer的邻接变量authenticated从而绕过密码验证程序这样一件有趣的事情。
2.2.2 突破密码验证程序
实验环境要求如表2-2-1所示。
表2-2-1 实验环境
说明:如果完全采用实验指导所推荐的实验环境,将精确地重现指导中所有的细节;否则需要根据具体情况重新调试。
请您在开始实验前务必先确定实验环境是否符合要求。
按照程序的设计思路,只有输入了正确的密码“1234567”之后才能通过验证。程序运行情况如图2.2.2所示。
图2.2.2 程序正常运行时的情况
假如我们输入的密码为7个英文字母“q”,按照字符串的序关系“qqqqqqq”>“1234567”,strcmp应该返回1,即authenticated为1。OllyDbg动态调试的实际内存情况如图2.2.3所示。
图2.2.3 栈帧布局
也就是说,栈帧数据分布情况如表2-2-2所示。
表2-2-2 栈帧数据分布情况
在观察内存的时候应当注意“内存数据”与“数值数据”的区别。在我们的调试环境中,内存由低到高分布,您可以简单地把这种情形理解成Win32系统在内存中由低位向高位存储一个4字节的双字(DWORD),但在作为“数值”应用的时候,却是按照由高位字节向低位字节进行解释。这样一来,在我们的调试环境中,“内存数据”中的DWORD和我们逻辑上使用的“数值数据”是按字节序逆序过的。
例如,变量authenticated在内存中存储为0x 01 00 00 00,这个“内存数据”的双字会被计算机由高位向低位按字节解释成“数值数据”0x 00 00 00 01。出于便于阅读的目的,OllyDbg在栈区显示的时候已经将内存中双字的字节序反转了,也就是说,栈区栏显示的是“数值数据”,而不是原始的“内存数据”,所以,在栈内看数据时,从左向右对于左边地址的偏移依次为3、2、1、0。请您在实验中注意这一细节。
下面我们试试输入超过7个字符,看看超过buffer[8]边界的数据能不能写进authenticated变量的数据区。为了便于区分溢出的数据,这次我们输入的密码为“qqqqqqqqrst”(‘q’、‘r’、‘s’、‘t’的ASCII码相差1),结果如图2.2.4所示。
图2.2.4 覆盖邻接变量
栈中的情况和我们分析的一样,从输入的第9个字符开始,将依次写入authenticated变量。按照我们的输入“qqqqqqqqrst”,最终authenticated的值应该是字符‘r’、‘s’、‘t’和用于截断字符串的null所对应的ASCII码0x00747372。
这时的栈帧数据如表2-2-3所示。
表2-2-3 栈帧数据
authenticated变量的值来源于strcmp函数的返回值,之后会返回给main函数作为密码验证成功与否的标志变量。当authenticated为0时,表示验证成功;反之,验证不成功。
我们已经知道越过数组buffer[8]的边界的后续数据可以改写变量authenticated,那么如果我们用这段溢出数据恰好把authenticated改为0,是不是就可以直接通过验证了呢?
字符串数据最后都有作为结束标志的NULL(0),当我们输入8个‘q’的时候,按照前边的分析,buffer所拥有的8个字节将全部被‘q’的ASCII码0x71填满,而字符串的第9个字符——作为结尾的NULL将刚好写入内存0x0012FB20处,即下一个双字的低位字节,恰好将authenticated从0x 00 00 00 01改成 0x 00 00 00 00,如图2.2.5所示。
图2.2.5 修改邻接变量
这时系统栈内的变化过程如表2-2-4所示。
表2-2-4 栈帧数据
经过上述分析和动态调试,我们知道即使不知道正确的密码“1234567”,只要输入一个为8个字符的字符串,那么字符串中隐藏的第9个截断符NULL就应该能够将authenticated低字节中的1覆盖成0,从而绕过验证程序!修改邻接变量成功的界面如图2.2.6所示。
图2.2.6 修改邻接变量成功
题外话:严格说来,并不是任何8个字符的字符串都能冲破上述验证程序。由代码中的authenticated=strcmp(password,PASSWORD),我们知道authenticated的值来源于字符串比较函数strcmp的返回值。按照字符串的序关系,当输入的字符串大于“1234567”时,返回1,这时authenticated在内存中的值为0x00000001,可以用字串的截断符NULL淹没authenticated的低位字节而突破验证;当输入字符串小于“1234567”时(例如,“0123”等字符串),函数返回-1,这时authenticated在内存中的值按照双字-1的补码存放,为0xFFFFFFFF,如果这时也输入8个字符的字符串,截断符淹没authenticated低字节后,其值变为0xFFFFFF00,所以这时是不能冲破验证程序的。图2.2.6所示的“01234567”输入就属于这种情形。如果您感兴趣,可以尝试进一步调试研究这种情况。