技术 2022 年 3 月 27 日

从汇编角度看C语言 - 1

反汇编C语言程序的解读。

从汇编角度看 C 语言 - 1

写在前面

参考书目: 《加密与解密》

  • 系统平台: Windows 10;
  • 调试工具: OllyDBG (吾爱破解专版) ;
  • 开发工具: Visual Studio 2008 Debug 模式;
  • 基础要求: 了解 C 语言和汇编语言;
  • 大致内容: 简述了一些汇编语言与 C 语言的关系,方便初学者更好的认识程序的底层运行机制。

开始

用 VS2008 编译如下代码:
#include <stdio.h>

int main(int argc,char* argv[])
{
	printf("Hello World\n");
	return 0;
}
将生成的可执行文件拖入OllyDBG

如何寻找 C 程序入口?

明确两点:
  1. mainCRTStartup 和 wmainCRTStartup 是控制台环境下多字节编码和 Unicode 编码的启动函数;
  2. WinMainCRTStartup 和 wWinMainCRTStartup 是 windows 环境下多字节编码和 Unicode 编码的启动函数。
mainCRTStartup 做了哪些事?
如何通过 mainCRTStartup 来找到 main 函数入口?
以上述程序为例,寻找其main函数入口。
image-20220327214647589
初步调试文件,可以发现许多jmp指令,这是编译器生成的启动代码,往下按 F8 跟随跳转。
image-20220327214714185
连续按F8来到call tmainCRTStartuptringtionFilter再一次按F8后,整个程序就会返回,因此按F7单步进入该函数。
接下来连续按下F8,并且观察控制台输出:
image-20220327214738686
发现再call hello.00241140后控制台打出 Hello World,因此在此处设下断点。
按下Ctrl+F2后重新启动程序,按下F9运行到该断点,按下F7单步进入。
image-20220327214803763
按几次 F8 后看到如上界面,可以看到 Hello World 字符串,程序的开头即初始化栈帧操作,显然已经成功来到了 main 函数中。
image-20220327214826902
查看如上高亮的指令,该指令将“Hello World”字符串的首地址压入栈中,而后调用 printf,将字符串打印在控制台上。
image-20220327215012755
事实也正是如此!

函数

C 语言程序是由具有不同功能的函数组成的,因此在逆向分析中应将重点放在函数的识别及参数的传递上,这样做可以将注意力集中在某一段代码上。

函数的识别

下面讨论函数的识别
调用函数的代码保存了一个返回地址,该地址会与参数一起传递给被调用的函数。绝大多数情况下编译器都使用 call 和 ret 指令来调用函数和返回调用位置。
call 指令与跳转指令功能类似,但 call 指令保存返回信息,这里的返回信息实际上主要就是返回地址。
call 指令执行时会将其之后的指令地址压入栈的顶部,当遇到 ret 指令时返回这个地址。
也就是说,call 指令给出的地址就是被调用函数的起始地址,ret 指令用于结束函数的执行。
因此可以通过定位 call 指令或 ret 指令来识别函数,call 的操作数就是所调用函数的首地址。
话不多说,看一个例子。
用 vs2008 编译如下代码,使用 OllyDBG 进行调试:
#include <stdio.h>

int add(int x,int y)
{
	return x+y;
}

int main(int argc,char* argv[])
{
	int a = 5,b = 6;
	add(a,b);
	return 0;
}
进入 OD 后,依然要越过启动代码,寻找真正的main函数。
image-20220327220701475
连续按 F8。
按照以往的经验,调用main函数至少在调用GetCommandLine函数之后。
image-20220327220724315
于是这里继续往下执行。
image-20220327220810610
看到这里可以 push 了 3 个参数,发现 argv 和 argc 的字样,那么下一个 call 十有八九会到达main函数了。
按 F7 单步进入。
image-20220327220839595
来到这里就已经很明显了,可以明显的看到下图中由mov ..., 0x5mov ..., 0x6两个语句,这明显是在给变量赋值上 5 和 6,那么就可以推测call test.00DD135C实际上在调用add函数,将光标移动指令处,按回车键。
image-20220327220903701
如下图可以看到add字样,因此猜想是对的。
image-20220327220934315
那么回到之前的main函数,可以看到代码将 0x5 和 0x6 放入 rax 和 ecx 寄存器后,又对其进行了压栈操作,实际等价于push 0x5push 0x6
image-20220327221004603
push 操作就是 x86 架构下典型的压栈方式,符合**__cdecl**调用约定(C/C++程序的默认调用约定,在此不作赘述),在该约定下,可以看到压栈顺序是逆序的,右边的参数先进栈,左边的参数后进栈,栈顶指针 esp 指向栈中第 1 个可用的数据项。
在调用函数时,调用者依次将参数压入栈,然后调用函数。函数被调用以后,在栈中取得数据并进行计算,函数计算结束后,由调用者或者函数本身修改栈,使栈恢复原样(平衡栈数据)。
现在将程序运行到 call 指令之前,查看 OD 的栈区数据,可以看到显示 Arg1=5,Arg2=6,显然这两个参数已经被压栈。
image-20220327221037201
进入 add 函数后,可以看到程序将 arg1 存入 eax 寄存器,再和 arg2 相加,就完成了计算。
image-20220327221100245
另外还有几种调用约定,如fastcallstdcall

