5. Windows注入技术全面解析

1 Dll注入

dll文件我们知道,它是不能自己运行的,只能被加载到其它进程中去执行代码。

而利用这个特性,我们就能够使用dll来完成注入操作,方法的原理为:

  1. 将我们的代码写在一个dll文件中
  2. 强迫目标进程加载我们的dll文件

一旦我们的dll文件被加载进入了目标进程,那我们自然可以为所欲为了,因为dll与它在一个进程内,你可以任意访问其内部的地址。

如果不用注入,虽然也能实现动态修改,但会更加麻烦:

  1. 使用VirtualQueryExVirtualProtectEx函数查询、修改目标进程内存的权限。
  2. 使用ReadProcressMemoryWriteProcessMemory函数读取、写入目标进程内存中的数据。

由于不在一个进程内,这些函数实际上都是在跨进程操作,并且这种方式想要让目标进程执行自己的代码非常麻烦,一般只会用于小块数据的修改,这个后文会对其进行介绍。

而dll不一样,一旦被加载了,是可以执行我们自己的代码的:动态库与静态库

dll文件也有一个入口函数,一旦被加载,就会执行该函数,我们就可以在里面写一些自己的代码来操作该进程。

一般情况下,程序加载dll有以下三种方式:

  1. 进程创建时,会加载其输入表中dll,也就是静态输入
  2. 进程主动调用LoadLibrary加载,也就是动态加载
  3. 通过系统机制要求,比如输入法为什么能在任意程序中使用?这就是系统机制导致的,它允许某些特性功能的模块直接加载到目标进程中。

1.1 输入表

首先是第一种方式,静态输入,通过修改输入表完成。

当一个进程被创建后,它并不会直接到exe本身去执行代码,首先被执行的是ntdll.dll中的LdrInitializeThunk函数。

这个ntdll.dll是一个非常重要的基础模块,前面也提到过,它并不会依赖于你的可执行文件是否导入,它在进程的创建阶段就会被自动映射到新进程中。

而这个LdrInitializeThunk函数又会调用LdrpInitializeProcess函数做一些必要的初始化功能。

LdrpInitializeProcess函数又会调用LdrpWalkImportDescriptor对输入表进行处理,也就是加载输入表中的模块。

所以只要我们在输入表被处理之前对其进行干预,为输入表增加一个项目,使其指向要加载的目标dll,或者替换原输入表中的DLL并对其函数调用进行转发,那么就能实现加载dll的功能了。

简单起见,这里我自己写一个dll,在vs自动生成的代码中添加一个导出函数、并在被注入的时候弹出一个提示框即可:

image-20231107102257003

然后生成32位的,下面我们的目标就是将这个生成的32位dll注入到前面那个TraceMe32.exe程序中去。

输入表中每一项的结构体为:IMAGE_IMPORT_DESCRIPTOR,简称为IID,一个输入表本质上就是IID数组并且以0作为数组的结尾。

所以想要添加添加一个项,就得在这个数组最后写入我们的dll信息才行。

但由于一般编译器生成的PE结构内存用的会很紧凑,前面我们也已经见识过了,PE头的后面都是紧邻其它结构的,这里也一样,一般IID数组后面紧邻的结构是OriginalFirstThunkFirstThunk,我们不能直接将其覆盖掉。

所以此时我们的想法就是直接移动这整个输入表的位置到其它空间更大的地方。

一个项的大小为20字节:

image-20231107103906571

换算为16进制就是14,然后我们再看看原表的大小:

image-20231107104000884

168,所以总的加起来我们就需要新地方有182的大小才行,可以从节中找找看:

image-20231107104810150

由于是修改映射之前的文件中去,所以要找上面两边对其后剩余空间的最小值,而RAW中布局非常紧凑,偏移地址+大小都等于下一个节的偏移,也就是各个节之间没有空隙,所以这样不行。

