堆喷射技术——概述

0x00    前言

    恶意代码注入系列暂告一段落,这次我们来搞一些PWN类型的文章。首先,我们来看看现有的攻击技术主要有哪些:

    ·覆盖返回地址——可通过GS机制进行保护

    ·覆盖SEH——通过SafeSEHSEHOP保护

    ·覆盖本地变量——可能被编译器重新整理或优化

    ·覆盖空闲堆双向链表——通过safe unlinking保护

    ·覆盖堆块头——HeaderCookieXOR HeaderData

    以上的几种攻击方式都已经有了成熟的应对措施,但是我们还可以通过覆盖函数指针/对象指针,覆盖这些指针之后如何让程序准确的指向shellcode呢?这个时候我们就要利用堆喷射技术了。



0x01    应用程序内存模型

    在理解堆喷射原理和用途之前,我们首先需要复习一下应用程序的虚拟内存结构。

    从windows95开始,windows操作系统就开始构建在平坦内存模型上面,该模型在32位系统上总共提供了4GB的可寻址空间。一般来讲,顶层的一半(0x8000 0000~0xFFFF FFFF)被保留用做内核空间(NT系统),这种划分是可以修改的。而低地址的0x0000 0000~0x7FFF FFFF里面,前64K与后64K都是不可以使用的(前64K估计是为了兼容DOS程序或者设置NULL指针而保留,严禁进程访问,后64K用于隔离进程的用户和内核空间),因此进程可用的区域就为0x0001 0000~0x7FFE FFFF。为了进一步简化程序员的编程工作,Windows操作系统通过虚拟寻址来管理内存。从本质上来说,虚拟内存模型为每个运行中的进程提供了它自己的4GB虚拟地址空间。这项工作是通过一个从虚拟地址到物理地址的转换来完成的,并且是在一个内存管理单元(Memory Management UnitMMU)的帮助下完成的。

image.png

    下面写一段程序来探测内存空间的分配情况:

#include <stdio.h>
#include <malloc.h>
#include <windows.h> 
int g_i = 100;
int g_j = 200;
int g_k, g_h;
int main()
{
    const int MAXN = 100;
    int *p = (int*)malloc(MAXN * sizeof(int));
    static int s_i = 5;
    static int s_j = 10;
    static int s_k;
    static int s_h;
    int i = 5;
    int j = 10;
    int k = 20;
    int f, h;
    char *pstr1 = "Hello World 1111";
    char *pstr2 = "Hello World 2222";
    char *pstr3 = "Hello World 3333";
    printf("堆中数据地址:x%08x\n", p);
    putchar('\n');
    printf("栈中数据地址(有初值):x%08x = %d\n", &i, i);
    printf("栈中数据地址(有初值):x%08x = %d\n", &j, j);
    printf("栈中数据地址(有初值):x%08x = %d\n", &k, k);
    printf("栈中数据地址(无初值):x%08x = %d\n", &f, f);
    printf("栈中数据地址(无初值):x%08x = %d\n", &h, h);
    putchar('\n');
    printf("静态数据地址(有初值):x%08x = %d\n", &s_i, s_i);
    printf("静态数据地址(有初值):x%08x = %d\n", &s_j, s_j);
    printf("静态数据地址(无初值):x%08x = %d\n", &s_k, s_k);
    printf("静态数据地址(无初值):x%08x = %d\n", &s_h, s_h);
    putchar('\n');
    printf("全局数据地址(有初值):x%08x = %d\n", &g_i, g_i);
    printf("全局数据地址(有初值):x%08x = %d\n", &g_j, g_j);
    printf("全局数据地址(无初值):x%08x = %d\n", &g_k, g_k);
    printf("全局数据地址(无初值):x%08x = %d\n", &g_h, g_h);
    putchar('\n');
    printf("字符串常量数据地址:x%08x 指向0x%08x 内容为-%s\n", &pstr1, pstr1, pstr1);
    printf("字符串常量数据地址:x%08x 指向0x%08x 内容为-%s\n", &pstr2, pstr2, pstr2);
    printf("字符串常量数据地址:x%08x 指向0x%08x 内容为-%s\n", &pstr3, pstr3, pstr3);
    free(p);
    system("pause");
    return 0;
}

    运行结果如下图:

image.png

    注意,这里我们测试时要用WindowsXP(即NT5.1内核),或编译时关闭ASLR地址随机化,否则堆栈空间的起始地址就是随机的,如下图(win10):

image.png

    从图中的结果可以看出,各类型数据在内存中分布的位置(从低到高)为:栈--全局静态数据&常量数据。其中全局静态数据和常量数量都是在操作系统加载应用程序时直接映射到内存的,一般映射的起始地址是0x 0040 0000,而应用程序依赖的DLL一般都映射在这个地址之后。当然这些不是绝对的,DLL可以映射在应用程序本身的前面,应用程序自身也可以通过修改编译选项映射到其他地址,至于堆的区域,则很可能分布在虚拟地址空间的很多地方,但是这些都属于特殊情况。

    从上面的分析可知,一个进程的内存空间在逻辑上可以分为3个部分:代码区,静态(全局)数据区和动态数据区。而动态数据区又可以分为“堆”和“栈”两种动态数据。其中“堆”的起始分配地址是比较低的。



0x02    堆喷射

    既然在没有ASLR保护的情况下,堆地址都在一个固定的较低位置,那么当我们申请大量内存的时候,就可以覆盖到这几个地址:0x0A0A0A0A0x0C0C0C0C0x0D0D0D0D160MB192MB208MB),如下图:

image.png

