其操作方式类似于数据结构中的栈金沙js娱乐场官方网站,下文中的C语言代码如没有特别声明

这篇文章以实践的方式验证go语言函数之间是如何传递数组类型变量的,即把原array内容做一个拷贝,然而每次发生函数调用,参考,2、堆区(heap) — 一般由程序员分配释放,其操作方式类似于数据结构中的栈,来了解一下 C 语言的变量是如何在内存分部的,先来看下面这段代码,其他语言中的方法、过程等本质上都是函数,C语言中的重要概念

金沙js娱乐场官方网站 6
 useArray 44d6b8: 48 89 e7 mov %rsp,%rdi 44d6bb: 48 8d b4 24 80 00 00 lea 0x80,%rsi 44d6c2: 00 44d6c3: 48 89 6c 24 f0 mov %rbp,-0x10 44d6c8: 48 8d 6c 24 f0 lea -0x10,%rbp 44d6cd: e8 fe ae ff ff callq 4485d0 <runtime.duffcopy+0x310> 44d6d2: 48 8b 6d 00 mov 0x0,%rbp 44d6d6: e8 25 00 00 00 callq 44d700 <main.useArray>

caller 和 callee

首先说明一个概念,下面的代码片段中,我们说 main
函数是主调函数(caller),compute 函数则是被调函数(callee)。

int main(void)
{
    compute(2, 4); 
    return 0;
}    

_CRTIMP int (__cdecl *printf)(const char *, …); //定义STL函数printf 
/*————————————————————————— 
写到这里,我们顺便来复习一下前面所讲的知识: 
(*注)printf函数是C语言的标准函数库中函数,VC的标准函数库由msvcrt.dll模块实现。 
由函数定义可见,printf的参数个数是可变的,函数内部无法预先知道调用者压入的参数个数,函数只能通过分析第一个参数字符串的格式来获得压入参数的信息,由于这里参数的个数是动态的,所以必须由调用者来平衡堆栈,这里便使用了__cdecl调用规则。BTW,Windows系统的API函数基本上是__stdcall调用形式,只有一个API例外,那就是wsprintf,它使用__cdecl调用规则,同printf函数一样,这是由于它的参数个数是可变的缘故。 
—————————————————————————*/ 
void main() 

HANDLE hHeap=GetProcessHeap(); 
char *buff=HeapAlloc(hHeap,0,0×10); 
char *buff2=HeapAlloc(hHeap,0,0×10); 
HMODULE hMsvcrt=LoadLibrary(“msvcrt.dll”); 
printf=(void *)GetProcAddress(hMsvcrt,”printf”); 
printf(“0x%08x\n”,hHeap); 
printf(“0x%08x\n”,buff); 
printf(“0x%08x\n\n”,buff2); 

金沙js娱乐场官方网站 1

接着再看函数useArray的实现语句

寄存器

寄存器位于CPU内部,用于存放程序执行中用到的数据和指令,CPU从寄存器中取数据,相比从内存中取快得多。寄存器又分通用寄存器特殊寄存器
通用寄存器有
ax/bx/cx/dx/di/si,尽管这些寄存器在大多数指令中可以任意选用,但也有一些特殊的规定,比如某些指令只能用某个特定的通用寄存器,例如函数返回时,需将返回值
mov 到 ax 寄存器中,特殊寄存器有 bp/sp/ip
等,特殊寄存器均有特定用途,对于有特定用途的几个寄存器,简要介绍如下:
ax(accumulator): 可用于存放函数返回值
bp(base pointer): 用于存放执行中的函数对应的栈帧的栈底地址
sp(stack poinger): 用于存放执行中的函数对应的栈帧的栈顶地址
ip(instruction pointer): 指向当前执行指令的下一条指令
另外不同架构的 CPU,寄存器名称被添以不同前缀以指示寄存器的大小。例如对于
x86 架构,字母 “e” 用作名称前缀,指示各寄存器大小为 32 位,对于 x86_64
寄存器,字母 “r” 用作名称前缀,指示各寄存器大小为 64 位。

0x0012ff68 
0x0012ff6c 
0x0012ff70 

在阅读本文之前,如果你连堆栈是什么多不知道的话,请先阅读文章后面的基础知识。 
 
 接触过编程的人都知道,高级语言都能通过变量名来访问内存中的数据。那么这些变量在内存中是如何存放的呢?程序又是如何使用这些变量的呢?下面就会对此进行深入的讨论。下文中的C语言代码如没有特别声明,默认都使用VC编译的release版。 
 
 首先,来了解一下 C 语言的变量是如何在内存分部的。C
