0x00 环境搭建

鲁迅说:人生最大的痛苦是梦醒了无路可走。Part6直接上来了一个满汉全席,介绍了Windows平台上的保护机制和绕过方法(没有涉及堆上的东西),也有安全机制的原理的介绍,虽然没有《0day》中的细致,但在绕过利用中的思路和缘由的介绍还是很清晰的,可以相互参看理解。本文结合《C++反汇编与逆向技术技术解密》中类的逆向知识一并做了个总结。

原文中举的例子都是自己编写的漏洞函数,这和《0day》中的讲解思路一致,我之前的博客中也有相关文章,这次就不重复了,仅做关键的摘要总结。

0x01 《C++》9-11章读书笔记

8.8 函数指针

与函数调用的最大区别在于函数是至调用,而函数指针的调用需要取出指针变量中保存的地址数据,间接调用数据。

9.1 对象的内存布局

  1. 在C++中,结构体和类都具有构造函数、析构函数和成员函数,两者只有一个区别:结构体的访问控制默认为public,而类的默认访问控制是private。
  2. 对象的大小只包含数据成员,类成员函数属于执行代码,不属于类对象的数据。影响对象大小的计算有继承、虚函数、空类、内存对齐和静态数据成员等情况。
  3. 全局对象所在的内存地址空间为全局数据区,而局部对象的内存地址空间在栈中。对象中先定义的数据成员在低地址处,后定义的数据成员在高地址处,依次排列。

9.2 this指针

  1. 利用寄存器ecx保存对象的首地址,并以寄存器传参的方式传递到成员函数中,这便是this指针的由来。
  2. C++中的成员函数默认使用thiscall的调用方式,被调用者清栈,不支持变参,调用过程中使用ecx作为第一个参数,传递对象的首地址。
  3. 当成员函数使用其他调用方式(如__stdcall)时,this指针将不再使用ecx传递,而是改用栈传递。

9.3 静态数据成员

静态数据成员在反汇编代码中很难被识别,因为其展示形态与全局变量相同,很难被还原成对应的高级代码。可参考代码的功能,酌情处理。

9.4 对象作为函数参数

  1. 传参时不会像数组那样以首地址作为参数传递,而是先将对象中的所有数据进行备份(复制),将复制的数据作为形参传递到调用函数中使用。
  2. 当参数为对象的指针类型时,在函数内的操作都是针对原对象的,不存在对象被复制的问题。(浅拷贝)由于没有副本,因此在函数进入和退出时不会调用构造函数和析构函数,也就不存在资源释放的错误隐患。(Double Free)

9.5 对象作为返回值

  1. 对象作为返回值与对象作为参数的处理方式非常类似。对象作为返回值时,进入函数后将申请返回对象使用的栈空间,在退出函数时,将返回对象中的数据复制到临时栈空间中,以这个临时栈空间的首地址作为返回值。
  2. 在返回对象函数的调用者栈空间中还会存在一个临时对象,因为如果调用者有针对返回对象的操作,而此时返对象函数已退出,其栈帧也被关闭。函数退出后去操作局部对象显然不合适,因此只能由函数的调用方准备空间,建立临时对象,然后将函数中的局部对象复制给临时对象,再把这个临时对象交给调用方去操作。
  3. 由于使用了临时对象进行数据复制,当临时对象被销毁时,会执行析构函数,就有可能造成同一个资源被释放的错误发生。
  4. 当对象作为函数的参数时,可以传递指针;当对象作为返回值时,如果对象在函数内部被定义为局部变量,则不可返回此对象的首地址引用,以避免返回已经被释放的局部变量。

10.1 构造函数的出现时机

  1. 构造函数与析构函数都是类中特殊的成员函数,构造函数支持函数重载,而析构函数只能是一个无参函数。它们不可定义返回值,调用构造函数后,返回值为对象首地址,也就是this指针。
  2. 将对象进行分类:不同类型对象的构造函数被调用的时机发生变化,但都会遵循C++语法:定义的同时调用构造函数。那么,只要知道了对象的生命周期,便可推断出构造函数的调用时机。
  3. 局部对象:构造函数的必要条件:a.该成员函数是这个对象在作用域内调用的第一个成员函数,根据this指针即可以区分每个对象;b.这个函数返回this指针。
  4. 堆对象:识别重点在于识别堆空间的申请与使用。申请后,在判断成功的分支跳转处可以迅速定位并得到构造函数。
  5. 参数对象:如果在函数调用时传递参数对象,参数会进行复制,形参是实参的副本,相当于拷贝构造了一个全新的对象。由于定义了新对象,因此会触发拷贝构造函数,在这个特殊的构造函数中完成对两个对象间数据的复制,其中包括间接访问到的资源数据,这种拷贝方式属于深拷贝,也就避免了同一资源多次释放的问题。如没有定义拷贝构造函数,编译器会对原对象与拷贝对象中的各数据成员直接进行数据复制,称为默认拷贝构造函数,这种拷贝方式属于浅拷贝。
  6. 返回对象:在函数返回之前,利用拷贝构造函数将函数中局部对象的数据复制到参数指向的对象中,起到了返回对象的作用。返回值和参数为对象指针类型的函数,不会使用以参数为目标的拷贝构造函数,而是直接使用指针保存对象首地址。
  7. 全局对象与静态对象:全局对象与静态对象的构造时机相同,它们的构造函数的调用被隐藏在深处,但识别过程很容易。由于构造函数需要传递对象的首地址作为this指针,而且构造函数可以带各类参数,因此编译器将为每个全局对象生成一段传递this指针和参数的代码,然后使用无参的代理函数去调用构造函数。

