前言:
大学的时候想学pwn,但是因为所学专业的原因,想学习但是找不到成体系的教程,也没钱买课。最近有空想整理一下pwn的学习笔记,顺便也当给新人的教程吧。
这系列教程面向的人群:已经学习完毕c语言的大学生。鄙人当时c二级考了90多分吧(学校里有个表可以自己查成绩),评级是优,大概这个水平就能读懂本系列博客。其他知识需要能读懂简单的汇编指令,如果学过操作系统就更好了。
为什么学习pwn要在Linux平台上?因为Linux开源,并且有丰富的pwn教程,学习难度低。并且基本原理是一样的,学习完Linux平台转移到Windows上也很简单。
程序传参相关知识:
程序调用约定:
调用约定可以认为是在发生函数调用时,调用函数和被调用函数 遵顼的一些参数传递、栈清理等细节方面的约定。常用的有4种调用约定:stdcall、fastcall、__cdecl、thiscall。
初学者不应当关注这些调用的细节实现,我们只要关注:参数如何传递、函数结束时谁来清理栈。
x86:
调用约定 | 参数传递 | 栈清理 | 备注 |
stdcall | 参数从右向左压入堆栈 | 被调函数自身清理堆栈 | 别称pascal调用约定 |
__cdecl | 参数从右向左压入堆栈 | 调用者负责清理堆栈 | 不额外声明调用约定就是它 |
fastcall | 函数的第一个和第二个DWORD参数(或者尺寸更小的)通过ecx和edx传递,其他参数通过从右向左的顺序压栈 | 被调函数自身清理堆栈 | 快速调用方式 |
thiscall | 参数从右向左入栈。如果参数个数不确定,this指针在所有参数压栈后被压入堆栈。如果参数个数确定,this指针通过ecx传递给被调用者。 | 如果参数个数不确定,调用者清理堆栈,否则函数自己清理堆栈 | C++类成员函数缺省的调用约定 |
x64:
Linux:熟练掌握
参数依旧是从右向左传输,但是寄存器传参,存储方式不太一样。一般是第一到第六个参数分别放到 rdi、rsi、rdx、rcx、r8、r9,剩余参数从右向左依次压栈。举个例子:
int add(int a,int b,int c,int d,int e,int f,int g,int h)
{
return a+b+c+d+e+f+g+h;
}
调用:add(1,2,3,4,5,6,7,8)
那么会有:先将8压栈然后7压栈,之后有rdi=1; rsi=2; rdx=3; rcx=4; r8=5; r9=6。返回值存储在rax中。
Windows:简单了解
Windows下x64传参可不一样,win下是fast-call调用约定:并且和传入的参数类型有关。
默认情况下,x64 应用程序二进制接口 (ABI) 使用四寄存器 fast-call 调用约定。 系统在调用堆栈上分配空间作为影子存储,供被调用方保存这些寄存器。
函数调用的参数与用于这些参数的寄存器之间有着严格的一一对应关系。 任何无法放入 8 字节或者不是 1、2、4 或 8 字节的参数都必须按引用传递。 单个参数永远不会分布在多个寄存器中。
未使用 x87 寄存器堆栈。 被调用方可能会使用它,但请考虑到它跨函数调用的易失性。 所有浮点数运算都使用 16 个 XMM 寄存器完成。
整数参数在寄存器 RCX、RDX、R8 和 R9 中传递。 浮点数参数在 XMM0L、XMM1L、XMM2L 和 XMM3L 中传递。 16 字节参数按引用传递。 要了解参数传递,请参阅参数传递一文。 这些寄存器和 RAX、R10、R11、XMM4 和 XMM5 被视为易失,或者可能在返回时由被调用方更改。 要详细了解寄存器的使用方法,请参阅 x64 寄存器使用和由调用方/被调用方保存的寄存器。
对于原型函数,在传递参数之前,所有参数都将转换为所需的被调用方类型。 调用方负责为被调用方的参数分配空间。 调用方必须始终分配足够的空间来存储 4 个寄存器参数,即使被调用方不使用这么多参数。 此约定简化了对非原型 C 语言函数和 vararg C/C++ 函数的支持。 对于 vararg 或非原型函数,任何浮点值都必须在相应的通用寄存器中重复。 调用之前,必须将除前 4 个参数外的其他参数存储在影子存储后面的堆栈中。 可以在 Vararg 中找到 Vararg 函数的详细信息。 要了解非原型函数,请参阅非原型函数一文。
举个例子:
func1(int a, int b, int c, int d, int e, int f);
// a in RCX, b in RDX, c in R8, d in R9, f then e pushed on stack
func2(float a, double b, float c, double d, float e, float f);
// a in XMM0, b in XMM1, c in XMM2, d in XMM3, f then e pushed on stack
func3(int a, double b, int c, float d, int e, float f);
// a in RCX, b in XMM1, c in R8, d in XMM3, f then e pushed on stack
func4(__m64 a, __m128 b, struct c, float d, __m128 e, __m128 f);
// a in RCX, ptr to b in RDX, ptr to c in R8, d in XMM3,
// ptr to f pushed on stack, then ptr to e pushed on stack
以上windows调用方式是抄的MSDN的官方文件,更具体的请参看:x64 调用约定。
x86下经典栈帧结构:
所有栈溢出攻击的基础知识,必须熟练掌握的知识。先盗个栈帧结构图,看其他博客都用这图讲,具体来源不可考。
图1中的stack frame pointer 我更喜欢称呼它为old ebp,即它是保存的之前的ebp指针中的地址。ebp指针是栈底指针,将old ebp恢复到ebp寄存器里就是恢复栈的一步。并且栈是动态变化的,图1中展示的栈结构是已经进入被调函数后完成前期开辟栈空间的情况(未完待续)
调试个程序看看栈的变化:
//test.c
//gcc -g -m32 -z execstack -z norelro -fno-stack-protector -no-pie -fno-pie -o a.out test.c
#include <stdio.h>
int add(int a,int b,int c,int d,int e,char f)
{
int g = a+b+c+d+e+f;
return g;
}
void print()
{
printf("hello world!\n");
}
int main()
{
add(1,2,3,4,5,'a');
return 0;
}
编译:gcc -g -m32 -z execstack -z norelro -fno-stack-protector -no-pie -fno-pie -o a.out test.c
这个编译参数是指要编译出一个带symbol信息,32位 关闭NX、关闭Relro、关闭Canary、关闭PIE的叫a.out的文件。
使用pwndbg调试,然后用b add打个断点,r执行。
图2
图2是add函数的汇编指令,可以看到从ebp+8开始是第一个参数,然后一直到ebp-0x14出现是最后一个参数,可能是编译器进行过优化了,最后一个参数‘a’其ascii值为0x61,应该出现在ebp+0x1C的位置,但是编译器发现ebp-0x14也是一个'a'就给优化了,但是传参顺序一定是不变的我们直接观察栈,使用命令stack 30
图3
图3就是栈分布,由于ebp和esp寄存器分别是栈底和栈顶指针。05行就是ebp指向的位置,对应图1的stack frame pointer或者叫old ebp,这个位置里存储的old ebp值是0xffffcf28。我们可以发现从07行到0c行就是咱们传入的参数,对应图1的aruments。但是注意这个图3的布局是高地址在下,和图1正好反着,所以越向下离ebp越远,参数越早入栈,所以是从右向左入栈没问题!
然后ebp+0x4的位置就是return address的地址,我们之后的栈溢出主要就是覆盖这个地方。
payload布局相关问题
这部分问题不算基础知识,不应该先看。当你对程序栈概念是动态变化的这句话的理解不到位就会有如下的问题。
payload即为攻击载荷。就是我们发一段精心设计的数据给程序,让程序改变原来的执行流进而执行我们设计的程序,而我们发送的这段精心设计的数据叫做payload。
调用某个函数的payload大概是:payload = padding+要调用的函数地址+下一个返回地址+参数1+参数2+...
其中padding为了实现栈溢出而填充在缓冲区上以及return addr之前的的字节。
我们观察上边的payload例子发现:payload布局时调用函数地址后边紧跟的是“下一个返回地址”。可是根据图1 返回地址前边不应该是old ebp吗?那么“下一个返回地址”前面应该是“old ebp”啊,怎么是“要被调用的函数地址”哪?
问payload为什么不是:payload = padding +要调用的函数地址+old ebp+下一个返回地址+参数1+参数2+...
答:函数栈是动态变化的,举个例子如果要调用的函数地址是add函数的地址,那么在add函数里边会有一个leave;ret 指令来达到栈平衡,并且在进入add函数最开始的地方存储old ebp时也是add函数自己操作的,以此来保证栈平衡。也就是说在函数add返回之前ebp就已经恢复好了,不需要你手动给他一个old ebp。
leave指令相当于:mov esp,ebp;pop ebp
ret指令相当于:pop rip
一般函数结尾都会有leave;ret指令,我们看看函数执行中和函数执行完但还没有ret时的栈结构:
图4
可以看到 图4所示在被调函数执行完leave后执行ret前,old ebp已经被恢复了,所以你不需要在payload上额外写一个old ebp了。
继续执行被调函数的最后一条汇编:ret。情况如图5所示:
图5
return addr 也被pop到eip中了,程序流又回到了调用函数中。
推荐阅读的书籍:
《程序员的自我修养——链接、装载与库》。讲了很多动态链接中的细节,也是很牛的一本书,读了对后面的ret2dlresolve有很大帮助。
《汇编语言》作者:王爽。我的汇编语言入门书籍,阅读完感觉不错
还有个推荐的知识库,我之后的好多东西都是抄的这个,然后这个也是抄了很多ctf-wiki上的东西。