语言有全局变量(Global)、本地变量(Local),静态变量(Static)、寄存器变量(Regeister)。每种变量都有不同的分配方式。先来看下面这段代码: 
 
 #include <stdio.h> 
 
 int g1=0, g2=0, g3=0; 
 
 int main() 
 { 
 static int s1=0, s2=0, s3=0; 
 int v1=0, v2=0, v3=0; 
 
 //打印出各个变量的内存地址 
 
 printf(“0x%08x\n”,&v1); //打印各本地变量的内存地址 
 printf(“0x%08x\n”,&v2); 
 printf(“0x%08x\n\n”,&v3); 
 printf(“0x%08x\n”,&g1); //打印各全局变量的内存地址 
 printf(“0x%08x\n”,&g2); 
 printf(“0x%08x\n\n”,&g3); 
 printf(“0x%08x\n”,&s1); //打印各静态变量的内存地址 
 printf(“0x%08x\n”,&s2); 
 printf(“0x%08x\n\n”,&s3); 
 return 0; 
 } 
 
 编译后的执行结果是: 
 
 0x0012ff78 
 0x0012ff7c 
 0x0012ff80 
 
 0x004068d0 
 0x004068d4 
 0x004068d8 
 
 0x004068dc 
 0x004068e0 
 0x004068e4 
 
 输出的结果就是变量的内存地址。其中v1,v2,v3是本地变量,g1,g2,g3是全局变量,s1,s2,s3是静态变量。你可以看到这些变量在内存是连续分布的,但是本地变量和全局变量分配的内存地址差了十万八千里,而全局变量和静态变量分配的内存是连续的。这是因为本地变量和全局/静态变量是分配在不同类型的内存区域中的结果。对于一个进程的内存空间而言,可以在逻辑上分成3个部份:代码区,静态数据区和动态数据区。动态数据区一般就是“堆栈”。“栈(stack)”和“堆(heap)”是两种不同的动态数据区,栈是一种线性结构,堆是一种链式结构。进程的每个线程都有私有的“栈”,所以每个线程虽然代码一样,但本地变量的数据都是互不干扰。一个堆栈可以通过“基地址”和“栈顶”地址来描述。全局变量和静态变量分配在静态数据区,本地变量分配在动态数据区,即堆栈中。程序通过堆栈的基地址和偏移量来访问本地变量。 
 
 
 ├———————┤低端内存区域 
 │ …… │ 
 ├———————┤ 
 │ 动态数据区 │ 
 ├———————┤ 
 │ …… │ 
 ├———————┤ 
 │ 代码区 │ 
 ├———————┤ 
 │ 静态数据区 │ 
 ├———————┤ 
 │ …… │ 
 ├———————┤高端内存区域 
 
 
 堆栈是一个先进后出的数据结构,栈顶地址总是小于等于栈的基地址。我们可以先了解一下函数调用的过程,以便对堆栈在程序中的作用有更深入的了解。不同的语言有不同的函数调用规定,这些因素有参数的压入规则和堆栈的平衡。windows
