Linux内核堆隔离技术对抗释放后使用漏洞利用

本文详细介绍了Linux内核堆隔离技术(SLAB_QUARANTINE)的设计与实现,探讨其如何通过延迟释放和随机化策略来防御释放后使用漏洞的利用,包括性能测试结果和安全讨论。

Linux内核堆隔离技术对抗释放后使用漏洞利用

作者:Alexander Popov
发布日期:2020年11月30日

2020年,隔离无处不在——而我也在写一篇关于隔离的文章。但这里的隔离是另一种类型。在本文中,我将描述我开发的Linux内核堆隔离(Linux Kernel Heap Quarantine),用于缓解内核释放后使用(use-after-free,UAF)漏洞的利用。我还将总结关于这个安全功能原型在Linux内核邮件列表(LKML)上的讨论。

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

Linux内核中的释放后使用(UAF)漏洞在利用中非常流行。有许多漏洞利用示例,包括:

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

UAF利用通常涉及堆喷洒(heap spraying)。一般来说,这种技术旨在将攻击者控制的字节放置在堆上定义的内存位置。在Linux内核中利用UAF的堆喷洒依赖于这样一个事实:当调用kmalloc()时,slab分配器返回最近释放的内存地址:

因此,分配具有相同大小和攻击者控制内容的内核对象可以覆盖易受攻击的已释放对象:

注意:用于越界利用的堆喷洒是一种单独的技术。

一个想法

2020年7月,我想到了一种打破这种UAF利用堆喷洒技术的方法。8月,我找到了一些时间来尝试它。我从KASAN功能中提取了slab空闲列表隔离,并将其称为SLAB_QUARANTINE。

如果启用此功能,已释放的分配将存储在隔离队列中,等待实际释放。因此,UAF利用应该无法立即重新分配和覆盖它们。

换句话说,使用SLAB_QUARANTINE,内核分配器的行为如下:

8月13日,我将第一个早期概念验证(PoC)发送到LKML,并开始深入研究其安全属性。

Slab隔离安全属性

为了研究内核堆隔离的安全属性,我开发了两个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

如您所见,覆盖易受攻击对象所需的分配次数几乎相同。这对于稳定的UAF利用是有利的,但不应允许。这就是为什么我开发了隔离随机化。这种随机化需要对堆隔离机制进行非常小的黑客式更改。

堆隔离以批次存储对象。在启动时,所有隔离批次都被对象填充。当隔离缩小时,我随机选择并释放来自随机选择批次的一半对象。随机化隔离然后在不可预测的时刻释放已释放的对象:

 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

然而,仅凭这种随机化无法阻止攻击者:隔离在喷洒对象中存储了攻击者的数据(有效载荷)!这意味着重新分配和覆盖的易受攻击对象包含有效载荷,直到下一次重新分配(非常糟糕!)。这使得在将堆对象放入堆隔离之前擦除它们变得重要。

此外,用零填充它们有机会检测到对非零数据的UAF访问,只要对象留在隔离中(很好!)。此功能已存在于内核中,称为init_on_free。我将其与CONFIG_SLAB_QUARANTINE集成。

在那项工作中,我发现了一个错误:在CONFIG_SLAB中,init_on_free发生得太晚。堆对象在仍然“脏”时进入KASAN隔离。我在一个单独的补丁中提供了修复。

为了更深入地理解堆隔离的内部工作原理,我提供了一个额外的补丁,其中包含详细调试(不用于合并)。它非常有用,请参见输出示例:

 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时,隔离释放了随机选择的对象批次的一部分(有关更多详细信息,请参见随机化补丁)。

性能如何?

我在真实硬件和虚拟机中对隔离PoC进行了简要性能测试:

  • 使用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

  • 构建defconfig内核
    time make -j2

我比较了三种模式下的原始Linux内核:

  • init_on_free=off
  • init_on_free=on(上游功能)
  • CONFIG_SLAB_QUARANTINE=y(启用init_on_free)

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

  • 与init_on_free=off相比,init_on_free=on的吞吐量降低了28.0%。
  • 与init_on_free=on相比,CONFIG_SLAB_QUARANTINE的吞吐量降低了2.0%。

