与将所有的指令通过链接器在生成exe的时候都链接在一起并安排好各个指令的位置不同,windows还提供了动态链接(DLL)技术。一般情况下我们会处于以下原因采用DLL技术:
1.扩展了应用程序的特性。DLL可以被动态地载入到进程的地址空间中。
2.简化了项目管理,可以让不同的开发团队管理不同的模块。
3.有助于节省内存。一个dll可被多个程序共享。多个程序调用同一个dll内的同一个函数时,系统却只需将该dll加载一次。
4.促进资源共享。
5.促进了本地化。可以使应用程序只包含代码但不包含用户界面组件。
6.有助于解决平台间差异。使用延迟加载机制,程序仅仅加载需要的函数,使程序可以在老版本的系统中运行,可不是在某些函数不被兼容时拒绝运行。
7.可以用于特殊目的。如钩子函数等等。
DLL在为我们减少这些好处的同时也对需要我们在其他方面付出复杂度增加的代价,是否采用DLL技术需要在收获与付出之间做除权衡。要理解DLL我们需要不断的对比静态链接(生成DLL时连接所有命名)和动态连接(在需要的时候进行链接),来发现系统为DLL做了那些事情,需要付出哪些代价。
首先我们应该认识到无论采用静态链接还是动态链接,最后得到的结果都是指令被载入到内存中并被执行。在静态链接中编译器会帮我们把所有的指令安排在合适的位置随着进程的启动exe文件被映射到进程的逻辑地址之中,在需要线程只需要负责执行指令就行。而对于保存在dll中的指令可以在进程启动时载入到进程中的某个位置(隐式dll链接)或者在运行的过程中调用载入DLL的接口将指令载入(显示dll链接),两种DLL加载方式如下图所示:
显而易见这两个过程中间关键的部分就是如何找到要访问的指令的位置(然后跳转过去执行)。
要解决这个问题首先我们要确定DLL会提供哪方面的支持(函数和数据),这个要由我们自己来设计。在确定了要提供支持,需要我们将函数或资源的地址记录到DLL中,这个过程称为导出,我们把要导出的函数或者资源(地址)名称按一定的规则编写为导出符号(其实就是字符串),写入到DLL的某个位置(导出段)中。所以我们需要在编译DLL中指定要导出那些内容,可以通过源文件中在要导出的函数名或变量名之前加_declspec(dllexport)来指定。由于C++编译器通常会对函数名称做一些改编,从而会导致导出符号不是我们预想的字符,并函数调用方式被默认指定为cdecl,在使用stdcall方式调用函数的使用者中也会产生问题,所以我们一般需要明确的指定导出符号和调用方式。调用方式在函数名之前指定就可以了,而符号名称可以通过以下方式指定:
1.在函数声明时使用extern "C"来指明函数按C函数的方式编制(其实就是不改编)
2.在def文件中明确符号名称为原函数名称不做改变:
EXPORTS
function_name
3.若已知被改编后的符号名称(function_name_)使用编译指令指定一个符号名称(function_name)来指代他
#pragma comment(linker,"/Export: function_name=function_name_")
在将DLL文件导入到进程的地址空间之前先要确认文件存在,一般而言DLL的标准搜索以下路径:
1.可执行文件目录。
2.windows系统目录。
3.windows目录的System目录。
4.windows目录。
5.进程当前目录。
6.PATH环境变量所列出的目录。
使用DLL时我们需要将DLL导入到进程的地址空间中,在完成将不同段的分页分别映射并赋予不同的保护属性,检查dll依赖的其他dll依次加载,执行dllmain这三件时后像静态链接一样执行DLL中的指令。导入有隐式和显示两种方式:
1.隐式导入就是在进程启动的时候就明确了要载入的DLL和载入的地址以及使用的符号。这里就需要在头文件中声明函数和变量时在前边加入_declspec(import)指定其为导入符号,并在链接时链接DLL生成的lib文件确认符号来之某个DLL并确实存在这样的符号这样生成的可执行文件就会在进程启动时就载入全部需要的DLL并安排每个DLL的地址,并将这些符号指向其所代表的指令的地址。
2.显示的导入就是在进程启动时也不导入,在运行的过程中调用API来导入DLL和找到符号所对应的函数或变量。导入DLL的API为:
HMODULE LoadLibrary(
PCTSTR pszDLLPathName //文件路径
);
HMODULE LoadLibraryEx(
PCTSTR pszDLLPathName, //文件路径
HANDLE hFile, //保留项 传NULL
DWORD dwFlags //指定载入类型
);
文件路径如果包含“\”字符就直接根据路径载入DLL,否则执行标准的搜索路径。
dwFlags可为0或下列标志的组合
DONT_RESOLVE_DLL_REFERENCES:只将该DLL映射到进程地址空间,但不调用DllMain函数及不检查该DLL导入段中的其他额外DLL,这也意味着不自动载入额外的DLL。(一般应避免使用该标志,因为代码所依赖的DLL可能尚未被载入!)
LOAD_LIBRARY_AS_DATAFILE:将DLL作为数据文件映射到进程。(一般用在一个DLL只包含资源而没有函数时或想用一个EXE文件中包含的资源时可用这个标志,而且载入EXE时必须使用这个标志)
LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE:与LOAD_LIBRARY_AS_DATAFILE标志相似,唯一不同的是以独占方式来打开这个DLL文件,以防止其他程序对其修改。
LOAD_LIBRARY_AS_IMAGE_RESOURCE:与LOAD_LIBRARY_AS_DATAFILE标志相似,但不同之处在于当系统载入DLL的时候,会对相对虚拟地址(RVA)进行修复。这样RVA就可以直接使用,而不必再根据DLL载入的内存地址来转换了。(当需要对DLL进行遍历其PE段时,这个标志特别有用)
LOAD_IGNORE_CODE_AUTHZ_LEVEL:用来关闭UAC对代码在执行过程中可以拥有的特权加以控制。
LOAD_WITH_ALTERED_SEARCH_PATH:用来改变LoadLibrary对DLL文件进行定位所使用的搜索算法。
当程序不再需要DLL中的内容的时候可以取消DLL映射(计数减1,当所有模块都不需要时在从空间中清除)系统提供如下函数:
//DLL之外的代码调用卸载DLL
BOOL FreeLibrary( HMODULE hInstDll );
//DLL内的函数调用
//这个函数在不在DLL空间中的好处在于DLL卸载掉之后DLL映射的地址已经无效,在进程仍能继续执行
//而ExitThread直接终止了线程无需再返回
VOID FreeLibraryAndExitThread(HMODULE hInstDll,DWORD dwExitCode)
{
FreeLibrary(HMODULE hInstDll);
ExitThread(dwExitCode);
}
在DLL载入之后可以使用API通过符号得到DLL导出的函数或变量:
//获取导出的函数和变量,返回值必须被赋值给正确声明的对象
FARPROC GetProcAddr(
HMODULE hInstDll, //loaddll得到的句柄
PCSTR pszSymbolName //符号名称
);
这样就能像静态链接一样使用DLL了。
想要安全灵活的使用DLL还需要了解以下内容:
1.入口函数:在进程调用LoadLibrary(Ex)的时候会调用dllmain函数,当这个函数为实现时系统会帮我们默认创建一个。而调用DisableThreadLibraryCalls可以使得线程在创建和退出的时候不用通知指定dll的dllmain函数。为了避免同时创建多个线程以dll_thread_attach调用dllmain时产生竞争,所有dll的dllmain的调用被加载锁(loader lock,进程唯一的)序列化了。对于c++编写的dll,实质上系统开发通知的是__dllmaincrtstartup,当fdwr eason是dll_process_attach和dll_process_detach时,它会调用全局变量的构造或析构函数,然后再调用dllmain。
BOOL WINAPI DllMain(HINSTANCE hInstDll, DWORD fdwReason, PVOID fImpLoad) {
switch (fdwReason) {
case DLL_PROCESS_ATTACH: // DLL第1次被映射到进程的地址空间时
break;
case DLL_THREAD_ATTACH: // 创建一个线程时
break;
case DLL_THREAD_DETACH: // 线程终止时
break;
case DLL_PROCESS_DETACH: // 进程撤销一个DLL映射时
break;
}
return(TRUE);
}
函数返回值只在 DLL_PROCESS_ATTACH通知时有用,用来表示DLL的初始化是否成功,如果return FALSE表示加载DLL失败,如果系统会终止整个进程或撤销对该DLL的映射。其他通知时,系统将忽略这个返回值。fdwReason参数说明如下:
DLL_PROCESS_ATTACH:第一次被加载的时候传入,对于隐式加载的dll是主线程执行,而显式加载的dll由loadlibrary线程执行,用于执行dll初始化操作。
DLL_PROCESS_DETACH:隐式卸载的时候由主线程执行,显示卸载的时候由freelibrary线程执行,负责清理资源。
DLL_THREAD_ATTACH:线程在创建时,会检查进程已经加载的dll,然后依次通知每个dll的dllmain函数。进程启动时会先创建主线程,再加载各个dll,因此这时主线程调用dllmain只会传入DLL_PROCESS_ATTACH而不是DLL_THREAD_ATTACH。
DLL_THREAD_DETACH:线程退出的时候检测所有已经加载的dll依次调用dllmain。
2.延迟载入:对于隐式载入的DLL我们也可以像显式载入一样在需要的时候才载入,这样带来两个好处:1.如果应用程序使用了多个DLL,那么它的初始化可能比慢,因为加载程序要将所有必需的DLL映射到进程的地址空间。利用延迟加载可将载入过程延伸到执行过程时。2.如果我们的代码调用的操作系统的一个新函数,但程序又试图在老版本的操作系统运行。这时程序会被终止。这时可以有两种解决方法:一种是判断利用GetVersionEx操作系统,在老系统中使用旧函数而不使用新函数。另一种是通过延迟载入,通过SEH来捕获异常不调用此函数,转而使用可以在老的系统上使用的其它函数。当然这些也都是有限制的:1.导出全局变量的DLL是无法延迟加载的。2.Kernel32.dll是无法延迟加载的,LoadLibrary和GetProcAddress都在该模块中。必须加载该模块才可以调用它们。3.不应该在DllMain中代用延迟加载函数,这样会导致程序崩溃。
我们通过编译器的设置来在链接是为DLL添加延迟加载功能:
增加/DelayLoad开关,在解决方案的该项目“属性”->“配置属性”->“链接器”->“输入”->“延迟加载的Dll”中输入MyDll.dll(注意/DelayLoad:MyDll.dll这个开关不能用#pragma comment(linker, "/DelayLoad:MyDll.dll")来设置。
增加/Lib:DelayImp.lib开关:这可以用#include <delayimp.h>和#pragma comment(lib, "Delayimp.lib")。这会在连接时将MyDll.dll从.exe的导入段去除,这样操作系统就不会隐式载入该DLL。在.exe中嵌 入一个新的延迟载入段(Delay Import Section,称为.didata)表示要从MyDll.dll中导入哪些函数。对延迟载入函数的调用会跳转到__delayLoadHelper2函数,来完成对延迟载入函数的解析(调用LoadLibrary和GetProcAddress)。
还可以手动卸载Dll,则需在可选“链接器”->“高级”中指定“卸载延迟加载的DLL”中输入“MyDll.dll”。但要注意两点:一是卸载时只能调用__FUnloadDelayLoadedDll2(PCSTR szDll)函数,而不能调用FreeLibrary。二是该卸载操作是可选的,不是必需的,只有在需要手动卸载Dll时才设置。
3.函数转发器:我们可以在导出段中指出当调用DLL函数时实际上调用的是另外一个DLL的导出函数我们称之为函数转发器。主要的工作就是把导出段中的函数指向另外一个DLL的函数:
#pragma comment(linker,"/export:MyFunc =OtherDll.OtherFunc")
4.己知的Dll:操作系统对某些DLL进行了特殊处理,这些Dll被称为己知的Dll。在载入它们的时候,总是从"%SystemRoot%\System32"目录下查找。这些Dll被记录在注册表的hkey_local_machine\system\currentcontrolset\control\session manager\knowndlls。当LoadLibrary("SomeLib")时,系统会用正常的搜索规则(获取注册表中键SomeLib的值)来定位这个Dll。可是调用LoadLibrary("SomeLib.dll")时,系统也会先将扩展名.dll去掉,然后在注册表查找键"SomeLib",若找到该键则以其值为"%SystemRoot%\System32"中的文件名,作为LoadLibrary的参数载入DLL,若载入不成功,会返回NULL,GetLastError将返回(ERROR_FILE_NOT_FOUND).
5.DLL重定向:一个默认关闭的特性,可以在HKLM\Software\Microsoft\WindowsNT\CurrentVesion\Image File Execution Options注册表项中增一个项名为DevOverrideEnable的DWORD型项,并将值设为1之后。在应用程序目录中,建一个文件名为AppName.local的文件(此处的AppName如MyApp.exe,内容无关紧要)。可以强制加载程序先检查应用程序的目录,只有当加载程序无法找到这个文件时,才会在其他目录中搜索。
6.模块基地址重定位和绑定:dll中的代码访问dll中的全局变量时用的是绝对地址,同时会增加一个reloc段(重定位段)记录所有引用绝对地址的代码,如果dll最终加载的位置不是默认基址,之前使用的绝对地址需要根据reloc的记录被修正,这就是重定位开发过程 。可见如果进程加载的时候,多个dll基址发生冲突,需要被重定位,修复绝对地址的操作增加了加载时间,同时也会因为修改image内存页造成写拷贝,增加了系统开发的虚拟内存占用。最理想的情况下,所有使用dll的进程都不需要重定位,这就需要安排合理的基址。可以使用/fixed开关删除reloc段,禁用重定位。默认情况下,模块的引入段,会在进程加载模块后被填入导入函数的绝对地址,因此包含引入段的模块会发生内存页的写拷贝。使用bind.exe开发工具,可以在映像文件中的idata段填入绝对地址和对应dll的时间戳,当进程加载时,发现被依赖dll没有被重定位(即基址和默认基址相同)且时间戳和绑定的dll相同,那么idata段就可以不用修改直接使用绑定值,避免了写拷贝。如果引入段最终包含所依赖的dll的函数地址,如果所依赖的dll没有被重定位,那引入段不用被修改避免了写拷贝;dll内部的全局变量是用绝对地址访问,如果dll本身没有被重定位,这些绝对地址不用被修改也避免了写拷贝。因此用rebase.exe开发工具合理安排所有dll的基址,然后在用bind.exe开发工具写入导入函数地址,能提升性能和减少内存占用。
7.动态tls:每个线程都有一个内部dword 数组用于存放用户数据,ms保证数组至少有tls_minimum_available(64)个元素。用tlsalloc申请一个空闲索引,调用tlssetvalue、tlsgetvalue时传入这个索引可以访问每个线程上的用户数组,用tlsfree释放索引,windows会保证被释放的索引在各个线程上的数据都被清零。dll中使用动态tls的标准方式:dll_process_attach的时候tlsalloc一个索引;在dll_process_detach的时候用tlsfree释放;在dll功能函数内部检测tlsgetvalue返回的指针是否为空,为空的话分配一块内存包含dll要使用的所有线程相关数据;在dll_thread_detach中检测tlsgetvalue返回值非空则释放掉。
8.静态tls:声明为__declspec(thread)的静态变量会保存在模块的tls段中, 每个线程在创建的时候会根据当前所有模块的tls段总大小分配一块内存与线程对象关联,这块线程相关内存的大小也会随loadlibrary、freelibrary增删包含tls段的dll进行调整。静态tls只在vista以上才被完美实现。考虑这样一种实现:每个模块都有一个动态tls索引(__tls_index),每个线程的该索引下保存的是malloc出来的特定模块的tls段数据,可以认为系统开发是通过1节中描述的惯用法实现静态tls的。
在常规情况下我们使用DLL都是导入DLL到进程空间,再通过导出符号来执行DLL中命令或者获取其中的资源。若我们在无法重新生成进程的情况下把我们的DLL载入到目标进程的地址空间,并执行DLL中的指令(重点是载入时会执行dllmain)需要采用一些特殊的手段。一般有如下几种方式:
1.使用注册表来注入DLL:当User32.dll被载入进程的时候会在dllmain函数中的attach_process分支中检查注册表中的hkey_local_machine\software\microsoft\windows nt\currentversion\windows\若有loadappinit_dlls为1,则会去加载键appinit_dlls下的一系列的dll。从而将我们的DLL载入到进程的地址空间中。这种方式有如下需要注意的地方:
2.利用钩子来注入dll:利用系统提供的hook函数我们可以获取目标进程或者线程的消息,指定并指定处理过程为我们的DLL中的函数时会载入DLL到进程空间中:
HHOOK WINAPI SetWindowsHookEx(
int idHook, \\钩子类型
HOOKPROC lpfn, \\回调函数地址
HINSTANCE hMod, \\dll句柄
DWORD dwThreadId \\线程ID
);
idHook为WH_GETMESSAGE,并线程id为0的时候会注入系统中所有有消息循环的进程。在不需要hook的时候调用unhook会将内核计数减1,减到0的时候取消映射。
BOOL WINAPI UnhookWindowsHookEx(HHOOK hhk);
3.利用远程线程注入:用CreateRemoteThread在目标进程中创建一个线程,线程函数就是loadlibrary,线程函数的参数是要注入的dll名。使用的接口和过程如下:
HANDLE WINAPI CreateRemoteThread(
HANDLE hProcess, //进程句柄
LPSECURITY_ATTRIBUTES lpThreadAttributes, //线程的安全属性
SIZE_T dwStackSize, //线程初始大小,如果为0,那么使用系统默认大小
LPTHREAD_START_ROUTINE lpStartAddress, //远程进程的地址空间中,该线程的线程函数的起始地址
LPVOID lpParameter, //传给线程函数的参数
DWORD dwCreationFlags, //线程的创建标志
LPDWORD lpThreadId //指向所创建线程ID的指针,如果创建失败,该参数为NULL.
);
//与VirtualAlloc差不多就是多了进程句柄
LPVOID VirtualAllocEx(
HANDLE hProcess, //申请内存所在的进程句柄
LPVOID lpAddress, //保留页面的内存地址;一般用NULL自动分配
SIZE_T dwSize, //欲分配的内存大小
DWORD flAllocationType, // 分配的类型
DWORD flProtect // 该内存的初始保护属性
);
BOOL ReadProcessMemory(
HANDLE hProcess, // 目标进程句柄
LPCVOID lpBaseAddress, // 读取数据的起始地址
LPVOID lpBuffer, // 存放数据的缓存区地址
DWORD nSize, // 要读取的字节数
LPDWORD lpNumberOfBytesRead // 实际读取数存放地址
);
BOOL WriteProcessMemory(
HANDLE hProcess, // 目标进程句柄
LPVOID lpBaseAddress, // 写入数据的起始地址
LPVOID lpBuffer, // 写入数据的缓存区地址
DWORD nSize, // 要写入的字节数
LPDWORD lpNumberOfBytesWritten // 实际写入数存放地址
);
4. 利用转发器替换dll:要用自己的a.dll替换合法的b.dll,先用转发器在a.dll中转发所有的函数到b.dll,再实现自己的功能,最后将a.dll改名为b.dll,原本的b.dll改成其他名。也可以在a.dll中转发后,修改依赖b.dll的模块的引入表,将它依赖的dll名改为a.dll,这种方式避免了改名。
5.利用createprocess注入dll:父进程用createprocess创建子进程时暂停子进程的主线程,然后查询子进程的入口函数(main),将入口函数的头几个字节改为跳到注入代码,而注入代码的末尾会跳转回入口函数开头。执行的过程如下:
有时候在注入DLL也无发满足需求的时候我们可以尝试通过API拦截的方式来改变函数行为常用的有两种方法:
1.通过替换指令的办法,跳转到我们设计的指令上:
2.修改导入段中符号表的地址来拦截API:
ImageDirectoryEntryToData取得导出段的符号表,通过GetProcAddress或对比函数名称得到偏移,再用WriteProcessMemory修改内存地址内容。
本篇大部分内容引用自:http://www.makaidong.com/%E5%8D%9A%E5%AE%A2%E5%9B%AD%E6%96%87/71405.shtml