API的调用规则和ANSI
C的函数调用规则是不一样的,前者由被调函数调整堆栈,后者由调用者调整堆栈。两者通过“__stdcall”和“__cdecl”前缀区分。先看下面这段代码: 
 
 #include <stdio.h> 
 
 void __stdcall func(int param1,int param2,int param3) 
 { 
 int var1=param1; 
 int var2=param2; 
 int var3=param3; 
 printf(“0x%08x\n”,¶m1); //打印出各个变量的内存地址 
 printf(“0x%08x\n”,¶m2); 
 printf(“0x%08x\n\n”,¶m3); 
 printf(“0x%08x\n”,&var1); 
 printf(“0x%08x\n”,&var2); 
 printf(“0x%08x\n\n”,&var3); 
 return; 
 } 
 
 int main() 
 { 
 func(1,2,3); 
 return 0; 
 } 
 
 编译后的执行结果是: 
 
 0x0012ff78 
 0x0012ff7c 
 0x0012ff80 
 
 0x0012ff68 
 0x0012ff6c 
 0x0012ff70 
 
 
 
 ├———————┤<—函数执行时的栈顶(ESP)、低端内存区域 
 │ …… │ 
 ├———————┤ 
 │ var 1 │ 
 ├———————┤ 
 │ var 2 │ 
 ├———————┤ 
 │ var 3 │ 
 ├———————┤ 
 │ RET │ 
 ├———————┤<—“__cdecl”函数返回后的栈顶(ESP) 
 │ parameter 1 │ 
 ├———————┤ 
 │ parameter 2 │ 
 ├———————┤ 
 │ parameter 3 │ 
 ├———————┤<—“__stdcall”函数返回后的栈顶(ESP) 
 │ …… │ 
 ├———————┤<—栈底(基地址 EBP)、高端内存区域 
 
 
 上图就是函数调用过程中堆栈的样子了。首先,三个参数以从又到左的次序压入堆栈,先压“param3”,再压“param2”,最后压入“param1”;然后压入函数的返回地址(RET),接着跳转到函数地址接着执行(这里要补充一点,介绍UNIX下的缓冲溢出原理的文章中都提到在压入RET后,继续压入当前EBP,然后用当前ESP代替EBP。然而,有一篇介绍windows下函数调用的文章中说,在windows下的函数调用也有这一步骤,但根据我的实际调试,并未发现这一步,这还可以从param3和var1之间只有4字节的间隙这点看出来);第三步,将栈顶(ESP)减去一个数,为本地变量分配内存空间,上例中是减去12字节(ESP=ESP-3*4,每个int变量占用4个字节);接着就初始化本地变量的内存空间。由于“__stdcall”调用由被调函数调整堆栈,所以在函数返回前要恢复堆栈,先回收本地变量占用的内存(ESP=ESP+3*4),然后取出返回地址,填入EIP寄存器,回收先前压入参数占用的内存(ESP=ESP+3*4),继续执行调用者的代码。参见下列汇编代码: 
 
 ;————–func 函数的汇编代码——————- 
 
 :00401000 83EC0C sub esp, 0000000C //创建本地变量的内存空间 
 :00401003 8B442410 mov eax, dword ptr [esp+10] 
 :00401007 8B4C2414 mov ecx, dword ptr [esp+14] 
 :0040100B 8B542418 mov edx, dword ptr [esp+18] 
 :0040100F 89442400 mov dword ptr [esp], eax 
 :00401013 8D442410 lea eax, dword ptr [esp+10] 
 :00401017 894C2404 mov dword ptr [esp+04], ecx 
 
 ……………………(省略若干代码) 
 
 :00401075 83C43C add esp, 0000003C ;恢复堆栈,回收本地变量的内存空间 
 :00401078 C3 ret 000C ;函数返回,恢复参数占用的内存空间 
 ;如果是“__cdecl”的话,这里是“ret”,堆栈将由调用者恢复 
 
 ;——————-函数结束————————- 
 
 
 ;————–主程序调用func函数的代码————– 
 
 :00401080 6A03 push 00000003 //压入参数param3 
 :00401082 6A02 push 00000002 //压入参数param2 
 :00401084 6A01 push 00000001 //压入参数param1 
 :00401086 E875FFFFFF call 00401000 //调用func函数 
 ;如果是“__cdecl”的话,将在这里恢复堆栈,“add esp, 0000000C” 
 
 聪明的读者看到这里,差不多就明白缓冲溢出的原理了。先来看下面的代码: 
 
 #include <stdio.h> 
 #include <string.h> 
 
 void __stdcall func() 
 { 
 char lpBuff[8]=”\0″; 
 strcat(lpBuff,”AAAAAAAAAAA”); 
 return; 
 } 
 
 int main() 
 { 
 func(); 
 return 0; 
 } 
 
 编译后执行一下回怎么样?哈,“”0x00414141″指令引用的”0x00000000″内存。该内存不能为”read”。”,“非法操作”喽!”41″就是”A”的16进制的ASCII码了,那明显就是strcat这句出的问题了。”lpBuff”的大小只有8字节,算进结尾的‘\0‘,那strcat最多只能写入7个”A”,但程序实际写入了11个”A”外加1个‘\0‘。再来看看上面那幅图,多出来的4个字节正好覆盖了RET的所在的内存空间,导致函数返回到一个错误的内存地址,执行了错误的指令。如果能精心构造这个字符串,使它分成三部分,前一部份仅仅是填充的无意义数据以达到溢出的目的,接着是一个覆盖RET的数据,紧接着是一段shellcode,那只要着个RET地址能指向这段shellcode的第一个指令,那函数返回时就能执行shellcode了。但是软件的不同版本和不同的运行环境都可能影响这段shellcode在内存中的位置,那么要构造这个RET是十分困难的。一般都在RET和shellcode之间填充大量的NOP指令,使得exploit有更强的通用性。 
 
 
 ├———————┤<—低端内存区域 
 │ …… │ 
 ├———————┤<—由exploit填入数据的开始 
 │ │ 
 │ buffer │<—填入无用的数据 
 │ │ 
 ├———————┤ 
 │ RET │<—指向shellcode,或NOP指令的范围 
 ├———————┤ 
 │ NOP │ 
 │ …… │<—填入的NOP指令,是RET可指向的范围 
 │ NOP │ 
 ├———————┤ 
 │ │ 
 │ shellcode │ 
 │ │ 
 ├———————┤<—由exploit填入数据的结束 
 │ …… │ 
 ├———————┤<—高端内存区域 
 
 
 windows下的动态数据除了可存放在栈中,还可以存放在堆中。了解C++的朋友都知道,C++可以使用new关键字来动态分配内存。来看下面的C++代码: 
 
 #include <stdio.h> 
 #include 
 #include <windows.h> 
 
 void func() 
 { 
 char *buffer=new char[128]; 
 char bufflocal[128]; 
 static char buffstatic[128]; 
 printf(“0x%08x\n”,buffer); //打印堆中变量的内存地址 
 printf(“0x%08x\n”,bufflocal); //打印本地变量的内存地址 
 printf(“0x%08x\n”,buffstatic); //打印静态变量的内存地址 
 } 
 
 void main() 
 { 
 func(); 
 return; 
 } 
 
 程序执行结果为: 
 
 0x004107d0 
 0x0012ff04 
 0x004068c0 
 
 可以发现用new关键字分配的内存即不在栈中,也不在静态数据区。VC编译器是通过windows下的“堆(heap)”来实现new关键字的内存动态分配。在讲“堆”之前,先来了解一下和“堆”有关的几个API函数: 
 
 HeapAlloc 在堆中申请内存空间 
 HeapCreate 创建一个新的堆对象 
 HeapDestroy 销毁一个堆对象 
 HeapFree 释放申请的内存 
 HeapWalk 枚举堆对象的所有内存块 
 GetProcessHeap 取得进程的默认堆对象 
 GetProcessHeaps 取得进程所有的堆对象 
 LocalAlloc 
 GlobalAlloc 
 
 当进程初始化时,系统会自动为进程创建一个默认堆,这个堆默认所占内存的大小为1M。堆对象由系统进行管理,它在内存中以链式结构存在。通过下面的代码可以通过堆动态申请内存空间: 
 
 HANDLE hHeap=GetProcessHeap(); 
 char *buff=HeapAlloc(hHeap,0,8); 
 
 其中hHeap是堆对象的句柄,buff是指向申请的内存空间的地址。那这个hHeap究竟是什么呢?它的值有什么意义吗?看看下面这段代码吧: 
 
 #pragma comment(linker,”/entry:main”) //定义程序的入口 
 #include <windows.h> 
 
 _CRTIMP int (__cdecl *printf)(const char *, …);