调度器压力测试:

  • 与init_on_free=off相比,init_on_free=on的hackbench运行速度慢了5.3%。
  • 与init_on_free=on相比,CONFIG_SLAB_QUARANTINE的hackbench运行速度慢了91.7%。 在QEMU/KVM虚拟机中运行此测试产生了44.0%的性能损失,这与真实硬件(Intel Core i7-6500U CPU)的结果大不相同。

构建defconfig内核:

  • 与init_on_free=off相比,init_on_free=on的内核构建速度慢了1.7%。
  • 与init_on_free=on相比,CONFIG_SLAB_QUARANTINE的内核构建速度慢了1.1%。

如您所见,这些测试的结果非常多样化,并且取决于工作负载的类型。

反击

我的补丁系列在LKML上得到了反馈。我感谢Kees Cook、Andrey Konovalov、Alexander Potapenko、Matthew Wilcox、Daniel Micay、Christopher Lameter、Pavel Machek和Eric W. Biederman的分析。

主要的赞誉归于Jann Horn,他审查了我的slab隔离缓解的安全属性,并创建了一个反击,重新启用了Linux内核中的UAF利用。

令人惊讶的是,与Jann的讨论发生在Kees的Twitch流中,他正在测试我的补丁系列(顺便说一句,我推荐观看录制内容)。

引用邮件列表:

2020年10月6日21:37,Jann Horn写道:

周二,2020年10月6日19:56,Alexander Popov写道:

所以我认为,如果攻击者没有“无限喷洒”——无限能力在不释放的情况下将受控数据存储在所需大小的内核空间对象中,那么对释放后使用访问时间的控制对攻击者没有帮助。 […] 你同意吗?

但是你有一个单一的隔离(每CPU)用于所有对象,对吗?所以对于slab A上的UAF,攻击者可以只是大量分配和释放slab B上的对象,以几乎确定性地将slab A中的所有内容刷新回SLUB空闲列表?

啊!好的一击,Jann,我明白了。

另一个slab缓存可用于刷新随机化隔离,因此最终易受攻击的对象返回到其缓存中的分配器空闲列表,并且可以再次使用原始堆喷洒。

目前,我认为用于所有slab对象的全局隔离的想法已经失败。

我立即在Kees的Twitch流聊天中分享了这一点,Kees调整了我的PUSH_THROUGH_QUARANTINE测试来实现这种攻击。它奏效了。砰!

进一步的想法

Jann提出了另一个缓解Linux内核中UAF利用的想法。Kees、Daniel Micay、Christopher Lameter和Matthew Wilcox对此发表了评论。我在这里引用连续消息中的一些内容来描述这个想法。然而,我建议阅读整个讨论。

Jann:

像防止以不同类型重新分配虚拟内核地址这样的事情,这样攻击者只能用另一种相同类型的对象替换UAF对象。 … 并且,为了使其更有效,像编译器插件这样的东西,通过类型而不仅仅是大小类来隔离kmalloc(sizeof())分配。

Kees:

大的麻烦是kmalloc缓存,它们没有关联的类型。在那里基于分配的类型拥有隐式kmem缓存可能需要一些相当广泛的管道,我认为?

Jann:

你需要教导编译器前端从sizeof()中获取类型名称,并将该类型信息放在某处,例如通过生成一个引用类型的额外函数参数,或类似的东西。

Daniel:

当整个slab被释放时,它将重用它用于其他事情。如果不通过虚拟内存以及更高级别的区域管理来支持,以避免严重的碎片化和元数据浪费,这并不现实。它将很大程度上依赖于具有更细粒度的slab缓存。

Christopher:

实际上,类型化这些访问可能会消除许多kmalloc分配,并有助于简化对象的管理和控制。

这可能是一项大任务,因为kmalloc的普遍存在以及需要创建大量新的slab缓存。这将显著降低缓存命中率。

结论

原型化Linux内核堆隔离并针对释放后利用技术进行测试是一个快速而有趣的研究项目。它没有变成适合主线的最终解决方案,但确实给了我们有用的结果和想法。我写这篇文章是为了总结这些努力,以供将来参考。

现在,让我以几天前睡前写的一首小诗结束:

隔离补丁版本三
不会出现。不需要。
让我们像往常一样
利用释放后使用 ;)

– a13xp0p0v

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