10.2 每个对象都有默认的构造函数吗

  1. 本类、本类中定义的成员对象或者父类中有虚函数存在,编译器会添加默认的构造函数用于隐式完成虚表的初始化工作。
  2. 父类或本类中定义的成员对象带有构造函数。父类中带有构造函数时,编译器会添加默认的构造函数来完成这个过程。
  3. 在其他情况下VC++ 6.0不会对类提供默认的构造函数,因为会降低程序的执行效率。

10.3 析构函数的出现时机

  1. 局部对象:作用域结束前调用析构函数。
  2. 堆对象:释放对空间前调用析构函数。
  3. 参数对象:退出函数前,调用参数对象的析构函数。
  4. 返回对象:如无对象引用定义,退出函数后,调用返回对象的析构函数,否则与对象引用的作用域一致。
  5. 全局对象与静态对象:main函数退出后调用析构函数。

11.1 虚函数的机制

  1. 对象的多态性需要通过虚表和虚表指针来完成,虚表指针被定义在对象首地址的前4字节处,因此虚函数必须作为成员函数使用。由于非成员函数没有this指针,因此无法获得虚表指针,进而无法获取虚表,也就无法访问虚函数。在类中定义了虚函数之后,如果没有提供默认的构造函数,编译器必须提供默认的构造函数,用以完成虚表指针的初始化。
  2. 由于虚表信息在编译后会被链接到对应的执行文件中,因此所获得的虚表地址是一个相对固定的地址。虚表中虚函数的地址的排列顺序依据虚函数在类中的声明顺序而定,先声明的虚函数的地址会被排列在虚表中靠前的位置。
  3. 通过虚表间接寻址访问的情况只有在使用对象的指针或者引用来调用虚函数时候才会出现。当直接使用对象调用自身的虚函数时,没有构成多态性,也就没有必要查表访问。
  4. 识别构造函数的充分条件是——虚表指针初始化,识别析构函数的充分条件是——写入虚表指针。所谓虚表指针初始化,是指对象原来的虚表指针位置不是有效的,初始化后才指向了正确的虚函数表;而写入虚表指针,是指对象的虚表指针可能是有效的,已经指向了正确的虚函数表,将对象的虚表指针重新赋值后,其指针可能指向了另一个虚表,其虚表的内容不一定和原来的一样(防止在析构函数中调用虚函数时取到非自身虚表,从而导致函数调用错误)。

11.2 虚函数的识别

  1. 判读是否为虚函数时,需要鉴别类中是否出现了如下特征:类中隐式定义了一个数据成员;该数据成员在首地址处,占4字节;构造函数会将此数据成员初始化为某个数组的首地址;这个地址属于数据区,是相对固定的地址;在这个数组内,每个元素都是函数指针;仔细观察这些函数内部,它们被调用时,第一个参数必然是this指针(要注意调用约定);在这些函数内部,很有可能会对this指针使用相对间接的访问方式。
  2. 识别虚函数,就要知道虚表的首地址,最终转变成识别构造函数或析构函数,根据特性可以区分:构造函数一定出现在析构函数之前,而且在构造函数中虚表指针没有指向虚表的首地址;而析构函数出现在所以成员函数之后,在实现过程中,虚表指针已经指向了某一个虚表的首地址。
  3. 由于构造函数可以被重载,分析起来相对复杂,因此可以先从任何一个构造函数或者析构函数入手,找到虚表的操作部分,使用IDA的交叉参考找到所有对此虚表指针有修改的函数的地址,除析构函数的地址外,剩余的就是构造函数。

0x02 安全机制绕过思路总结

绕过GS

  1. 利用未被保护的缓冲区:从GS机制本身,寻找程序内没有GS的利用点。
  2. 直接获取GS Cookie的值:Cookie是静态的,或者直接预测计算出cookie的值可能比较少见,也可以考虑信息泄露。
  3. 同时替换栈中和.data中Cookie的值:需要一个任意地址写才能达到一个替换的效果,通常会考虑寻找mov dword ptr[reg1], reg2这样的write4 gadget。
  4. 攻击异常处理:通过系统特性绕过,触发异常转就换成基于SHE的漏洞利用,一般是把栈打满,但也要考虑绕过safeSEH的保护。
  5. 攻击虚函数:通过程序特性绕过,核心是虚表指针保存在栈上,栈溢出后即可控制虚表地址,一般会将其指向我们源输入的相关地址,也就控制了虚表中保存的虚函数地址,调用虚函数即可劫持eip。