//定义STL函数printf 
 /*————————————————————————— 
 写到这里,我们顺便来复习一下前面所讲的知识: 
 (*注)printf函数是C语言的标准函数库中函数,VC的标准函数库由msvcrt.dll模块实现。 
 由函数定义可见,printf的参数个数是可变的,函数内部无法预先知道调用者压入的参数个数,函数只能通过分析第一个参数字符串的格式来获得压入参数的信息,由于这里参数的个数是动态的,所以必须由调用者来平衡堆栈,这里便使用了__cdecl调用规则。BTW,Windows系统的API函数基本上是__stdcall调用形式,只有一个API例外,那就是wsprintf,它使用__cdecl调用规则,同printf函数一样,这是由于它的参数个数是可变的缘故。 
 —————————————————————————*/ 
 void main() 
 { 
 HANDLE hHeap=GetProcessHeap(); 
 char *buff=HeapAlloc(hHeap,0,0×10); 
 char *buff2=HeapAlloc(hHeap,0,0×10); 
 HMODULE hMsvcrt=LoadLibrary(“msvcrt.dll”); 
 printf=(void *)GetProcAddress(hMsvcrt,”printf”); 
 printf(“0x%08x\n”,hHeap); 
 printf(“0x%08x\n”,buff); 
 printf(“0x%08x\n\n”,buff2); 
 } 
 
 执行结果为: 
 
 0x00130000 
 0x00133100 
 0x00133118 
 
 hHeap的值怎么和那个buff的值那么接近呢?其实hHeap这个句柄就是指向HEAP首部的地址。在进程的用户区存着一个叫PEB(进程环境块)的结构,这个结构中存放着一些有关进程的重要信息,其中在PEB首地址偏移0x18处存放的ProcessHeap就是进程默认堆的地址,而偏移0x90处存放了指向进程所有堆的地址列表的指针。windows有很多API都使用进程的默认堆来存放动态数据,如windows
2000下的所有ANSI版本的函数都是在默认堆中申请内存来转换ANSI字符串到Unicode字符串的。对一个堆的访问是顺序进行的,同一时刻只能有一个线程访问堆中的数据,当多个线程同时有访问要求时,只能排队等待,这样便造成程序执行效率下降。 
 
 最后来说说内存中的数据对齐。所位数据对齐,是指数据所在的内存地址必须是该数据长度的整数倍,DWORD数据的内存起始地址能被4除尽,WORD数据的内存起始地址能被2除尽,x86
CPU能直接访问对齐的数据,当他试图访问一个未对齐的数据时,会在内部进行一系列的调整,这些调整对于程序来说是透明的,但是会降低运行速度,所以编译器在编译程序时会尽量保证数据对齐。同样一段代码,我们来看看用VC、Dev-C++和lcc三个不同编译器编译出来的程序的执行结果: 
 
 #include <stdio.h> 
 
 int main() 
 { 
 int a; 
 char b; 
 int c; 
 printf(“0x%08x\n”,&a); 
 printf(“0x%08x\n”,&b); 
 printf(“0x%08x\n”,&c); 
 return 0; 
 } 
 
 这是用VC编译后的执行结果: 
 0x0012ff7c 
 0x0012ff7b 
 0x0012ff80 
 变量在内存中的顺序:b(1字节)-a(4字节)-c(4字节)。 
 
 这是用Dev-C++编译后的执行结果: 
 0x0022ff7c 
 0x0022ff7b 
 0x0022ff74 
 变量在内存中的顺序:c(4字节)-中间相隔3字节-b(占1字节)-a(4字节)。 
 
 这是用lcc编译后的执行结果: 
 0x0012ff6c 
 0x0012ff6b 
 0x0012ff64 
 变量在内存中的顺序:同上。 
 
 三个编译器都做到了数据对齐,但是后两个编译器显然没VC“聪明”,让一个char占了4字节,浪费内存哦。 
 
 
 基础知识: 
 堆栈是一种简单的数据结构,是一种只允许在其一端进行插入或删除的线性表。允许插入或删除操作的一端称为栈顶,另一端称为栈底,对堆栈的插入和删除操作被称为入栈和出栈。有一组CPU指令可以实现对进程的内存实现堆栈访问。其中,POP指令实现出栈操作,PUSH指令实现入栈操作。CPU的ESP寄存器存放当前线程的栈顶指针,EBP寄存器中保存当前线程的栈底指针。CPU的EIP寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行。 
 
 
 参考:《Windows下的HEAP溢出及其利用》by: isno 
  《windows核心编程》by: Jeffrey Richter 

将寄存器 EDI、ESI、EBX 恢复原值;将 ESP 调回到 EBP 处;将
EBP原值弹出。此时 ESP
指向函数返回地址。执行出栈指令,将函数的返回地址弹入 EIP
寄存器返回到主调函数。此时堆栈中只残留有调用函数时压入的参数还没有清理。
主调函数中的堆栈平衡语句如图 7 所示:

逐段分析生成的哦汇编代码,先看数组是如何定义的:

反汇编分析

输入以下命令,进入调试环境。

gdb compute

进入调试环境以后,输入start命令开始调试。start
命令用于拉起被调试程序,并执行至 main
函数的开始位置,程序被执行之后与一个用户态的调用栈关联。

start

主要的输出信息如下:

Temporary breakpoint 1, main () at compute.c:26
26      compute(2, 4);

现在我们的程序跑在main函数中,并在第 26 行处,也就是 compute(2, 4)
这个位置停住了(该行的代码还没执行)。我们disassemble命令显示当前函数的汇编信息。我们用到了-r参数和-m参数。-m参数是指定显示的计算机指令用16进制表示。/m参数指定显示汇编指令的同时,显示相应的源代码。

disassemble /rm

显示的主要结果如下,其中 # 后面的内容为人为添加注释。

Dump of assembler code for function main:
25  {  # 源文件行号,该行代码
   0x000000000040055b <+0>: 55  push   %rbp
   0x000000000040055c <+1>: 48 89 e5    mov    %rsp,%rbp

26      compute(2, 4);
# 注意下面这个箭头,表明程序现在停在这个地方,该行代码还没有执行。
=> 0x000000000040055f <+4>: be 04 00 00 00  mov    $0x4,%esi
   0x0000000000400564 <+9>: bf 02 00 00 00  mov    $0x2,%edi
   0x0000000000400569 <+14>:    e8 b2 ff ff ff  callq  0x400520 <compute>

27      return 0;
   0x000000000040056e <+19>:    b8 00 00 00 00  mov    $0x0,%eax

28  }
   0x0000000000400573 <+24>:    5d  pop    %rbp
   0x0000000000400574 <+25>:    c3  retq   

