Linux内核动态内存隔离技术深度解析

本文详细探讨了Linux内核动态内存隔离机制SLAB_QUARANTINE,通过实验分析其对use-after-free漏洞利用的防护效果,包含性能测试结果和安全特性评估,并讨论了社区提出的绕过攻击方案。

Linux内核动态内存隔离机制 | Alexander Popov

2020年12月29日

2020年,隔离措施无处不在。本文讨论的也是一种隔离,但属于另一种类型。我将介绍针对Linux内核动态内存隔离的实验。这是一种安全机制,用于防御Linux内核中释放后使用(use-after-free,UAF)漏洞的利用。文章中我还将总结我在内核邮件列表(Linux Kernel Mailing List, LKML)上提交的补丁系列的讨论情况。

Linux内核中的释放后使用漏洞

UAF是Linux内核中非常流行的漏洞利用类型。存在大量公开的UAF内核漏洞利用原型:

  • CVE-2016-8655
  • CVE-2017-6074
  • CVE-2017-2636
  • CVE-2017-15649
  • CVE-2019-18683

利用UAF通常采用堆喷射(heap spraying)技术。该技术的目标是将攻击者控制的数据放置在特定的动态内存区域(也称为"堆")中。Linux内核中利用UAF的堆喷射技术基于以下原理:当调用kmalloc()时,slab分配器返回最近释放的内存区域的地址:

![堆喷射示意图1]

也就是说,创建另一个相同大小的内核对象并控制其内容,可以覆盖已释放的易受攻击对象:

![堆喷射示意图2]

注意:用于利用堆缓冲区溢出的堆喷射是一种不同的技术,其工作方式也不同。

设计理念

2020年7月,我产生了如何对抗Linux内核中UAF利用的堆喷射技术的想法。8月份,我找到了时间进行实验。我从KASAN功能中分离出了slab分配器的隔离机制,并将其命名为SLAB_QUARANTINE。

启用此机制后,释放的内存分配会被放置在隔离队列中,等待实际释放。因此,它们不能立即被重新分配并被UAF漏洞利用程序覆盖。也就是说,启用SLAB_QUARANTINE后,内核分配器的行为如下:

![隔离机制示意图]

8月13日,我将第一个早期隔离原型发送到LKML,并开始更深入地研究该机制的安全参数。

SLAB_QUARANTINE的安全特性

为了研究内核动态内存隔离的安全特性,我开发了两个lkdtm测试(在补丁系列中发布)。

第一个测试称为lkdtm_HEAP_SPRAY。它从单独的kmem_cache分配并释放一个对象,然后分配400,000个类似对象。换句话说,这个测试模拟了原始的UAF利用堆喷射技术:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#define SPRAY_LENGTH 400000
// ...
addr = kmem_cache_alloc(spray_cache, GFP_KERNEL);
// ...
kmem_cache_free(spray_cache, addr);
pr_info("Allocated and freed spray_cache object %p of size %d\n",
        addr, SPRAY_ITEM_SIZE);
// ...
pr_info("Original heap spraying: allocate %d objects of size %d...\n",
        SPRAY_LENGTH, SPRAY_ITEM_SIZE);
for (i = 0; i < SPRAY_LENGTH; i++) {
    spray_addrs[i] = kmem_cache_alloc(spray_cache, GFP_KERNEL);
    // ...
    if (spray_addrs[i] == addr) {
        pr_info("FAIL: attempt %lu: freed object is reallocated\n", i);
        break;
    }
}

if (i == SPRAY_LENGTH)
    pr_info("OK: original heap spraying hasn't succeeded\n");

如果禁用CONFIG_SLAB_QUARANTINE,释放的对象会立即被重新分配和覆盖:

1
2
3
4
5
# echo HEAP_SPRAY > /sys/kernel/debug/provoke-crash/DIRECT
lkdtm: Performing direct entry HEAP_SPRAY
lkdtm: Allocated and freed spray_cache object 000000002b5b3ad4 of size 333
lkdtm: Original heap spraying: allocate 400000 objects of size 333...
lkdtm: FAIL: attempt 0: freed object is reallocated

如果启用CONFIG_SLAB_QUARANTINE,400,000个新分配不会覆盖释放的对象:

1
2
3
4
5
# echo HEAP_SPRAY > /sys/kernel/debug/provoke-crash/DIRECT
lkdtm: Performing direct entry HEAP_SPRAY
lkdtm: Allocated and freed spray_cache object 000000009909e777 of size 333
lkdtm: Original heap spraying: allocate 400000 objects of size 333...
lkdtm: OK: original heap spraying hasn't succeeded

这是因为对象需要通过内存分配和释放才能通过隔离。只有当隔离超过其最大尺寸时,对象才会从隔离中释放。而隔离的大小在内存释放时增加。

