栈溢出
Windows栈溢出
原文链接:Windows 内存损坏漏洞的现代探索 – 第一部分:栈溢出 (cyberark.com)
代码地址:https://github.com/forrest-orr/ExploitDev
前排提示:由于翻译的差异,“栈”与“堆栈”指的实际上是同一个东西,说的都是“stack”,然而,面向大多数刚刚接触编程的同学们,对于“堆”(heap)和“栈”的理解区分至关重要。因此,下文中应为“stack”的地方,统一称作“栈”,但难免有遗漏之处,请读者分清栈或堆栈与堆的区别,并海涵出现的疏忽。
介绍
通过在现代操作系统上使用现代编译器记录自己的一些实验和研究来帮助解决过时的面向初学者的漏洞利用信息的问题。重点是Windows 10和Visual Studio 2019。
shellcode
shellcode是一段用于利用软件漏洞而执行的代码,shellcode为16进制的机器码,因为经常让攻击者获得shell而得名。shellcode常常使用机器语言编写。 可在寄存器eip溢出后,塞入一段可让CPU执行的shellcode机器码,让电脑可以执行攻击者的任意指令。
shellcode可以按照攻击者执行的位置分为本地shellcode和远程shellcode。
本地shellcode
本地运行的shellcode经常用于利用软件漏洞提升权限。比如在Linux下由普通权限提升至root权限。
远程shellcode
利用软件漏洞获得特定的shellcode,再经由C或Python编写远程攻击程序,进而取得对方电脑的root权限。
经典栈溢出
经典栈溢出是最容易理解的内存损坏漏洞。易受攻击的应用程序包含一个函数,该函数将用户控制的数据写入栈而不验证其长度。这允许攻击者:
- 将shellcode写入栈。
- 覆盖当前函数的返回地址以指向shellcode。
如果栈可以在不破坏应用程序的情况下以这种方式损坏,则shellcode将在被利用的函数返回时执行。此概念的示例如下:
(经过测试,运行这段代码需要在VS中关闭编译设置中的“缓冲区安全检查(GS)”选项)
(我用的vs2022,就算关闭了这个选项它依然进行了缓冲区溢出检查,老版本也许能成功,成功运行后回报的错误应该是“无效的运行地址”,而不是栈溢出。)
1 |
|
其中,安全cookie是一个在我们使用的栈下方加入的一个结构:
程序对cookie的值进行监测,一旦cookie中的值发生变化,则认为程序已经造成了栈溢出,立即退出程序并回报错误。如果在上一步中没有关闭,就会造成这样的结果:
栈溢出是一种技术(与字符串格式错误和堆溢出不同)仍然可以在现代Windows应用程序中利用,使用与几十年前发布的Smashing the Stack for Fun and Profit相同的方法。但是,现在适用于此类攻击的缓解措施已经非常强大了。
与字符串格式错误和堆溢出漏洞不同,对于栈溢出来说,攻击者仍然可以使用几十年前“Smashing the Stack for Fun and Profit”一文中提出的方法,来利用现代Windows应用程序中的这种漏洞。然而,现在针对这种攻击的缓解措施已经变得非常强大了。
在Windows 10上,默认情况下,使用Visual Studio 2019编译的应用程序将继承一组针对栈溢出漏洞的安全缓解措施,其中包括:
- SafeCRT
- 栈Cookie与安全的变量排序
- 安全的结构化异常处理(SafeSEH)
- 数据执行保护(DEP)
- 地址空间布局随机化(ASLR)
- 结构化异常处理覆盖保护(SEHOP)
就算让易受攻击的CRT API(如strcpy)“退休”并通过SafeCRT库引入这些API的安全版本(如strcpy_s),也无法全面解决栈溢出的问题。这是因为像memcpy这样的API仍然有效,并且这些CRT API的非POSIX变体也是如此(例如KERNEL32.DLL!lstrcpyA)。当我们试图在Visual Studio 2019中编译包含这些“被退休”的API的应用程序时,会触发严重的编译错误,尽管这些错误是可抑制的。
栈Cookie是试图“修复”和防止栈溢出漏洞在运行时被利用的第一道防护机制。SafeSEH和SEHOP是在栈Cookie外围工作的两种缓解措复施,而DEP和ASLR并不是针对栈的缓解方法,因为它们并不能防止栈溢出攻击或EIP劫持的发生。相反,它们的作用提高通过这种攻击执行shellcode的难度。所有这些缓解措施都将随着本文的推进而深入探讨。下一节将重点讨论栈Cookie——目前我们在尝试利用栈溢出时的主要对手。
栈Cookie、GS与GS++
随着Visual Studio 2003的发布,微软在其MSVC编译器中加入了一个新的栈溢出防御功能,称为GS。两年后,他们在发布Visual Studio 2005时默认启用了该功能。
关于GS,网上的信息虽然非常丰富,但是大部分都是过时的和/或不完整的信息。之所以出现这种情况,是因为GS的安全缓解措施自最初发布以来已经发生了重大的变化:在Visual Studio 2010中,一个名为GS++的增强版GS取代了原来的GS功能。令人困惑的是,微软从未更新其编译器选项的名称,尽管实际上是GS++,但至今仍是“/GS”。
从根本上说,GS其实是一种安全缓解措施,它被编译进二进制级别的程序,在包含Microsoft所谓的“GS缓冲区”(易受栈溢出攻击的缓冲区)的函数中放置策略性栈损坏检查(借助于栈Cookie)。最初的GS只考虑包含8个或更多元素,元素大小为1或2(字符和宽字符)字节的数组作为GS缓冲区,而GS++对此定义进行了实质性扩展,包括:
- 任何数组(无论长度或元素大小)
- 结构体(无论其内容是什么)
这个增强技术对现代栈溢出漏洞有很大的意义,因为它基本上使所有容易受到栈溢出攻击的函数免于受到基于返回地址的EIP劫持技术的影响。这反过来又会对其他陈旧的利用技术产生影响,比如通过部分EIP覆盖来绕过ASLR的技术——2007年著名的Vista CVE-2007-0038 Animated Cursor漏洞就利用结构溢出而流行起来的。随着2010年GS++的出现,在典型的栈溢出情况下,部分EIP覆盖作为ASLR绕过的方法已经失效。
MSDN上关于GS的信息(最后一次更新是在四年前的2016年),在GS覆盖率方面,与我自己的一些测试结果是相矛盾的。例如,微软将以下变量列为非GS缓冲区的例子:
1 | char *pBuf[20]; |
然而在我自己使用VS2019进行的测试中,这些变量都会导致栈Cookie的产生。这说明GS确实被升级为了GS++,且作用域为上述的任何数组(无论长度或元素大小)、结构体(无论其内容是什么)。
究竟什么是栈Cookie,它们是如何工作的?
- 栈Cookie在Visual Studio 2019中是默认设置的。它们可以使用/GS标志进行配置,我们可以在项目设置的 Project -> Properties -> C/C++ -> Code Generation -> Security Check字段中设置该标志。
- 当加载了一个用/GS编译的PE时,它会初始化一个新的随机栈Cookie种子值,并将其作为一个全局变量存储在其.data段中。
- 每当一个包含GS缓冲区的函数被调用时,它都会将这个栈Cookie种子与EBP寄存器进行XOR运算,并将其存储在保存的EBP寄存器和返回地址之前的栈上。
- 在受保护的函数返回之前,它会再次用>EBP对其保存的伪唯一性栈Cookie进行XOR运算,以获得原始的栈Cookie种子值,并进行相应的检查,以确保它仍然与存储在.data段的种子相匹配。
- 如果这个值不匹配,应用程序会抛出一个安全异常并终止执行。
由于攻击者不可能在覆盖返回地址的同时不覆盖函数栈帧中保存的栈Cookie,这种机制能够阻止栈溢出漏洞利用代码通过RET指令劫持EIP,从而达到任意执行代码的目的。
在现代编译环境中编译并执行之前的栈溢出项目,会出现STATUS_STACK_BUFFER_OVERRUN异常(代码0xC0000409);使用调试器可以逐步剖析其出错原因。
我们可以看到,一顿开栈操作后,创建了一个位于0x057a028的安全栈cookie,并将这个地址里面的内容压入栈。这个内容正好占用的是四个字节。
在memcpy函数返回之后,我们可以看到栈确实被我们的预期数据破坏了,其中包括返回地址EDX,现在已经变为0x44444444。在过去,当这个函数返回时,会出现访问冲突异常,并断言0x44444444是一个要执行的无效地址。但是,栈Cookie安全检查将阻止这种情况。
首次执行该函数时,如果将存储在.data段中的栈Cookie种子与EBP进行XOR运算,然后将其保存到栈中。由于这个值在溢出期间将被值0x42424242所覆盖(如果我们希望能够覆盖返回地址并劫持EIP,这是不可避免的),从而生成有毒的栈Cookie值,即0xa33d2a88(在ECX中可以清楚看到),现在,该值将被传递给内部函数__security_check_Cookie进行验证。
一旦这个函数被调用,就会导致STATUS_STACK_BUFFER_OVERRUN异常(代码0xC0000409)。这虽然会导致进程崩溃,但也防止了攻击者成功利用该漏洞。
当您熟悉这些概念和实际例子后,会注意到关于栈Cookie的几个“有趣”的事情:
- 它们不能防止栈溢出的发生。攻击者仍然可以随心所欲地在栈上覆盖任意数量的数据。
- 它们只是针对每个函数的伪随机值。这意味着,如果.data中的栈Cookie种子发生内存泄漏,同时栈指针也发生泄漏,攻击者就可以准确地预测Cookie,并将其嵌入到其溢出中以绕过安全异常。
从根本上来说(假设它们无法通过内存泄漏进行预测),栈Cookie只能防止我们通过易受攻击的函数的返回地址来劫持EIP。这意味着我们仍然可以以任何方式破坏栈,并能够在安全检查和RET指令之前执行任意代码。那么,这在现代栈溢出的可靠利用过程中有什么价值呢?
SSH劫持
进程中的每个线程都可以注册handler函数(默认情况下也是如此),以便在触发异常时进行调用。这些handler函数的指针通常存储在栈上的EXCEPTION_REGISTRATION_RECORD结构体中。在任何版本的Windows上启动一个32位应用程序时,都至少会注册一个这样的handler,并将相关数据存储在栈中,具体如下图所示:
(VS找不到,IDA也没有这个关键字,只能用别人的图了)
上面高亮显示的EXCEPTION_REGISTRATION_RECORD结构体包含一个指向下一个SEH记录的指针(也存储在栈上),后面是指向handler函数的指针(在本例中是NTDLL.DLL库中的函数)。
1 | typedef struct _EXCEPTION_REGISTRATION_RECORD { |
在内部,指向SEH handler列表的指针都存储在每个线程的TEB的偏移量0处,并且每个EXCEPTION_REGISTION_RECORD都链接到下一个。如果handler不能正确处理抛出的异常,它会将执行权移交给下一个handler,以此类推。
因此,SEH实际上为攻击者提供了绕过栈Cookie的理想方法。我们可以利用栈溢出,覆盖现有的SHE handler(肯定至少会有一个),然后让应用程序崩溃(考虑到我们有能力破坏栈内存,这肯定不在话下)。这将导致在易受攻击函数最后调用__SECURITY_CHECK_COOKIE之前,EIP被重定向到EXCEPTION_REGISTION_RECORD结构体中被覆盖后的handler地址。因此,在执行shellcode之前,应用程序根本没有机会发现其栈已被破坏。因此,我们可以用自定义的SEH handler喷射栈,覆盖现有的EXCEPTION_REGISTRATION_RECORD结构体。
1 |
|
我们得到的不是EXE中FakeHandler函数上的断点,而是得到一个STATUS_INVALID_EXCEPTION_HANDLER异常(代码0xC00001A5)。这是一个源于SafeSEH的安全缓解异常。SafeSEH是一个安全缓解措施,仅适用于32位PE文件。在64位PE文件中,一个名为IMAGE_DIRECTORY_ENTRY_EXCEPTION的永久性(非可选)数据目录取代了原来在32位PE文件中的IMAGE_DIRECTORY_ENTRY_RIGHT数据目录。SafeSEH与GS特性都是在Visual Studio 2003版本中发布的,随后在Visual Studio 2005版本中成为了默认设置。
什么是SafeSEH,它是如何工作的?
- 在Visual Studio 2019中,SafeSEH是默认设置的。它通过使用/SAFESEH标志进行配置,我们可以在Project -> Properties -> Linker -> Advanced -> Image Has Safe Exception Handlers中进行相应的设置。
- SafeSEH编译的PE文件含有一个有效的SEH handler地址列表,位于名为SEHandlerTable的表中,我们可以在其IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG数据目录中指定。
- 每当触发异常时,在执行EXCEPTION_REGISTRATION_RECORD链表中的每个handler的地址之前,Windows会检查该handler是否位于映像内存的范围内(表明它与加载的模块有关),如果是的话,就会用它的SEHandlerTable检查这个handler地址对有关模块是否有效。
在上图中,我们是通过栈溢出的方式来注册handler的,通过这种方式创建的handler是无法被编译器所识别的(因此,也不会添加到SEHandlerTable中)。通常情况下,编译器会将作为__try __except语句的副作用而创建的handler添加到这个表中。在禁用SafeSEH后,再次运行这段代码会导致栈溢出,执行被喷入的handler。
下图所示为栈溢出,导致执行了伪造的SEH handler,该handler被编译为PE EXE映像的主映像。
当然,虽然自2005年以来Visual Studio就默认启用了SafeSEH,但是,在现代应用程序中是否仍然存在禁用了SafeSEH的已加载PE代码呢?在自己探索这个问题的时候,我写了一个PE文件扫描工具,以便在系统范围内检测每个文件是否存在(或缺乏)漏洞缓解措施。当我使用这个扫描工具处理我的Windows 10虚拟机上的SysWOW64文件夹(并对非SafeSEH PEs进行过滤)后,结果令人大跌眼镜。
看来,微软本身也有相当多的非SafeSEH PE,特别是至今仍在随Windows10一起提供的DLL。扫描我的Program Files文件夹后,得到的结果则更有说服力,大约有7%的PE文件缺乏SafeSEH保护。事实上,尽管我的虚拟机上安装的第三方应用程序很少,但从7-zip、Sublime Text到VMWare Tools,几乎每个应用程序都至少含有一个非SafeSEH模块。即使在进程的地址空间中只有一个这样的模块,也足以绕过其栈Cookie缓解措施,进而使用本文中探讨的技术利用栈溢出漏洞。
值得注意的是,在如下所示两种不同的情况下,SafeSEH可以被认为对PE生效的,它们是我的工具在扫描中使用的标准:
- 在IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG数据目录中存在上述的SEHandlerTable以及SEHandlerCount大于零的情况。
- IMAGE_DLLCHARACTERISTICS_NO_SEH标志被设置在IMAGE_OPTIONAL_HEADER.DllCharacteristics的header字段。
假设一个没有采用SafeSEH措施的模块被加载到一个易受攻击的应用程序中,对于exploit编写者来说,仍然还面临令一个重要的障碍。回到图10,尽管一个伪造的SEH HANDLER通过栈溢出被成功执行,但是这个handler被编译到了PE EXE映像本身中。所以,为了实现任意代码执行,我们需要执行一个存储在栈上的伪造SEH HANDLER(一个shellcode)。
DEP & ASLR
由于存在DEP和ASLR防御机制,在栈上将我们的shellcode用作伪异常handler存在多个障碍:
- 由于存在ASLR机制,我们不知道Shellcode在栈上的地址,因此无法将其嵌入到我们的溢出内容中以喷射到栈中。
- 由于存在DEP机制,在默认情况下,栈本身以及扩展的shellcode是不可执行的。
随着2004年Windows XP SP2的问世,DEP首次在Windows世界得到了广泛的采用,并且从那时起,DEP已经成为当今使用的几乎所有现代应用程序和操作系统的普遍特性。它是通过使用硬件层内存页的PTE头部中的一个特殊位(NX,也就是不可执行位)来实现的,默认情况下,该位将在Windows中所有新分配的内存上被设置。这意味着攻击者必须显式创建可执行内存区域,方法是通过诸如KERNEL32.DLL!VirtualAlloc之类的API分配具有可执行权限的新内存,或者通过使用诸如KERNEL32.DLL!VirtualProtect之类的API将现有的非可执行内存修改为可执行的。这样做的一个副作用是,由于栈和堆在默认情况下都是不可执行的,因此,我们无法直接从这些位置执行shellcode,换句话说,我们必须首先为它开辟一个可执行的内存区域。
从exploit编写的角度来看,理解DEP的关键在于,DEP是一种要么全有要么全无的缓解措施:要么应用于进程内的所有内存,要么不应用于进程内的所有内存。如果使用/NXCOMPAT标志编译生成进程的主EXE,则整个进程将启用DEP。与诸如SafeSEH或ASLR之类的缓解措施形成鲜明对比的是,并不存在非DEP DLL模块之类的东西。
从exploit编写的角度来看,DEP的解决方案早已被理解为面向返回的编程(ROP)。原则上,现有的可执行内存将与攻击者提供的栈一起以小片段的形式回收,以实现为我们的shellcode划分可执行区域的目标。创建自己的ROP链时,我选择使用KERNEL32.DLL!VirtualProtect API,以便使存放shellcode的栈区域是可执行的。该API的原型如下所示:
1 | BOOL VirtualProtect( |
在ASLR问世之前,如果可以通过溢出来控制栈,就可以将这五个参数作为常量植入栈,然后触发一个EIP重定向,使其指向KERNEL32.DLL中的VirtualProtect函数(其基地址是静态的)。在这里,唯一的障碍是——我们不知道作为第一个参数传递或作为返回地址使用的shellcode的确切地址。后来,攻击者利用NOP sledding技术(在shellcode的前面填充一大段NOP指令,即0x90)解决了这个问题。然后,exploit编写者可以推断出shellcode在栈中的大致区域,并在这个范围内选取一个地址并将其直接植入溢出内容中,从而通过NOP sled将这个猜测转化为精确的代码执行。
随着2006年Windows Vista中ASLR的出现,ROP链的创建变得有些棘手,因为现在:
- DLL的基址和VirtualProtect的基址变得不可预测。
- shellcode的地址难以猜测。
- 包含可执行代码片段的模块的地址变得不可预测。
这不仅对ROP链提出了更多的要求,同时,还要求其实现要更加精确,因此,NOP sled(1996年左右的经典形式)成为ASLR时代的牺牲品。这也导致了ASLR绕过技术成为了DEP绕过技术的前提条件。如果不绕过ASLR,从而至少定位含有漏洞的进程中一个模块的基地址,就无法知道ROP Gadget的地址,从而无法执行ROP链,也就无法调用VirtualProtect函数来绕过DEP。
要创建一个现代的ROP链,我们首先需要这样一个模块:我们可以在运行时预测其基地址的模块。在大多数现代漏洞利用技术中,这是通过使用内存泄漏漏洞来实现的(这个主题将在本系列的字符串格式错误和堆损坏续集中加以探讨)。为了简单起见,我选择在易受攻击进程的地址空间中引入一个非ASLR模块(来自我的Windows 10虚拟机的SysWOW64目录)。在继续之前,必须了解非ASLR模块背后的概念(以及在exploit编写过程中的作用)。
从exploit编写的角度来看,以下是我认为最有价值的ASLR概念:
- 在Visual Studio 2019中,ASLR是默认设置的。它使用/DYNAMICBASE标志进行配置,我们可以在项目设置的Project -> Properties -> Linker -> Advanced -> Randomized Base Address字段中进行配置。
- 当使用该标志编译PE文件时,它(在默认情况下)总是导致创建一个IMAGE_DIRECTORY_ENTRY_BASERELOC数据目录(存储在PE文件的.reloc段中)。如果没有这些重定位信息,Windows就无法重建模块的基地址并执行ASLR。
- 编译后的PE将在其IMAGE_OPTIONAL_HEADER.DllCharacteristics头部中设置IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE标志。
- 当PE被加载时,将为其选择一个随机的基地址,并且其代码/数据中的所有绝对地址都将使用重定位部分进行重定位。这个随机地址在每次启动时都是不同的。
- 如果用于启动进程的主PE(EXE)启用了ASLR,也会导致栈和堆被随机化。
您可能会注意到,这实际上会导致两种不同的情况,其中可能会出现非ASLR模块。第一种情况是显式编译模块以排除ASLR标志(或在该标志存在之前编译),第二种情况是设置了ASLR标志,但由于缺少重新定位而无法应用。
开发人员的一个常见错误是,在他们的编译器中联合使用ASLR标志和“strip relocations”选项,他们认为这样生成的二进制文件是受ASLR保护的,而实际上它仍然是易受攻击的。从历史上看,非ASLR模块非常常见,甚至在Windows7+ Web浏览器攻击中被滥用,并在商业恶意软件中大获成功。现在,这类模块已经逐渐变得稀缺,这在很大程度上是因为ASLR已经成为诸如Visual Studio之类的IDE中默认启用的一种安全缓解措施。令人惊讶的是,我的扫描软件在我的Windows10虚拟机上发现了大量非ASLR模块,许多位于在System32和SysWOW64目录中。
值得注意的是,图12中显示的所有非ASLR模块都具有非常不同(且唯一)的基地址。这些都是Microsoft编译的PE文件,其本意就是不使用ASLR,之所以这么做,很可能是出于性能或兼容性的原因。它们将始终加载到image_optional_header.imageBase中指定的映像基地址处(上图中突出显示的值)。显然,这些独特的映像基地址是编译器在创建时随机选择的。通常情况下,PE文件都会在其PE头部中包含默认映像基地址值,如0x00400000(用于EXE)和0x1000000(用于DLL)。这种专门创建的非ASLR模块与因失误而创建的非ASLR模块(如下图所示)形成了鲜明的对比。
这是在最新版本的HXD Hex Editor中作为重定位剥离(不知情的开发人员的旧优化习惯)副作用而创建的非ASLR模块的一个主要例子。值得注意的是,您可以在上面的图13中看到,与图12中的模块(具有随机基地址)不同,这些模块都具有相同的默认映像基地址0x00400000(已经被编译到它们的PE头部中)。这与其PE头部中存在的IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE标志相结合,表明编译它们的开发人员假设它们将使用随机地址进行加载,而不是在0x00400000处进行加载,并认为它们会受到ASLR机制的保护。然而,在实践中,我们可以肯定它们总是被加载到地址0x00400000处,尽管已经启用了ASLR——因为在没有重新定位数据的情况下,操作系统是无法在初始化期间重新设置它们的基地址的。
通过回收非ASLR模块的可执行段(通常是它们的.text段)中的代码,我们能够构造相应的ROP链来调用KERNEL32.DLL!VirtualProtect API,并为栈上shellcode禁用DEP保护机制。
在图12中可以看出,我选择了SysWOW64中的非ASLR模块msvbvm60.dll作为ROP链,因为它不仅缺少ASLR保护,而且还缺少SafeSEH(考虑到我们必须知道在溢出时写入栈的伪造SEH handler/stack pivot gadget的地址,这是一个至关重要的细节)。此外,这里还通过IAT导入了KERNEL32.DLL!VirtualProtect,这一细节极大地简化了ROP链的创建过程。
创建ROP链
第一步,我使用Ropper从msvbvm60.dll中提取所有可能有用的可执行代码片段(以RET、JMP或CALL指令结束)。实际上,我创建ROP链有三个主要意图:
- 通过从msvbvm60.dll的IAT加载其地址来调用KERNEL32.DLL!VirtualProtect(以绕过KERNEL32.DLL的ASLR)。
- 动态控制VirtualProtect的第一个参数,使其指向栈上的shellcode(以绕过DEP)。
- 人为控制VirtualProtect的返回地址,令其返回时,动态地执行栈上的shellcode(现在的权限是+RWX)。
在编写ROP链的时候,我先用汇编语言描述所需逻辑的伪代码,然后,设法用ROP Gadget来复现该逻辑。
下为ROP链的伪代码逻辑
1 | Gadget #1 | MOV REG1, <Address of VirtualProtect IAT thunk> ; RET |
值得注意的是,在上面精心构造的的逻辑中,使用了msvbvm60.dll中一个包含VirtualProtect地址的解除引用的IAT thunk地址,以解决KERNEL32.DLL的ASLR问题。Windows在加载msvbvm60.dll时,会为我们解析VirtualProtect的地址,并且这个地址将始终保存在msvbvm60.dll内的同一位置处。这里,我打算使用JMP指令来调用它,而不是CALL指令。这是因为我需要为调用VirtualProtect创建一个伪造的返回地址,这个返回地址将导致shellcode(现在已经摆脱了DEP)直接执行。这个伪造的返回地址会指向一个JMP ESP gadget。我这么做的理由是:尽管不知道(也无法知道)通过溢出写入栈中的shellcode的具体位置,但该gadget返回后,ESP会指向ROP链的末端,而我可以精心构造溢出内容,使shellcode正好位于这个ROP链后面。
此外,我在第4个gadget中也使用了同样的技术:通过两个push指令让ESP动态生成VirtualProtect的第一个参数。与JMP ESP指令不同(其中ESP会直接指向我的shellcode),这里的ESP会与我的shellcode的地址略有偏差(运行时ESP与ROP链末端的距离)。这并不是一个问题,因为充其量只是在ROP链的末端除了shellcode本身之外,还将禁用DEP。
在构建ROP链的过程中(也就是将上述逻辑付诸实施过程中),我发现gadget #4(我的伪代码gadget中最稀有和最不可替代的一个)没有出现在msvbvm60.dll中。这个挫折是一个很好的例子,说明了为什么在任何公共漏洞利用代码中几乎每个ROP链都在使用PUSHAD指令,而不是类似于我所描述的伪代码逻辑。
简而言之,PUSHAD指令允许exploit编写者动态地将ESP的值(以及栈上的shellcode)与所有其他相关的KERNEL32.DLL!VirtualProtect参数一起放到栈上,而无需使用任何罕见的gadget。他们所有需要做的就是正确填充每个通用寄存器的值,然后执行PUSHAD ; RET gadget来完成攻击。关于这方面的详细介绍,请访问Corelan撰写的“ Exploit writing tutorial part 10 : Chaining DEP with ROP – the Rubik’s[TM] Cube”一文。最终,我们的ROP链需要通过如下方式设置相关的寄存器:
1 | EAX = NOP sled |
在实践中,上面的逻辑可以被ROP gadget替换,具体如下面的伪码所示:
1 | Gadget #1: MOV EAX, <msvbvm60.dll!VirtualProtect> |
上面的伪码逻辑最终可以转换为来自msvbvm60.dll的ROP链数据,具体如下所示:
1 | uint8_t RopChain[] = |
执行任意代码
构建了ROP链,也搞定了劫持EIP的方法,现在剩下的唯一任务就是构建exploit。为此,我们必须了解当伪造的SEH handler收到程序的控制权时栈的布局情况。理想情况下,我们当然希望ESP直接指向ROP链的顶部,并结合EIP重定向,使其指向链中的第一个gadget。在实践中,这是不可能的。让我们回顾一下栈喷射代码,并在在伪造的handler的开始处设置一个断点,以观察发生溢出和EIP劫持后栈的状态。
在右边的突出显示的区域,我们可以看到栈的底部位于0x010FF3C0处。然而,您可能会注意到,栈中的值都不是我们溢出的内容——大家可能还记得,在发生访问冲突之前,我们在不断向栈中喷射伪造的SEH handler的地址。在左边突出显示的区域,我们可以看到,我们的溢出内容起始于0x010FFA0C附近。因此,在异常发生后,NTDLL.DLL让ESP向我们用溢出内容覆盖的栈区域下方偏移了0x64C字节(记住,栈是向下生长的,而不是向上生长的)。有了这些信息,就不难理解发生了什么。当NTDLL.DLL处理异常时,它开始使用异常发生时ESP下方的栈区域,而这个区域是我们鞭长莫及的,因此,也就无法写入我们的ROP链。
因此,这就产生了一个有趣的问题。也就是说,要想执行ROP链,我们需要让伪造的SEH handler设法让ESP(栈顶指针)重新指向由溢出内容覆盖的栈区域。当我们的断点被击中时,检查ESP的值,我们可以在0x010FF3C0处看到一个返回NTDLL.DLL的地址(无用),其后是另一个位于我们所能控制的栈范围(0x010FF4C4)下方的地址(也无用),它位于0x010FF3C4处。然而,0x010FF3C8处的第三个值0x010FF3A74直接落在从0x010FFA0C开始的受控区域的地址范围内,其偏移值为0x64。重新审视异常处理程序的原型,就会发现这第三个值(代表传递给处理程序的第二个参数)对应的是Windows传递给SEH handler的“已建帧”的指针。
1 | EXCEPTION_DISPOSITION __cdecl SehHandler(EXCEPTION_RECORD* pExceptionRecord, void* pEstablisherFrame, CONTEXT* pContextRecord, void* pDispatcherContext) |
在我们的调试器中,检查栈中0x010FF3A74这个地址处的内容,我们可以更进一步地了解这个参数(也称为NSEH)的指向。
果然,我们可以看到,这个地址指向我们的溢出所控制的栈的一个区域(现在该区域已经被喷入的handler地址填满了)。具体来说,它直接指向前面提到的EXCEPTION_REGISTRATION_RECORD结构体的开始位置,而我们早就覆盖了这个结构体并用它来劫持EIP。在理想情况下,我们伪造的SEH handler会将ESP设置为[ESP + 8],并且我们会将ROP链的开头部分放在被我们的溢出内容覆盖的EXCEPTION_REGISTRATION_RECORD结构体的开始处。对于这种类型的栈pivot,一个理想的gadget是POP REG;POP REG;POP ESP;RET或这种逻辑的一些变体,然而,msvbvm60.dll中并没有这种gadget,我不得不设法设计一个不同的解决方案。如前所述,当NTDLL将EIP重定向到我们伪造的SEH handler时,ESP在栈上的偏移量0x64C已经超出了我们用溢出控制的区域(具体来说,跑到该区域的下方了)。因此,对于栈pivot的这个问题,一个不太优雅的解决方案就是直接给ESP加上一个大于或等于0x64C的值。Ropper提供了一个功能,可以提取潜在的栈pivot gadget:
ADD ESP,0x1004 ; RET是一个略显混乱的gadget:它超出溢出开始处0x990个字节,但由于它是唯一一个值大于0x64C的ADD ESP,因此别无选择。这个栈pivot会让ESP从我们的溢出起始处超出0x990或0x98C个字节(当然,对于同一应用程序的不同实例以及Windows的不同版本来说,该值还能会有所变化)。这意味着我们需要在实际ROP链开始之前,用0x98C个垃圾字节和ROPNOP来填充溢出。
将这些知识整合到一段代码中:
(为了避免引发本机反应,以下代码需要在虚拟机中运行)
1 |
|
上面的代码中有几个细节值得注意。首先,您可能注意到,我通过将垃圾异常处理程序(0xdeadc0de)链接到TEB(FS[0])中的处理程序列表,显式注册了该处理程序。之所以这样做,是因为我发现在栈顶部覆盖NTDLL.DLL注册的默认处理程序的做法不太可靠。这是因为有时栈的顶端没有足够的空间来容纳shellcode,这会触发VirtualProtect的STATUS_CONFICTING_ADDRESS错误(代码0xc0000015)。
图20中另一个值得注意的细节是,我在ROP链末端的溢出内容中加入了自己的shellcode。这是我编写的一个自定义的shellcode(源代码可以从Github上下载),它在ROP链化后的栈上被执行后会弹出一个消息框。
编译完含有溢出漏洞程序后,我们可以进行单步跟踪,看看溢出数据是如何结合在一起来执行shellcode的。
(上面那段代码的内联汇编语法有错误,不知道是他写的有问题还是我的设置问题,报错信息为C2400 “操作码”中的内联汇编语法错误;找到“[”,所以下面都是别人的图)
在漏洞程序发生栈溢出之前的状态
在第一个断点处,我们可以看到,栈上的目标EXCEPTION_REGISTRATION_RECORD位于0x00B9ABC8处。在发生溢出之后,我们可以期待该handler字段将被我们伪造的SEH handler的地址所覆盖。
memcpy对栈末端之外执行写入操作是抛出的访问违例异常(Access violation exception)
在memcpy函数中,由于rep MOVSB指令试图将数据写入栈的末端之外的内存时,发生了访问违例异常。在0x00B9ABCC处,我们可以看到EXCEPTION_REGISTRATION_RECORD结构体的handler字段已经被我们msvbvm60.dll中的栈pivot gadget的地址所覆盖。
伪造的SEH handler让ESP跳回由溢出控制的地区
在栈中向上跳过0x1004字节,我们可以看到在突出显示区域,ESP现在指向我们ROP链的开始地址。这个ROP链将填充所有相关寄存器的值,以便为PUSHAD gadget做好相应的准备,之后,该gadget将把这些值移到栈上,从而为调用KERNEL32.DLL!VirtualProtect做好准备。
PUSHAD为绕过DEP准备好相应的调用栈
在PUSHAD指令执行后,我们可以看到ESP现在指向msvbvm60.dll中的ROPN,其后紧跟KERNEL32.DLL中VirtualProtect的地址。在0x00B9B594处,我们可以看到传递给VirtualProtect的第一个参数,就是我们栈上0x00B9B5A4处的shellcode的地址(该地址在上图中已经突出显示)。
ROP链的最后一个gadget将EIP设置为ESP
一旦VirtualProtect返回,ROP链中的最后一个gadget就会将EIP重定向到ESP的值,这样,ESP将指向我们直接存储在ROP链之后的shellcode的起始位置。您可能已经注意到,shellcode的前4个字节实际上就是ROP链通过PUSHAD指令动态生成的NOP指令,而不是通过溢出写入的shellcode的起始位置。
弹出消息框的shellcode在栈上成功执行,从而完成了漏洞的利用过程
SEHOP
实际上,在Windows中还有一种更为强大的SEH劫持缓解机制,称为SEH覆写保护(SEH Overwrite Protection,SEHOP),它可以抗衡这里描述的方法。引入SEHOP的目的,是为了既可以检测EXCEPTION_REGISTRATION_RECORD损坏,又无需重新编译应用程序或依靠每个模块的漏洞利用缓解方案,如SafeSEH。为此,SEHOP将在SEH链的底部引入一个额外的链接,并在异常发生时通过检查是否可以通过遍历SEH链而到达该链接,来实现SEH劫持的防御机制。由于EXCEPTION_REGISTRATION_RECORD的NSEH字段存储在handler字段之前,因此,在通过栈溢出破坏现有的SEH handler时,必然会破坏NSEH,以及破坏整个链(原理上类似于栈金丝雀(stack canary),其中金丝雀就是NSEH字段本身)。SEHOP是在Windows Vista SP1(在默认情况下禁用)和Windows Server 2008(在默认情况下启用)中引入的,在过去的十年中,SEHOP一直处于这种半启用状态(在工作站上禁用,在服务器上启用)。值得注意的是,最近随着Windows 10 v1709的发布,这种情况已经发生了变化:SEHOP已经成为默认启用的漏洞缓解功能了。
这似乎与上一节在Windows 10虚拟机上探讨的SEH劫持溢出情况相矛盾。为什么SEHOP没有能够在exploit的初始阶段阻止EIP重定向到栈pivot?虽然我们还不是十分清楚,然而这似乎是微软方面配置错误的问题。当我对之前探索溢出所用EXE程序单独进行设置,并手动点选“Override system settings”框后,SEHOP机制就开始发挥威力:栈pivot将无法执行。令人费解的是,在默认情况下,系统已经在该进程上启用了SEHOP机制。
下图为某exe文件,没有手动勾选。
这有可能是微软方面有意的配置,只是在上面的截图中被曲解了。由于SEHOP与第三方应用程序(如Skype和Cygwin)不兼容,因此,SEHOP在历史上一直被广泛禁用(微软在这里讨论了这个问题)。当SEHOP与本文中讨论的其他漏洞缓解措施一起正确启用时,在没有链式内存泄漏(任意读取)或任意写入原语的情况下,SEH劫持将无法用于利用栈溢出漏洞。任意读取原语可以允许NSEH字段在溢出前被泄漏,这样就可以制作溢出数据,以便在EIP劫持期间不破坏SEH链。通过任意写入原语(在下一节讨论),攻击者可以覆盖存储在栈上的返回地址或SEH handler,而不会破坏NSEH或栈金丝雀的值,从而绕过SEHOP和栈Cookie缓解措施。
任意写入&局部变量破坏
在某些情况下,攻击者根本就不需要溢出函数栈帧的末尾来触发EIP重定向。如果他们可以在不需要覆盖栈Cookie的情况下成功地获得代码执行权限,那么栈Cookie验证检查就可以轻松绕过。为此,有一种方法是使用栈溢出来破坏函数中的局部变量,以便让应用程序将我们选择的值写入我们选择的地址。下面的示例函数包含可以用这种方式利用的逻辑。
1 | uint32_t gdwGlobalVar = 0; |
从根本上讲,我们要利用的是一个非常简单的代码模式:
- 函数必须包含一个容易发生栈溢出的数组或结构。
- 该函数必须包含至少两个局部变量:一个解引用的指针和一个用于写入该指针的值。
- 函数必须使用局部变量写入解引用的指针,并在栈溢出发生后执行这个操作。
- 函数必须以这样的方式进行编译:即溢出的数组在栈上存储的位置比局部变量低。
最后一点是一个值得进一步研究的问题。我们希望MSVC(Visual Studio 2019使用的编译器)以这样的方式编译图29中的代码:Buf的16个字节被放在分配给栈帧内存的最低区域(当包含栈Cookie时,应该是总共28个字节),然后是最高区域的dwVar1和pdwVar2。这个顺序与源代码中声明这些变量的顺序是一致的;这允许Buf向前溢出到更高的内存中,并用我们选择的值覆盖dwVar1和pdwVar2的值,从而使我们用于覆盖dwVar1的值被放在我们选择的内存地址上。然而在现实中,情况并非如此,编译器给出的汇编代码如下所示:
1 | push ebp |
从上面的反汇编代码中我们可以看到,编译器已经在EBP-0x4和EBP-0x14之间的最高内存部分中选择了一个对应于Buf的区域,并且已经在EBP-0x1C和EBP-0x18的最低内存部分中分别为dwVar1和pdwVar2选择了一个区域。这种排序使易受攻击的函数免受局部变量通过栈溢出而损坏的影响。也许最有趣的是,dwVar1和pdwVar2的排序与它们在源代码中相对于Buf的声明顺序相矛盾。这最初让我觉得很不解,因为我认为MSVC会根据变量的声明顺序来排序,但进一步的测试证明事实并非如此。实际上,进一步的测试证明,MSVC并不是根据变量的声明、类型或名称的顺序来排序,而是根据它们在源代码中被引用(使用)次数来排序:引用次数多的变量将优先于那些引用次数少的变量。
1 | void Test() { |
因此,我们可以预期这个函数的编译会按以下方式排列变量:C、B、A、D。这符合变量引用(使用)次数的顺序,而不是它们被声明的顺序。其中,C将被放在第一位(内存中最高地址处,距离EBP的偏移量最小),因为它被引用了两次,而其他变量都只被引用了一次。
1 | push ebp |
果然,我们可以看到,变量都已经按照我们预测的顺序排列,其中,C位于EBP – 4处,也就是排在第一位。尽管如此,MSVC使用的排序逻辑与我们在图30中看到的情况相矛盾。毕竟,dwVar1和pdwVar2的引用次数(各两次)都比Buf高(在memcpy中只有一次),而且都是在Buf之前引用的。那么这是怎么回事呢?GS包含了一个额外的安全缓解功能,它试图安全地对局部变量进行排序,以防止由于栈溢出而导致可利用的局部变量破坏。
在GS机制下生成的具有安全变量顺序栈布局
绕过在项目设置中禁用GS,会产生以下代码:
1 | push ebp |
仔细对比上图34中的汇编代码和图30中的原始(安全)汇编代码,大家就会发现,从这个函数中删除的可不仅仅是栈Cookie检查。事实上,MSVC已经完全重新排列了栈上的变量,使其与正常规则一致,因此将Buf数组放在了内存的最低区域(EBP – 0x18)。因此,这个函数现在很容易通过栈溢出导致局部变量损坏。
在用多种不同的变量类型(包括其他数组类型)测试了同样的逻辑后,我得出如下结论:MSVC对数组和结构体(GS缓冲区)有一个特殊的规则,即总是将它们放在内存的最高区域,以防止编译后的函数的局部变量因栈溢出而遭到破坏。了解到这些信息后,我开始尝试评估这个安全机制的复杂程度,并设法通过边缘案例(edge cases)来绕过它。我发现了多个,下面是我认为最显著的例子。
首先,让我们来看看如果memcpy被移除会发生什么情况。
1 | void Overflow() { |
我们希望MSVC的安全排序规则总是将数组放置在内存的最高区域,以为函数提供安全保护,然而汇编代码表明,事情并非如此。
1 | push ebp |
如您所见,MSVC已经从函数中删除了栈Cookie。同时,MSVC还将Buf数组放在了内存的最低区域,这违背了其典型的安全策略;如果缓冲区未被引用,它将不考虑GS缓冲区的安全重排序规则。这样就提出了一个有趣的问题:何谓引用?令人惊讶的是,答案并不像我们所期望的那样(引用就是函数中对变量的任何使用)。针对某些类型的变量使用并不能算作引用,因此不会影响变量的排序。
1 | void Test() { |
在上面的例子中,我们希望Buf被放置在内存的第一个(最高)槽(slot)中,因为它被引用了三次,而dwVar1和pdwVar2各只被引用了两次。这个函数的汇编代码与此相矛盾。
1 | push ebp |
尽管Buf是一个数组,而且比其他任何一个局部变量使用得更多,但是,它却被保存在栈内存的最低处EBP–0x18。上面的汇编代码的另一个有趣的细节是,MSVC没有给上面的函数添加安全Cookie检查。这就意味着,该返回地址仍会受到经典栈溢出以及任意写入漏洞的影响。
越界写入漏洞
1 |
|
编译并执行上面的代码会导致一个没有栈Cookies和没有进行安全的变量排序的函数,这样的话,攻击者就可以通过精确覆盖0x0019FF1c处的返回地址来劫持EIP(在本例中,我已经禁用了ASLR)。
根据这些实验,我们可以得出如下所示的结论:
- MSVC中存在一个安全漏洞:错误地评估了一个函数对栈溢出攻击的潜在敏感性。
- 这个安全漏洞源于MSVC使用某种形式的内部引用次数来决定变量顺序,而当一个变量的引用次数为零时,它被排除在常规的安全排序和栈Cookie安全缓解措施之外(即使它是一个GS缓冲区)。
- 按索引读/写数组不计入引用次数。因此,以这种方式访问数组的函数将缺乏针对栈溢出漏洞的保护。
对于可能无法适当防止栈溢出的代码模式,我还有几个其他的想法,首先是结构体/类的概念。虽然函数栈帧内的变量排序没有标准化或约定俗成(完全由编译器决定),但对于结构体来说,情况就不一样了;编译器必须精确地遵循源代码中声明变量的顺序。因此,如果一个结构体中包含一个数组,后面还有额外的变量,这些变量就无法安全地重新排序,因此,可能会因溢出而被破坏。
1 | struct MyStruct { |
上面用于结构体的概念同样也适用于C++类,前提是它们被声明为局部变量并在栈上分配内存空间。
1 | class MyClass { |
当涉及到类时,一个额外的攻击手法是破坏其vtable指针。这些vtable包含指向可执行代码的其他指针,这些可执行代码可以在RET指令之前通过被破坏的类的方法进行调用,从而提供了一种通过破坏局部变量来劫持EIP的新方法,而无需使用任意写入原语。
最后一个容易被局部变量破坏的代码模式的例子是使用运行时栈分配函数,如_alloca。由于这类函数的内存分配过程,是在函数的栈帧已经建立后,通过减去ESP来实现的,因此,这类函数分配的内存将始终处于较低的栈内存中,所以无法重新排序或免受此类攻击的威胁。
1 | void OverflowAlloca(uint8_t* pInputBuf, uint32_t dwInputBufSize) { |
请注意,尽管上面的函数中没有数组,但MSVC足够聪明,它知道只要使用了_alloca函数就有必要在生成的函数代码中放入栈Cookie。
这里讨论的技术代表了一种现代Windows的栈溢出的攻击面,到目前为止,还没有明确的安全缓解措施。然而,它们的可靠利用依赖于这里讨论的特定代码模式以及(在任意写入的情况下)一个链式内存泄漏原语。
小结
对于栈溢出漏洞,虽然现代的操作系统已经提供了许多防御机制,但在今天的Windows应用程序中仍然存在并且可被利用。如果存在非Safeseh模块,那么利用这种溢出漏洞就比较容易,因为还没有哪种默认的安全缓解机制强大到可以防止局部变量破坏而导致的任意写入攻击。就目前来说,防御这类攻击的最强机制是ASLR,为了绕过这种机制,攻击者需要借助于非ASLR模块或内存泄漏漏洞。正如我们在这篇文章中所展示的那样,非Safeseh模块和非ASLR模块在如今的Windows 10系统以及许多第三方应用程序中仍不乏足迹。
与过去相比,尽管栈溢出漏洞的利用技术变得愈加复杂,但与堆中的其他内存破坏类型的漏洞相比,栈溢出是最容易理解的一个。