End of assembler dump.

对于上面的输出,介绍一下0x000000000040055b <+0>: 55 push %rbp各个字段的含义。

- 0x000000000040055b: 该指令对应的虚拟内存地址
<+0>: 该指令的虚拟内存地址偏移量
55: 该指令对应的计算机指令
push %rbp: 汇编指令

其实 main 函数并不是程序并不是程序拉起后的第一个执行的函数,main
函数也是一个被调函数。它被 _start 函数调用,这里不深究。只需要知道 main
函数是也被一个叫 _start 的函数调用的即可。这里也先不分析下面两行。

push   %rbp
mov    %rsp,%rbp

_start
函数执行时,栈上情况大致如下图所示,我们用绿色表示正在执行的函数的栈帧:

金沙js娱乐场官方网站 2

当 _start 函数调用了这里的 main
函数后,根据上面给出的输出结果中=>的位置来看,这时的 main
函数刚刚开始执行,栈上的情况大致如下图所示:

金沙js娱乐场官方网站 3

执行以下命令,执行 3 行汇编代码。

si 3

由于执行完 start 命令后,程序停在 0x000000000040055c
位置,所以这里执行以下三行代码

mov    $0x4,%esi
mov    $0x2,%edi
callq  0x400520 <compute>

一个函数调用另一个函数,需先将参数准备好。main 函数调用 compute
函数,所以前两行代码就是将两个参数传入通用寄存器中,对于参数传递的方式,x86和x86_64定义了不同的函数调用规约(calling
convention)。相比x86_64将参数传入通用寄存器的方式,x86则是将参数压入调用栈中。这又是另一个专题了,不做讨论,接下来就要执行
call 指令了。

callq  0x400520 <compute>

这是一条 call 指令,call 指令要完成两个任务。

  1. 将主调函数 main 中的下一条指令(callq
    的下一条指令)所在的虚拟内存地址压入栈中(这里为 0x000000000040056e
    )压入栈中,被调函数(compute)返回后将取这个地址的指令继续执行。时时刻刻要注意,每次入栈操作,rsp
    寄存器的值都是会更新的。
  2. call 指令会更新 rip
    寄存器的值,使其值为被调函数(compute)所在的起始地址,这里为
    0x400520。

当 call 指令执行完成后,这个时候,程序就执行到 compute
函数里了。我们仍旧使用以下命令查看当前函数的汇编信息。

disassemble /rm

显示的结果如下:

Dump of assembler code for function compute:
18  {
=> 0x0000000000400520 <+0>: 55  push   %rbp
   0x0000000000400521 <+1>: 48 89 e5    mov    %rsp,%rbp
   0x0000000000400524 <+4>: 48 83 ec 18 sub    $0x18,%rsp
   0x0000000000400528 <+8>: 89 7d ec    mov    %edi,-0x14(%rbp)
   0x000000000040052b <+11>:    89 75 e8    mov    %esi,-0x18(%rbp)

19      int e = add(a, b);
   0x000000000040052e <+14>:    8b 55 e8    mov    -0x18(%rbp),%edx
   0x0000000000400531 <+17>:    8b 45 ec    mov    -0x14(%rbp),%eax
   0x0000000000400534 <+20>:    89 d6   mov    %edx,%esi
   0x0000000000400536 <+22>:    89 c7   mov    %eax,%edi
   0x0000000000400538 <+24>:    e8 b0 ff ff ff  callq  0x4004ed <add>
   0x000000000040053d <+29>:    89 45 f8    mov    %eax,-0x8(%rbp)

20      int f = mul(a, b);
   0x0000000000400540 <+32>:    8b 55 e8    mov    -0x18(%rbp),%edx
   0x0000000000400543 <+35>:    8b 45 ec    mov    -0x14(%rbp),%eax
   0x0000000000400546 <+38>:    89 d6   mov    %edx,%esi
   0x0000000000400548 <+40>:    89 c7   mov    %eax,%edi
   0x000000000040054a <+42>:    e8 b8 ff ff ff  callq  0x400507 <mul>
   0x000000000040054f <+47>:    89 45 fc    mov    %eax,-0x4(%rbp)

21      return e * f;
   0x0000000000400552 <+50>:    8b 45 f8    mov    -0x8(%rbp),%eax
   0x0000000000400555 <+53>:    0f af 45 fc imul   -0x4(%rbp),%eax

22  }
   0x0000000000400559 <+57>:    c9  leaveq 
   0x000000000040055a <+58>:    c3  retq   

End of assembler dump.

执行以下命令,将 rbp 寄存器中的地址入栈,然后将 rsp 中的地址 赋值给 rbp
寄存器(也就是让 rbp 指向当前 rsp)。

si 2

此时栈上的情况如下图所示:

金沙js娱乐场官方网站 4

接下来要执行的语句就是下面这条语句。这条语句的含义是栈帧扩展。我们上文提到过,栈从高地址向第地址生长,所以减操作是栈的扩展操作。这里就是为被调用的函数的栈帧预先开辟空间,空间大小为24个字节。到这里就分析完成了,接下来就是
compute()
函数的执行过程了。个人能力有限,有些地方展开说不清楚,大家可以自行更深入的研究一下。

sub    $0x18,%rsp

这里补充一个概念,计算机是按字节编址,按字节编址的含义就是说每个地址对应一个字节。64
位操作系统,每个地址由 8 个字节表示。

