本文的起源,来自于在学校BBS上的C++版上,有一个人问了一个问题,然后我给他已解答。这个帖子的原文是这样的:
标 题: 这个怎么一直不停输出啊,菜鸟求教
发信站: 飘渺水云间 (Mon Sep 20 16 : 52 : 30 2010 ), 转信
看了半天找不出毛病
char str1[] = " go? " ;
char str2[] = " back. " ;
int i = 0 ;
int j = 0 ;
int count = 0 ;
while (str1[i])
i ++ ;
while (str2[j])
j ++ ;
for (;count < j;count ++ )
str1[i ++ ] = str2[count];
str1[i] = ' \0 ' ;
printf( " \n%s\n " ,str1);
return 0 ;
--
※ 来源:·飘渺水云间 zju88.org·[FROM: lisanbai]
上面的代码看起来,是把 str2 的内容附加到 str1 的尾部(完成 strcat 的功能),很显然,他的错误是 str1 的空间不足够容纳 str2,作者之所以犯了这个错误,可能是因为他对内存管理不够熟悉导致的。下面是我对该贴的回复:
(1)你的str1恐怕不够容纳str2的内容,换句话说,你写内存的时候越界了。
(2)我用IDA看了下在函数的栈上的分布,大致情况如下:
低地址:(栈顶部方向)
-14h j: ....
-10h i: [0][0][0][0]
-0Ch str2: [b][a][c][k][.][0][0][0]
-04h str1: [g][o][?][0]
00h : 被保护的寄存器值(如果有的话)
ebp
返回跳转地址
高地址:
你附加字符的时候,把函数返回地址那里给覆盖了。换句话说,这个函数的Stack Frame被你给破坏掉了。
(3)假设这些代码放在 main 函数里,用 IDA 运行这个程序,在函数返回前,可以手工把栈里的被破坏掉的ebp复原,但是返回地址好像没法复原(没法直接改栈上的数据),因为返回地址不对(返回地址最低位的字节被改成0)又跳回到 mainCRTStartup 里面的比较靠前的地方去了,正好跳到调用GetVersion的地方。然后过会又执行到调用 main 函数,然后又进入我在 main 函数里设的断点位置(如果反复手工修改被破坏的ebp,就形成了一个死循环状态)。
如果不手工复原 ebp ,回到 mainCRTStartup 里面的时候,在会报一个内存不能写的异常。。。(还好是在自己的进程空间里)因为ebp的原来的值是 mainCRTStartup 里的可能也是用于访问栈的一个指针,总之在 mainCRTStartup 的开头的地方有mov ebp, esp。
这里需强调的是,这个代码是可以通过 __chkesp 的检测。因为 __chkesp 只检测ebp的当前值(函数入口点的栈顶地址)和函数释放栈上空间以后的esp是否一致。这个代码不会影响到 ebp 当前值(函数入口点的栈顶),也不会没有破坏 esp 当前值,而是破坏了 ebp 的原值(在上一个函数中的值)和返回地址。因此这个代码属于缓冲区溢出,__chkesp 对此情况无法检测。
(4)注:改正方法很简单,第一行代码改成例如 char str[32]="go?" 即可。该数字保证大于 str1+str2 的长度即可。
=================================================================
尽管这个问题应该说很容易解答,到这里也基本算可以了。不过我在 IDA 调试的时候发现编译器附加的 __chkesp 函数对这个问题里的代码是不起作用的,这引起了我的注意。通常人们不可能故意的让自己的函数产生缓冲区溢出这样的错误(本文的提问者是无意的),常见的比较底层的方法例如使用 FlushInstructionCache 修改函数入口地址来完成一些 hook。但是如果我们自己故意让我们的代码产生缓冲区溢出则另当别论了,所以我按照这个代码的思路,可修改函数返回时跳转的地址,让函数返回时进入另一个函数,这也是比较有趣的一个事情。为了不能让系统觉察到异常,必须再无痕迹跳转回正确的位置,相当于我们自己hack自己了。
下面我提供一个演示的代码,首先简单介绍以下原理,这里存在一些没有保障的假设,例如在进入函数的时候,我们认为函数的栈是这样的分布:
ebp的原值(通常是上一个函数中的栈指针)
函数返回跳转地址(调用者中的某个地址)
然后我修改函数(NormalFunc)的返回地址,让他跳转到另一个函数(Test2),注意这和常规的函数调用不同!如果这个函数有编译器产生的开场白(prolog),必须手动先添加一个收场白(epilog)去抵消掉开场白的影响(稍后我再介绍这一点)。为了简单起见,我使用 naked 关键字,要求编译器不要添加开场白和收场白,这样进入这个函数的时候可以直接去执行我们自己的代码,执行完用户代码以后再跳回正确的地址(调用者 main 的内部)。这样在系统不知道的情况下,我们就用“神不知鬼不觉的方式”“调用”了另一个函数(Test2)!
下面是范例的代码,使用VC6.0,Console Application:
//
#include " stdafx.h "
// 保存函数返回地址(跳回到main)
unsigned int retAddress;
void Test2();
void NormalFunc()
{
// data[1]: ebp的值;data[2]:函数返回地址
unsigned int data[ 1 ] = { 0x0 };
// 保存返回地址
retAddress = data[ 2 ];
data[ 2 ] = (unsigned int )Test2;
return ;
}
// naked函数(手工指定prolog 和 epilog)
__declspec(naked) void Test2()
{
printf( " Naked: hello world!\n " );
// 跳回到main函数体中!
__asm
{
jmp [retAddress]
}
}
int main( int argc, char * argv[])
{
NormalFunc();
printf( " before exit\n " );
return 0 ;
}
这个函数产生下面的输出,看起来就和调用了 Test2 一样:
Naked: hello world!
before exit
在 main 函数里本质上调用的是 NormalFunc 函数, 在这个函数里我修改了它返回时的跳转地址,同时也把正确的返回时跳转地址保存到了一个全局变量(retAddress)中。然后这个函数返回时进入了 Test2, 在 Test2 里执行了一些代码以后,再通过全局变量跳回到 main 中的正确位置,这个过程对编译器和系统来说是透明的。
在 Test2 里我们使用 naked 关键字防止编译器自动产生那些开场白和收场白。如果没有加这个关键字,函数的开头和结尾会有系统产生的那些开场白和收场白,因为我们并非常规的函数调用,可以理解为我们还位于原来的函数体中,所以在执行我们自己的代码前,需要手工抵消掉函数的开场白(只需要把编译器产生的收场白嵌入到函数的用户代码前面即可,为此,首先观察编译器产生的开场白和收场白(函数主体部分省略):
. text: 00401070 push ebp
. text: 00401071 mov ebp, esp
. text: 00401073 sub esp, 40h
. text: 00401076 push ebx
. text: 00401077 push esi
. text: 00401078 push edi
. text: 00401079 lea edi, [ebp+var_40]
. text: 0040107C mov ecx, 10h
. text: 00401081 mov eax, 0CCCCCCCCh
. text: 00401086 rep stosd
; 函数的结尾部分
. text: 0040109B pop edi
. text: 0040109C pop esi
. text: 0040109D pop ebx
. text: 0040109E add esp, 40h
. text: 004010A1 cmp ebp, esp
. text: 004010A3 call __chkesp
. text: 004010A8 mov esp, ebp
. text: 004010AA pop ebp
. text: 004010AB retn
现在我们写一个普通的函数(不加 naked 关键字),我们在函数前面嵌入“收场白”的等效汇编代码,则非 naked 的 Test2 函数的代码如下,具体的开场白有可能会依赖编译器,嵌入的收场白代码怎样写,最好还是用反汇编查看一下再确定(本例使用的是VC6.0):
{
//普通函数,我们必须手工抵消函数的开头
__asm
{
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
}
//现在做一些事情
printf( " hello world!\n " ) ;
//跳回到main函数体中!
__asm
{
jmp [retAddress]
}
}
范例中的 Test2 函数很显然是不能直接调用的,因为全局变量 retAddress 的初值是 0,直接调用会导致进程异常终止。但如果我们先调用 NormalFunc 是全局变量(retAddress)被赋正确的值 ,Test2 就可以正常调用了,但是 Test2 返回时是从 NormalFunc 函数调用后面的语句继续执行的,所以这样会产生死循环。所以我们可以少许改造下 Test2,让它最多被调用 5 次以后进程退出(否则因为死循环屏幕将一直输出字符串)。改造后的代码可以在屏幕上打印五行字符串内容:
#include < stdlib.h >
// 保存函数返回地址(跳回到main)
unsigned int retAddress;
void Test2();
void NormalFunc()
{
// data[1]: 可能是
unsigned int data[ 1 ] = { 0x0 };
// 保存返回地址
retAddress = data[ 2 ];
data[ 2 ] = (unsigned int )Test2;
return ;
}
// naked函数(手工指定prolog 和 epilog)
__declspec(naked) void Test2()
{
static int i;
printf( " Naked: hello world!\n " );
i ++;
if(i == 5)
exit(0 );
// 跳回到main函数体中!
__asm
{
jmp [retAddress]
}
}
int main( int argc, char * argv[])
{
NormalFunc();
Test2();
printf( " before exit\n " );
return 0 ;
}
--hoodlum1980
--2010-9-20