|
原文链接:网络安全编程:导入地址表钩子(IAT HOOK)
导入地址表是PE文件结构中的一个表结构。在介绍PE文件结构的时候提到了数据目录。数据目录在IMAGE_OPTIONAL_HEADER中的DataDirectory中,它的定义如下:
- //
- // 可选头部格式
- //
- typedef struct _IMAGE_OPTIONAL_HEADER {
- //
- // 标准字段
- //
- ……
- //
- // NT 其他字段
- //
- ……
- DWORD NumberOfRvaAndSizes;
- IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
- } IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
复制代码
NumberOfRvaAndSizes:该字段表示数据目录的个数,该个数的定义为 16,具体如下:
- #define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
复制代码 DataDirectory:数据目录表,由 NumberOfRvaAndSize 个 IMAGE_DATA_DIRECTORY结构体组成。该数组包含输入表、输出表、资源等数据的 RVA。IMAGE_DATA_DIRECTORY的定义如下:
- //
- // 目录格式
- //
- typedef struct _IMAGE_DATA_DIRECTORY {
- DWORD VirtualAddress;
- DWORD Size;
- } IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
复制代码
该结构体的第一个变量为该目录的相对虚拟地址的起始值,第二个是该目录的长度。导入表就由数据目录中的某项给出。
01 导入表简介
在可执行文件中使用其他DLL可执行文件的代码或数据,称为导入或者输入。当PE文件需要运行时,将被系统加载至内存中,此时Windows加载器会定位所有的导入的函数或数据将定位到的内容填写至可执行文件的某个位置供其使用。这个定位是需要借助于可执行文件的导入表来完成的。导入表中存放了所使用的DLL的模块名称及导入的函数名称或函数序号。
在加壳与脱壳的研究中,导入表是非常关键的部分。加壳要尽可能地隐藏或破坏原始的导入表。脱壳一定要找到或者还原或者重建原始的导入表,如果无法还原或修复脱壳后的导入表的话,那么可执行文件仍然是无法运行的。
在免杀中也有与导入表相关的内容,比如移动导入表函数、修改导入表描述信息、隐藏导入表……这些操作都是杀毒软件将特征码定位到了导入表上才需要这样做。不过可以看出,导入表也同样受到杀毒软件的“关注”。
02 导入表的数据结构定义
在数据目录中定位第二个目录,即IMAGE_DIRECTORY_ENTRY_IMPORT。该结构体中保存了导入函数的RVA地址,通过该RVA地址可以定位到导入表的具体位置。描述导入表的结构体是IMAGE_IMPORT_DESCRIPTOR,被导入的每个DLL都对应一个IMAGE_IMP ORT_DESCRIPTOR结构。也就是说,导入的DLL文件与IMAGE_IMPORT_DESCRIPTOR是一对一的关系。IMAGE_IMPORT_DESCRIPTOR在文件中是一个数组,但是导入信息中并没有明确地指出导入表的个数,而是以一个导入表中全“0”的IMAGE_IMPORT_DESC RIPTOR为结束的。导入表对应的结构体定义如下:
- typedef struct _IMAGE_IMPORT_DESCRIPTOR {
- union {
- DWORD Characteristics;
- DWORD OriginalFirstThunk;
- };
- DWORD TimeDateStamp;
- DWORD ForwarderChain;
- DWORD Name;
- DWORD FirstThunk;
- } IMAGE_IMPORT_DESCRIPTOR;
复制代码
导入表中只有这5个成员,而其中重要的只有3个。分别介绍该结构体中每个成员的含义,具体如下。
OriginalFirstThunk:该字段指向导入名称表(导入名称表 INT)的 RVA,该 RVA 指向的是一个 IMAGE_THUNK_DATA 的结构体。
TimeDataStamp:该字段可以被忽略,一般为 0 即可。
ForwarderChain:该字段一般为 0。
Name:该字段为指向 DLL 名称的 RVA 地址。
FirstThunk:该字段包含导入地址表(导入地址表 IAT)的 RVA,IAT 是一个 IMAGE_THUNK_DATA 的结构体数组。
在上面介绍的各个字段中,TimeDataStamp和ForwarderChain是不经常使用的,一般不考虑这两个字段。其余的3个字段非常重要,在写加壳软件或进行脱壳时,一般都会涉及。
上面的INT和IAT都会指向一个IMAGE_THUNK_DATA结构体,该结构体的定义如下:- typedef struct _IMAGE_THUNK_DATA32 {
- union {
- DWORD ForwarderString;
- DWORD Function;
- DWORD Ordinal;
- DWORD AddressOfData;
- } u1;
- } IMAGE_THUNK_DATA32;
复制代码
该结构体的成员是一个联合体,虽然联合体中有若干个变量,但由于该结构体中包含的是一个联合体,那么这个结构体也就相当于只有一个成员变量,只是有时代表的意义不同。看其本质,该结构体实际上是一个DWORD类型。
每一个IMAGE_THUNK_DATA对应一个DLL中的导入函数。IMAGE_THUNK_DATA与IMAGE_IMPORT_DESCRIPTOR类似,同样是以一个全“0”的IMAGE_THUNK_DATA为结束的。
当IMAGE_THUNK_DATA值的最高位为1时,表示函数以序号方式导入,这时低31位被看作一个导入序号。当其最高位为0时,表示函数以函数名字符串的方式导入,这时DWORD的值表示一个RVA,并指向一个IMAGE_IMPORT_BY_NAME结构。
- IMAGE_IMPORT_BY_NAME 结构体的定义如下:
- typedef struct _IMAGE_IMPORT_BY_NAME {
- WORD Hint;
- BYTE Name[1];
- } IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
复制代码
该结构体的成员变量含义如下。
Hint:该字段表示该函数在其所属 DLL 中的导出表中的序号。该值并不是必需的,一些连接器为此值给 0。
Name:该字段表示导入函数的函数名。导入函数是一个以 ASCII 编码的字符串,并以 NULL 结尾。在 IMAGE_IMPORT_BY_NAME 中使用 Name来定义该字段,表示这是只有 1 个长度大小的字符串,但是函数名不可能只有 1 字节的长度。其实这是一种编程的技巧,通过越界访问来达到访问变长字符串的功能。
IMAGE_IMPORT_DESCRIPTOR 结构体中的 OriginalFirstThunk 和 FirstThunk 都指向了 IMAGE_THUNK_DATA 结构体,但是两者是有区别的。当文件在磁盘上时,两者指向的 IMAGE_THUNK_DATA 结构体是相同的内容,而当文件被装入内存后,两者指向的就是不同的 IMAGE_THUNK_DATA 了。
在磁盘上时,OriginalFirstThunk指向的IMAGE_THUNK_DATA中保存的是指向函数名的RVA,因此称其为INT。FirstThunk通常指向的IMAGE_THUNK_DATA中保存的也是指向函数名的RVA。它们在磁盘上是没有差别的。
当文件被加载入内存后,OriginalFirstThunk指向的IMAGE_THUNK_DATA中保存的仍然是指向函数的RVA,而FirstThunk指向的IMAGE_THUNK_DATA中则变成了由装载器填充的导入函数的地址,即IAT。
03 手动分析导入表
在学习PE文件结构时借助了十六进制编辑器,现在仍然通过十六进制编辑器来学习PE文件结构中的重要结构体,即导入表的结构体IMAGE_IMPORT_DESCRIPTOR。这里随便找个PE(EXE文件格式)文件来进行分析。
用C32Asm打开要进行分析的PE文件,首先定位到数据目录的第二项,如图1所示。
图1 IMAGE_IMPORT_DESCRIPTOR的RVA及大小
在图1中看到了数据目录中的第二项内容,其值分别是0x00004404和0x0000003C。0x00004404的值表示IMAGE_IMPORT_DESCRIPTOR的RVA,注意这里给出的是RVA。现在使用十六进制编辑器打开的是磁盘文件,那么就要通过RVA转换为FileOffset,也就是从相对虚拟地址转换为文件偏移地址。使用LordPE来进行转换,如图2所示。
图2 计算IMAGE_IMPORT_DESCRIPTOR的FileOffset
从图2中可以看出,0x00004404这个RVA对应的FileOffset为0x00004404(从这里可以看出,IMAEG_OPTIONAL_HEADER中的SectionAlignment和FileAlignment的值是相同的)。那么在C32Asm中转移到0x00004404的位置处,按下Ctrl + G组合键,在弹出的对话框中填入“4404”,如图3所示。
图3 C32Asm中的“跳转到”
单击“确定”按钮,来到文件偏移为0x00004404的位置处,如图4所示。
图4 导入表位置
来到文件偏移的0x00004404处就是IMAGE_IMPORT_DESCRIPTOR的开始位置。从图4中可以看出,该文件有两个IMAGE_IMPORT_DESCRIPTOR结构体。按照数据目录的长度0x3C来进行计算,应该有3个IMAGE_IMPORT_DESCRIPTOR结构体,但是第三个IMAGE_IMPORT_DESCRIPTOR结构体是一个全“0”结构体。这两个结构体的对应关系如图5所示。
图5 IMAGE_IMPORT_DESCRIPTOR数据整理表一
对于IMAGE_IMPORT_DESCRIPTOR结构体,只关心OriginalFirstThunk、Name和FirstThunk 3个字段,其余并不关心。首先来看一下Name字段的数据。在图5中,两个Name字段的值分别是0x0000454C和0x00004568,这两个值同样是RVA。直接在C32Asm中查看这两个偏移地址处的内容,分别如图6和图7所示。
图6 0x0000454C处的内容为“KERNEL32.dll”
图7 0x00004568处的内容为“USER32.dll”
重新对图5所示的数据表格进行整理,如图8所示。
图8 IMAGE_IMPORT_DESCRIPTOR数据整理表二
接着来分析OriginalFirstThunk和FirstThunk两个字段的内容,这两个字段的内容都保存了一个IMAGE_THUNK_DATA数组起始的RVA地址。看一下第一条IMAGE_IMPORT_DESCRIPTOR结构体中的OriginalFirstThunk和FirstThunk的数据内容。在C32Asm中查看0x00004440和0x00004000处的内容,如图9和图10所示。
图9 OriginalFirstThunk数据的内容
图10 FirstThunk数据的内容
从图9和图10中可以看出,在磁盘文件中,OriginalFirstThunk和FirstThunk字段中RVA指向的DWORD类型数组是相同的。在枚举导入函数时,通常会读取OriginalFirstThunk字段的RVA来找到导入函数。但是有些情况下,OriginalFirstThunk的值为0,这时需要通过读取FirstThunk的值得到导入函数的RVA。
在图9中,0x00004440地址处的DWORD值为0x000044E8,该值指向IMAGE_ IMPORT_BY_NAME结构体。在C32Asm中查看0x000044E8的值,如图11所示。
图11 0x000044E8处IMAGE_IMPORT_BY_NAME结构体内容
IMAGE_IMPORT_BY_NAME结构体的定义,具体如下:
- typedef struct _IMAGE_IMPORT_BY_NAME {
- WORD Hint;
- BYTE Name[1];
- } IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
复制代码
对照其定义结构可以看出,IMAGE_IMPORT_BY_NAME的前两个字节Hint表示序号,在图11中,该值为0x03AD;Name表示导入函数的名称,在图11中,该值为WriteProcess Memory。
由于该kernel32.dll导入的函数较多,关于导入函数,请大家自行整理到表格中进行观察。
在磁盘文件中,OriginalFirstThunk和FirstThunk字段指向的内容相同,但当文件被载入内存后,OriginalFirstThunk仍然指向导入函数的名称表,而FirstThunk字段指向的内容会变为导入函数的地址。将该程序载入OD中,然后直接分析其FirstThunk指向的内容。
在前面的分析中知道,kernel32.dll文件的FirstThunk的RVA为0x00004000。将其转换为VA,其VA地址为0x00404000。在OD的数据窗口中直接查看0x00404000地址处的内容,如图12所示。
图12 FirstThunk在内存中的数据
从图12中可以看出,FirstThunk指向RVA的数据已经发生了变化,这些值即为导入函数的地址表。在数据窗口单击右键,在弹出的菜单中选择“长型->地址”,再次观察FirstThunk的内容,如图13所示。
图13 FirstThunk指向的值即为导入函数地址表
从图13中可以清楚地看出,First Thunk指向的RVA处的内容是导入函数的地址表。地址0x00404000处保存的是0x7C802213,即为WriteProcessMemory函数的入口地址。在OD中的反汇编窗口中,通过Ctrl+G快捷键来到0x7C802213地址处,如图14所示。
图14 0x7C802213即为WriteProcessMemory函数入口地址
这里详细分析了导入表在磁盘文件和内存中的存在形式与FirstThunk的数据差异。导入表在PE文件结构中是至关重要的一个结构体,希望对导入表的详细介绍使大家熟练掌握导入表的相关知识。
04 编程枚举导入地址表
从上面的分析过程中已经学习了IMAGE_IMPORT_DESCRIPTOR结构体。那么,下面就用代码实现枚举导入地址表的内容。一个DLL文件对应一个IMAGE_IMPORT_DESCRIPTOR结构,而一个DLL文件中有多个函数,那么需要使用两个循环来进行枚举。外层循环枚举所有的DLL,而内层循环枚举所导入的该DLL的所有函数名及函数地址。
关键代码如下:
- PIMAGE_IMPORT_DESCRIPTOR pImpDes=(PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEn tryToDa
- ta(lpBase,TRUE,IMAGE_DIRECTORY_ENTRY_IMPORT, &dwNum);
- PIMAGE_IMPORT_DESCRIPTOR pTmpImpDes = pImpDes;
- while ( pTmpImpDes->Name )
- {
- printf("DllName = %s \r\n", (DWORD)lpBase + (DWORD)pTmpImpDes->Name);
- PIMAGE_THUNK_DATAthunk= (PIMAGE_THUNK_DATA)(pTmpImpDes->FirstThunk+ (DWORD)lpBase);
- int n = 0;
- while ( thunk->u1.Function )
- {
- if ( thunk->u1.Ordinal & IMAGE_ORDINAL_FLAG )
- {
- printf("Ordinal = %08X \r\n", thunk->u1.Ordinal & 0xFFFF);
- }
- else
- {
- PIMAGE_IMPORT_BY_NAME pImName= (PIMAGE_IMPORT_BY_NAME)thunk->u1.Functi on;
- printf("FuncName = %s \t \t", (DWORD)lpBase + pImName->Name);
- DWORD dwAddr = (DWORD)((DWORD *)((DWORD)pNtHdr->OptionalHeader.ImageBase
- + pTmpImpDes->FirstThunk) + n);
- printf("addr = %08x \r\n", dwAddr);
- }
- thunk ++;
- n ++;
- }
- pTmpImpDes ++;
- }
复制代码
只要对手动分析导入表能够理解的话,那么上面这段代码就不难理解了。对某个程序进行测试,看其输出结果,如图15所示。
图15 测试程序的导入信息表
用OD验证,对该测试程序的导入表信息的获取是否正确。用OD载入测试程序,然后在数据窗口中按下Ctrl + G组合键,输入地址“424190”,然后在数据窗口上单击鼠标右键,在弹出的菜单中选择“长型”→“地址”命令,看数据窗口的内容,如图16所示。
图16 测试程序在OD中的导入表信息
那么说明程序是正确的。关于导入表的知识就介绍到这里。接下来介绍如何对IAT进行HOOK的内容。
05 IAT HOOK
在前面的内容中提到这样一个问题,IMAGE_IMPORT_DESCRIPTOR中有两个IMA GE_THUNK_DATA结构体,第一个为导入名字表(INT),第二个为导入地址表(IAT)。两个结构体在磁盘文件中时是没有差别的,但是当PE文件被装载内存后,FirstThunk字段指向的IMAGE_THUNK_DATA的值会被Windows进行填充。该值为一个RVA,该RVA加上映像基址后,虚拟地址就保存了真正的导入函数的入口地址。
在这个描述中知道,要对IAT进行HOOK大概有3个步骤,第一步是获得要HOOK函数的地址,第二步是找到该函数所保存的IAT中的地址,最后一步是把IAT中的地址修改为HOOK函数的地址。这样就完成了IAT HOOK。也许这样的描述不是很清楚,下面就来举例说明。
比如要在IAT中HOOK系统模块kernel32.dll中的ReadFile()函数,第一步是获得ReadFile()函数的地址,第二步是找到ReadFile()所保存的IAT地址,最后一步是把IAT中的ReadFile()函数的地址修改为HOOK函数的地址。这样是不是就明白了?下面通过一个实例来介绍IAT HOOK的具体过程和步骤。
06 IAT HOOK实例
这里对记事本进程的CreateFileW()函数进行IAT HOOK。对CreateFileW()函数进行HOOK后主要是管控记事本要打开的文件是否允许被打开,下面一步一步地来完成代码。
先建立一个DLL文件,然后定义好DLL文件的主函数,并定义一个HookNotePad ProcessIAT()函数,在DLL被进程加载的时候,让DLL文件去调用HookNotePadProcessIAT()函数。代码如下:
- BOOL APIENTRY DllMain( HANDLE hModule,
- DWORD ul_reason_for_call,LPVOID lpReserved)
- {
- switch ( ul_reason_for_call )
- {
- case DLL_PROCESS_ATTACH:
- {
- // 在 DLL 被加载时调用 HookNotePadProcessIAT()
- HookNotePadProcessIAT();
- break;
- }
- }
- return TRUE;
- }
复制代码 遍历某程序的导入表时是通过文件映射来完成的,但是当一个可执行文件已经被Windows装载器装载入内存后,便可以省去CreateFile()、CreateFileMapping()等诸多繁琐的步骤,取而代之的是通过简单的GetModuleHandle()函数就可以得到EXE文件的模块映像地址,并能够很容易地获取DLL文件导入表的虚拟地址。代码如下:
- // 获得 Createfile
- HMODULE hMod = LoadLibrary("kernel32.dll");
- DWORD dwFuncAddr = (DWORD)GetProcAddress(hMod, "CreateFileW");
- CloseHandle(hMod);
- // 获取记事本进程模块基址
- HMODULE hModule = GetModuleHandleA(NULL);
- // 定位 PE 结构
- PIMAGE_DOS_HEADER pDosHdr = (PIMAGE_DOS_HEADER)hModule;
- PIMAGE_NT_HEADERSpNtHdr = (PIMAGE_NT_HEADERS)((DWORD)hModule + pDosHdr->e_lfanew);
- // 保存映像基址及导入表的 RVA
- DWORD dwImageBase = pNtHdr->OptionalHeader.ImageBase;
- DWORDdwImpRva=pNtHdr->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPO
- RT].VirtualAddress;
- // 导入表的 VA
- PIMAGE_IMPORT_DESCRIPTOR pImgDes = (PIMAGE_IMPORT_DESCRIPTOR)(dwImageBase +
- dwImpRva);
复制代码 在获得导入表的位置以后,要在导入表中找寻要HOOK函数的模块名,也就是说,要对CreateFileW()函数进行HOOK,首先要找到该进程中是否有“kernel32.dll”模块。一般情况下,kernel32.dll模块一定会存在于进程的地址空间内,因为它是Win32子系统的基本模块。当然,并不是简单地要找到该模块是否存在,关键是要找到这个模块所对应的IMAGE_IMP ORT_DESCRIPTOR结构体,这样才能通过kernel32.dll所对应的IMAGE_THUNK_DATA结构体去查找保存CreateFileW()函数的地址,并进行修改。代码如下:
- char szAddr[10] = { 0 };
- PIMAGE_IMPORT_DESCRIPTOR pTmpImpDes = pImgDes;
- BOOL bFound = FALSE;
- // 查找欲 HOOK 函数的模块名
- while ( pTmpImpDes->Name )
- {
- DWORD dwNameAddr = dwImageBase + pTmpImpDes->Name;
- char szName[MAXBYTE] = { 0 };
- strcpy(szName, (char*)dwNameAddr);
- if ( strcmp(strlwr(szName), "kernel32.dll") == 0 )
- {
- bFound = TRUE;
- break;
- }
- pTmpImpDes ++;
- }
- // 判断是否找到欲 HOOK 函数所在的函数名
- if ( bFound == TRUE )
- {
- bFound = FALSE;
- char szAddr[10] = { 0 };
- // 逐个遍历该模块的 IAT 地址
- PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)(pTmpImpDes->FirstThunk + dwImageBase);
- while ( pThunk->u1.Function )
- {
- DWORD *pAddr = (DWORD *)&(pThunk->u1.Function);
- // 比较是否与欲 HOOK 函数的地址相同
- if ( *pAddr == dwFuncAddr )
- {
- bFound = TRUE;
- dwCreateFileWAddr = (CREATEFILEW)*pAddr;
- DWORD dwMyHookAddr = (DWORD)MyCreateFileW;
- // 修改为 HOOK 函数的地址
- WriteProcessMemory(GetCurrentProcess(), (LPVOID)pAddr, &dwMyHookAddr,
- sizeof(DWORD), NULL);
- break;
- }
- pThunk ++;
- }
- }
复制代码 对CreateFileW()函数进行HOOK,目的是为了对其打开的文件进行管控。由于这是演示程序,在D盘下建立一个test.txt文件,然后对其进行管控。也就是说,如果用记事本打开这个程序的话,可以选择性地允许打开,或者不允许打开。代码如下:
- HANDLE
- WINAPI
- MyCreateFileW(LPCWSTR lpFileName,DWORD dwDesiredAccess,
- DWORD dwShareMode,LPSECURITY_ATTRIBUTES lpSecurityAttributes,
- DWORD dwCreationDisposition,DWORD dwFlagsAndAttributes,
- HANDLE hTemplateFile)
- {
- WCHAR wFileName[MAX_PATH] = { 0 };
- wcscpy(wFileName, lpFileName);
- if ( wcscmp(wcslwr(wFileName), L"d:\\test.txt") == 0 )
- {
- if ( MessageBox(NULL, "是否打开文件", "提示", MB_YESNO) == IDYES )
- {
- return dwCreateFileWAddr(lpFileName,dwDesiredAccess,
- dwShareMode,pSecurityAttributes,dwCreationDisposition,
- dwFlagsAndAttributes,hTemplateFile);
- }
- else
- {
- return INVALID_HANDLE_VALUE;
- }
- }
- else
- {
- return dwCreateFileWAddr(lpFileName,dwDesiredAccess,
- dwShareMode,lpSecurityAttributes,dwCreationDisposition,
- dwFlagsAndAttributes,hTemplateFile);
- }
- }
复制代码
这里HOOK的函数是CreateFileW()。通过函数中的W可以看出,这个函数是一个UNICODE版本的字符串,也就是宽字符串。在CreateFileW()函数的参数中,lpFileName的类型是一个指向宽字符的指针变量。那么,就需要在操作该字符串时使用宽字符集的字符串函数,而不应该再使用操作ANSI字符串的函数。在代码中,wcscpy()、wcscmp()、wcslwr()都是针对宽字符集的字符串。WCHAR是定义宽字符集类型的关键字。L"d:\test.txt"中的“L”表示这个字符串常量是一个宽字符型的。
打开一个记事本程序,然后将编译连接好的DLL文件注入记事本进程中。当注入并HOOK成功后,会用对话框提示“Hook Successfully !”。然后用记事本打开D盘下的test.txt文件,会弹出对话框询问“是否打开文件”,单击“否”按钮,也就是拒绝打开该文件,如图17和图18所示。
图17 询问是否打开文件
图18 选择“否”后的提示
在图18中,单击“确定”按钮,可以看到记事本并没有打开D盘下的test.txt文件。这说明对D盘下的test.txt文件的管控算是成功(这个程序并不完善,希望大家可以自行将其完善)。
以上实例演示了如何对IAT进行HOOK。不过上面针对IAT进行HOOK的做法只能针对隐型调用。也就是说,可执行文件是直接调用了DLL的导出函数,用上面的代码可以对IAT进行HOOK。如果是显式调用的话,以上的例子就无法达到HOOK的作用了。当可执行文件直接通过调用LoadLibrary()函数和GetProcAddress()函数来使用某个函数的话,上面的HOOK代码是无能为力的。如何解决这样的问题,答案是要对LoadLibrary()和GetProcAddress()函数也进行HOOK,这样就可以避免对DLL的显式加载和对函数的显式调用。
|
|