一般而言,局部变量在栈中的分布是相邻的(但也可能出于编译优化等需要而出现例外)。如果这些局部变量中有数组之类的缓冲区,并且程序确实没有防护数组越界,那么越界的数组元素就有可能破坏栈中相邻变量的值,甚至破坏栈帧中所保存的EBP的值、返回地址等重要数据。
0x00 源码
又是一份密码验证功能的C程序代码:
#include <stdio.h>
#include <string.h>
#define PASSWD "qazwsxe"
int verify_password(char* password) {
int authenticated;
char buffer[8];
authenticated = strcmp(password, PASSWD);
strcpy(buffer, password);
return authenticated;
}
void main() {
int valid_flag;
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;
}
}
}
程序的“初衷”是验证密码“qazwsxe”,如果输入错误将循环等待下次输入,只有输入正确了才能跳出循环。
程序使用TDM-GCC 9.2.0 32-bit Debug进行编译;务必避开Visual Studio系列编译的GS选项,这个选项会规避栈溢出。
0x01 分析
函数verify_password()新增了局部变量char buffer[8],注意是紧邻着变量authenticated的,这个声明位置很重要。另外,在字符串比较后有函数strcpy(buffer, password),没有发挥实现验证功能的作用,非常违和,仅仅是为了构成栈溢出漏洞。
当程序执行到调用函数verify_password()时,其栈帧状态如下所示:
char buffer[0~3](ASCII编码的输入前4位) |
char buffer[4~7](ASCII编码的输入第5到8位,期望第8位为字符串截断符) |
int authenticated |
返回地址 |
上一个栈帧的EBP |
形参:password |
…… |
authenticated是int类型,在内存中占用一个DWORD,即4个字节。如果能让buffer数组越界,buffer[8]、buffer[9]、buffer[10]、buffer[11]将写入相邻变量authenticated中。
authenticated承接的是函数strcmp()的返回值,之后会返回给main函数作为密码验证成功与否的标志变量。按照strcmp()的设计,authenticated为0表示验证成功;任何非0值都表示不成功。
如果输入超过了7个字符(本来buffer第8位是用来存放字符串截断符NULL的,截断符必须占用1个字节),则越界字符的ASCII码会修改掉authenticated的值:恰好为0,则程序流程就会发生改变。
0x02 调试
先通过IDA获得函数strcpy()的VA:
使用x96dbg打开程序,在这个VA处下断点:
输入错误密码“zzzzzzz”,按照ASCII编码,有字符串的序关系“zzzzzzz”>“qazwsxe”,则strcmp()函数应当返回1,即预计authenticated会被赋值1。实际情况符合预期(断点后进行一次单步步过):
0x7A
是小写字母“z”的ASCII编码,下方的0x00000001
正是authenticated的值。栈帧数据分布情况一目了然。
局部变量名 | 内存地址 | 偏移3处的值 | 偏移2处的值 | 偏移1处的值 | 偏移0处的值 |
---|---|---|---|---|---|
buffer[0~3] | 0x0065FA94 |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
buffer[4~7] | 0x0065FA98 |
NULL |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
authenticated | 0x0065FA9C |
0x00 |
0x00 |
0x00 |
0x01 |
注意:实际上,变量authenticated在内存中存储为0x01000000
(小端存储),由调试器自行反转显示以便阅读。所以偏移量(相对左边给出的内存地址的距离)从左至右依次为3、2、1、0。
0x03 溢出
下面尝试输入超过7个字符,看看越界数据能否写入authenticated的数据区。输入“zzzzzzzzyxw”(“w”、“x”、“y”、“z”的ASCII码依次递增1):
与预期一致,从第9个字符开始的数据被依次写入authenticated的数据区,即authenticated的值变为了0x00777879
。
此时的栈帧数据分布情况为:
局部变量名 | 内存地址 | 偏移3处的值 | 偏移2处的值 | 偏移1处的值 | 偏移0处的值 |
---|---|---|---|---|---|
buffer[0~3] | 0x0065FA94 |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
buffer[4~7] | 0x0065FA98 |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
authenticated(被覆盖前) | 0x0065FA9C |
0x00 |
0x00 |
0x00 |
0x01 |
authenticated(被覆盖后) | 0x0065FA9C |
NULL |
0x77 ('w') |
0x78 ('x') |
0x79 ('y') |
现已知溢出数据确实能够覆写authenticated,只需要使authenticated为0就能通过程序验证了。
输入8个字符时,第9位的截断符NULL(0x00
)将会被写入内存0x0065FA9C
,即下一个双字的低位字节,恰好将authenticated从0x00000001
变为0x00000000
。
此时栈帧数据为:
局部变量名 | 内存地址 | 偏移3处的值 | 偏移2处的值 | 偏移1处的值 | 偏移0处的值 |
---|---|---|---|---|---|
buffer[0~3] | 0x0065FA94 |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
buffer[4~7] | 0x0065FA98 |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
0x7A ('z') |
authenticated(被覆盖前) | 0x0065FA9C |
0x00 |
0x00 |
0x00 |
0x01 |
authenticated(被覆盖后) | 0x0065FA9C |
0x00 |
0x00 |
0x00 |
0x00 (NULL) |
结论就是非常简单,输入8位字符即可,让截断符去覆盖0x01
。
0x04 补充
并不是所有的8位字符串都可以绕过验证。
如果输入的字符串按照ASCII编码的序关系小于正确密码“qazwsxe”,如“aaaaaaa”<“qazwsxe”,strcmp()函数会返回-1,此时authenticated存储着补码形式的-1,即0xFFFFFFFF
。这样NULL覆写后也只能变成0xFFFFFF00
。