背景:最近在看一些教学视频,然后主讲人敲了一段栈溢出的代码。我当场蒙蔽了-。- ! 而且他也没有细讲原理,最为一名爱探险星人,我决定Get it。
栈溢出示例代码:
#include<Windows.h>
#include<stdio.h>
#include<stdlib.h>
void Msg() {
MessageBoxA(NULL, "嘿嘿!", "堆栈溢出测试", 0);
}
int Add(int a, int b) {
int* p = &a;
*(p-1) = (int)Msg;
return a + b;
}
void main() {
printf("%d", Add(1, 2));
system("pause");
return;
}
运行结果:
按下确定以后出现异常:
首先在讲解原理之前首先介绍一些基本知识便于理解原理:
汇编层面的函数调用过程
每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器ebp指向当前的栈帧的底部(高地址),寄存器esp指向当前的栈帧的顶部(低地址)。
下图表示当正在执行FunctonA函数时的栈情况:系统栈可以认为是全部栈空间。栈帧对应每一个函数调用,EBP寄存器存放当前活动栈帧的栈底,ESP寄存器存放当前活动栈帧的栈顶。当前函数可以在当前的栈帧区域内存放局部变量和信息,全局的变量不存放在栈,有专门的区域存放。
下图表示call FunctionB之前做的工作:首先PUSH 函数的参数,从右向左压入,然后保存call FunctionB下一条指令的地址,便于函数返回。这个保存下一条指令地址和跳转到FunctionB处由 call 指令完成。
下图表示创建新的FunctionB的栈帧:首先PUSH EBP 保存旧栈帧的栈底,用于函数返回。然后MOV EBP,ESP,设置当前EBP为旧栈帧栈底的地址处(如下图),最后SUB ESP, 0X0C0H ,ESP向上开辟空间,具体开辟多少根据编译器。到此新的栈帧开辟完了。(题外话:FunctionB可以通过EBP+8 获取到arg0,EBP+12获取到arg1,这就是为什么倒着压入参数的原因。如果FunctionB里面有局部变量,则可以放在EBP和ESP这段栈空间里面。)
下图表示FunctionB函数返回后栈的变化:首先 MOV ESP,EBP POP EBP来还原EBP为旧的栈帧栈底。然后RET 到call FunctionB的下一条指令处(RET 包含POP JMP,所以下一条指令地址恰好被提出),最后ADD ESP,8 ,去掉压入的参数,8是因为压入了2个参数。到此已经还原了原来的环境了。整个调用过程结束了。
现在进入主题,介绍原理:上面的代码核心思想就是改变调用Add(1,2)时,改变返回的地址(就是下一条指令的地址):
修改这个地址内容为Msg()入口地址,这样就会执行Msg()代码。关键时怎么确定这个地址,然后写入Msg()入口地址搞定他。其实我们可以通过Add(int a,int b)的参数a确定下一条地址的地址,如图:获取a变量的地址,然后向上退就可以到下一条指令的地址,如后覆盖为Msg(),入口地址即可。
关键代码解释:
int* p = &a;//获取a变量的地址
*(p-1) = (int)Msg;//上退覆盖地址为Msg入口地址,这里(p-1)而不是-4是因为p为地址,减一就是减一个字
下面上几张运行关键图:
友情提示:内存数据倒着读。可见 77 18 31 01 -> 01 31 18 77 确实是 add esp,8 的地址。
可以看到01 31 11 13并不是Msg地址,其实这个还要跳转一下,如下图:
下面讲一下崩溃的原因。由于正常调用Msg需要call 指令,而call 指令通常会有一个PUSH 下一条指令操作,而这里是直接通过Add 的RET POP 地址到EIP 执行,没有PUSH 操作,所以当执行玩Msg以后函数返回时RET 到EIP 的其实是参数a=1,然后EIP去00 00 00 01取指令当然GG了-。-!。可以贴一张内存证明下:
-------------------------------------------------------------------------------------------------------------------------------------------------------
例子原理算是讲完了,这个报错只是说明这个溢出姿势不够高雅。但是作为一名爱探险星人,有必要稍微高雅点,所以下面我就改进了下代码,使其正常运行结束。 这里我改的层面是C语言不是汇编层,所以想要即跳转执行Msg又完全还原寄存器状态当前没成功(有精力的同志去尝试下吧),当然汇编可以的。最后Add显示的结果就不是3了,因为状态没有完全还原。
int Add(int a, int b) {
int* p = &a;
*p = *(p - 1) + 4;//添加的代码,p-1指向原来跳转地址,然后加4
*(p - 1) = (int)Msg;
return a + b;
}
由于报错的原因是因为参数a=1,出问题于是我们可以首先保存原来的跳转地址到a处,但是不能为原来的地址,需要加4跳到如图指令:
首先不能跳到add esp,8 是因为正常返回以后ESP 在参数a=1处,所以add esp,8可以去掉压入的两个参数,但是现在返回时已经在b=2处了,所以这句不要执行,否则会破坏以前的数据。push eax也不跳,因为按照正常的流程返回以后栈如下图:
然后push eax 压入prinf第二个参数(注意位置位于b=2处),即显示的Add结果,在push 第一个参数(注意位置位于a=1处)。但是当中途执行Msg返回后栈如图:
待定原跳转地址就是现在讨论的地址,可以看见ESP指向了b=2,这里本来应该是push eax存放Add返回结果的(这就是为什么显示的结果为2的原因,运行结果显示在下面),而上面的待命原跳转地址应该放printf的第一个参数,即"%d"。现在明白了吧,我们跳到压入第二个参数的地方,不跳第一个push eax。因为现在ESP已经指向了b=2,可以认为已经压完了第一个参数,如果你一定要跳第一个参数处,没问题!报错在等你!所以待定的跳转地址为原跳转地址加4。
代码展示:
int Add(int a, int b) {
int* p = &a;
*p = *(p - 1) + 4;//添加的代码,*(p-1)提取旧的跳转地址,然后加4
*(p - 1) = (int)Msg;
return a + b;
}
运行效果图:
-------------------------------------------------------------------------------------------------------------------------------------------
看了这篇文字,不说别的,相信读者调试BUG的境界已经上升了一个境界-。- 可以在内存里面找BUG了。