标签:nec 相同 sea 网络游戏 资源 main handle 对象 协议
在Windows网络数据通讯层,通过封包技术在客户端挡截游戏服务器发送来的游戏控制数 据包,分析数据包并修改数据包;同时还可以按照游戏数据包结构创建数据包,再模拟客户端发送给游戏服务器,这个过程其实就是一个封包的过程。
封包的技术是实现第二类游戏外挂的最核心的技术。封包技术涉及的知识很广泛,实现方法也很多,如挡截 WinSock 、挡截 API 函数、挡截消息、 VxD 驱动程序等。在此我们也不可能在此文中将所有的封包技术都进行详细介绍,故选择两种在游戏外挂程序中最常用的两种方法:挡截 WinSock 和挡截 API 函数。
1 . 挡截WinSock
众所周知, Winsock 是 Windows 网络编程接口,它工作于 Windows 应用层,它提供与底层传输协议无关的高层数据传输编程接口。在 Windows 系统中,使用 WinSock 接口为应用程序提供基于 TCP/IP 协议的网络访问服务,这些服务是由 Wsock32.DLL 动态链接库提供的函数库来完成的。
由上说明可知,任何 Windows 基于 TCP/IP 的应用程序都必须通过 WinSock 接口访问网络,当然网络游戏程序也不例外。由此我们可以想象一下,如果我们可以控制 WinSock 接口的话,那么控制游戏客户端程序与服务器之间的数据包也将易如反掌。按着这个思路,下面的工作就是如何完成控制 WinSock 接口了。由上面的介绍可知, WinSock 接口其实是由一个动态链接库提供的一系列函数,由这些函数实现对网络的访问。有了这层的认识,问题就好办多了,我们可以制作一个类似的动态链接库来代替原 WinSock
接口库,在其中实现 WinSock32.dll 中实现的所有函数,并保证所有函数的参数个数和顺序、返回值类型都应与原库相同。在这个自制作的动态库中,可以对我们感兴趣的函数(如发送、接收等函数)进行挡截,放入外挂控制代码,最后还继续调用原 WinSock 库中提供的相应功能函数,这样就可以实现对网络数据包的挡截、修改和发送等封包功能。
下面重点介绍创建挡截 WinSock 外挂程序的基本步骤:
(1) 创建 DLL 项目,选择 Win32 Dynamic-Link Library ,再选择 An empty DLL project 。
(2) 新建文件 wsock32.h ,按如下步骤输入代码:
① 加入相关变量声明:
HMODULE hModule=NULL; // 模块句柄
char buffer[1000]; // 缓冲区
FARPROC proc; // 函数入口指针
② 定义指向原WinSock库中的所有函数地址的指针变量,因WinSock库共提供70多个函数,限于篇幅,在此就只选择几个常用的函数列出,有关这些库函数的说明可参考MSDN相关内容。
// 定义指向原 WinSock 库函数地址的指针变量。
SOCKET (__stdcall *socket1)(int ,int,int);// 创建 Sock 函数。
int (__stdcall *WSAStartup1)(WORD,LPWSADATA);// 初始化 WinSock 库函数。
int (__stdcall *WSACleanup1)();// 清除 WinSock 库函数。
int (__stdcall *recv1)(SOCKET ,char FAR * ,int ,int );// 接收数据函数。
int (__stdcall *send1)(SOCKET ,const char * ,int ,int);// 发送数据函数。
int (__stdcall *connect1)(SOCKET,const struct sockaddr *,int);// 创建连接函数。
int (__stdcall *bind1)(SOCKET ,const struct sockaddr *,int );// 绑定函数。
...... 其它函数地址指针的定义略。
(3) 新建 wsock32.cpp 文件,按如下步骤输入代码:
① 加入相关头文件声明:
#include
#include
#include "wsock32.h"
② 添加DllMain函数,在此函数中首先需要加载原WinSock库,并获取此库中所有函数的地址。代码如下:
BOOL WINAPI DllMain (HANDLE hInst,ULONG ul_reason_for_call,LPVOID lpReserved)
{
if(hModule==NULL){
// 加载原 WinSock 库,原 WinSock 库已复制为 wsock32.001 。
hModule=LoadLibrary("wsock32.001");
}
else return 1;
// 获取原 WinSock 库中的所有函数的地址并保存,下面仅列出部分代码。
if(hModule!=NULL){
// 获取原 WinSock 库初始化函数的地址,并保存到 WSAStartup1 中。
proc=GetProcAddress(hModule,"WSAStartup");
WSAStartup1=(int (_stdcall *)(WORD,LPWSADATA))proc;
// 获取原 WinSock 库消除函数的地址,并保存到 WSACleanup1 中。
proc=GetProcAddress(hModule i,"WSACleanup");
WSACleanup1=(int (_stdcall *)())proc;
// 获取原创建 Sock 函数的地址,并保存到 socket1 中。
proc=GetProcAddress(hModule,"socket");
socket1=(SOCKET (_stdcall *)(int ,int,int))proc;
// 获取原创建连接函数的地址,并保存到 connect1 中。
proc=GetProcAddress(hModule,"connect");
connect1=(int (_stdcall *)(SOCKET ,const struct sockaddr *,int ))proc;
// 获取原发送函数的地址,并保存到 send1 中。
proc=GetProcAddress(hModule,"send");
send1=(int (_stdcall *)(SOCKET ,const char * ,int ,int ))proc;
// 获取原接收函数的地址,并保存到 recv1 中。
proc=GetProcAddress(hModule,"recv");
recv1=(int (_stdcall *)(SOCKET ,char FAR * ,int ,int ))proc;
...... 其它获取函数地址代码略。
}
else return 0;
return 1;
}
③ 定义库输出函数,在此可以对我们感兴趣的函数中添加外挂控制代码,在所有的输出函数的最后一步都调用原WinSock库的同名函数。部分输出函数定义代码如下:
// 库输出函数定义。
//WinSock 初始化函数。
int PASCAL FAR WSAStartup(WORD wVersionRequired, LPWSADATA lpWSAData)
{
// 调用原 WinSock 库初始化函数
return WSAStartup1(wVersionRequired,lpWSAData);
}
//WinSock 结束清除函数。
int PASCAL FAR WSACleanup(void)
{
return WSACleanup1(); // 调用原 WinSock 库结束清除函数。
}
// 创建 Socket 函数。
SOCKET PASCAL FAR socket (int af, int type, int protocol)
{
// 调用原 WinSock 库创建 Socket 函数。
return socket1(af,type,protocol);
}
// 发送数据包函数
int PASCAL FAR send(SOCKET s,const char * buf,int len,int flags)
{
// 在此可以对发送的缓冲 buf 的内容进行修改,以实现欺骗服务器。
外挂代码 ......
// 调用原 WinSock 库发送数据包函数。
return send1(s,buf,len,flags);
}
// 接收数据包函数。
int PASCAL FAR recv(SOCKET s, char FAR * buf, int len, int flags)
{
// 在此可以挡截到服务器端发送到客户端的数据包,先将其保存到 buffer 中。
strcpy(buffer,buf);
// 对 buffer 数据包数据进行分析后,对其按照玩家的指令进行相关修改。
外挂代码 ......
// 最后调用原 WinSock 中的接收数据包函数。
return recv1(s, buffer, len, flags);
}
....... 其它函数定义代码略。
(4) 、新建 wsock32.def 配置文件,在其中加入所有库输出函数的声明,部分声明代码如下:
LIBRARY "wsock32"
EXPORTS
WSAStartup @1
WSACleanup @2
recv @3
send @4
socket @5
bind @6
closesocket @7
connect @8
...... 其它输出函数声明代码略。
(5) 、从 “ 工程 ” 菜单中选择 “ 设置 ” ,弹出 Project Setting 对话框,选择 Link 标签,在 “ 对象 / 库模块 ” 中输入 Ws2_32.lib 。
(6) 、编译项目,产生 wsock32.dll 库文件。
(7) 、将系统目录下原 wsock32.dll 库文件拷贝到被外挂程序的目录下,并将其改名为 wsock.001 ;再将上面产生的 wsock32.dll 文件同样拷贝到被外挂程序的目录下。重新启动游戏程序,此时游戏程序将先加载我们自己制作的 wsock32.dll 文件,再通过该库文件间接调用原 WinSock 接口函数来实现访问网络。上面我们仅仅介绍了挡载 WinSock 的实现过程,至于如何加入外挂控制代码,还需要外挂开发人员对游戏数据包结构、内容、加密算法等方面的仔细分析(这个过程将是一个艰辛的过程),再生成外
挂控制代码。关于数据包分析方法和技巧,不是本文讲解的范围,如您感兴趣可以到网上查查相关资料。
2. 挡截 API
挡截 API 技术与挡截 WinSock 技术在原理上很相似,但是前者比后者提供了更强大的功能。挡截 WinSock 仅只能挡截 WinSock 接口函数,而挡截 API 可以实现对应用程序调用的包括 WinSock API 函数在内的所有 API 函数的挡截。如果您的外挂程序仅打算对 WinSock 的函数进行挡截的话,您可以只选择使用上小节介绍的挡截 WinSock 技术。随着大量外挂程序在功能上的扩展,它们不仅仅只提供对数据包的挡截,而且还对游戏程序中使用的 Windows API 或其它 DLL 库函数的挡截,以使外挂的功能更加强大。例如,可以通过挡截相关
API 函数以实现对非中文游戏的汉化功能,有了这个利器,可以使您的外挂程序无所不能了。
挡截 API 技术的原理核心也是使用我们自己的函数来替换掉 Windows 或其它 DLL 库提供的函数,有点同挡截 WinSock 原理相似吧。但是,其实现过程却比挡截 WinSock 要复杂的多,如像实现挡截 Winsock 过程一样,将应用程序调用的所有的库文件都写一个模拟库有点不大可能,就只说 Windows API 就有上千个,还有很多库提供的函数结构并未公开,所以写一个模拟库代替的方式不大现实,故我们必须另谋良方。
挡截 API 的最终目标是使用自定义的函数代替原函数。那么,我们首先应该知道应用程序何时、何地、用何种方式调用原函数。接下来,需要将应用程序中调用该原函数的指 令代码进行修改,使它将调用函数的指针指向我们自己定义的函数地址。这样,外挂程序才能完全控制应用程序调用的 API 函数,至于在其中如何加入外挂代码,就应需求而异了。最后还有一个重要的问题要解决,如何将我们自定义的用来代替原 API 函数的函数代码注入被外挂游戏程序进行地址空间中,因在 Windows 系统中应用程序仅只能访问到本进程地址空间内的代码和数据。
综上所述,要实现挡截 API 函数,至少需要解决如下三个问题:
● 如何定位游戏程序中调用 API 函数指令代码?
● 如何修改游戏程序中调用 API 函数指令代码?
● 如何将外挂代码(自定义的替换函数代码)注入到游戏程序进程地址空间?
下面我们逐一介绍这几个问题的解决方法:
(1) 、定位调用 API 函数指令代码
我们知道,在汇编语言中使用 CALL 指令来调用函数或过程的,它是通过指令参数中的函数地址而定位到相应的函数代码的。那么,我们如果能寻找到程序代码中所有调用被挡截的 API 函数的 CALL 指令的话,就可以将该指令中的函数地址参数修改为替代函数的地址。虽然这是一个可行的方案,但是实现起来会很繁琐,也不稳健。庆幸的是, Windows 系统中所使用的可执行文件( PE 格式)采用了输入地址表机制,将所有在程序调用的 API 函数的地址信息存放在输入地址表中,而在程序代码 CALL 指令中使用的地址不是 API
函数的地址,而是输入地址表中该 API 函数的地址项,如想使程序代码中调用的 API 函数被代替掉,只用将输入地址表中该 API 函数的地址项内容修改即可。具体理解输入地址表运行机制,还需要了解一下 PE 格式文件结构,其中图三列出了 PE 格式文件的大致结构。
图三: PE 格式大致结构图 (003.jpg)
PE 格式文件一开始是一段 DOS 程序,当你的程序在不支持 Windows 的环境中运行时,它就会显示 “This Program cannot be run in DOS mode” 这样的警告语句,接着这个 DOS 文件头,就开始真正的 PE 文件内容了。首先是一段称为 “IMAGE_NT_HEADER” 的数据,其中是许多关于整个 PE 文件的消息,在这段数据的尾端是一个称为 Data Directory 的数据表,通过它能快速定位一些 PE 文件中段( section )的地址。在这段数据之后,则是一个
“IMAGE_SECTION_HEADER” 的列表,其中的每一项都详细描述了后面一个段的相关信息。接着它就是 PE 文件中最主要的段数据了,执行代码、数据和资源等等信息就分别存放在这些段中。
在所有的这些段里,有一个被称为 “.idata” 的段(输入数据段)值得我们去注意,该段中包含着一些被称为输入地址表( IAT , Import Address Table )的数据列表。每个用隐式方式加载的 API 所在的 DLL 都有一个 IAT 与之对应,同时一个 API 的地址也与 IAT 中一项相对应。当一个应用程序加载到内存中后,针对每一个 API 函数调用,相应的产生如下的汇编指令:
JMP DWORD PTR [XXXXXXXX]
或
CALL DWORD PTR [XXXXXXXX]
其中, [XXXXXXXX] 表示指向了输入地址表中一个项,其内容是一个 DWORD ,而正是这个 DWORD 才是 API 函数在内存中的真正地址。因此我们要想拦截一个 API 的调用,只要简单的把那个 DWORD 改为我们自己的函数的地址。
(2) 、修改调用 API 函数代码
从上面对 PE 文件格式的分析可知,修改调用 API 函数代码其实是修改被调用 API 函数在输入地址表中 IAT 项内容。由于 Windows 系统对应用程序指令代码地址空间的严密保护机制,使得修改程序指令代码非常困难,以至于许多高手为之编写 VxD 进入 Ring0 。在这里,我为大家介绍一种较为方便的方法修改进程内存,它仅需要调用几个 Windows 核心 API 函数,下面我首先来学会一下这几个 API 函数:
DWORD VirtualQuery(
LPCVOID lpAddress, // address of region
PMEMORY_BASIC_INFORMATION lpBuffer, // information buffer
DWORD dwLength // size of buffer
);
该函数用于查询关于本进程内虚拟地址页的信息。其中, lpAddress 表示被查询页的区域地址; lpBuffer 表示用于保存查询页信息的缓冲; dwLength 表示缓冲区大小。返回值为实际缓冲大小。
BOOL VirtualProtect(
LPVOID lpAddress, // region of committed pages
SIZE_T dwSize, // size of the region
DWORD flNewProtect, // desired access protection
PDWORD lpflOldProtect // old protection
);
该函数用于改变本进程内虚拟地址页的保护属性。其中, lpAddress 表示被改变保护属性页区域地址; dwSize 表示页区域大小; flNewProtect 表示新的保护属性,可取值为 PAGE_READONLY 、 PAGE_READWRITE 、 PAGE_EXECUTE 等; lpflOldProtect 表示用于保存改变前的保护属性。如果函数调用成功返回 “T” ,否则返回 “F” 。
有了这两个 API 函数,我们就可以随心所欲的修改进程内存了。首先,调用 VirtualQuery() 函数查询被修改内存的页信息,再根据此信息调用 VirtualProtect() 函数改变这些页的保护属性为 PAGE_READWRITE ,有了这个权限您就可以任意修改进程内存数据了。下面一段代码演示了如何将进程虚拟地址为 0x0040106c 处的字节清零。
BYTE* pData = 0x0040106c;
MEMORY_BASIC_INFORMATION mbi_thunk;
// 查询页信息。
VirtualQuery(pData, &mbi_thunk, sizeof(MEMORY_BASIC_INFORMATION));
// 改变页保护属性为读写。
VirtualProtect(mbi_thunk.BaseAddress,mbi_thunk.RegionSize,
PAGE_READWRITE, &mbi_thunk.Protect);
// 清零。
*pData = 0x00;
// 恢复页的原保护属性。
DWORD dwOldProtect;
VirtualProtect(mbi_thunk.BaseAddress,mbi_thunk.RegionSize,
mbi_thunk.Protect, &dwOldProtect);
(3) 、注入外挂代码进入被挂游戏进程中
完成了定位和修改程序中调用 API 函数代码后,我们就可以随意设计自定义的 API 函数的替代函数了。做完这一切后,还需要将这些代码注入到被外挂游戏程序进程内存空间中,不然游戏进程根本不会访问到替代函数代码。注入方法有很多,如利用全局钩子注入、利用注册表注入挡截 User32 库中的 API 函数、利用 CreateRemoteThread 注入(仅限于 NT/2000 )、利用 BHO 注入等。因为我们在动作模拟技术一节已经接触过全局钩子,我相信聪明的读者已经完全掌握了全局钩子的制作过程,所以我们在后面的实例中,将继续利用这个全局钩子。至于其它几种注入方法,如果感兴趣可参阅
MSDN 有关内容。
有了以上理论基础,我们下面就开始制作一个挡截 MessageBoxA 和 recv 函数的实例,在开发游戏外挂程序 时,可以此实例为框架,加入相应的替代函数和处理代码即可。此实例的开发过程如下:
(1) 打开前面创建的 ActiveKey 项目。
(2) 在 ActiveKey.h 文件中加入 HOOKAPI 结构,此结构用来存储被挡截 API 函数名称、原 API 函数地址和替代函数地址。
typedef struct tag_HOOKAPI
{
LPCSTR szFunc;// 被 HOOK 的 API 函数名称。
PROC pNewProc;// 替代函数地址。
PROC pOldProc;// 原 API 函数地址。
}HOOKAPI, *LPHOOKAPI;
(3) 打开 ActiveKey.cpp 文件,首先加入一个函数,用于定位输入库在输入数据段中的 IAT 地址。代码如下:
extern "C" __declspec(dllexport)PIMAGE_IMPORT_DESCRIPTOR
LocationIAT(HMODULE hModule, LPCSTR szImportMod)
// 其中, hModule 为进程模块句柄; szImportMod 为输入库名称。
{
// 检查是否为 DOS 程序,如是返回 NULL ,因 DOS 程序没有 IAT 。
PIMAGE_DOS_HEADER pDOSHeader = (PIMAGE_DOS_HEADER) hModule;
if(pDOSHeader->e_magic != IMAGE_DOS_SIGNATURE) return NULL;
// 检查是否为 NT 标志,否则返回 NULL 。
PIMAGE_NT_HEADERS pNTHeader = (PIMAGE_NT_HEADERS)((DWORD)pDOSHeader+ (DWORD)(pDOSHeader->e_lfanew));
if(pNTHeader->Signature != IMAGE_NT_SIGNATURE) return NULL;
// 没有 IAT 表则返回 NULL 。
if(pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress == 0) return NULL;
// 定位第一个 IAT 位置。
PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD)pDOSHeader + (DWORD)(pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress));
// 根据输入库名称循环检查所有的 IAT ,如匹配则返回该 IAT 地址,否则检测下一个 IAT 。
while (pImportDesc->Name)
{
// 获取该 IAT 描述的输入库名称。
PSTR szCurrMod = (PSTR)((DWORD)pDOSHeader + (DWORD)(pImportDesc->Name));
if (stricmp(szCurrMod, szImportMod) == 0) break;
pImportDesc++;
}
if(pImportDesc->Name == NULL) return NULL;
return pImportDesc;
}
再加入一个函数,用来定位被挡截 API 函数的 IAT 项并修改其内容为替代函数地址。代码如下:
extern "C" __declspec(dllexport)
HookAPIByName( HMODULE hModule, LPCSTR szImportMod, LPHOOKAPI pHookApi)
// 其中, hModule 为进程模块句柄; szImportMod 为输入库名称; pHookAPI 为 HOOKAPI 结构指针。
{
// 定位 szImportMod 输入库在输入数据段中的 IAT 地址。
PIMAGE_IMPORT_DESCRIPTOR pImportDesc = LocationIAT(hModule, szImportMod);
if (pImportDesc == NULL) return FALSE;
// 第一个 Thunk 地址。
PIMAGE_THUNK_DATA pOrigThunk = (PIMAGE_THUNK_DATA)((DWORD)hModule + (DWORD)(pImportDesc->OriginalFirstThunk));
// 第一个 IAT 项的 Thunk 地址。
PIMAGE_THUNK_DATA pRealThunk = (PIMAGE_THUNK_DATA)((DWORD)hModule + (DWORD)(pImportDesc->FirstThunk));
// 循环查找被截 API 函数的 IAT 项,并使用替代函数地址修改其值。
while(pOrigThunk->u1.Function)
{
// 检测此 Thunk 是否为 IAT 项。
if((pOrigThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG) != IMAGE_ORDINAL_FLAG)
{
// 获取此 IAT 项所描述的函数名称。
PIMAGE_IMPORT_BY_NAME pByName =(PIMAGE_IMPORT_BY_NAME)((DWORD)hModule+(DWORD)(pOrigThunk->u1.AddressOfData));
if(pByName->Name[0] == ‘//0‘) return FALSE;
// 检测是否为挡截函数。
if(strcmpi(pHookApi->szFunc, (char*)pByName->Name) == 0)
{
MEMORY_BASIC_INFORMATION mbi_thunk;
// 查询修改页的信息。
VirtualQuery(pRealThunk, &mbi_thunk, sizeof(MEMORY_BASIC_INFORMATION));
// 改变修改页保护属性为 PAGE_READWRITE 。
VirtualProtect(mbi_thunk.BaseAddress,mbi_thunk.RegionSize, PAGE_READWRITE, &mbi_thunk.Protect);
// 保存原来的 API 函数地址。
if(pHookApi->pOldProc == NULL)
pHookApi->pOldProc = (PROC)pRealThunk->u1.Function;
//修改API函数IAT项内容为替代函数地址。
pRealThunk->u1.Function = (PDWORD)pHookApi->pNewProc;
//恢复修改页保护属性。
DWORD dwOldProtect;
VirtualProtect(mbi_thunk.BaseAddress, mbi_thunk.RegionSize, mbi_thunk.Protect, &dwOldProtect);
}
}
pOrigThunk++;
pRealThunk++;
}
SetLastError(ERROR_SUCCESS); //设置错误为ERROR_SUCCESS,表示成功。
return TRUE;
}
(4) 定义替代函数,此实例中只给 MessageBoxA 和 recv 两个 API 进行挡截。代码如下:
static int WINAPI MessageBoxA1 (HWND hWnd , LPCTSTR lpText, LPCTSTR lpCaption, UINT uType)
{
// 过滤掉原 MessageBoxA 的正文和标题内容,只显示如下内容。
return MessageBox(hWnd, "Hook API OK!", "Hook API", uType);
}
static int WINAPI recv1(SOCKET s, char FAR *buf, int len, int flags )
{
// 此处可以挡截游戏服务器发送来的网络数据包,可以加入分析和处理数据代码。
return recv(s,buf,len,flags);
}
(5) 在 KeyboardProc 函数中加入激活挡截 API 代码,在 if( wParam == 0X79 ) 语句中后面加入如下 else if 语句:
......
// 当激活 F11 键时,启动挡截 API 函数功能。
else if( wParam == 0x7A )
{
HOOKAPI api[2];
api[0].szFunc ="MessageBoxA";// 设置被挡截函数的名称。
api[0].pNewProc = (PROC)MessageBoxA1;// 设置替代函数的地址。
api[1].szFunc ="recv";// 设置被挡截函数的名称。
api[1].pNewProc = (PROC)recv1; // 设置替代函数的地址。
// 设置挡截 User32.dll 库中的 MessageBoxA 函数。
HookAPIByName(GetModuleHandle(NULL),"User32.dll",&api[0]);
// 设置挡截 Wsock32.dll 库中的 recv 函数。
HookAPIByName(GetModuleHandle(NULL),"Wsock32.dll",&api[1]);
}
......
(6) 在 ActiveKey.cpp 中加入头文件声明 "#include "wsock32.h" 。 从“工程”菜单中选择“设置”,弹出Project Setting对话框,选择Link标签,在“对象/库模块”中输入Ws2_32..lib。
(7) 重新编译 ActiveKey 项目,产生 ActiveKey.dll 文件,将其拷贝到 Simulate.exe 目录下。运行 Simulate.exe 并启动全局钩子。激活任意应用程序,按 F11 键后,运行此程序中可能调用 MessageBoxA 函数的操作,看看信息框是不是有所变化。同样,如此程序正在接收网络数据包,就可以实现封包功能了。
如何使用WINSOCK Api hook拦截修改socket数据包
标签:nec 相同 sea 网络游戏 资源 main handle 对象 协议
原文地址:https://www.cnblogs.com/yangfang777/p/11626434.html