因此,我开发了第二个测试,名为lkdtm_PUSH_THROUGH_QUARANTINE。它从单独的kmem_cache分配并释放一个对象,然后对该缓存执行kmem_cache_alloc()+kmem_cache_free()操作400,000次。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
addr = kmem_cache_alloc(spray_cache, GFP_KERNEL);
// ...
kmem_cache_free(spray_cache, addr);
pr_info("Allocated and freed spray_cache object %p of size %d\n",
        addr, SPRAY_ITEM_SIZE);

pr_info("Push through quarantine: allocate and free %d objects of size %d...\n",
        SPRAY_LENGTH, SPRAY_ITEM_SIZE);
for (i = 0; i < SPRAY_LENGTH; i++) {
    push_addr = kmem_cache_alloc(spray_cache, GFP_KERNEL);
    // ...
    kmem_cache_free(spray_cache, push_addr);

    if (push_addr == addr) {
        pr_info("Target object is reallocated at attempt %lu\n", i);
        break;
    }
}

if (i == SPRAY_LENGTH) {
    pr_info("Target object is NOT reallocated in %d attempts\n",
            SPRAY_LENGTH);
}

通过此测试,对象通过隔离并在返回到分配器的空闲对象列表后被重新分配:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# echo PUSH_THROUGH_QUARANTINE > /sys/kernel/debug/provoke-crash/
lkdtm: Performing direct entry PUSH_THROUGH_QUARANTINE
lkdtm: Allocated and freed spray_cache object 000000008fdb15c3 of size 333
lkdtm: Push through quarantine: allocate and free 400000 objects of size 333...
lkdtm: Target object is reallocated at attempt 182994
# echo PUSH_THROUGH_QUARANTINE > /sys/kernel/debug/provoke-crash/
lkdtm: Performing direct entry PUSH_THROUGH_QUARANTINE
lkdtm: Allocated and freed spray_cache object 000000004e223cbe of size 333
lkdtm: Push through quarantine: allocate and free 400000 objects of size 333...
lkdtm: Target object is reallocated at attempt 186830
# echo PUSH_THROUGH_QUARANTINE > /sys/kernel/debug/provoke-crash/
lkdtm: Performing direct entry PUSH_THROUGH_QUARANTINE
lkdtm: Allocated and freed spray_cache object 000000007663a058 of size 333
lkdtm: Push through quarantine: allocate and free 400000 objects of size 333...
lkdtm: Target object is reallocated at attempt 182010

可以注意到,覆盖易受攻击对象所需的分配次数几乎没有变化。这对防御不利,但对攻击者有利,因为它提供了通过更长时间的喷射来稳定绕过隔离的可能性。因此,我开发了隔离随机化。这本质上是隔离队列内部工作机制的一个巧妙小改进。

在隔离中,对象存储在"批次"中。随机化的工作方式如下:首先所有批次都填充内核对象。然后,当隔离超过最大尺寸并需要释放多余对象时,选择一个随机批次,从中释放大约一半的内核对象,这些对象也是随机选择的。现在隔离在不可预测的时刻释放对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
lkdtm: Target object is reallocated at attempt 107884
lkdtm: Target object is reallocated at attempt 265641
lkdtm: Target object is reallocated at attempt 100030
lkdtm: Target object is NOT reallocated in 400000 attempts
lkdtm: Target object is reallocated at attempt 204731
lkdtm: Target object is reallocated at attempt 359333
lkdtm: Target object is reallocated at attempt 289349
lkdtm: Target object is reallocated at attempt 119893
lkdtm: Target object is reallocated at attempt 225202
lkdtm: Target object is reallocated at attempt 87343

然而,这种随机化本身并不能防止利用:隔离中的对象包含攻击者的数据(漏洞利用的有效载荷)。这意味着被重新分配并被喷射覆盖的目标内核对象在下一次重新分配之前将包含攻击者的数据(非常糟糕)。

因此,在将内核堆对象放入隔离之前清理它们非常重要。此外,在某些情况下,将它们填充为零可以检测到释放后使用:会发生空指针解引用。这样的功能已经存在于内核中,称为init_on_free。我将其与CONFIG_SLAB_QUARANTINE集成。

在这项工作中,我发现了一个内核错误:在CONFIG_SLAB中,init_on_free功能执行得太晚,内核对象在没有清理的情况下被发送到隔离。我在一个单独的补丁中准备了修复(已被mainline接受)。

为了更深入地理解CONFIG_SLAB_QUARANTINE与随机化的工作方式,我准备了一个额外的补丁,包含详细的调试输出(不用于mainline接受的补丁)。此类调试输出的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
quarantine: PUT 508992 to tail batch 123, whole sz 65118872, batch sz 508854
quarantine: whole sz exceed max by 494552, REDUCE head batch 0 by 415392, leave 396304
quarantine: data level in batches:
  0 - 77%
  1 - 108%
  2 - 83%
  3 - 21%