函数的返回值

再来讨论函数的返回值
add函数修改一下,如下所示:
int add(int x,int y)
{
	int temp;
	temp = x + y;
	return temp;
}
进入 OD,再次回到 add 函数中。
image-20220327221142426
mov eax, [local.2]是将计算的最后结果就保存在 eax 寄存器中,eax 就作为存放返回值的寄存器。
image-20220327221206970
众所周知,带回返回值的方法不只return,还可传引用,查看如下代码:
#include <stdio.h>

void max(int* a,int* b)
{
	if(*a < *b) {
		*a = *b;
	}
}

int main(int argc,char* argv[])
{
	int a = 5,b = 6;
	max(&a, &b);
	printf("max: %d",a);
	return 0;
}
这里定义了一个max函数,接收ab的地址,将其中较大数放入变量a中。使用 OD,进入main函数。
image-20220327220253195
进入max函数,mov eax, [arg.1]mov ecx, [arg.2]是将参数ab的值加载到两个寄存器。
可以看到cmp指令,这是一个用于比较大小的指令,紧跟着的是条件跳转指令,表示如果 a<b 则不跳转,继续往下执行,这里不多说。
直接看到mov dword ptr ds:[eax], edxdword ptr是指明数据宽度,而这一步操作就是将结果填入变量 a 所在的内存地址处。
image-20220327220315921

数据结构

局部变量

现在来谈谈局部变量。
局部变量是函数内部定义的一个变量,其作用域和生命周期局限于所在函数内。从汇编角度看,局部变量分配空间时通常会使用栈和寄存器。
编译如下代码:
#include <stdio.h>

int add(int x,int y)
{
	int z;
	z = x+y;
	return z;
}

int main(void)
{
	int a=5,b=6;
	add(a,b);
	return 0;
}
进入add函数,sub esp,0xcc即下降栈顶 0xcc 个字节,实际上是为局部变量开辟空间,同时也在预防栈溢出攻击(一种攻击手法,此处不作赘述)。
这里开辟后的空间一部分是用来存放变量 z 的。
image-20220327220210074
call调用完后,会出现add esp,0x8这步操作实际上是在平衡栈,可以理解为“回收现场”。
image-20220327220111908
局部变量的起始值是随机的,是其他函数执行后留在栈中的垃圾数据,因此须要对其进行初始化。

全局变量

全局变量作用于整个程序,它一直存在,放在全局变量的内存区中。
局部变量则存在于函数的栈区中,函数调用结束后便会消失
在大多数程序中,常数一般放在全局变量中。
全局变量通常位于数据区块(.data)的一个固定地址处,当程序要访问全局变量时,一般会用一个固定的硬编码地址直接对内存进行寻址。
如下是示例程序,z是一个全局变量:
#include <stdio.h>

int z;

int add(int x,int y)
{
	return x+y+z;
}

int main(void)
{
	int a=5,b=6;
	z=7;
	add(a,b);
	return 0;
}
这里是对 z 赋值,直接用mov将 7 写入一个固定的内存地址。
image-20220327220033227
add函数中,同样直接从固定的地址中取出z的值到 eax 寄存器中。
image-20220327220014288

数组

最后看看数组
#include <stdio.h>

int main(void)
{
	static int a[3]={0x11,0x22,0x33};
	int i,s=0,b[3];

	for(i=0;i<3;i++)
	{
		s=s+a[i];
		b[i]=s;
	}

	for(i=0;i<3;i++)
	{
		printf("%d\n",b[i]);
	}
	return 0;
}
一般对数组的访问是通过基址加变址寻址实现的。
image-20220327215920733
在内存中数组可存在于栈、数据段及动态内存中,本例中a[]数组就保存在数据段.data 中,其寻址用“基址+偏移量”实现。
b[]数组放在栈中,这些栈在编译时分配。数组在声明时可以直接计算偏移地址,针对数组成员寻址时是采用实际的偏移量完成的。
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: [object Object] 发表日期:2022 年 3 月 27 日