C语言程序的机器表示(逆向+函数调用栈详解版)
C语言程序的机器表示
1 基本数据类型
在Windows系统中,通常一个字等于两个字节,在32位程序和64位程序,在处理数据时,通常对8,4,2和1字节数据进行处理
x86使用的是浮点寄存器,Intel提供了8个128位的寄存器,xmm0~xmm7,每一个寄存器可以存放4个 (32位)单精度的浮点数
大端:Motorola的PowerPC系列CPU采用的大端序
- 字节数据的高位字节存放于内存的低地址端(内存的起始地址),低位字节存放于内存的高地址端, 这是人们正常读写数值的方法
小端: Intel的x86系列CPU使用的小端序
-
字节数据的低位字节存放于内存的低地址端(内存的起始地址),高位字节存放于内存的高地址端
2 变量表现形式
存在于堆栈中的变量为动态变量,考虑到其动态的性质,不适合存储全局变量,因此,动态变量均为局部变量
存在于数据段中的变量为静态变量,其作用域可能为函数内部(局部静态变量)、文件内部(全局静态变量),以及进程内部(全局变量)
PE(Windows下可执行程序)文件包括以下节区:
-
textbss/BSS BSS段通常是用来存放程序中未初始化的全局变量的一块内存区域,属于静态内存分配
-
text/code :代码段通常是指用来存放程序执行代码的一块内存区域。 这部分区域的大小在程序运行前就已经确定,并且内存区域属于只读在代码段中,也有可能包含一些只读的常量
-
rdata :只读数据段
-
**data:**数据段通常是指用来存放程序中已初始化的全局变量的一块内存区域,属于静态内存分配
-
idata 导入段,包含程序需要的所有DLL文件信息
-
edata 导出段,包含所有提供给其他程序使用的函数和数据
-
rsrc 资源数据段,程序需要用到的资源数据
-
reloc 重定位段,如果加载PE文件失败,将基于此段进行重新调整
栈中的局部变量:
int main(int argc, char *argv[]){ // argc:表示命令行参数的数量,即用户在命令行中输入的参数个数(包括程序的名称)// argv[]:是一个指向字符串数组的指针数组,包含了命令行输入的所有参数,其中 argv[0] 通常是程序的名称。char str1[20]={0,};// 通过 {0,} 初始化,str1 中的所有位置都填充了 0char *p1 = str1; // str1的第一个位置int intValue=80;printf("%d\n", argc);// 使用 printf 函数将命令行参数的个数 argc 输出到控制台。return 0;
}
从[ebp+var_14](ebp-20)
到[ebp+var_1](ebp-1)
之间的栈空间均被初始化。
xor eax, eax
是一个非常高效的指令,因为它只需要 一个机器周期 来执行,同时它的指令编码更短,占用的字节也少(只需要 2 个字节的指令长度)。
mov eax, 0
需要加载立即数0
,通常需要 5 个字节 的指令长度,并且执行速度也可能稍慢现代 CPU 中,
xor eax, eax
是一个完全数据无关的操作,它不会对缓存、数据依赖等有任何影响。可以提高指令流水线的效率。而
mov eax, 0
需要访问立即数,并可能导致一些数据相关性
char *p1 = str1
[ebp+var_18]
(EBP - 24
)存储的是一个指针(即地址),而[ebp+var_14]
(EBP - 20
)可能直接存储数组的数据。地址和数据在栈上的存储是有区别的,地址指向的是一个数组的首地址,而数据是该数组内的元素。
int intValue=80 程序的整型局部变量则存储在了
[ebp+var_1C](ebp- 28)
处而[ebp+arg_0]则是参数argc的值
全局变量:
**静态变量:**静态变量是程序执行前系统就为之静态分配存储空间的一类变量,如存储于数据段中的一个固定位置, 存在于进程的整个生命周期
全局静态变量:
-
只是全局静态变量只能在本文件内使用,全局静态变量的作用域和全局变量略有区别
-
全局静态变量等价于编译器限制外部源码文件访问的全局变量
局部静态变量:
C语言中局部静态变量的赋值只进行一次,而且它不会随作用域的结束而消失,并且在未进入作用域之前就已经存在,局部静态变量存储在.data段
byte_4255D8最低位为零,and后为零,test后为0,jnz则不转移,通过or c1,1,将 byte_4255D8最低位置为1.
jnz
(Jump if Not Zero)是一个条件跳转指令,它的作用是根据零标志(ZF)的状态来决定是否跳转。
- 如果 ZF = 0:这意味着上一次的操作结果非零,在这种情况下,程序将跳转到指定的标签。
- 如果 ZF = 1:这意味着上一次的操作结果为零,程序将继续执行下一条指令,而不进行跳转。
当然并不是所有编译器的局部静态变量赋值算法都是这样,程序因为使用的是VC6.0++debug版本,如 果选择VS2015等更高版本或者是其他编译器,赋值 的算法则不相同
3 流程控制语句
int main(int argc, char *argv[]){if(argc<2) printf("Usage: %s [arbitrary string]\n",argv[0]);return 0;
}
可以通过ida清楚地看到if语句控制块的模样,if argc< 2,在汇编中是一句jge short loc_401041代码, 如果大于等于2,则跳出,小于,执行printf函数,满足条件红色
switch case:
将选择的序号值给了edx,根据edx,jmp到 off_4010D2这个table中的某个地址
while:
Int main(){int i=100;while(i--){printf("Hello %d\n",i);}return0;
}
for:
Int main(){int i=100;for(;i;i--){printf("Hello %d\n",i);}return0;
}
do while:
4 函数调用及程序栈
这个视频解释函数栈,但是没有解释汇编语言
https://www.bilibili.com/video/BV1wh41117uB/?spm_id_from=333.337.search-card.all.click&vd_source=d76ad0aadca055336653cd966075f064
汇编语言函数调用栈:
https://www.bilibili.com/video/BV1iS4y1z7V5/?spm_id_from=333.337.search-card.all.click&vd_source=d76ad0aadca055336653cd966075f064
栈:每一个函数拥有自己的函数栈,函数栈中保存着函数的局部变量、流控制结构等等,每次一个函数调用后函数栈会被收回,并再次分配给其他被调用的函数
首先,什么是栈帧?C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。
传递给func的参数被压到栈中,最后一个参数先进栈,所以第一个参数是位于栈顶的。所以说函数是从右往左进行参数的入栈的,这和变长参数有关。此外,func中声明的局部变量以及函数执行过程中需要用到的一些临时变量也都存在栈中。
程序栈是由高地址向低地址生长,在程序中函数站的栈顶地址是比栈底地址低的
假设栈的起始地址是
0x7FFFFFFF
,并且从这里向低地址分配。
- 程序开始时,栈顶和栈底都在
0x7FFFFFFF
。- 调用第一个函数时,栈顶移动到
0x7FFFFF00
(降低),并且该函数的局部变量和状态被存储在这个范围内。- 调用另一个函数时,栈顶继续向低地址移动到
0x7FFFFE00
。
ESP是栈寄存器,指示当前栈顶的位置,EBP是栈基址寄存器,这个寄存器指向栈底的位置
push和pop是栈的基本操作,call、leave、ret可以用来构建函数栈和销毁函数栈
首先被调用的函数必须建立它自己的栈帧。EBP寄存器现在正指向main的栈帧中的某个位置,这个值必须被保留,因此,EBP进栈。然后ESP的内容赋值给了EBP。这使得函数的参数可以通过对EBP附加一个偏移量得到,而栈寄存器ESP便可以空出来做其他事情。第一个参数的地址是EBP加8,因为main的EBP和返回地址各在栈中占了4个字节。
栈的生成与销毁:
当函数调用发生时,新的栈帧被压入栈中,而当函数返回时,栈帧将从栈中弹出。
- push参数入栈 母函数调用子函数时,在母函数栈帧再往内存低地址方向的邻接区域,创建子函数的栈帧,首先把参数压入栈中;
- call 子函数地址 返回地址入栈,将指令寄存器eip中保存的下一条执行指令的地址做为返回母函数的返回地址压入栈,当子函数调用结束后返回时,程序应该按照返回地址跳转到母函数的下一条指令继续执行;代码区跳转,处理器从当前母函数的代码区跳转到子函数程序的入口,即程序的控制权转移到被调用的子函数;
- push ebp ; ebp中母函数栈帧基址指针入栈,子函数将基址寄存器ebp中保存的母函数帧栈的基地址指针压入栈中保存;
- mov ebp, esp ; esp值装入ebp,ebp更新为新栈帧基地址。把esp最新栈顶指针拷贝到基址寄存器ebp中,这时ebp中保存的就是正在被调用子函数的基地址,即子函数的栈帧底部地址;
- sub esp, xxx ; 给新栈帧分配空间,根据函数需要保存局部变量的空间大小,栈顶指针esp从子函数的基地址向内存低地址偏移, 为局部变量留出一定空间
- mov esp,ebp 栈顶指针esp恢复到栈底位置,也就是将分配的栈空间全部释放,将分配的栈空间全部释放
- pop ebp 将ESP会指向的栈帧中母函数栈帧基址指针弹出保存在 ebp
- ret 按照返回地址指向的位置,返回main函数下一步指令
#include<stdio.h>
Int foo(int a){printf("argv = %d",a);return 0;
}
Int main(){int a;printf("hello stack \n");a=1;foo(a);return 0;
}
-
在.text 40109c处,执行了mov eax, [ebp+var_4]指 令,ebp+var_4位置的偏移是变量a在栈帧中的位置, 继续执行了push eax指令,导致a被压入栈中,ESP 向低地址增长4字节。
-
在.text 4010a0执行了call foo指令,call指令会将调用函数结束的返回地址,即函数中下一条指令add esp,4的地址0x4010a5压入栈中,程序进入foo函数中执行。
-
进入foo函数后,首先执行的是push ebp指令,该指令的目的是保存上一函数栈的栈基址位置,便于函数结束后恢复上一函数栈。指令执行后,EBP中的数据被压入栈中,ESP向低地址增长4字节。
-
在.text 401021执行了mov ebp,esp指令,该指令将EBP寄存器的值改写为当前ESP寄存器的值,导致栈底上移,新栈开始分配
-
在.text 401023执行了sub esp,40h的指令,该指令均导致ESP寄存器向低地址增长了64字节,也就是说栈的长度为64字节,新栈分配到此结束。
-
从.text 401023至.text 401053为函数调用了printf函数, 是函数正常的执行逻辑及编译器添加的检查函数。
-
在.text 401058处开始执行了mov esp,ebp和pop ebp 两条操作,在某些编译器中这两条也被整合成为leave 指令。
首先mov esp,ebp指令将栈顶指针esp恢复到栈底位置,也就是将分配的栈空间全部释放
再执行pop ebp指令,由之前的步骤可知,当前ESP寄存器指向空间的内容是main函数栈的栈基址位置,指令结束后,ESP会指向返回地址位置,EBP寄存器会恢复到main函数函数栈的栈底位置。
-
在.text 40105b处执行了ret指令,该指令内容可以理解为pop eip,执行后程序跳转回main函数,并且栈帧也恢复回到main函数执行call foo之前的状态
**函数调用约定:**对函数调用时如何传递参数的一种约定
**Cdecl:**是C/C++默认的参数传递方 式,参数从右向左入栈
此时相比未压栈时,ESP指针向减小了3*4=12(0x0c)函数参数由调用者清除,也称为手动清栈
当执行过callfoo_cdecl后,ESP指针是会恢复到压入参数后的位置,下一条执行add esp,0x0c就会把ESP指针增加 12字节,也就恢复到了压入参数之前的位置,从而实现了参数清除
**Stdcll:**stdcall是windows API默认方式,参数从右向左入栈
调用foo_stdcall相比foo_cdecl,缺少add esp , 0ch指令,也就是说调用者未对传入参数进行清理,相应的,被调用函数负责对参数进行清理
foo_stdcall采用的是retn 0ch的指令,而非retn
该指令的作用是retn + pop 0x0c字节,即返回后使ESP增加12个字节,这与foo_cdecl的指令执行是一致的
stdcall方式的优点在于,被调用者函数内部存在清理参数代码,与调用函数后再执行add esp,xxx相比,代码尺寸要小,是Win 32 API库使用的函数调用约定
**Fastcall:**与stdcall方式基本类似,参数的清理也由被调用函数来负责
但该方式通常会使用寄存器(而非栈内存)去传递参数。
若函数需要传递多个参数,参数会从右向左由栈传入,同时,最后两个参数使用寄存器ECX和EDX来传递
fastcall方式是很快的,寄存器的访问要快于对栈所在内存的访问速度, 但有时需要额外的系统开销来管理寄存器,如在调用函数前ECX、EDX中存有重要数据,那么需要先进行备份,此外,如果函数本身需要使用这两个寄存器,也需要将参数迁移到其他位置进行使用