Arm堆利用
在前面的文章中,我已经讨论过的内存破坏漏洞,一个老的(但重要)类叫做“堆栈缓冲区溢出”,以及我们如何,因为攻击者可以利用这些漏洞采取远程程序的控制,并使其运行我们的shellcode。
对于使用称为“堆栈金丝雀”的漏洞利用缓解措施的应用程序,事实证明,这些堆栈缓冲区溢出漏洞可能更难被攻击者利用,并且通常需要额外的漏洞才能可靠地利用它们。当开发人员使用各种基于堆栈的漏洞利用缓解措施时,攻击者通常会使用与堆相关的漏洞来构建他们的漏洞利用,例如释放后使用、双重释放和堆溢出。这些基于堆的漏洞比基于堆栈的漏洞更难理解,因为针对基于堆的漏洞的攻击技术可能非常依赖于堆分配器的内部实现的实际工作方式。
出于这个原因,在我写关于利用基于堆的漏洞之前,我将使用本系列的前两部分来讨论堆是如何工作的。第一篇文章将介绍一些高级概念,并讨论如何创建新的堆块。在下一篇文章中,我将深入探讨如何释放和回收块的技术实现。
堆的工作方式是针对特定平台和实现的;存在许多不同的堆实现。例如,Google Chrome 的PartitionAlloc与 FreeBSD 中使用的jemalloc堆分配器非常不同。Linux 中默认的glibc堆实现也与Windows 中堆的工作方式大不相同。因此,对于这篇文章和接下来的几篇文章,我将重点介绍glibc堆分配器,即默认情况下,堆分配如何为在Linux设备上运行的 C/C++ 程序工作。这个堆派生自ptmalloc堆实现,它本身派生自更老的dlmalloc (Doug Lea malloc) 内存分配器。
第一件事:什么是堆,它有什么用?
该堆被用于由C和C ++程序员在程序执行期间手动分配过程存储器的新区域。程序员要求堆管理器通过调用像malloc这样的堆函数来分配这些内存区域。然后,程序员可以使用、修改或引用这些已分配的内存区域或“分配”,直到程序员不再需要它并通过调用free将分配返回给堆管理器。
下面是一个 C 程序如何在堆上分配、使用和随后释放结构的示例:
类型定义结构
{
int field1;
字符*字段2;
} SomeStruct ;
int main()
{
SomeStruct * myObject = ( SomeStruct *)malloc( sizeof ( SomeStruct ));
如果(我的对象!= NULL)
{
myObject->field1 = 1234;
myObject->field2 = “Hello World!”;
do_stuff(myObject);
自由(我的对象);
}
返回0;
}
当然,malloc和free并不是 C 和 C++ 程序员与堆交互的唯一方式。C++ 开发人员通常通过 C++ 运算符new和new[]分配内存。必须使用相应的 C++ 运算符delete和delete[]而不是使用free来释放这些分配。程序员还可以通过 malloc 兼容的堆函数(如calloc、realloc和memalign )分配内存,这些函数与malloc一样,最终通过free 释放。
为简单起见,我最初只讨论malloc和free。稍后我们会看到,一旦我们理解了这两个,大多数其他堆函数就变得很容易理解了。
下面是一个 C++ 程序如何在堆上分配、使用和随后释放结构的示例:
类 SomeClass
{
公共:
int field1;
字符* 字段 2;
};
int main()
{
SomeClass * myObject = new SomeClass ();
myObject->field1 = 1234;
myObject->field2 = “Hello World!”;
do_stuff(myObject);
删除我的对象;
返回0;
}
假设程序员通过malloc请求 10 字节的内存。为了服务这个请求,堆管理器需要做的不仅仅是找到一个程序员可以写入的随机 10 字节区域。堆管理器还需要存储有关分配的元数据。此元数据与程序员可以使用的 10 字节区域一起存储。
堆管理器还需要确保分配在 32 位系统上按 8 字节对齐,在 64 位系统上按 16 字节对齐。如果程序员只想存储一些数据,例如文本字符串或字节数组,则分配的对齐并不重要,但是如果程序员打算使用分配来存储更多数据,则对齐会对程序的正确性和性能产生很大影响复杂的数据结构。由于malloc无法知道程序员将在他们的分配中存储什么,因此堆管理器必须默认确保所有分配都对齐。
此分配元数据和对齐填充字节与malloc将返回给程序员的内存区域一起存储。出于这个原因,堆管理器在内部分配比程序员最初要求的稍大的内存“块”。当程序员请求 10 字节的内存时,堆管理器会找到或创建一个新的内存块,该内存块足够大以存储 10 字节空间加上元数据和对齐填充字节。然后,堆管理器将此块标记为“已分配”,并返回指向块内对齐的 10 字节“用户数据”区域的指针,程序员将其视为malloc调用的返回值。
那么堆管理器内部是如何分配这些块的呢?
首先,让我们看一下分配小块内存的(高度简化的)策略,这是堆管理器所做的大部分工作。我将在下面更详细地解释这些步骤中的每一个,一旦我们完成,我们就可以看看巨大分配的特殊情况。
小块的简化块分配策略是这样的:
- 如果有一个先前释放的内存块,并且该块足够大来为请求提供服务,堆管理器将使用该释放的块进行新的分配。
- 否则,如果堆顶部有可用空间,堆管理器将从该可用空间中分配一个新块并使用它。
- 否则,堆管理器会要求内核在堆的末尾添加新的内存,然后从这个新分配的空间中分配一个新的块。
- 如果所有这些策略都失败,则无法为分配提供服务,并且malloc返回 NULL。
一旦堆顶部的空闲空间用完,堆管理器将不得不要求内核向堆尾添加更多内存。
在初始堆上,堆管理器通过调用sbrk要求内核在堆的末尾分配更多内存。在大多数基于 Linux 的系统上,此函数在内部使用称为“ brk ”的系统调用。这个系统调用有一个非常令人困惑的名字——它最初的意思是“改变程序中断位置”,这是一种复杂的说法,它在程序加载到内存之后的区域添加更多内存。由于这是堆管理器创建初始堆的地方,因此该系统调用的作用是在程序初始堆的末尾分配更多内存。
最终,用sbrk扩展堆会失败——堆最终会变得如此之大,以至于进一步扩展它会导致它与进程地址空间中的其他东西发生冲突,例如内存映射、共享库或线程的堆栈区域。一旦堆到达这一点,堆管理器将使用对mmap 的调用将新的非连续内存附加到初始程序堆。
如果mmap也失败了,那么该进程根本无法分配更多内存,并且malloc返回 NULL。
在多线程应用程序上,堆管理器需要保护内部堆数据结构免受可能导致程序崩溃的竞争条件。在ptmalloc2之前,堆管理器通过在每次堆操作之前简单地使用全局互斥体来确保在任何给定时间只有一个线程可以与堆交互来做到这一点。
尽管此策略有效,但堆分配器的使用率非常高且对性能非常敏感,这会导致使用大量线程的应用程序出现严重的性能问题。针对这种情况,ptmalloc2堆分配器引入了“arenas ”的概念。每个 arena 本质上都是一个完全不同的堆,它完全独立地管理自己的块分配和空闲 bin。每个 arena 仍然使用互斥锁序列化对其内部数据结构的访问,但是线程可以安全地执行堆操作而不会相互拖延,只要它们与不同的 arena 交互。
程序的初始(“main”)arena 只包含我们已经看到的堆,对于单线程应用程序,这是堆管理器将永远使用的唯一arena。但是,当新线程加入进程时,堆管理器会为每个新线程分配和附加辅助区域,以尝试减少线程在尝试执行诸如malloc和免费的。
对于加入进程的每个新线程,堆管理器尝试找到一个没有其他线程使用的 arena 并将该 arena 附加到该线程。一旦所有可用的 arena 都被其他线程使用,堆管理器会创建一个新的,最多可达32 位进程中的 2x cpu-cores和64 位进程中的8x cpu-cores的最大数目。一旦最终达到该限制,堆管理器就会放弃,多个线程将不得不共享一个舞台,并冒着执行堆操作将需要其中一个线程等待另一个线程的风险。
但是等一下!这些次要领域是如何运作的?之前我们看到主堆位于程序加载到内存并使用brk系统调用扩展的位置之后,但对于次要领域来说这也不是真的!
答案是这些次要领域使用一个或多个使用mmap和mprotect创建的“子堆”来模拟主堆的行为。
子堆的工作方式与初始程序堆大致相同,但有两个主要区别。回想一下,初始堆位于程序加载到内存之后的位置,并由sbrk动态扩展。相比之下,每个子堆都使用mmap定位到内存中,堆管理器使用mprotect手动模拟子堆的增长。
当堆管理器想要创建子堆时,它首先要求内核通过调用mmap*来保留子堆可以增长到的内存区域。保留这个区域并不直接将内存分配到子堆中;它只是要求内核不要在该区域内分配线程堆栈、mmap 区域和其他分配等内容。
*默认情况下,子堆的最大大小(以及为子堆保留的内存区域)在 32 位进程上为 1MB,在 64 位系统上为 64MB。
这是通过向mmap询问标记为PROT_NONE 的页面来完成的,它作为对内核的提示,它只需要为该区域保留地址范围;它还不需要内核将内存附加到它。
在初始堆使用sbrk增长的情况下,堆管理器通过手动调用mprotect将区域中的页面从 PROT_NONE 更改为 PROT_READ 来模拟将子堆“增长”到此保留地址范围内。PROT_WRITE。这会导致内核将物理内存附加到这些地址,实际上导致子堆缓慢增长,直到整个mmap区域已满。一旦整个子堆用完,arena 只会分配另一个子堆。这允许辅助领域几乎无限期地保持增长,只有在内核耗尽内存或进程耗尽地址空间时才会最终失败。
回顾一下:初始(“主要”)arena 仅包含主堆,它位于程序二进制文件加载到内存的位置之后,并使用sbrk 进行扩展。这是用于单线程应用程序的唯一领域。在多线程应用程序中,新线程被赋予二级分配区域。使用 arenas 通过减少线程在能够执行堆操作之前需要等待互斥锁的可能性来加速程序。与主领域不同,这些次要领域从一个或多个子堆中分配块,这些子堆在内存中的位置首先使用mmap建立,然后使用mprotect增长。
现在我们终于知道了一个块可能被分配的所有不同方式,这些块不仅包含将作为malloc返回值提供给程序员的“用户数据”区域,还包含元数据。但是这个块元数据实际上记录了什么,它在哪里?
内存中块元数据的确切布局可能有点令人困惑,因为堆管理器源代码将一个块末尾的元数据与下一个块开头的元数据组合在一起,并且存在或使用了几个元数据字段,具体取决于关于块的各种特性。
现在,我们只看实时分配,它有一个size_t* 标头,位于提供给程序员的“用户数据”区域的后面。这个字段,源代码称为mchunk_size,在malloc期间写入,稍后由free用来决定如何处理分配的释放。
*size_t 值在 32 位系统上是 4 字节整数,在 64 位系统上是 8 字节整数。
该mchunk_size店四种信息:块的大小,和三位被称为“A”,“M”和“P”。这些都可以存储在相同的size_t字段中,因为块大小总是 8 字节对齐(或 16 字节对齐 64 位),因此块大小的低三位始终为零。
的“A”标志是用来告诉堆管理器,如果该块属于次级领域,而不是在主竞技场。在释放期间,堆管理器只得到一个指向程序员想要释放的分配的指针,堆管理器需要确定该指针属于哪个领域。如果在块的元数据中设置了 A 标志,堆管理器必须搜索每个 arena 并查看指针是否位于该 arena 的任何子堆中。如果未设置标志,堆管理器可以短路搜索,因为它知道块来自初始 arena。
“M”标志用于指示块是一个巨大的分配,它是通过mmap分配到堆外的。当此分配最终传递回free 时,堆管理器将立即通过munmap将整个块返回给操作系统,而不是尝试回收它。出于这个原因,释放的块永远不会设置这个标志。
“P”标志令人困惑,因为它确实属于前一个块。它表示前一个块是一个空闲块。这意味着当这个块被释放时,它可以安全地连接到前一个块上以创建一个更大的空闲块。
在我的下一篇文章中,我将更详细地讨论如何将这些空闲块“合并”在一起,并且我将讨论如何使用不同类型的“bin”来分配和回收块。之后,我们将研究一些不同类别的堆漏洞,以及攻击者如何利用它们来远程控制易受攻击的程序。
原文:Heap Exploitation Part 1: Understanding the Glibc Heap Implementation | Azeria Labs