11. 动态代码注入技术详解

1.前言

注入技术中,最常用的实际上是dll注入,其好处在于方便省事,你只需要关注你dll中本身的代码逻辑即可。

一个很常见的例子是,你会看到很多破解软件的方式,都是让你将一个dll文件放在软件安装目录下完成破解。

其原理并不复杂,因为该dll一般就是该程序必加载的系统dll,一般放在系统目录下而不是在软件安装目录下。

可windows系统加载dll的机制却是先在当前目录下找,找不到才去找系统目录,找到了就直接加载,这样就轻松完成了dll注入操作。

但各种安全管家都会将这种行为视作一种病毒行为,会拦截这样的操作,现象便是你一复制进安装目录,安全管家就给你删掉了。

这种便是静态注入的方式。

而本文要介绍的动态注入就要复杂的多了,它涉及到非常多Windows机制原理,其最终能够实现的效果便是,无需理会其原程序,我们直接在自己的程序中控制目标程序的各种行为。

比如常见的外挂软件,就是一个独立的程序,但它却能在游戏启动的时候识别、并修改游戏中的各种属性数据。

2.理论知识

想要做到上面这样的效果,我们必须要了解一些底层知识。

我们在C/C++中经常使用指针,而指针实际上就是一个存储地址的变量,那么你有没有想过,如果两个exe程序中都去修改、读取同一个地址上的变量,那么它们实际操作的是同一个内存数据吗?

显然,根据我们的经验就能推测出来,这肯定是不行的,毕竟我们电脑上运行了这么多软件,软件的作者不尽相同,怎么可能知道别人用了哪些内存地址上的数据?

为了防止这样混乱的行为发生,windows系统就提供了进程空间的机制。

虽然电脑所有软件用的都是相同的电脑内存,但软件不能直接通过地址访问到实际的内存,而是要通过一个中转站。

类似下面这样:

graph TD;
	A[进程1]--访问0x123456地址-->C[Windows内存管理器];
	B[进程2]--访问0x123456地址-->C;
	C--进程1访问0x222地址-->D[电脑真实内存];
	C--进程2访问0x333地址-->D;

虽然两个进程访问的都是同一个地址,但经过了中转站后,它们访问的真实内存地址并不相同。

而中转站在这里仅仅起到了一个映射作用,比如此时上图的映射关系为:进程1 0x123456 == 0x222进程2 0x123456 == 0x333

这样做的好处就在于,不同进程之间完全隔离开了,无论怎么访问地址,都无法操作其它进程中的数据。

而我们之所以要进行代码注入,就是为了能让我们的代码运行在目标进程空间内部,这样代码操作的内存数据才是目标内存中的数据。

dll注入就是最简便的注入方式,只要目标进程加载了dll,那么dll就存在于目标进程之中,此时就可以随意修改数据信息了。

而本文要介绍的方式便是更为高级、复杂的手段,让我们可以在自己的进程中去操作目标进程中的数据,无需dll注入。

3.从零开始

经过前面的学习我们已经知道了,Windows系统提供了我们以下几个win api可以让我们控制其它进程:

  1. 在本进程中通过OpenProcress函数打开想要Hook的程序
  2. 使用VirutalAllocEx在目标进程中分配一块空间。
  3. WriteProcessMemory函数在分配的空间中写上我们想要让其执行的代码,以及在哪里进行跳转等等
  4. 一般还需要用ReadProcessMemory函数读取、保留其原本位置的数据,用于后面的恢复
  5. 可能还需要用VirutalProtectEx函数更改目标内存的权限,使其允许我们读写目标进程的内存。
  6. 可能还需要使用CreateRemoteThread函数在目标进程内新创建一个线程执行我们复制过去的代码

但现在问题来了,我们能复制过去的代码有哪些?怎么复制?难不成要自己手搓二进制然后复制过去不成?

这显然是不现实的,大多数时候,我们更倾向于使用vs编写C/C++函数,然后直接将编译好的整个函数二进制都复制到目标进程中去。

就像下面这样:

image.png

