CTF-PWN-平衡栈帧


栈溢出

第一讲–初始

先说明,以下讨论均在32位机器下进行讨论!

这个栈溢出的例子,我是从b站up主Innks那里看到的。因为有些细节不理解,所以动手敲了一遍。

改进之前

改进之前由于没有平衡栈空间,导致栈空间被破坏,程序无法正确返回。

#include <iostream>
#include <Windows.h>

#pragma optimize("",off)

void MsgBox()
{
    int ary[2];
    ary[4] = ary[3]; //相当于把ret返回地址复制了一遍
    ary[3] = (int)MessageBoxA;
    //ary[4] = ary[3]; //这条指令写到这个位置和写在上面一个位置是完全不一样的哟!
    ary[5] = 0;
    ary[6] = (int)"NO_CALL 恭喜你中毒了";
    ary[7] = (int)"NO_CALL 你中毒了";
    ary[8] = MB_OK;
}
int main()
{

    MsgBox();
    MessageBoxA(0, "恭喜你中毒了", "你中毒了", MB_OK);
}

改进之后

改进之后,可以正确的让程序结束

#include <iostream>
#include <Windows.h>

#pragma optimize("",off)

void MsgBox()
{
    int ary[2];
    ary[4] = ary[3]; //相当于把ret返回地址复制了一遍
    ary[3] = (int)MessageBoxA;
    //ary[4] = ary[3]; //这条指令写到这个位置和写在上面一个位置是完全不一样的哟!
    ary[5] = 0;
    ary[6] = (int)"NO_CALL 恭喜你中毒了";
    ary[7] = (int)"NO_CALL 你中毒了";
    ary[8] = MB_OK;
}
int main()
{
    __asm push ebp;
    __asm push ebp;
    __asm push ebp;
    __asm push ebp;
    __asm push ebp;
    MsgBox();
    MessageBoxA(0, "恭喜你中毒了", "你中毒了", MB_OK);
}

改进前后的栈帧对比

由于ipencil长时间不用,现在才发现已经被我摔坏了,以后不常用的东西还是保管好呢,不要满不在意,用的时候才发现坏了。

所以我就直接用手写的图了,懒得画图了。

也确实是该换手机了,手机前置摄像头找出来的照片有点黑。

栈空间对比

总结

因为我们采用栈溢出的方式调用了函数,那么应该push到栈中的参数占的空间占用了其他栈帧的空间。所以会导致后续程序流发生不可控制的变化。

MessageBoxA的汇编代码

汇编中的retn 10h就是为了平衡call函数之前push到栈里面的参数所占的空间。第一开始不理解的地方就在于此,我觉得retn 10h平衡的也就是4个参数--16字节。但是up主却用了五个push ebp

实际上,retn 10h使栈空间减少了20个字节的空间。

retn操作:先eip=esp,然后esp=esp+4
retn N操作:先eip=esp,然后esp=esp+4+N

所以是20个字节!也就是五个push ebp就可以提前把这20个字节的空间弄出来。而不用影响到后续main函数的栈帧。

除此之外呢,我还发现vs—debug编译模式和release模式,是非常不一样的。

vs-debug&release

debug简单来说是为了方便分析程序,release模式是发布程序。我使用ida反汇编之后发现,debug生成的exe的汇编代码中添加了很多关于栈空间和一些寄存器的检查工作。而release模式下,是没有这些检查函数的。

第二讲–改进

通过前面的第一讲,我们明白了要解决通过栈溢出调用函数而导致的栈平衡问题。

up 还留了一个坑。 就是要采用什么样的方式去平衡栈,而不用写汇编。

预知识

函数调用有__cdecl__stdcall

__cdecl 是C Declaration的缩写(declaration,声明),表示C语言默认的函数调用方法:所有参数从右到左依次入栈,这些参数由调用者清除,称为手动清栈。被调用函数不会要求调用者传递多少参数,调用者传递过多或者过少的参数,甚至完全不同的参数都不会产生编译阶段的错误。

_stdcall 是StandardCall的缩写:所有参数从右到左依次入栈,如果是调用类成员的话,最后一个入栈的是this指针。这些堆栈中的参数由被调用的函数在返回后清除,使用的指令是 retnX,X表示参数占用的字节数,CPU在ret之后自动弹出X个字节的堆栈空间。称为自动清栈。函数在编译的时候就必须确定参数个数,并且调用者必须严格的控制参数的生成,不能多,不能少,否则返回后会出错。

MessageBoxA显然属于_stdcall。由被调用函数自己清栈。这也是系统API的特点之一。这样做的好处就是,严格控制了传递参数的个数,或多或少都不行。
MessageBoxA

MsgBox 属于_cdecl调用方式,由调用者自己清栈,这个过程中你传递参数的个数可以变化,这也是为什么可以定义可变参数的原因把。

MsgBox

思路

通过预知识的学习,我们知道了自定义函数和系统API调用采用的平栈方式不同,那么我们能不能利用这个特性来实现平栈呢?

改进思路

通过上图可以发现,我们通过给自定义函数增加参数,实现的效果和push ebp的效果一致。但是仅仅是这样不能够平栈,因为系统调用的时候还是会retn 10h,而由于_cdecl平栈的特性,其还add esp 14h。所以也就相当于进行了两次平栈操作。

那么 评论区大lao 的思路就是跳过 add esp 14h。由于这句指令是 3 字节,所以我们要在 ary[4] = ary[3] + 3。这样就跳到了下一条指令push 0的地址。

