C语言内存中执行外部代码详解(一)

应用程序执行外部代码一般有多种方式,最显然的就是操作系统提供的链接库的方式,链接库是一个个的库文件,在编译时直接链接到应用程序中,或者是一个独立的库文件供应用程序调用。

但常常会有例如下面的一些应用场合:

  • 你想保护程序的核心代码,想把核心代码加密成一个自己定义的文件,然后在程序运行时加载到内存中执行;
  • 出于保护或者为了定时更新,你希望自己的核心代码每次都是从网络上下载的,并且不希望在用户硬盘上留下任何痕迹(这儿当然还有更好的DCOM之类的技术更加保护);
  • 你想注入到其它进程的地址空间中执行;等等。

那么这篇文章或许对你有所帮助。希望大家不要拿来写后门程序之类的那些违背职业操守和损害他人利益的事 ^_^

计划连载成二篇,第一篇讲基本原理,每二写个实例。

本文地址: http://www.hoverlees.com/blog/?p=953

虚拟地址空间

虚拟地址空间是现代操作系统一种先进的内存管理机制,它让任何一个进程拥有自已独立的地址空间(32位CPU 4GB,64位CPU 2^64B),理论上进程可以访问所有这个范围(当然操作系统有用户地址空间和内核地址空间的划分)。只有实际使用的虚拟地址空间的数据才会被映射到物理内存或者磁盘文件上。

虚拟地址空间上可以分配连续的地址段,提交后会对应到物理内存为主的存储设备上,且在存储设备上不需要一定是连续的。这些虚拟地址段可以有几种模式:只读,只写,读写和执行等。

操作系统在创建进程时,首先会创建进程的一个虚拟地址空间,然后,把可执行程序中的数据段和代码段拷到虚拟内存中从某个位置开始的地址空间里,这个位置在Linux ELF文件和Windows PE文件里有提供,是程序链接程序填入的,例入Windows的exe文件默认入口地址是400000H,在链接时可以修改这个地址,有时候C语言要实现本文的目的功能,修改入口地址是非常重要的一种方式,汇编语言则无所谓。

语言相关知识

现在来看地址编排,

首先看变量:

  • 对于全局变量,它是在程序编译时,由编译器固定放到某个虚拟地址空间上的,编译好后相当于就是定死位置了,程序中任何访问该变量都是使用的这个位置且程序员不用管,但对于动态插入的代码段,很显然,他很难知道这个变量的虚拟地址。
  • 对于函数内的局部变量,基本上是保存在堆栈中的,这个是动态的,所以动态插入的代码可以直接访问到。
  • 对于参数传递的变量,是在堆栈或者寄存器中的,动态插入的代码也可以直接访问。

从上面可以看出来,动态插入的代码要访问主程序的全局变量,直接访问是不大可行的,但在被调用时,全局变量的地址可以通过参数传进来,就能直接访问到了。

接下来看函数:

函数的地址,也是在程序编译时生成的,生成后就定死了,程序中的任何调用,都将会直接转化成调用这个地址。同样的,动态插入的代码也很难直接知道这些函数的地址。当然,反编译原程序去找地址,再写到动态插入代码中是种方法,不过万一原程序有改动,所有的相对地址可能就全变了.

实际情况

下面我们来看看实际的情况,这里,我们以X86家族的32位linux和windows目标程序为例.首先,在两个平台下,编译下面的代码:

#include <stdio.h>
void kernel(void){
	int a=10;
	int i;
	for(i=0;i<10;i++){
		a++;
	}
	printf("%d\n",a);
	return;
}
void main(int argc,char* argv[]){
	printf("%x\n",kernel);//输出函数kernel的虚拟地址
}

执行的结果如下图所示:

windows执行结果


linux执行结果

结果输出了kernel函数的虚拟地址,那么,我们把exe反汇编来看,先看windows.

图中可以看出来,windows的编译器,输出的程序虚拟地址不是函数的真实地址,而是一个跳转表中的条目,这儿的指令是一个跳转,跳转后的地址才是真实地址.

跳转到00401020H后,这儿才是kernel函数的真正开始.55 (push ebp)一般是一个函数开始的标志.我们看到,windows上的程序,实际预留了48个字节作为临时变量空间,而我们只声明了两个int,实际上只用了8个字节.其中a是[ebp-4],i是[ebp-8].使用函数内部变量是基于堆栈的,不会出现寻址错误.而for循环用的是一个短jmp,jmp是相对于当前地址的,所以也不会出现寻址错误,最后call 004010F0是调用printf,这个地址我们不能确保编译器在每一个应用程序中都使用同样的地址.所以如果这段代码移动到其它进程的任意虚拟地址去执行,其它的都可以正确执行,唯有这个printf可能失败.

再来看看gcc编译出来的:

gcc比windows上的编译器要优化得多了,在取得函数地址的时候,直接就是函数的真实地址,没有到一个跳转指令上去.同样的,移到其它进程的任意虚拟地址上执行,除了调用printf有可能出错外,其它也能顺利执行.

要在以后的其它进程中执行这个代码,需要把这个代码复制出来到一个文件中,所以,在拷贝这段代码出来的时候,需要判断这个需要弄出来的函数地址的第一个字节是不是0x55,如果是,那这个地址就是函数的真实地址,如果是一个long jump(0xe9),那就将后面的4个(32位)或8个(64位)字节读出来计算相对地址(当前地址加上这个值,还要加上指令本身长度5或9),拷贝到什么时候结束呢?拷贝到ret结束,即intel指令0xc3.不过这个可行性不大,除非你知道所有CPU指令的长度及参数的长度。所以最好的办法就是多拷点,只要保证函数全部拷出去即可。

拷贝出来的内容就可以以文件的形式和网络下载的形式供其它进程加载了,当然你也可以加密文件,在进程中解密.加载进程通过分配可执行的内存页(windows上的VirtualAlloc或Linux上的mmap),将数据放到内存的指定虚拟地址上,通过函数指针的方式调用.

对于其它的CPU,也是类似的思路.

本文就讲到这里,下一篇文章将做一个完整的流程示例.

Leave a comment

Your email address will not be published. Required fields are marked *