这里的get_kernel_addr就是一个函数,而这个函数名实际上就是这个函数在本进程中的内存地址,直接将这个函数整个复制到目标进程中即可。

所以现在我们已经知道了如何将一个函数复制到目标进程中去,那么下一个问题便是,这个复制过去的函数里面我们能写什么东西?

因为此时你要知道,由于该函数是在本进程中进行编译运行的,所以该函数内部如果调用任何函数,比如即使是最简单的printf函数,该函数的地址也属于本进程,一旦将其复制到目标进程,就会在相应的地址上找不到函数,一运行就会崩溃。

流程如下:

sequenceDiagram
	participant A as 进程1
	participant B as 进程2
	A-->>A: printf函数的地址为0x123456
	B-->>B: printf函数的地址为0x654321
	A->>B: 复制含printf函数地址0x123456的代码
	B-->>B: 由于0x123456地址上不是printf函数,程序运行该函数会崩溃

这样一来就出现了一个非常严重的问题:我们可以将代码复制过去,但这段代码中不能调用任何函数。

可一旦无法调用任何函数,那么我们将这段代码复制过去的意义是什么呢?根本什么都干不了!

这便是本节从零开始的含义,我们需要从零在目标进程中找到我们所需要的函数地址,然后就能调用它们!

这一切的源头便在于kernel32.dll这个Windows动态库,这是windows的核心动态库,windows系统中所有的exe程序,只要运行起来,都会默认自动加载该dll。

并且最重要的是,几乎所有重要的winapi,都存放在了这个dll中,因此只要我们能够找到这个dll在目标进程中的地址,我们就能够从该dll中解析出来其中win api的地址,然后我们就能在目标进程中调用这些win api!

此时流程如下:

sequenceDiagram
	participant A as 注入函数
	A-->>A: 查找kernel32.dll地址
	A-->>A: 解析kernel32.dll中GetProcAddress函数
	A-->>A: 使用GetProcAddress函数直接查询其它所有需要的函数

由于目前绝大部分电脑与软件都已经过渡到了64位,因此如果再继续介绍32位程序的方案实在有点不合时宜了,因此本文将直接使用64位软件结构进行讲解。

4.解析kernel32.dll

对于windows系统中每一个进程,都存在一个PEB(Process Environment Block)结构,翻译过来就是“进程环境块”,其中存储了所有与本进程相关的信息,包括kernel32.dll的地址。

其官方文档为:PEB

而它在程序中的位置非常固定,在32位环境下可以通过下面汇编代码获取:

mov eax,DWORD PTR FS:[0x30]

而在64位下,汇编代码如下:

mov rax, gs:[60h]

此时PEB结构就就放在了rax寄存器中。

但仅仅如此还远远不够,我们需要的是找到kernel32.dll模块的地址,其结构很深,需要下面这些步骤:

PEB->Ldr->InMemoryOrderModuleList->遍历链表对比模块名

其中前三个步骤比较简单,根据PEB的结构信息,可以直接通过下面这段汇编代码获取:

get_kernel_addr PROC
    xor rax, rax
    mov rax, gs:[60h]
    mov rax, [rax + 18h]
    mov rax, [rax + 10h]
    mov rax, [rax]
    ret
get_kernel_addr ENDP

而最后一步遍历链表,则使用C/C++代码遍历对比即可:

	HMODULE kernel_addr = 0;
	PLDR_MODULE Lib = get_kernel_addr();
	//check if we're at a valid list entry
	while (Lib->BaseDllName.Buffer != NULL) {
		bool is_eque = true;
		for (int i = 0; Lib->BaseDllName.Buffer[i] != 0; i++) {
			if (i > 13 || Lib->BaseDllName.Buffer[i] != param->kernel[i]) {
				is_eque = false;
				break;
			}
		}
		if (is_eque) {
			kernel_addr = (HMODULE)Lib->BaseAddress;
			break;
		}
		//iterate over the _LDR_DATA_TABLE_ENTRYs
		Lib = (PLDR_MODULE)Lib->InLoadOrderModuleList.Flink;
	}