...
  125 - 75%
  126 - 12%
  127 - 108%
quarantine: whole sz exceed max by 79160, REDUCE head batch 12 by 14160, leave 17608
quarantine: whole sz exceed max by 65000, REDUCE head batch 75 by 218328, leave 195232
quarantine: PUT 508992 to tail batch 124, whole sz 64979984, batch sz 508854
...

PUT操作在内核内存释放过程中执行。REDUCE操作在内核内存分配过程中执行,当隔离大小超过限制时。从隔离中释放的内核对象返回到分配器的空闲对象列表。在此输出中还可以看到,当执行REDUCE操作时,隔离从随机选择的批次中释放部分对象。

性能如何?

我在真实设备和虚拟机上对我的原型进行了几次性能测试:

使用iperf进行网络吞吐量测试:

  • 服务器:iperf -s -f K
  • 客户端:iperf -c 127.0.0.1 -t 60 -f K

内核任务调度器负载测试:

  • hackbench -s 4000 -l 500 -g 15 -f 25 -P

默认配置下的内核编译:

  • time make -j2

我测试了三种模式下的原始Linux内核:

  1. init_on_free=off
  2. init_on_free=on(官方内核机制)
  3. CONFIG_SLAB_QUARANTINE=y(包含init_on_free)

使用iperf进行网络吞吐量测试显示:

  • init_on_free=on的吞吐量比init_on_free=off低28%
  • CONFIG_SLAB_QUARANTINE的吞吐量比init_on_free=on低2%

内核任务调度器负载测试:

  • hackbench在init_on_free=on时比init_on_free=off慢5.3%
  • hackbench在CONFIG_SLAB_QUARANTINE时比init_on_free=on慢91.7%。在QEMU/KVM虚拟机上的测试显示性能下降44%,这与真实设备(Intel Core i7-6500U CPU)的测试结果显著不同。

默认配置下的内核编译:

  • init_on_free=on时内核编译比init_on_free=off慢1.7%
  • CONFIG_SLAB_QUARANTINE时内核编译比init_on_free=on慢1.1%

如您所见,测试结果变化很大,并且取决于工作负载类型。

注意:此版本的隔离原型没有进行性能优化。我的主要任务是研究机制的安全特性。我认为如果想法被证明是好的,性能优化最好稍后进行。

反击

在LKML上,关于CONFIG_SLAB_QUARANTINE的讨论非常有趣。感谢内核开发人员花时间并给我的补丁系列提供了详细的反馈。这些开发人员包括Kees Cook、Andrey Konovalov、Alexander Potapenko、Matthew Wilcox、Daniel Micay、Christopher Lameter、Pavel Machek和Eric W. Biederman。

我特别感谢Jann Horn。他想出了一种反击方法,成功地绕过了CONFIG_SLAB_QUARANTINE并利用了Linux内核中的UAF。

值得注意的是,我与Jann的讨论与Kees在Twitch上的直播同时进行,期间他测试了我的补丁(推荐观看录制内容)。

来自反击想法通信的引用:

On 06.10.2020 21:37, Jann Horn wrote:

On Tue, Oct 6, 2020 at 7:56 PM Alexander Popov wrote:

So I think the control over the time of the use-after-free access doesn’t help attackers, if they don’t have an “infinite spray” – unlimited ability to store controlled data in the kernelspace objects of the needed size without freeing them. […] Would you agree?

But you have a single quarantine (per CPU) for all objects, right? So for a UAF on slab A, the attacker can just spam allocations and deallocations on slab B to almost deterministically flush everything in slab A back to the SLUB freelists?

Aaaahh! Nice shot Jann, I see.

Another slab cache can be used to flush the randomized quarantine, so eventually the vulnerable object returns into the allocator freelist in its cache, and original heap spraying can be used again.

For now I think the idea of a global quarantine for all slab objects is dead.

也就是说,攻击者可以利用内核分配器中的另一个slab缓存,在其中分配和释放大量对象,这将导致目标对象从隔离中被刷新回空闲对象列表。之后,攻击者可以使用标准的堆喷射技术来利用UAF。

我立即在Kees的Twitch直播聊天中分享了这段通信。他根据Jann的想法改进了我的PUSH_THROUGH_QUARANTINE测试并执行了攻击。砰!

强烈建议完整阅读LKML中的这段通信。其中讨论了对抗内核中UAF利用的新想法。

结论

我研究了Linux内核动态内存隔离的安全特性,进行了显示其对use-after-free漏洞利用影响的实验。这是一个快速而有趣的项目。虽然未能创建应用于mainline的可靠保护措施,但我们获得了有用的结果和想法,这些将在未来的Linux内核保护工作中发挥作用。

现在,请允许我以睡前想到的一首小诗结束:

1
2
3
4
Quarantine patch version three
Won't appear. No need.
Let's exploit use-after-free
Like we always did ;)

– a13xp0p0v

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计