目前的许多恶意软件已经逐渐开始抛弃了这两年盛行已久的ROP 攻击,在过去两年中,有一种新型代码重用攻击的技术开始流行,即伪造面向对象编程的攻击技术,它全称为Counterfeit Object-Oriented Programming (COOP),代表着针对forward-edge的控制流完整性(CFI)的最先进的攻击。目前COOP基本上只存在于理论中,还尚未出现在实际的攻击利用中。一方面,因为强CFI的实现会让更多的内核漏洞暴露,而另一方面可能是因为攻击者更倾向于使用更容易实现的攻击方法。在Windows 10周年更新中,微软开始启用控制流保护(CFG,Control Flow Guard)。而在CFG中,还没有实施针对backward-edge CFI的攻击。因为这个针对返回地址检测的forward-edge 或backward-edge CFI的实现几乎干掉了代码重用攻击的可能性,但是当返回流量保护(Return Flow Guard ,RFG)出现后并且攻击者不再依赖于破坏堆栈上的返回地址来进行攻击时,代码重用攻击是否还可行吗?
译者注:加州大学和微软公司于2005年提出了控制流完整性的防御机制。其核心思想是限制程序运行中的控制转移,使之始终处于原有的控制流图所限定的范围内。具体做法是通过分析程序的控制流图,获取间接转移指令(包括间接跳转、间接调用、和函数返回指令)目标的白名单,并在运行过程中,核对间接转移指令的目标是否在白名单中,本文就试图对微软的CFG防护和HA-CFI的COOP重用攻击防护进行评估。
译者注:HA-CFI,一种全新的利用硬件辅助的CFI防御机制。
微软的CFG
目前已经有很多文章在讨论CFG了,但比较有分量的文章还是最近的有关Clang CFI和微软CFG的两篇文章。第一篇文章的重点是Clang,第二个重点是微软执行CFI,而另外的一些文章则提供了CFG实施的进一步细节。
过去几年,关于如何绕过CFG一直是安全专家关注的焦点。不过黑客的力量总是让我们惊叹不已,有些黑客已经可以完全绕过CFG了,不过在此我们要强调一点,即CFI可以进一步细分为两种:forward-edge CFI 和backward-edge CFI。
forward-edge CFI:间接保护CALL或JMP站点,forward-edge CFI的解决方案包括Microsoft CFG和Endgame的HA-CFI。
backward-edge CFI:保护RET指令,backward-edge CFI解决方案包括Microsoft Return Flow Guard,EndGame的DBI漏洞利用预防的组件,以及包括Intel CET在内的其他ROP检测。
之所以会选用这种分类,是因为这样有助于描述CFG保护的逻辑顺序,即间接呼叫站点——非保护站点——返回栈。例如,最近的一个Edge的恶意软件就利用POC并最终在使用R / W的原始方法下修改了堆栈上的返回地址。但这种方法不适用于CFG,不应被视为CFG保护的漏洞。不过,这也表明了CFG保护的有效性,并使攻击者转向劫持控制流,而不是间接呼叫站点。同时,这个例子也暴露了CFG的缺陷,即攻击者可以利用未受保护的呼叫站点,重新映射包含CFG代码指针的只读存储器区域,并将其改变为可以通过检查的代码。
很多forward-edge CFI也被称为粗粒度CFI,即一个特定的调用或者转跳进入一个函数,CFI的实现将允许攻击者调用的数量非常大:包括程序以外的合法的执行调用和在程序和库里的。这些粗粒度实现有两个原因:性能和信息限制。更细粒度的实现通常会为每次调用和转跳带来更多的安全检查。
CFG会向每个受保护的DLL添加__guard_fids_table,由二进制内部的间接调用站点的有效或敏感目标的RVA列表组成。其中一个地址用作CFG位图的索引,它可以根据地址是否有效来切换,还有一个API是用来修改这个位图的,例如为了支持JIT编码的页面:kernelbase!SetProcessValidCallTargets通过调用ntdll!SetInformationVirtualMemory,来更新位图。
Windows 10 Creators Update中的CFG新增函数可以阻止这种调用。换句话说,CFG保护呼叫站点可以把这种调用地址标记为非法目标地址。执行此操作时,需要使用CFG位图每一个地址的第二位,另外在构建每个位图的初始化进程时,需要使用每个RVA条目的__guard_fids_table中的标志字节。
在Windows 64位系统中,位于9-63位的地址是用来从CFG位图检索qword的,并且使用第3-10位的地址来访问qword中的特定位。在调用被抑制后,这些在给定地址的CFG权限将由CFG位图中的两个位表示。此外,大多数DLL中的__guard_dispatch_icall_fptr现在已设置为指向ntdll!LdrpDispatchUserCallTargetES,其中有效的调用目标必须从CFG位图中省略“01”。
因为使用GetProcAddress意味着后续代码可以将返回值作为函数指针调用,因此实现新的抑制调用函数会变得复杂一些。只要该条目以前未被标记为敏感或未标记为非法,例如VirtualProtect,SetProcessValidCallTargets等,那么CFG就会通过将CFG位图中相应的两位条目从“10”(输出禁止)更改为“01”(有效的呼叫站点)来处理此事件。因此,一些调用将会在进行创建时以不合法的间接调用启用,但借用运行时的代码,最终这些非法的调用将会成为有效的调用目标。下面我们列出7个发生这种情况时的样本所调用的堆栈:
00 nt!NtSetInformationVirtualMemory 01 nt!setjmpex 02 ntdll!NtSetInformationVirtualMemory 03 ntdll!RtlpGuardGrantSuppressedCallAccess 04 ntdll!RtlGuardGrantSuppressedCallAccess 05 ntdll!LdrGetProcedureAddressForCaller 06 KERNELBASE!GetProcAddress 07 USER32!InitializeImmEntryTable
解密COOP
攻击者会将COOP作为进行CFI攻击时的潜在漏洞,如果要绕过所有的forward-edge CFI并同时执行代码,可以利用连续的攻击序列并重用现有的虚拟函数。这样就能使用与ROP类似的方式,单独执行一系列小段合法的函数,例如,将值加载到RDX中,不过也可以把这些各个小段的合法函数组合到一起进行更复杂的任务。
COOP的基本组成部分是利用主循环函数,该函数可以遍历链表或对象数组,并在每个对象上调用虚拟方法。攻击者就是利用内存中伪造的对象,然后把单个对象拼凑在一起,这样就能在某些情况下与真实的对象重叠,覆盖对象后,就能在主循环中按攻击者拼接好的顺序调用合法的虚函数。 起初攻击者会使用COOP有效载荷的方法,攻击目标仅限于Windows 7 32位和Windows 7 64位的Internet Explorer 10,以及Linux 64位的Firefox。随着研究的扩展,攻击者发现使用 COOP有效载荷也可以使用递归或具有许多间接调用的函数,并将攻击范围扩大到Objective-C运行环境中。
总的来说,这方面的研究非常有趣和前沿。我们希望将这一理论能够应用于现代CFI攻击中,以对以下三个方面作出评估:
1.在加固的浏览器中构建COOP有效载荷的难度;
2.是否可以绕过CFG和HA-CFI;
3.是否能改进CFI使其能检测到COOP类型的攻击;
找出COOP有效载荷
COOP的主要目标是Windows 10上的Microsoft Edge,因为它代表了一个完全加固的CFG应用,并且我们还能在它的内存中使用JavaScript来准备我们的COOP有效内容。虽然在目标实现过程中,我们还发现了许多漏洞,但我们只专注于劫持CFI的控制流程,,并对攻击者作出了下列假设:
1.任意的读写原始都是取自于JavaScript;
2.允许使用硬编码的偏移量,因为在运行时需要动态查找小段代码;
3.启用了所有Microsoft最新的Creators更新缓解措施,例如ACG,CIG,可以调用抑制的CFG;
4.除了使用COOP之外,攻击者不得以任何方式绕过CFG;
根据我们的初步研究,已经有人在Windows 10周年更新(操作系统版本为14393.953)上利用了Edge的POC。我们使用更新中的防御机制设计了COOP的有效载荷,并在启用导出禁用的Windows 10 Creator(操作系统版本为15063.138)更新时,对该POC进行了验证。
理想的POC将执行攻击者的shellcode或启动恶意应用程序,攻击者的经典代码执行模型是将内存中的一些受控数据映射为+ X,然后跳转到新修改的+ X区域中的shellcode。不过,我们的真正目标是生成能够执行的COOP有效载荷,有意义的东西,同时保护forward-edge CFI。这样的有效载荷提供了数据点,我们可以用它来测试和改进我们自己的CFI算法。此外,Arbitrary Code Guard(ACG)或Edge的子进程策略超出我们的研究范围。所以我们就把目标定位了对Windows 10 Creator 更新的使用COOP有效载荷来禁用CFG,以实现在DLL内能够跳转或是调用任意位置的代码。下面,我们就是我们所研究出的了两个主要的COOP有效载荷:
1.在Windows 10周年的纪念更新版本中,缺少ACG,我们的有效载荷可以将我们控制的数据映射为可执行文件,然后在禁用CFG后跳转到受控shellcode的区域;
2.对于Windows 10 Creator更新来说,我们的最终目标就是劫持CFG;
寻找COOP函数小片段
首先我们的第一个任务是确定COOP各个组成部分的术语,学术论文是将每个重用函数称为虚拟函数片段(virtual function gadget)或vfgadget,并且在描述每个特定类型的vfgadget时使用缩写,如将主循环vfgadget缩写为ML-G。本文中,我们会采用以非正式的方式来命名每种类型的小片段。
Looper:在执行复杂COOP有效载荷中,起重要作用的的主循环小片段本文称为ML-G;
Invoker:一个调用函数指针的vfgadget,本文称为INV-G;
Arg Populator:一个虚拟函数,它将一个参数加载到一个寄存器中(本文称为LOAD-R64-G),或移动堆栈指针或把值加载进栈中(本文称为MOVE-SP-G);
另外,我们编写了脚本来帮助我们识别给定二进制文件中的vfgadget。我们使用了IDA Python,利用推理帮助我们找到了循环器,调用者和参数弹出器。在我们的研究中,我们发现COOP的一个实用方法,在返回到JavaScript之前,把vfgadget链接到一起并依次执行少量的vfgadget。根据需要通过额外的COOP 有效载荷来重复这个过程。因此,为了实现我们本文的目的,我们没有将二进制代码提升到IR。不过,为了将巨大的COOP有效载荷拼接在一起,比如说完全通过重用代码运行一个C2 socket线程,我们可能需要将二进制代码提升到IR以便将所需的vfgadget拼接在一起。对于vfgadget的每个子类型,我们定义了一些规则,并使用该规则在Edge(chakra.dll和edgehtml.dll)的两个二进制文件中进行搜索。这些规则包括:
1.存在于__guard_fids_table上的函数;
2.包含一个不带参数的间接循环调用;
3.循环不能让参数寄存器崩溃;
在vfgadget的所有类中,搜索循环器是最耗时的。因为有许多潜在的循环器在使用时都有这样或那样的限制,这使得它很难被使用。鉴于此,我们提出了调用函数指针的vfgadget来寻找, vfgadget的运行速度非常快,可以轻松地从单个伪造的对象中一次性填充多达六个参数。因此,当尝试调用单个API时,除非需要返回值,COOP可以使用其vfgadget的快速运行方式,来完全避免需要循环或递归。在x64程序上能够找到许多寄存器来对参数寄存器进行填充。
COOP有效载荷的利用
通过从脚本语言触发COOP,我们实际上可以在COOP中将一些复杂的任务删除,因为一次性把所有小片段进行拼接可能会让执行变得复杂。我们可以使用JavaScript来实现我们的小片段执行优势,并反复调用精简过的COOP有效载荷序列。这样我们就可以将算术和条件操作等重新转移回JavaScript,并通过COOP将原始函数重用到关键API的准备和调用中。此外,我们会展示一个利用该方法的样本,包括在我们劫持到的#1 部分将COOP的返回值传回到JavaScript,并讨论如何调用LoadLibrary。
为了方便阐述,我们将只介绍一种最简单的COOP有效载荷。本文中的所有有效载荷的最终目标都是要调用VirtualProtect。由于VirtualProtect和eshims API先前已被标记为敏感的,而不是CFG的有效目标,所以我们必须在Windows 10 Creator更新中使用包装器函数,比如可以在.NET库mscoree.dll和mscories.dll中找到一些方便的包装器,例如UtilExecutionEngine :: ClrVirtualProtect。由于Microsoft的ACG可以防止创建新的可执行内存,或者将现有的可执行内存更改为可写入,因此需要一种替代方法。由于只读内存可以通过VirtualProtect进行重写,所以我们可以将包含chakra!__ guard_dispatch_icall_fptr的页面重新映射为可写,然后覆盖函数指针以指向chakra.dll中的任意包含一个jmp rax指令的位置。实际上,在大多数DLL中已经存在一个函数__guard_dispatch_icall_nop,这是一个单一的jmp rax指令。因此,我们就可以有效地禁用CFG,因为chakra.dll中的所有受保护的调用站点将立即跳转到目标地址,以通过所有的安全检查。然后,我们可以据此来进一步探索函数重用来攻击ACG。为了完成这个探索,我们需要满足以下3个条件:
1.将mscoree.dll加载到Edge进程;
2.在chakra.dll的只读内存区域调用ClrVirtualProtect + W;
3.覆盖__guard_dispatch_icall_fptr以便通过所有安全检查;
从上面的vfgadget列表中可以看出,edgehtml是COOP的重要库。因此,我们的第一个任务便是泄漏edgehtml的基地址以及任何其他必要的组成部分,例如我们伪造的内存区域。这样,COOP有效载荷就能包含硬编码的偏移并在运行时重新定位。使用POC中的信息泄漏漏洞,我们可以获得我们需要的所有基地址。
//OS Build 10.0.14393 var chakraBase = Read64(vtable).sub(0x274C40); var guard_disp_icall_nop = chakraBase.add(0x273510); var chakraCFG = chakraBase.add(0x5E2B78); //_guard_dispatch_icall... var ntdllBase = Read64(chakraCFG).sub(0x95260); //Find global CDocument object, VTable, and calculate EdgeHtmlBase var [hi, lo] = PutDataAndGetAddr(document); CDocPtr = Read64(newLong(lo + 0x30, hi, true)); EdgeHtmlBase = Read64(CDocPtr).sub(0xE80740); //Rebase our COOP payload rebaseOffsets(EdgeHtmlBase, chakraBase, ntdllBase, pRebasedCOOP);
触发COOP
使用COOP的关键一步就是一开始就把JavaScript转化为循环器的函数,使用我们假设的读写原语,就可以很容易地劫持一个vfgadget列表中的looper,但是要把我们伪装的数据迭代到looper中,我们还需要使用CTravelLog :: UpdateScreenshotStream。
注意在循环前的第一个块中,代码是在+ 0x30 h处检索到链表的指针。为了正确启动looper,我们必须劫持一个JavaScript对象的vtable,将地址包含在我们的looper中,然后将一个指针放在对象+ 0x30 h处,指向我们伪装的对象列表的开头。实际的伪造对象数据可以通过JavaScript进行定义和重新定位。还要注意,循环对象会在+ 0x80h处的下一个指针的列表中进行迭代。在制作伪装的对象时,这点很重要。另外,请注意,在偏移量为+ 0xF8h处间接呼叫站点的vtable。我们的伪造对象中的任何假的虚拟vtable都必须在所需函数指针的地址处减去0xF8h,这通常会发生在vtable表中间位置的相邻部分。要启动我们的COOP有效载荷,就需要选择劫持一个JavascriptNativeIntArray对象,专门覆盖freeze()和seal()的虚函数如下:
var hijackedObj = new Array(0); [hi, lo] = PutDataAndGetAddr(hijackedObj); var objAddr = new Long(lo, hi, true); Write64(objAddr.add(0x30), pRebasedCOOP); Write64(objAddr, pFakeVTable); Object.seal(hijackedObj); //Trigger initial looper
劫持方法1:调用LoadLibrary
如前所述,我们的最终目标是绕过启用导出抑制函数的Win10 Creator更新里Edge上的CFG。看看在kernel32和kernelbase中导出的各种LoadLibrary调用,就知道即使是使用了最新的CFG,把一个新的DLL加载到我们的进程也是很容易的。首先,LoadLibraryExW实际上已被标记为kernel32.dll中__guard_fids_table中有效的调用目标。
其次,kernel32和kernelbase中的其他LoadLibrary调用刚开始是被抑制的,但在Edge中,这些被抑制的调用最终成为有效的调用站点。这似乎源于MicrosoftEdgeCP!_delayLoadHelper2中的一些延迟加载而最终导致的GetProcAddr在LoadLibraryX API上被调用。如前所述,要让所有函数导出无效的呼叫目标是非常困难,即使这样,其他的LoadLibrary 调用门还是会被抑制或是临时开放,要想达成我们的目的,我们可以直接使用kernel32!LoadLibraryExW,因为它在初始化时就是一个合法的目的地址。
为了使我们想要的VirtualProtect包装器能加载到Edge进程中,就需要调用LoadLibraryExW(“mscoree.dll”,NULL,LOAD_LIBRARY_SEARCH_SYSTEM32)。不过我们可以利用上述的调用方法来一次填充我们的所有参数,而不是使用looper vfgadget创建一个传统的COOP有效载荷来迭代四个被假冒对象。
我们的第一次迭代将使用0x800填充r8d。 要用vfgadget来填充r8d,CHTMLEditor :: IgnoreGlyphs是一个很好的选择。以后,参数0x800(LOAD_LIBRARY_SEARCH_SYSTEM32)都将在+ 0xD8h处加载。回想一下,我们的伪造对象中的下一个指针必须在+ 0x80h。我们可以在内存中创建四个连续的伪造对象,每个对象的大小都大于0xD8h,或者我们可以将下一个指针放在对象的末尾。如果我们选择了将下一个指针放在对象的末尾,可能会出现一个重叠对象,所以我们必须小心,这个+ 0xD8的偏移量不会影响我们在内存中第二个对象上进行的第二次迭代的vfgadget,用于填充r8d的第一个伪造对象如下所示。
从这个vfgadget返回后,looper就会遍历我们的假链接列表,所以我们现在必须再次调用另一个vfgadget的值0x0(NULL)来填充rdx。为了实现这一点,我们使用了Tree :: ComputedRunTypeEnumLayout :: BidiRunBox :: RunType()。我们可以从我们的伪造对象+ 0x28h处加载我们的值(0x0)。
现在我们已经为API调用填充了第二个和第三个参数,不过我们还差第一个参数,该参数是一个指向我们的'mscoree.dll'字符串的指针,然后调用函数指针转到LoadLibraryExW.为了实现这个目的,我们需要一个调用器vfgadget,Microsoft::WRL::Details::InvokeHelper::Invoke(),汇编代码和对应的第三个伪装对象如下。
现在LoadLibraryExW已经调用完毕,不过我们希望将mscoree.dll也加载到我们的进程中,为了实现这个目标,我们需要将返回地址返回给JavaScript以重新附加COOP有效载荷。looper和CFG都使用RAX作为间接分支目标,因此我们需要找到另一种方式来将新加载的模块的虚拟地址重新设为JavaScript。幸运的是,在退出LoadLibraryExW时,RDX还包含模块地址的副本。因此,我们可以将一个最终的vfgadget添加到我们的对象列表中,以便将RDX移回到我们的假冒对象内存区域。为了在最后一次迭代中进行循环,我们将调用CBindingURLBlockFilter :: SetFilterNotify(),它会将RDX复制到我们当前伪造对象的0x88h处。
打针机然后到达我们的列表的末尾,并从被劫持的密封()呼叫转移控制返回到我们的JavaScript代码。第一个COOP有效载荷已经完成,mscoree.dll已经加载到Edge,现在我们可以从下面的代码片段中的JavaScript获取mscoree的基地址。
然后looper会到达列表的末尾,并从劫持到的seal()中进行呼叫转移,以便将调用中的控制权返回到JavaScript代码。这样,第一个COOP 有效载荷就算完成了,现在mscoree.dll已经加载到Edge中了,我们可以从下面代码片段中的JavaScript获取mscoree的基地址。
//Retrieve loadlibrary return val from coop region var mscoreebase = Read64(pRebasedCOOP.add(0x128)); alert("mscoree.dll loaded at: 0x" + mscoreebase.toString(16));
劫持方法2:调用VirtualProtect包装函数
成功完成第一个COOP有效载荷后,我们现在可以重定位第二个COOP有效载荷了,以便在包含chakra!__ guard_dispatch_icall_fptr的只读内存区域中调用ClrVirtualProtect,以使其可写,我们的目标是调用ClrVirtualProtect即chakraPageAddress,0x1000,PAGE_READWRITE,pScratchMemory。不过这次我们将不会通过使用单个伪装的对象来填充所有参数并调用函数指针来利用循环或递归。我们将像以前一样使用相同的调用器vfgadget,不过这次,它主要用于将伪造对象移动到rcx中。
我们从我们原来的JavascriptNativeIntArray中劫持了freeze()虚函数,该函数指向Microsoft :: WRL :: Details :: InvokeHelper :: Invoke。这个vfgadget会根据这个+ 0x10的地址来移动这个指针,并将这个+ 0x18h地址看作一个函数指针。因此,在JavaScript中的R / W原语中,除了劫持vtable来调用调用器trampoline函数,还需要覆盖对象+0x10和+0x18处的值。
Write64(objAddr.add(0x10),pCOOPMem2); Write64(objAddr.add(0x18),EdgeHtmlBase.add(0x2DC540)); Object.freeze(objAddr);
请注意,我们伪造的对象将加载ClrVirtualProtect的所有必需参数,并通过从另一个虚假的vtable解析索引+ 0x100h,并将ClrVirtualProtect的地址填充到rax中。这样,chakra.dll中所需的页面就可写:
在完成了COOP后,最后一步便是禁用chakra.dll的CFG。我们可以在包含指令jmp rax的chakra.dll中选择任意地址,一旦选好地址,我们就可以使用JavaScript的写入原语来覆盖chakra!__ guard_dispatch_icall_fptr的函数指针,使其指向选好的地址。这样CFG验证程序就变为了nop指令,并允许我们从JavaScript中劫持一个vtable。
//Change chakra CFG pointer to NOP check Write64(chakraCFG, guard_disp_icall_nop); //trigger hijack to 0x4141414141414141 Object.isFrozen(hijackedObj);
由于下图中WinDbg输出所说明的那样, CFG已被禁用,我们劫持已经成功,并且当我们尝试跳转到未映射到内存的地址0x4141414141414141时,进程会崩溃。不过要注意的是,由于CFG被禁用,我们可能会将此劫持跳转到进程地址空间中的任何地方。另外,由于0x41414141414141在位图中会显示为无效地址,因此CFG会显示为一个异常,我们将在调用堆栈中看到我们迭代的原始CFG进程ntdll!LdrpDispatchUserCallTargetES:
总结
我们本文所讨论的COOP目前还仅停留在学术界,属于一种比较前沿的代码重用攻击,本文就为大家阐述了如何使用它来攻击控制流完整性(CFI),如Microsoft CFG。总的来说,COOP是非常容易实现的,特别是把复杂有效载荷分解成较小的片段拼接时,因为拼接在一起的vfgadget与经过汇编的ROP gadget的并没有什么本质区别。非要说有区别,那也就是在目标进程空间内查找和标记各种类型的vfgadget要耗费大量时间。
由于Microsoft的Control Flow Guard被认为是一个粗粒度的CFI实现,因此更容易受到函数重用攻击的影响。相比之下,可以通过预防间接调用的一些关键项来预防细粒度的CFI攻击,例如VTable,验证参数数量,甚至参数类型等。
在CFI的攻击策略中会引入太多的复杂性进程,从而让攻击成本增加。但这并不代表攻击者不会使用COOP方法,尽管微软已经使用forward-edge和backward-edge CFI来进行了防护,但对最新的代码重用攻击还是要重点关注。
为了让CFG的防护面更宽一些局,微软也开始专注多种多样化的预防措施,例如在CFG和Arbitrary Code Guard中通过抑制出口调用来保护关键的通话门,如VirtualProtect。
目前来看, HA-CFI解决方案可以完全实现内核和硬件函数的防护,即使在该方案下受到函数重用攻击,使得由于权限分离而让进程更难以被篡改。