上面这段代码此时你看起来可能会感到很奇怪,比如:明明前面刚说不能调用函数,为什么我这里直接调用了get_kernel_addr函数?它不是在本进程的地址空间中吗?

为啥这里对比名字这么复杂,居然连kernel32.dll这个字符串都不存在,那么是这么对比的?param->kernel[i]是什么?

之所以有这些疑问,是因为这并不是完整的代码,这里仅仅只是过一遍流程,讲解一下理论方法,后文会对其整体运行流程做更详细的介绍的。

总之,使用上面这段代码遍历这个链表,通过对比名字,我们就能拿到kernel32.dll在目标进程中的地址。

有了这个地址,我们就能用到前面学到的PE格式知识,解析出相应的函数地址、并进行调用了:

	char* image = (char*)kernel_addr;
	
	int64_t get_pro_addr = 0;

	PIMAGE_DOS_HEADER pDos_hdr = (PIMAGE_DOS_HEADER)image; //Get dos header
	PIMAGE_NT_HEADERS pNt_hdr = (PIMAGE_NT_HEADERS)(image + pDos_hdr->e_lfanew); //Get PE header by using the offset in dos header + the base address of the file image
	IMAGE_OPTIONAL_HEADER opt_hdr = pNt_hdr->OptionalHeader; //Get the optional header
	IMAGE_DATA_DIRECTORY exp_entry = opt_hdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
	PIMAGE_EXPORT_DIRECTORY pExp_dir = (PIMAGE_EXPORT_DIRECTORY)(image + exp_entry.VirtualAddress); //Get a pointer to the export directory 
	unsigned* func_table = (unsigned*)(image + pExp_dir->AddressOfFunctions); //Get an array of pointers to the functions
	unsigned short* ord_table = (unsigned short*)(image + pExp_dir->AddressOfNameOrdinals); //Get an array of ordinals
	unsigned* name_table = (unsigned*)(image + pExp_dir->AddressOfNames); //Get an array of function names
	for (int i = 0; i < pExp_dir->NumberOfNames; i++) //until i is 1 less than how many names there are to iterate through elements
	{
		bool is_eque = true;
		char* f_name = (char*)(image + name_table[i]);
		for (int i = 0; f_name[i] != 0; i++) {
			if (i > 15 || f_name[i] != param->GetProcAddress[i]) {
				is_eque = false;
				break;
			}
		}
		if (is_eque) {
			unsigned short ord = ord_table[i];
			unsigned fun_addr = func_table[ord];
			get_pro_addr = (int64_t)image + fun_addr;
			break;
		}
	}

	if (get_pro_addr == 0){
		return 0;
	}

上面的这段代码逻辑也非常固定,就是使用PE文件格式一步一步解析出kernel32.dll模块中的导出函数表,然后挨个对比函数名,找到GetProcAddress函数的地址。

这里比较字符串的方式,由于不能使用函数,所以采用了手动for循环对比两个字符串。

而GetProcAddress这个字符串,依旧不能直接填写,否则必然会报错,因为这个字符串编译后会存放到当前程序的全局,此函数编译后只能拿到字符串的地址,一旦复制到目标进程就会因为找不到而运行崩溃。

5.参数注入

从上面简短的代码中我们就已经体会到难受了,因为即使是最简单的字符串对比函数都不能用、乃至字符串本身都不能直接写在代码中。

但这些都是可以用一种比较方便的方式克服的,本小节便讲解一下如何把我们所需要的所有参数数据都注入到目标进程中。

最简单的方式实际上就是使用CreateRemoteThread这个函数,它可以调用目标进程中的函数,并传入一个指针。

有了这个函数,我们就可以先定义好我们所有需要传入的数据结构:

struct InjectParam {
	void* get_kernel_addr_fun;
	wchar_t kernel[13] = L"KERNEL32.DLL";
	char GetProcAddress[15]="GetProcAddress";
作者:余识
全部文章:0
会员文章:0
总阅读量:0
c/c++pythonrustJavaScriptwindowslinux