绕过SafeSEH

  1. 攻击返回地址:当然需要绕过GS。
  2. 利用虚函数。
  3. 从堆中绕过:利用安全校验的缺陷,如果SHE的异常处理函数指针指向堆区,最终还是会跳转执行,但可能需要预测堆地址。
  4. 利用未启用SafeSEH模块中的跳转指令地址:一般会寻找程序自带模块中的pop pop ret指令。
  5. 利用加载模块之外的跳转指令地址:一般会寻找call dword ptr[esp+nn] / jmp dword ptr[esp+nn] / call dword ptr[ebp+nn] / jmp dword ptr[ebp+nn] / call dword ptr[ebp-nn] / jmp dword ptr[ebp-nn]这样的指令,其中的nn会是esp+8, esp+14, esp+1c, esp+2c, esp+44, esp+50, ebp+0c, ebp+24, ebp+30, ebp-04, ebp-0c, ebp-18

基于SHE的漏洞利用关键点之一是异常处理函数地址的选择,如果地址中包含\x00,那就考虑在nseh中回跳而且依旧能够触发异常。原文中也提到一种绕过方法,将异常处理函数覆盖为registered handler,在handler中不会破坏shellcode还会劫持eip,但这种情况很少发生。

绕过DEP

  1. 利用可执行内存:构造ROP链,使用memcpy将shellcode拷贝至可执行内存处,然后跳转至shellcode执行。因为传递的参数中可能存在\x00,要考虑漏洞处strcpy截断的问题。同时在rop构造过程中可使用push esp; jmp eax的gadget,将esp压栈作为参数之一,eax中保存着pop pop ret等构链指令。
  2. Ret2Libc之利用ZwSetInformationProcess:直接关闭进程的DEP,若想规避传参过程中被空字节截断的缺点,可以利用ntdll中现成的代码关闭DEP,利用过程中需要大致知道关闭DEP的过程,并事前用ROP调节好关键寄存器的值(如eax、esp、ebp),不同操作系统的关闭代码的逻辑和地址是不同的,可借助漏洞利用的插件寻找(如OllyFindAddr、mona)。
  3. Ret2Libc之利用VirtualProtect:修改指定内存为可执行状态(如栈地址)。
  4. Ret2Libc之利用VirtualAlloc:构造ROP分配可执行内存,拷贝shellcode至该内存,最后跳转执行shellcode。

原文中还提到了一种基于SHE的绕过方式,触发SHE就以为着可以控制eip,将handler覆盖为pop pop pop esp ret的地址(因为可能栈被打满),控制了esp也就可以继续后面的rop操作。

绕过ASLR

  1. 攻击未启用ASLR的模块。
  2. 利用部分覆盖进行定位内存地址:映像随机化只是对映像加载基址的前2个字节做随机化处理,借助off by one的思想,覆盖后2位字节为范围内的跳转指令地址。
  3. 利用Heap spray技术定位内存地址:通过申请大量的内存,占领内存中的0x0C0C0C0C的位置,并在这些内存中放置0x90和shellcode,最后控制程序转入0x0C0C0C0C执行。

原文还提到过预测和爆破地址的方法,当然利用信息泄露也是比较常见的。

绕过SEHOP

  1. 攻击返回地址和虚函数:不是SEHOP的保护面,另寻蹊径地绕过了。
  2. 利用未启用SEHOP的模块:虽然微软没有在编译器中提供这个选项,但出于兼容性的考虑,操作系统会根据PE头中MajorLinkerVersion和MinorLinkerVersion两个选项来判断是否为程序禁用SEHOP。
  3. 伪造SHE链表:nseh中保存的地址必须指向当前栈中,而且必须能被4整除;终极异常处理函数的地址还会受到ASLR的影响;突破SEHOP后还需要考虑绕过SafeSEH。

值得一提的是,SEHOP在Windows Server 2008默认启用,而在Windows Vista和Windows 7中SEHOP默认是关闭的。

0x03 总结

最近发生的一些事情,可能我目光短浅,总是在想为什么没有看到当代鲁迅的文章。我们被中学教育出来,在大学里浪漫一阵子就出溜一下子被扔到社会里,是非黑白错乱并不像题目的答案那么简单,最愚蠢的便是看着看着他人的言论思维就被带走了,还是先从自省与独思开始吧。

效率依然低下俨如咸鱼,真的不要做白日梦呀,稳扎稳打才能战胜自我夺冠归来。