缓冲区溢出还是问题吗?C++/CLI安全编码
from:http://dotnet.chinaitlab.com/VCNET/712748.html C++/CLI是对C++的一个扩展,其对所有类型,包括标准C++类,都添加了对属性、事件、垃圾回收、及泛型的支持。 Visual C++ 2005扩展了对使用C++/CLI(通用语言基础结构)开发运行于带有垃圾回收的虚拟机上的控件及应用程序的支持,而C++/CLI是对C++
from:http://dotnet.chinaitlab.com/VCNET/712748.html
C++/CLI是对C++的一个扩展,其对所有类型,包括标准C++类,都添加了对属性、事件、垃圾回收、及泛型的支持。
Visual C++ 2005扩展了对使用C++/CLI(通用语言基础结构)开发运行于带有垃圾回收的虚拟机上的控件及应用程序的支持,而C++/CLI是对C++编程语言的一个扩展,其对所有类型,包括标准C++类,都添加了如属性、事件、垃圾回收、及泛型等特性。
Visual C++ 2005支持.NET Framework通用语言运行时库(CLR),其是垃圾回收虚拟机Microsoft的实现。Visual C++ 2005对.NET编程的C++语法支持是从Visual C++ .NET 2003中引入的托管扩展C++演化而来的,托管扩展C++仍然被支持,但在倾向于新语法的情况下已不赞成使用。Visual C++ 2005同时也对本地编程添加了新的特性,包括64位处理器架构支持,及提高了安全性的新库函数。
在本文中,将主要讲解在以最小代价把现有老系统移植到使用CLR的新环境中来时,所要面临的问题,目的是为了确定这些程序是否仍然易受折磨C/C++程序多年的缓冲区溢出的影响。
例1会要求用户输入用户名及密码,除去用户名之外,程序只接受"NCC-1701"为有效的密码。如果用户输入了错误的密码,程序将退出。(这个程序只是作为C++/CLI代码的漏洞测试,而不是演示如何处理密码。) 例1:
1. #include <stdlib.h> 2. #include <stdio.h> 3. #include <windows.h> 4. char buff[1028]; 5. struct user { 6. char *name; 7. size_t len; 8. int uid; 9. }; 10. bool checkpassword() { 11. char password[10]; 12. puts("Enter 8 character password:"); 13. gets(password); 14. if (strcmp(password, "NCC-1701") == 0) { 15. return true; 16. } 17. else { 18. return false; 19. } 20. } 21. int main(int argc, char *argv[]) { 22. struct user *userP = (struct user *)0xcdcdcdcd; 23. size_t userNameLen = 0xdeadbeef; 24. userP = (struct user *)malloc(sizeof(user)); 25. puts("Enter user name:"); 26. gets(buff); 27. if (!checkpassword()) { 28. userNameLen = strlen(buff) + 1; 29. userP->len = userNameLen; 30. userP->name = (char *)malloc(userNameLen); 31. strcpy(userP->name, buff); // log failed login attempt 32. exit(-1); 33. } 34. } |
程序从21行的main()开始执行,在25及26行使用了一对puts()和gets()来提示输入用户名,导致了一个从标准输入到缓冲区字符数组(声明在第4行)的不受控制的字符串复制,程序中的这两处地方都有可能会导致一个缓冲区溢出的漏洞。checkpassword()函数由main()中的27行调用,并在12及13行中提示用户输入密码,这也是使用了一对puts()/gets()。对gets()的第二次调用也会导致一个定义在堆栈上的密码字符数组缓冲区溢出。
程序使用Microsoft Visual C++ 2005编译,并关闭了缓冲区安全检查选项(/GS-),打开了托管扩展(/clr)。默认情况下,缓冲区安全检查是打开的,把它关闭并不是个好做法(如本例所示),而/clr选项可允许由托管及非托管代码生成混合的程序集。
程序生成过程中产生的几个警告信息都可以忽略掉,例如,"warning C4996: 'gets' was declared deprecated"和"warning C4996: 'strcpy' was declared deprecated",编译器推荐使用gets_s()来代替gets(),用strcpy_s()来代替strcpy()。如果完全使用这些替代函数,那么就可消除缓冲区溢出潜在的可能性。然而,这些只是警告信息,可以忽略甚至关闭,忽略这些警告信息是符合用最小的代价移植现有老系统这个前提的。
当使用托管扩展时,编译器会为main()及checkpassword()函数生成Microsoft媒介语言(MSIL或称为通用媒介语言CIL),CIL字节码会被打包进一个可执行文件,在调用即时编译器(JIT)将其翻译为本地程序集指令后,接着把控制权交给main()。
程序运行时,提示用户输入用户名:
Enter user name: rcs |
接着程序要求用户输入密码,其被读入到声明在11行上的10个字符数组这个变量中,在插1中,如果在密码从标准输入读取之前,查看堆栈上的数组地址起始处的数据(本例中为0x002DF3D4),将会看到分配给密码的存储空间(以黑体字标出)及堆栈上的返回地址(以红色字标出)。返回地址在此为小尾字节序(Little Endian)。
代码段1:堆栈上数组地址起始处的数据
002DF3D4 00 00 00 00 04 f4 2d 00 a0 1b e7 79 80 63 54 00 ......-....y.cT. 002DF3E4 04 f4 2d 00 f9 0f 0a 02 01 00 00 00 79 3a 4e 00 ..-.........y:N. 002DF3F4 a8 2b 2f 00 38 f4 2d 00 da c4 fc 79 78 f4 2d 00 .+/.8.-....yx.-. 002DF404 48 f4 2d 00 60 13 40 00 01 00 00 00 50 53 54 00 H.-.`.@.....PST. |
倘若输入了更多的字符,以致密码字符数组存储空间无法容纳,一个攻击者就可以溢出此缓冲区,并以shellcode(可为任意的代码)地址覆盖掉返回地址。出于演示的目的,在此假定shellcode已被注入,且定位于0x00408130,为执行此代码,攻击者只需把下列字符串作为密码输入:
Enter 8 character password: 123456789012345678900|@ |
这个输入的字符串被复制到密码字符数组,溢出了此缓冲区并覆盖相应的内存包括返回地址。字符串中的三个字符0|@覆盖了返回地址的前三个字节,而返回地址的最后一个字节被一个由gets()函数产生的null结尾字符所覆盖。注意,如果这个null不在最后一个字节上,那么不可能复制整个字符串,因为gets()函数会把这个null字符解释为字符串的结尾。那为什么要以上这三个字符呢?因为,这些字符的十六进制形式提供了内存中表示地址所需的值,"0"的ASCII十六进制码为0x30,"|"为0x81,而"@"为0x40。如果把这三个字符以顺序{ '0', '|', '@' }连接起来,就可将shellcode(0x00408130)地址的小尾字节序表示形式写入到内存中。最后一个null字节 由字符串的null字符提供。(见代码段2。)
代码段2:
002DF3D4 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 36 1234567890123456 002DF3E4 37 38 39 30 30 81 40 00 01 00 00 00 79 3a 4e 00 78900.@.....y:N. 002DF3F4 a8 2b 2f 00 38 f4 2d 00 da c4 fc 79 78 f4 2d 00 .+/.8.-....yx.-. 002DF404 48 f4 2d 00 60 13 40 00 01 00 00 00 50 53 54 00 H.-.`.@.....PST. |
当checkpassword()函数返回时,控制权就传到shellcode而不是main()函数中的原始返回地址上。
为了简化这个攻击过程,在此,关闭了缓冲区安全检查选项/GS。如果这个选项没有关闭,编译器将会在声明在堆栈上的任何数组(缓冲区)之后插入一个"密探"--实际上为一个Cookie,见图1。
图1:基于"密探"的缓冲区溢出保护 |
如果要使用那些不受控制的字符串复制操作,如gets()或strcpy(),来覆盖掉由"密探"保护的返回地址(EIP)、基指针(EBP)、或堆栈上的其他值,一个攻击者将首先要覆盖掉这个"密探"。如果"密探"被修改了,当函数返回时,将会产生一个错误,导致攻击失败--除非是为了进行"拒绝服务攻击"。通过暴力枚举猜测这个值,或其他方法,还是有可能挫败这个"密探"的,但是,进行一次成功攻击的难度增加了。
打开/GS选项不会让程序对缓冲区溢出漏洞彻底免疫,堆栈中的缓冲区溢出仍会使程序崩溃,攻击者利用基于堆栈的溢出来执行任意代码的可能性,即使在打开/GS的情况下仍然存在。更重要的是,/GS选项不会检测堆中或数据段中的缓冲区溢出。
为举例说明,例2使用Win32 GUI重写了前面那个示例程序,这个程序提供一个带有一些简单选项的菜单栏--File菜单下有两个菜单项:"Login"和"Exit"。Login会用一个对话框来提示用户输入密码,一旦输入了密码,在用户点击"OK"按钮之后,将把输入的密码与之前记录的密码相比较。
例2:
1. #include "stdafx.h" 2. #include "TestItDan.h" 3. #include <stdlib.h> 4. #include <stdio.h> 5. #include <windows.h> 6. #define MAX_LOADSTRING 100 7. struct user { 8. wchar_t *name; 9. size_t len; 10. int uid; 11. }; 13. HINSTANCE hInst; 14. TCHAR szTitle[MAX_LOADSTRING]; 15. TCHAR szWindowClass[MAX_LOADSTRING]; 16. TCHAR lpszUserName[16] = L"guest"; 17. TCHAR lpszPassword[16] = L"0123456789abcde"; 18. struct user *userP = (struct user *)0xcdcdcdcdcdcdcdcd; 19. size_t userNameLen = 16; 20. size_t userPasswordLen = 0xffffffff; 25. int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow) { 26. UNREFERENCED_PARAMETER(hPrevInstance); 27. UNREFERENCED_PARAMETER(lpCmdLine); 28. MSG msg; 29. HACCEL hAccelTable; 30. LoadString(hInstance, IDS_APP_TITLE, szTitle, MAX_LOADSTRING); 31. LoadString(hInstance, IDC_TESTITDAN, szWindowClass, MAX_LOADSTRING); 32. MyRegisterClass(hInstance); 33. userP = (struct user *)malloc(sizeof(user)); 34. if (!InitInstance (hInstance, nCmdShow)) { 35. return FALSE; 36. } 37. hAccelTable =LoadAccelerators(hInstance, MAKEINTRESOURCE(IDC_TESTITDAN)); 38. while (GetMessage(&msg, NULL, 0, 0)) { 39. if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg)) { 40. TranslateMessage(&msg); 41. DispatchMessage(&msg); 42. } 43. } 44. return (int) msg.wParam; 45. } 109. INT_PTR CALLBACK GetPassword(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam) { 110. TCHAR lpszGuestPassword[16] = L"NCC-1701"; 111. UNREFERENCED_PARAMETER(lParam); 112. switch (message) { 113. case WM_INITDIALOG: 114. return (INT_PTR)TRUE; 115. case WM_COMMAND: 116. if (LOWORD(wParam) == IDOK) { 117. EndDialog(hDlg, LOWORD(wParam)); 118. SendDlgItemMessage(hDlg, 119. IDC_EDIT1, 120. EM_GETLINE, 121. (WPARAM) 0, // line 0 122. (LPARAM) lpszPassword 123. ); 124. userP->len = userNameLen; 125. if (wcscmp(lpszPassword, lpszGuestPassword) == 0) { 126. return true; 127. } 128. else { 129. MessageBox(hDlg, 130. (LPCWSTR)L"Invalid Password", 131. (LPCWSTR)L"Login Failed", 132. MB_OK 133. ); 134. } 135. return (INT_PTR)TRUE; 136. } 137. break; 138. } 139. return (INT_PTR)FALSE; 140. } |
这是一个非常简单的程序,尽管为了支持Windows GUI,它显得稍微有点长。在17至20行,有几个有意思的变量,lpszPassword是一个由16个宽字符(32字节)组成的已初始化的静态变量,紧跟其后的是userP指针及两个无符号整形:userNameLen和userPasswordLen,之后,userP在33行初始化。这些变量的地址如下:
&lpszPassword = 0x0040911C &userP = 0x0040913C &userNameLen = 0x00409140 &userPasswordLen = 0x00409144 |
userP的值为0x00554D30,userNameLen的值为0x00000010,userPasswordLen的值为0xffffffff。如果我们查看lpszPassword地址的起始处内存,可以非常清楚地看到这些变量的初始值(见插3)。
代码段3:
0040911C 30 00 31 00 32 00 33 00 34 00 35 00 36 00 37 00 0040912C 38 00 39 00 61 00 62 00 63 00 64 00 65 00 00 00 0040913C 30 4d 55 00 10 00 00 00 ff ff ff ff 8a 00 07 02 0040914C c6 00 07 02 02 01 07 02 00 00 00 00 01 00 00 00 |
此程序中的漏洞是在118至123行中对SendDlgItemMessage的调用,EM_GETLINE消息指定了从编辑控件IDC_EDIT1获取一行文本--编辑控件在Login对话框中,并把它复制到定长缓冲区lpszPassword中。这个缓冲区只能容纳15个Unicode字符及一个结尾的null,如果输入了多于15个字符,就会发生缓冲区溢出;在此假设输入了20个字符,第17及18个字符将会覆盖掉userP,第19及20个字符将会覆盖掉userNameLen,结尾的null将会覆盖掉userPasswordLen。
假定userP与userNameLen两者都被覆盖,当userNameLen被赋给存储在userP+4(user结构内len的偏移地址)的地址时,在124行就会导致对内存的任意写入。通过把一个地址覆盖为控制权最终要传递到的地址,攻击者就能利用内存的任意写入,把控制权传给任意的代码。而在本例中,堆栈上的返回地址被覆盖了。
因为lpszGuestPassword变量是一个声明在GetPassword函数中的自动变量,我们也可以查看这个变量地址起始处的内存。假定lpszGuestPassword定位在0x002DEB9C,那么可在这个位置查看堆栈的内容。经由程序调试,可以确定0x004f3a99的返回码位于堆栈上的0x002DEBD0处(见插4)。
代码段4:
002DEB9C 4e 00 43 00 43 00 2d 00 31 00 37 00 30 00 31 00 002DEBAC 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 002DEBBC 1e df b4 bd 00 00 00 00 50 15 40 00 64 ec 2d 00 002DEBCC ec eb 2d 00 99 3a 4f 00 05 27 00 01 00 00 00 002DEBDC b0 32 2f 00 84 ec 2d 00 da c4 fc 79 58 f1 2d 00 |
假定shellcode已被注入到程序中的0x00409028,那么接下来,攻击者可在Login对话框的密码输入栏中输入以下字符串:
"1234567812345678/xebcc/x002d/x9028/x0040" |
在缓冲区溢出之后,数据段的内存显示见插5:
代码段5:
0040911C 31 00 32 00 33 00 34 00 35 00 36 00 37 00 38 00 0040912C 31 00 32 00 33 00 34 00 35 00 36 00 37 00 38 00 0040913C cc eb 2d 00 28 90 40 00 00 00 ff ff 8a 00 07 00 0040914C c6 00 07 02 02 01 07 02 00 00 00 00 01 00 00 00 |
棕色的字节表示userP的值在何处被堆栈上的返回代码地址所覆盖(负4),绿色的字节表示userNameLen的值在何处被shellcode的地址所覆盖。当124行的内存任意写入执行之后,堆栈现在如插6所示。
代码段6:
002DEB9C 4e 00 43 00 43 00 2d 00 31 00 37 00 30 00 31 00 002DEBAC 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 002DEBBC 1e df b4 bd 00 00 00 00 50 15 40 00 64 ec 2d 00 002DEBCC ec eb 2d 00 28 90 40 00 0e 05 27 00 01 00 00 00 002DEBDC b0 32 2f 00 84 ec 2d 00 da c4 fc 79 58 f1 2d 00 |
红色表示的字节标出了堆栈上的返回值在何处被地址值所覆盖,在这,并没有修改堆栈上的其他任何字节(包括"密探"),使得运行时的系统很难发现这次攻击。结果,控制权在GetPassword()函数返回时,传到了shellcode中。
让我们再来回顾一下,首先,它演示了堆栈上的返回地址仍可被覆盖--甚至在打开缓冲区安全检查(/GS)的情况下,这些安全检查只会减轻声明在堆栈上的自动变量缓冲区溢出;其次,它也说明了一个在Visual Studio 2005环境中编译时毫无警告信息的程序并不是没有漏洞可言。例3就消除了这个缓冲区溢出,在发送消息之前,lpszPassword的第一个字设为以TCHAR表示的缓冲区大小,对Unicode文本而言,这表示字符数。第一个字中的大小被复制进来的字符数所覆盖,同样,对编辑控件来说,复制进来的字符串并不包含一个null结尾字符,返回值(所复制的TCHAR数)必须再设为以null结尾的字符串。
例3:
LRESULT Retval; *((WORD *)(&lpszPassword)) = (sizeof(lpszPassword)/sizeof(TCHAR))-1; Retval = SendDlgItemMessage(hDlg, IDC_EDIT1, EM_GETLINE, (WPARAM) 0, // line 0 (LPARAM) lpszPassword ); lpszPassword[Retval]='/0'; |
更多推荐
所有评论(0)