更加通用的情况下,其实也不用分析这些,我们直接扩展最后一节的大小不就行了吗?

首先从其PE头中可以看到,其文件对齐是200,后面的是块对齐,也就是内存对齐为1000

image-20231107110148485

记录一下当前最末尾的地址:

image-20231107110412616

  • 0x339000+0x224F4=0x35B4F4
  • 0x331600+0x22600=0x353C00

然后还要计算一下我们的输入表的位置,从输入表的地址可以看出,它在.data段内:

image-20231107142354228

所以计算公式就是:段RVA-导入表RVA+段RAW,因为段内的数据相对于段开头的距离是不会变的:0x1C8A94-0x17B000+0x179600=0x1C7094

大小为0x168,所以结束位置为0x1C7094+0x168=0x1C71FC

用十六进制编辑器打开文件,找到这个范围:

image-20231107143212775

上图所示的数据就是输入表,并且最后一个20字节长度的数据全为0,也满足我们前面说的,它是以0作为结尾的依据,就说明我们找到了它的位置!

然后我们要做的,就是将这块数据复制到前面计算出的结尾位置:0x353C00

其实也就是文件末尾重新追加一块内存,写入上面复制下来的数据:

image-20231107144220086

不过这个工具似乎并不支持直接追加,所以这里就不再演示,这里是亲手操作比较麻烦,更简单的还是直接编程实现,这里只大致说明实现方法:

  1. 通过文件二进制方式读取整个文件数据,然后取出上面0x1C7094-0x1C71FC这个范围的数据。
  2. 将自己的dll文件相关信息填好结构体,然后将其复制到末尾那块0区域,最后再最追加一块0区域作为数组结尾。
  3. 修改PE头中导出表的位置与大小,将其改到这个新位置。

这个过程很繁琐,且非常耗时,一般只用于学习原理,正常注入DLL一般不会这么搞,因为太麻烦了。

不过你可以将其写成一个通用的脚本文件,一键实现这个步骤,那也不错!

1.2 动态修改

第二种方法其实和上面的方法差不太多,唯一的区别是,上面是直接修改原文件实现的,而这种方法则是在创建进程、在其加载导入表前让其停下来,然后直接在内存中修改其导入表。

不过由于内存对齐一般是0x1000,间隙很大,一般可能并不用重新分配内存,但即使重新分配内存这一过程也并不难,调用一个函数即可解决。

这种方法除了将原本静态文件修改改为了运行期间修改外,并没有其它区别,过程同样很繁琐,这里不再赘述。

1.3 DLL替换

相比于前两种,这一种才是用的最多的,因为它实现起来非常简单!仅仅就是利用了一下windows系统的基本规则而已。

windows上运行的任何GUI程序,都会加载这几个dll:

image-20231107150930004

当然也有很多其它dll,这些dll正常来说是被放在系统目录里面的,比如:C:\Windows\System32

也就是说,当程序启动后,是会去系统目录里面找相应的dll的,但windows可能是为了方便、又或者是为了提升用户体验,它寻找dll有一个顺序,且当前目录下的文件排在第一。

也就是说,如果某个名字的dll文件在当前目录中就找到了,它就不会去其它地方找。

通过这一简单的原理,我们就可以非常简单的实现将我们自己的dll加载到目标进程中去:只需要将我们的dll名字改为其导入的某个dll文件名字、并将其放在可执行文件的同目录下就行了!

这是不是比前面两种方法要简单多了?

不过这种方法并不总是有效的,比如ntdll,它是在进程创建前就被导入的,所以肯定不行。

然后还有一些比较知名、通用的dll,比如kernel32.dll,会被预存在系统注册表中,如果该注册表里面存在存在,那么也不会去搜索,而是直接通过系统程序进行加载。

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs

只要这里面存放的动态库,那么是不会进行搜索的,而是直接通过系统程序进行加载:

image-20231107152221012

所以一般我们选择要替换的dll,会选择一些不那么常见的,也就是不在这个注册表内的dll文件。