基础知识: 
堆栈是一种简单的数据结构,是一种只允许在其一端进行插入或删除的线性表。允许插入或删除操作的一端称为栈顶,另一端称为栈底,对堆栈的插入和删除操作被称为入栈和出栈。有一组CPU指令可以实现对进程的内存实现堆

在函数内,遇到“{”时分配局部空间,并用值“0xCCH”进行初始化。未在定义时初始化的局部变量其初值就与“0xCCH”相关。因此
int 类型变量由于占四个字节,其初值为 –
858993460(0xCCCCC-CCCH);两个连续的 0xCCH 对应汉字“烫”字,因此当
以字符形式显示函数内未初始化的变量时会显示为“烫烫…”;指针类型变量就指向了地址为
0xCCCC-CCH 的内存。由此在调试模式下能很容易发现未初始化的变量。
堆栈基本的存储单位为四字节,对于小于四字节的数据按四字节对齐方式分配空间。因此
char 类型变量 ch 虽然数据本身需要两个字节,也分配了四个字节空间。array
字节数组分配空间时每个字符占一个字节,不够四个字符时按四字节对齐存放。因此局部变量
空间总数为 40H+4+4×2+4=50H。局部变量 ch 的地址为 EBP- 4,a、b
的地址分别为 EBP- 8 ,EBP- 0CH,array数组的地址为 EBP-
10h。函数左括号右括号间的所有的语句反汇编结果如图 5 所示:

从这段代码可以看到,main函数把ss的内容做了一个完整拷贝,函数runtime.duffcopy用来拷贝内存从%rsi拷贝到%rdi,细心的读者会发现这个函数和前面的runtime.duffzero函数一样有一个问题,即没有指定内存的大小,不知道该拷贝填充多大的内存,虽然指定了内存地址的开始地址,但是没有指定结束地址。这其实就是这两个函数设计的巧妙之处,后面我们再介绍。

函数调用是一个最简单不过的概念了,然而每次发生函数调用,CPU
和操作系统内核都做了大量的工作。这篇文章只分析到 compute() 函数执行。
参考

#pragma comment(linker,”/entry:main”) //定义程序的入口 
#include <windows.h> 

金沙js娱乐场官方网站 5

同时我们也能看到数组被定义的同时,也进行了初始化操作,调用runtime.duffzero函数,duffzero函数接收参数%rdi,把从%rdi地址开始的内存空间(在这个例子中就是数组ss的首地址)填满%xmm0的值,这个函数的设计很巧妙,后面我们介绍它。

准备

// compute.c
// 加法操作
int add(int c, int d)
{
    int e = c + d;
    return e;
}

// 运算
int compute(int a, int b)
{
    int e = add(a, b);
    return e;
}

// 主函数
int main(void)
{
    compute(2, 4);
    return 0;
}         

将以上这个 C
程序编译得到可执行文件,当这个可执行文件运行时,在操作系统中对应一个进程,这个进程在用户态对应一个调用栈结构(call
stack)。程序中每一个未完成运行的函数对应一个栈帧(stack
frame),栈帧可以理解成一个函数在栈上对应的一段连续空间。栈帧中保存函数局部变量、传递给被调函数的参数等信息。
当前帧的范围是由两个寄存器来界定的。这两个寄存器分别位 BP(Base
Pointer)
寄存器和 SP(Stack Pointer) 寄存器。BP
寄存器也叫基址寄存器。SP
寄存器也叫栈顶寄存器。另外栈底对应高地址,栈顶对应低地址,栈由内存高地址向低地址生长。上面所说的是一些概念,先知道这些概念,然后大概清楚程序执行过程中,随着函数调用的发生,该进程对应的栈结构大致如下。

金沙js娱乐场官方网站 6

#include <stdio.h> 

Function(i,&j)语句的反汇编代码如图 3 所示:

也就是直接调用函数的入口指令,而这两个函数都是带一个偏移量的,这也就只有像runtime.duffzero和runtime.duffcopy这种内部逻辑简单的函数可以这么调用。这实际上编译器处理了大量的工作。

编译

首先我们编译上面的程序,如果想用 gdb 调试工具进行调试,这里必须加上 -g
参数,加上 -g 参数后,目标文件中才能包含调试要用到的信息。

gcc -g compute.c -o compute

├———————┤<—低端内存区域 
│ …… │ 
├———————┤<—由exploit填入数据的开始 
│ │ 
│ buffer │<—填入无用的数据 
│ │ 
├———————┤ 
│ RET │<—指向shellcode,或NOP指令的范围 
├———————┤ 
│ NOP │ 
│ …… │<—填入的NOP指令,是RET可指向的范围 
│ NOP │ 
├———————┤ 
│ │ 
│ shellcode │ 
│ │ 
├———————┤<—由exploit填入数据的结束 
│ …… │ 
├———————┤<—高端内存区域 

先 找到主函数中的局部变量 i,j(其在堆栈中位置为 EBP- 8和 EBP-
4),将其压入堆栈。Visual C/C++的编译器对 C 语言程序的默认函数约定为
_cdecl[6]。此参数入栈约定为自右向左,并且对函数名前加“_”修饰符。先将
j 的地址压入堆栈,后将 i 的值压入堆
栈。通过 call 指令调用函数。从 Call 指令可见
fuction函数编译后加了“_”修饰符。Call
指令执行时自动将函数的返回地址入栈,之后转到 function
定义处开始执行此函数。
对funciton函数的“{”的反汇编结果如图 4 所示:

以如下go语言程序为例子:

int main() 

func(1,2,3); 
return 0; 

C语言函数调用堆栈常见形式如图 1 所示[4]:

 var ss [SIZE]int64 44d682: 48 8d bc 24 80 00 00 lea 0x80,%rdi 44d689: 00 44d68a: 0f 57 c0 xorps %xmm0,%xmm0 44d68d: 48 89 6c 24 f0 mov %rbp,-0x10 44d692: 48 8d 6c 24 f0 lea -0x10,%rbp 44d697: e8 ee ab ff ff callq 44828a <runtime.duffzero+0x10a> 44d69c: 48 8b 6d 00 mov 0x0,%rbp

printf(“0x%08x\n”,&v1); //打印各本地变量的内存地址 
printf(“0x%08x\n”,&v2); 
printf(“0x%08x\n\n”,&v3); 
printf(“0x%08x\n”,&g1); //打印各全局变量的内存地址 
printf(“0x%08x\n”,&g2); 
printf(“0x%08x\n\n”,&g3); 
printf(“0x%08x\n”,&s1); //打印各静态变量的内存地址 
printf(“0x%08x\n”,&s2); 
printf(“0x%08x\n\n”,&s3); 
return 0; 

金沙js娱乐场官方网站 7

0000000000448180 <runtime.duffzero>: 448180: 0f 11 07 movups %xmm0, 448183: 0f 11 47 10 movups %xmm0,0x10 448187: 0f 11 47 20 movups %xmm0,0x20 44818b: 0f 11 47 30 movups %xmm0,0x30 44818f: 48 83 c7 40 add $0x40,%rdi 448193: 0f 11 07 movups %xmm0, 448196: 0f 11 47 10 movups %xmm0,0x10 44819a: 0f 11 47 20 movups %xmm0,0x20 44819e: 0f 11 47 30 movups %xmm0,0x30 4481a2: 48 83 c7 40 add $0x40,%rdi ... 4482b0: c3 retq00000000004482c0 <runtime.duffcopy>: 4482c0: 0f 10 06 movups ,%xmm0 4482c3: 48 83 c6 10 add $0x10,%rsi 4482c7: 0f 11 07 movups %xmm0, 4482ca: 48 83 c7 10 add $0x10,%rdi 4482ce: 0f 10 06 movups ,%xmm0 4482d1: 48 83 c6 10 add $0x10,%rsi 4482d5: 0f 11 07 movups %xmm0, 4482d8: 48 83 c7 10 add $0x10,%rdi ... 448640: c3 retq 

2.3申请大小的限制 
栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。 
堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。 

若变量有初值,则反汇编就会为其生成一条
Mov指令为其赋值。对于没有初值的变量其每个字节都为0xCCH。对于字符数组,情况稍微复杂一些。字符串常量“abc”被存放在全局数据区中。当需要引用其值对数组进行初始化时,实际是将全局数据拷贝到堆栈中的
局部数组 array里。由于寄存器是 32 位,每次最多只能赋值 4
个字符,因此对数组赋初值的语句反汇编后可能产生一至多条汇编语句。对数组内容的访通过[
“EBP+ 数组首地址 +
偏移量]的寄存器间址来完成,因此局部数组初始化费时但访问时的效率高。
在函数内访问局部变量和参数通过 [EBP + 位移量 /-
位移量]来完成。函数返回值被放到 EAX 寄存器中供主调函数使用。
可见,在汇编层面上,函数内部并不存储局部变量,局部变量只有当函数调用发生时才会在栈上为函数分配空间。因此当函数调用后返回局部变量的值是错误的。

 44d697: e8 ee ab ff ff callq 44828a <runtime.duffzero+0x10a>... 44d6cd: e8 fe ae ff ff callq 4485d0 <runtime.duffcopy+0x310>

编译后执行一下回怎么样?哈,“”0x00414141″指令引用的”0x00000000″内存。该内存不能为”read”。”,“非法操作”喽!”41″就是”A”的16进制的ASCII码了,那明显就是strcat这句出的问题了。”lpBuff”的大小只有8字节,算进结尾的\0,那strcat最多只能写入7个”A”,但程序实际写入了11个”A”外加1个\0。再来看看上面那幅图,多出来的4个字节正好覆盖了RET的所在的内存空间,导致函数返回到一个错误的内存地址,执行了错误的指令。如果能精心构造这个字符串,使它分成三部分,前一部份仅仅是填充的无意义数据以达到溢出的目的,接着是一个覆盖RET的数据,紧接着是一段shellcode,那只要着个RET地址能指向这段shellcode的第一个指令,那函数返回时就能执行shellcode了。但是软件的不同版本和不同的运行环境都可能影响这段shellcode在内存中的位置,那么要构造这个RET是十分困难的。一般都在RET和shellcode之间填充大量的NOP指令,使得exploit有更强的通用性。 

in main values in useArray------------------+--------------------------|-------------------------%rsp + 0xf8 --> | ss[15] |%rsp + 0xf0 --> | ss[14] |... | |%rsp + 0x88 --> | ss[1] |%rsp + 0x80 --> | ss[0] |%rsp + 0x78 --> | reserved.param.ss[15] | <-- %rsp + 0x80%rsp + 0x70 --> | reserved.param.ss[14] | <-- %rsp + 0x78... | |%rsp + 0x8 --> | reserved.param.ss[1] | <-- %rsp + 0x10%rsp + 0x0 --> | reserved.param.ss[0] | <-- %rsp + 0x8 | (useArray return addr) | <-- %rsp + 0x0

void func() 

char *buffer=new char[128]; 
char bufflocal[128]; 
static char buffstatic[128]; 
printf(“0x%08x\n”,buffer); //打印堆中变量的内存地址 
printf(“0x%08x\n”,bufflocal); //打印本地变量的内存地址 
printf(“0x%08x\n”,buffstatic); //打印静态变量的内存地址 

金沙js娱乐场官方网站 8

这篇文章以实践的方式验证go语言函数之间是如何传递数组类型变量的。和slice相比,go对于array传参是传递整个array内容的,而不是引用,即把原array内容做一个拷贝,然后把拷贝后的内容值作为参数给被调用者使用。

2.7小结: 
堆和栈的区别可以用如下的比喻来看出: 
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。 
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。 

1、问题的提出
函数是
C语言中的重要概念。利用好函数能够充分利用系统库的功能写出模块独立、易于维护和修改的程序。函数并不是
C
语言独有的概念,其他语言中的方法、过程等本质上都是函数。可见函数在教学中的重要意义。在教学中一般采用画简单的堆栈图的方式描述函数调用,但由于学生对堆栈没有直观认识,难以深入理解,因此教学效果往往并不理想,从而限制了对模块化程序设计思想的理解和应用。
2、解决方法
在《微机原理》
课程介绍了堆栈、汇编语言等必要的相关知识之后,通过在高级语言开发环境下反汇编C
语言程序代码,使得学生通过分析汇编代码来理解函数调用中的堆栈变化,可以在实践中理解高级语言和低级语言的底层映射关系,理解函数调用的实质。本文通过在
Visual C++6.0 下反汇编一个 32 位
C语言程序的部分代码来解析解释函数调用的具体过程。
3、函数调用过程
函数调用过程主要由参数传递、地址跳转、局部变量分配和赋初值、执行函数体,结果返回等几个步骤组成[1]。
3.1、参数传递及函数跳转
参数由实参传递给形参。在底层实现上,即是实参按照函数调用规定压入堆栈。参数传递完成后就通过CALL指令由当前程序跳转到子程序处。
3.2、局部变量分配并赋值

数的“{”被认为是分配局部变量空间的时机。在汇编层面局部变量分配体现为堆栈中以
EBP 寄存器为基址向低地址端分配的一个连续区域,通过 EBP
寄存器的相对寻址方式来寻址函数内的局部变量。由于堆栈增长的方向是高地址端到低地址端,因此函数中先定义的局部变量地址较大,后定义的变量地址逐渐变小,相邻定义的变量其地址一定相邻[2]。由于全局数据和局部数据定义在不用的数据区而并不与局部变量相邻,根据程序局部性原理,相邻的数据会被缓存,因此对相同的运算,局部变量作为操作数的运算效率就可能高于有全局变量参与的运算。同时,局部变量分配和回收只需要移动堆栈指针ESP,因此效率最高。
3.3、寻址函数的参数
参数存放在以 EBP 为基址的高地址端。对参数的访问同样是通过EBP
寄存器相对寻址操作来实现。
3.4、执行函数体内的语句
函数内和具体功能相关的语句被转化成一系列汇编语句。
3.5、返回值
return 语句将返回值返回到主调函数。在底层,参数是通过 EAX 寄存器或 EDX
寄存器传递给主调函数。
3.6、返回主调函数
函数的“}”被解释为函数体已经执行完。遇到“}”时,会将堆栈中的局部变量、程序中压入堆栈的寄存器的值全部弹出,将之前
CALL指令执行时压入堆栈的函数返回地址弹到指令指针寄存器
EIP,从而返回到主调函数。
3.7、堆栈平衡
堆栈平衡指的是将函数调用前压入堆栈的参数弹出堆栈,使堆栈恢复到其调用前的状态[3]。由于函数调用完成后,参数就是无用的数据了,因此需要将其移出堆栈。

C语言中不需要进行堆栈平衡。而在汇编层面上却根据调用约定来确定由主调函数或是被调函数完成堆栈平衡。

可以看到这两个函数都非常整齐,就是由4条/5条指令组的重复,删除了所有的函数entry/exit的标准模板代码,然后在最后有一个RET指令。每一条指令组使用%xmm0寄存器来一次拷贝/赋值16字节的内容,每一组由4条指令来拷贝/赋值64字节内容。

HeapAlloc 在堆中申请内存空间 
HeapCreate 创建一个新的堆对象 
HeapDestroy 销毁一个堆对象 
HeapFree 释放申请的内存 
HeapWalk 枚举堆对象的所有内存块 
GetProcessHeap 取得进程的默认堆对象 
GetProcessHeaps 取得进程所有的堆对象 
LocalAlloc 
GlobalAlloc 

参数由主调函数压入堆栈,CALL
指令将函数返回地址入栈。进入子函数后,需要保存 EBP
原值、分配局部变量空间、保存寄存器初始值。函数内通过“EBP-位移量”方式访问局部变量,通过“EBP+位移量”方式访问参数[5]。
每发生一次函数调用,就会在堆栈中建立一个栈帧,栈帧在函数调用后释放。但是系统的堆栈资源有限,因此如果函数调用(如递归调用)层数过多,则可能发生堆栈溢出错误。
4.反汇编代码分析
以下将函数 function 的调用相关代码在VisualC++6.0
Debug模式反汇编,通过对汇编代码的分析揭示函数调用的关键点和细节。完整的
C语言程序代码如图 2 所示:

package mainconst SIZE = 16func main() { var ss [SIZE]int64 ss[0] = 0x1111 ss[SIZE-1] = 0x2222 useArray}func useArray(ss [SIZE]int64) { ss[0] = 0x3333 ss[SIZE-1] = 0x4444}

2.2 
申请后系统的响应 
栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。 
堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时, 
会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。 

根据 _cdecl
约定,需要由主调函数完成堆栈平衡。主调函数根据压入堆栈的参数的数目 2
和参数大小,利用指令 add ESP,8
将参数全部弹出。此时堆栈就恢复到其调用前的状态。一个完整的函数调用过程完成。