image.png

    上图中虽然绿色部分写的都是“shellcode”,但是有点常识就可以理解,shellcode并不会有几百MB的大小,那么这个绿色部分还包含了什么呢?为什么刚才要提到那三个特殊地址呢?



0x03    滑板指令(Slide Code

    如果我们通过覆盖函数指针或对象指针去执行在堆中植入的shellcode,这无疑是大海捞针,对于整个堆空间来说,通常只有几十KB到几百KBshellcode实在是太小了,分配的地址很难预测。既然很难命中shellcode,那么将整个堆空间都填满shellcode不就可以了么?答案是错误的,由于命中shellcode的目的是为了让其正确执行,所以只有命中其第一条指令才代表成功。

    我们来做一个简单的算术题,假设堆空间一共有10MB大小,而shellcode100KB,那么将其填满约需要100个重复的shellcode,也就是100处可以命中成功的地址,在10MB的空间内找这100个地址命中几率仍然接近0%,更何况shellcode在实际情况中比这要小得多,堆空间比这要大得多。因此将堆空间填满shellcode的方式也只是将这个接近于0%的概率稍微提升了一点点而已。

    为了提升命中率,我们需要借助slidecode辅助。所谓slidecode其实就是一段连续的无效指令(如NOP),CPU执行这种指令不会对程序产生任何影响,只是默默向下执行,那么我们就可以将shellcode放在slidecode的尾部,这样只需要随便命中一条slidecode,则可以自动执行到shellcode了,当然,通常情况下堆内空间也不是完全连续的,因此slidecode也需要“切片”,即分配多个足够大的slidecode+shellcode,保障随便命中哪一段都有大概率成功。

    那么slidecode该如何选取呢?如果我们用刚刚提到的NOP,那么其二进制代码对应为0x90909090,看上去没有什么问题,但仅仅是对C语言来说是可行的。在高级语言中我们很难直接通过一次跳转找到我们的shellcode,例如C++程序中我们攻击的指针实际上是虚函数指针,程序根据这个指针第一次跳转后做的操作是查虚函数表,那么我们构造的slidecode就变成了一个假的虚表。充满了0x90909090。虚表中的内容实际上还是指针,如果指针指向0x90909090会怎样呢?回顾一下第一张图的内存分配情况,这个地址是在内核空间的,造成的后果就是Crash。解决办法其实很简单,换一种slidecode就可以了,例如覆盖虚指针为0x0C0C0C0C,然后slidecode也是0x0C0C0C0C,用堆喷射的方法覆盖0x0C0C0C0C这个地址后,那么在查虚表的时候就会直接定位到0x0C0C0C0C这个地址,就可以借用slidecode继续向下执行了。如下图:

image.png

    当然,假设用上面的方法(堆喷覆盖的地址和slidecode中的内容相同)0x90909090也在内核空间,用户空间不可写入。就算可以任意写入,需要喷射的数据量也太过庞大,因此针对现代操作系统和程序的保护机制来说NOPs的喷射方案都是不可行的。


0x04    简单演示

    IE6下的堆喷射是最原始的一种,因为IE6那个时期是没有任何漏洞缓解措施的,所以只需要考虑如何分配内存即可。

    从代码执行的角度来看,IE6时期我们的利用主要分为两类。第一类是ActiveX类的漏洞,而且以栈溢出为常见。第二类是IE6本身的UAF漏洞。第一类漏洞只需要一个大致的地址+合适的nop跳板就可以实现最终的利用。至于第二类通常会使用一个固定的跳板地址,诸如著名的0x0C0C0C0C,关于它的原理我们之后再讲,这里我们也可以认为它只需要一个大致的地址就可以。

    但是由于IE6中javascript的实现,使得字符串赋值给一个变量时并不会开辟新的内存空间(类似于C中的指针取地址),只有当字符串发生连接操作时(substr或是+),才会为字符串开辟新的内存空间。

    我们写一段JS代码:

<html>
<SCRIPT language="JavaScript"> 
var sc = unescape("%ucccc%ucccc"); 
var nop = unescape("%u0c0c%u0c0c");
while (nop.length < 0x40000) 
    nop += nop; 
nop = nop.substring(0,0x40000-0x20-sc.length); 
heap_chunks = new Array(); 
for (i = 0 ; i < 500 ; i++) 
    heap_chunks[i] = nop+sc;
</SCRIPT>
</html>

    代码中每一个块的大小是0x80000(每个字符两个字节),为什么要取0x80000的大小呢?这问题在前面提到过,取别的大小其实也是可以的,单个块的大小和分配的块数是一种综合的考量,主要是要考虑到内存块分配的速度和内存布局的稳定性。至于为什么这个数是0x40000,只能说是前辈们在不断尝试中获得的经验。

    我们试图把内存喷射到0x0C0C0C0C,经过简单的计算可知0x0C0C0C0C约为202116108个字节。 500*0x80000约为262144000个字节,262144000大于202116108个字节,因此我们的喷射一定可以到达0x0C0C0C0C。下图就是喷射后的直观效果——内存占用率提升了。

image.png

    然后我们使用WinDbg调试一下,看看0x0C0C0C0C这个地址的内容,如下:

image.png

    这样我们得到一个连续分配的0x0C0C0C0C的slidecode,并且0x0C0C0C0C这个地址也包含在这个区域内,因此只要我们可以覆盖一个虚函数指针(或对象指针等),将其修改为0x0C0C0C0C这个地址,程序就可以由我们任意操控了。

    至于利用什么漏洞覆盖指针,shellcode如何构造,本节不做展开介绍。

    后一节将会继续介绍精确堆喷射和相关的实例。

本文 暂无 评论

回复给

Top