改名字、将dll文件放在exe目录中只是第一步,是为了让目标程序能主动把我们的dll加载进其地址空间中。

但一般应用程序之所以会导入这个dll,是因为会用到里面的某些函数。

如果我们替换后的dll中没有这些函数,那么依旧会报错,无法运行,所以我们的第二步就是在我们的dll中,将相应的函数调用重新引导到原本的dll调用中去。

而我们的dll就相当于做了一个中间商,只是将应用程序发起的函数调用请求交给其原本应该调用的那个dll。

这个过程并不难,有现成的工具可以让我们使用:AheadLib-bin.zip

常用的系统DLL有:lpk.dll、usp10.dll、version.dll、msimg32.dll、midimap.dll、ksuser.dll、comres.dll、ddraw.dll等。

不过具体使用哪一个,还是得由分析目标程序决定。

比如我这里,就直接选择一个看起来比较简单的dll了:

image-20231107153617870

然后下一步,就是去系统目录中找到它:

image-20231107153718062

将其拖进上面下载的程序中,选择要保存的文件位置,点击生成即可:

image-20231107165442770

注意,我这里是32位的,所以要找SysWOW64下动态库,且运行的是这个:

image-20231107161524722

生成的是一个cpp源代码文件,里面已经写好了所有需要转发的函数代码。

下一步就是将其放入我们的dll项目中编译即可,或者像我这样,直接将其生成代码复制进项目也是一样的,因为它已经将代码都写好了。

不过由于内容太多,我将其折叠起来了,这些都不用管,我们主要需要关注的是这个入口函数DllMain

image-20231107161958815

可以看到,它在进程被注入后,会运行两个函数:

  1. DisableThreadLibraryCalls:用于禁止其它两个DLL_THREAD_ATTACHDLL_THREAD_DETACH的通知。
  2. Load:自己写的函数,在前面命名空间AHeadLib中。

image-20231107162447476

这个函数中就是在加载原来的那个模块了,但这都是写好的,我们不用管它,可以像上图一样,直接在这里写一个弹出对话框的函数。

然后将其编译,放在TraceMe32.exe程序所在目录中即可,但要注意,如果你和我一样自建的dll项目,你可能会遇到下面两种情况:

  1. 前面直接全部复制粘贴,没有包含pch.h头文件,需要你自己重新包含一下。
  2. 项目默认用的宽字节,而它这里的MAKEINTRESOURCE展开后是窄字符,可能是bug,可以自己调一下项目字符集。

image-20231107163006017

最后,将C:\Windows\SysWOW64\winmm.dll文件复制一份,改名为winmmOrg.dll,然后将本项目生成的dll文件改名为winmm.dll,然后将这两个文件放在和exe同目录下即可:

image-20231107165657001

这个注入的过程是不是比前面那两个要简单多了!

这里之所以需要复制、改名为winmmOrg.dll,是这个软件自动生成的名字:

image-20231107175159766

也就是将原本的代码存放在winmmOrg.dll中,一旦应用程序调用了这个动态库中的函数,我们的dll就会去winmmOrg.dll找到该函数,然后进行调用。

为什么不能是原名winmm.dll?因为现在我们这个动态库要改成这个名字!

此时你在动态库中就可以任意写代码控制这个程序了,不过这是需要建立在逆向分析基础上的,不然你也不知道应该去修改哪里的数据。

2 动态代码注入

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

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

其原理并不复杂,因为该dll一般就是该程序必加载的系统dll,一般放在系统目录下而不是在软件安装目录下,可windows系统加载dll的机制却是先在当前目录下找,找不到才去找系统目录,找到了就直接加载,这样就轻松完成了dll注入操作。

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

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

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

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

2.1 理论知识

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

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

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

作者:余识
全部文章:0
会员文章:0
总阅读量:0
c/c++pythonrustJavaScriptwindowslinux