void MsgBox(int a, int b, int c, int d, int e)
{
    int ary[2];
    ary[4] = ary[3] + 3 ; //跳过 esp
    ary[3] = (int)MessageBoxA;
    ary[5] = 0;
    ary[6] = (int)"NO_CALL 恭喜你中毒了";
    ary[7] = (int)"NO_CALL 你中毒了";
    ary[8] = MB_OK;
}
int main()
{
    MsgBox(1,2,3,4,5);
    MessageBoxA(0, "恭喜你中毒了", "你中毒了", MB_OK);
}

然后up又对这个思路进行了改进。既然我们传递了一些参数,而且后续我们又把这些参数当作了MessageBoxA这个函数的参数,那么为什么不在传递参数的时候就把该传递的参数传进去呢。

void MsgBox(void* address,HWND hWnd,LPCSTR lpText,LPCSTR lpCaption,UINT uType)
{
    int ary[2];
    //交换ary[3] 和ary [4] ----也就是把MessageBoxA的地址换上来,把返回地址换下去
    ary[4] = ary[3] ^ ary[4];
    ary[3] = ary[3] ^ ary[4];
    ary[4] = ary[3] ^ ary[4];

    ary[4] = ary[4] + 3;
}
int main()
{
    MsgBox(MessageBoxA,0, "恭喜你中毒了", "你中毒了", MB_OK);
}

第三讲–完美

通过上述方式,我们知道了是需要跳过_cdecl或者是_stdcall两种平栈方式中的一种。那我们就可以利用这个欺骗编译器。

在声明的时候不给函数参数,但是在调用的时候,欺骗编译器这是个_stdcall类型且带有4个参数的函数,那么编译器会帮助我们将参数压栈,并且消除了add esp 14h的影响。

//完美版本
void MsgBox() {
    int ary[2];
    //交换ary[3] 和ary [4] ----也就是把MessageBoxA的地址换上来,把返回地址换下去
    ary[3] = ary[3] ^ ary[4];
    ary[4] = ary[3] ^ ary[4];
    ary[3] = ary[3] ^ ary[4];
}//ret----_cdecl 方式返回

typedef int* (_stdcall* _hMessageBoxA)(void* address, HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
//这样做的好处是,代码可复用性强。后续只需要写写声明就可以了。
typedef int* (_stdcall* _hMessageBoxW)(void* address, HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
int main()W
{
    //完美版本
    ((_hMessageBoxA)MsgBox) (MessageBoxA, 0, "恭喜你中毒了", "你中毒了", MB_OK);

    ((_hMessageBoxA)MsgBox) (MessageBoxA, 0, "恭喜你中毒了", "你中毒了", MB_OK);
}

所有的代码

#include <iostream>
#include <Windows.h>

#pragma optimize("",off)


// 初始版本
void MsgBox1(int a, int b, int c, int d, int e)
{
    int ary[2];
    ary[4] = ary[3]; //相当于把ret返回地址复制了一遍
    ary[3] = (int)MessageBoxA;
    //ary[4] = ary[3]; //这条指令写到这个位置和写在上面一个位置是完全不一样的哟!
    ary[5] = 0;
    ary[6] = (int)"NO_CALL 恭喜你中毒了";
    ary[7] = (int)"NO_CALL 你中毒了";
    ary[8] = MB_OK;
}

//进阶版本
void MsgBox2(int a, int b, int c, int d, int e)
{
    int ary[2];
    ary[4] = ary[3] + 3; //跳过 esp
    ary[3] = (int)MessageBoxA;
    //ary[4] = ary[3]; //这条指令写到这个位置和写在上面一个位置是完全不一样的哟!
    ary[5] = 0;
    ary[6] = (int)"NO_CALL 恭喜你中毒了";
    ary[7] = (int)"NO_CALL 你中毒了";
    ary[8] = MB_OK;
}
//进阶版本---改进
void MsgBox2_1(void* address, HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType)
{
    int ary[2];
    //交换ary[3] 和ary [4] ----也就是把MessageBoxA的地址换上来,把返回地址换下去
    ary[4] = ary[3] ^ ary[4];
    ary[3] = ary[3] ^ ary[4];
    ary[4] = ary[3] ^ ary[4];

    ary[4] = ary[4] + 3;
}
//完美版本
void MsgBox() {
    int ary[2];
    //交换ary[3] 和ary [4] ----也就是把MessageBoxA的地址换上来,把返回地址换下去
    ary[3] = ary[3] ^ ary[4];
    ary[4] = ary[3] ^ ary[4];
    ary[3] = ary[3] ^ ary[4];
}//ret----_cdecl 方式返回

typedef int* (_stdcall* _hMessageBoxA)(void* address, HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);

int main()
{
    //完美版本
    ((_hMessageBoxA)MsgBox) (MessageBoxA, 0, "恭喜你中毒了", "你中毒了", MB_OK);

    ((_hMessageBoxA) MsgBox) (MessageBoxA, 0, "恭喜你中毒了", "你中毒了", MB_OK);
}

总结

不知道怎么说,自己的水平还是差了很多,up主所提到的安全思维也没有。很有可能做一辈子也是个普通人,但那又能怎么样呢? 一直学下去呗。不断丰富自己,最后不会太差哒!


文章作者: 美食家李老叭
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 美食家李老叭 !
评论
  目录