内核电围栏(KFENCE)

内核电围栏(KFENCE)是一种低开销的基于采样的内存安全错误检测器。KFENCE 可以检测堆越界访问、释放后使用(use-after-free)以及无效释放(invalid-free)错误。

KFENCE 旨在生产内核中启用,且具有接近零的性能开销。与 KASAN 相比,KFENCE 以精度换取性能。KFENCE 设计背后的主要动机是,只要有足够的总运行时间,KFENCE 就能检测到非生产测试工作负载通常不会触发的代码路径中的错误。快速实现足够大的总运行时间的方法之一是将该工具部署在大量机器集群中。

用法

要启用 KFENCE,请通过以下配置内核

CONFIG_KFENCE=y

要构建支持 KFENCE 但默认禁用的内核(若要启用,请将 kfence.sample_interval 设置为非零值),请通过以下配置内核

CONFIG_KFENCE=y
CONFIG_KFENCE_SAMPLE_INTERVAL=0

KFENCE 提供了几个其他的配置选项来自定义其行为(更多信息请参阅 lib/Kconfig.kfence 中的各自帮助文本)。

性能调优

最重要的参数是 KFENCE 的采样间隔,可以通过内核启动参数 kfence.sample_interval 以毫秒为单位进行设置。采样间隔决定了堆分配受 KFENCE 保护的频率。默认值可以通过 Kconfig 选项 CONFIG_KFENCE_SAMPLE_INTERVAL 进行配置。设置 kfence.sample_interval=0 将禁用 KFENCE。

采样间隔控制着用于设置 KFENCE 分配的定时器。默认情况下,为了保持实际采样间隔的可预测性,即使系统完全空闲,普通定时器也会引起 CPU 唤醒。这在受功耗限制的系统上可能是不可取的。启动参数 kfence.deferrable=1 则会切换为一个“可推迟(deferrable)”定时器,它不会在空闲系统上强制唤醒 CPU,但代价是采样间隔可能会变得不可预测。默认值可通过 Kconfig 选项 CONFIG_KFENCE_DEFERRABLE 进行配置。

警告

使用可推迟定时器时,KUnit 测试套件极有可能失败,因为它目前会导致非常不可预测的采样间隔。

默认情况下,KFENCE 在每个采样间隔内只会对 1 次堆分配进行采样。突发模式(Burst mode)允许对连续的堆分配进行采样,其中内核启动参数 kfence.burst 可以设置为一个非零值,表示采样间隔内的额外连续分配次数;设置 kfence.burst=N 意味着在每个采样间隔内,会尝试通过 KFENCE 进行 1 + N 次连续分配。

KFENCE 内存池大小是固定的,如果内存池耗尽,将不再进行 KFENCE 分配。通过 CONFIG_KFENCE_NUM_OBJECTS(默认值为 255),可以控制可用受保护对象的数量。每个对象需要 2 个页面,一个用于对象本身,另一个用作保护页(guard page);对象页与保护页交错排列,因此每个对象页都被两个保护页所包围。

分配给 KFENCE 内存池的总内存可以通过以下公式计算

( #objects + 1 ) * 2 * PAGE_SIZE

使用默认配置,并假设页面大小为 4 KiB,结果将为 KFENCE 内存池分配 2 MiB。

注意:在支持巨页(huge pages)的架构上,KFENCE 将确保内存池使用的是大小为 PAGE_SIZE 的页面。这会导致分配额外的页表。

错误报告

启动参数 kfence.fault 可用于控制检测到 KFENCE 错误时的行为

  • kfence.fault=report:打印错误报告并继续(默认)。

  • kfence.fault=oops:打印错误报告并触发 oops。

  • kfence.fault=panic:打印错误报告并触发 panic。

一个典型的越界访问看起来像这样

==================================================================
BUG: KFENCE: out-of-bounds read in test_out_of_bounds_read+0xa6/0x234

Out-of-bounds read at 0xffff8c3f2e291fff (1B left of kfence-#72):
 test_out_of_bounds_read+0xa6/0x234
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

kfence-#72: 0xffff8c3f2e292000-0xffff8c3f2e29201f, size=32, cache=kmalloc-32

allocated by task 484 on cpu 0 at 32.919330s:
 test_alloc+0xfe/0x738
 test_out_of_bounds_read+0x9b/0x234
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

CPU: 0 PID: 484 Comm: kunit_try_catch Not tainted 5.13.0-rc3+ #7
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
==================================================================

报告的头部提供了参与访问的函数的简短摘要。紧随其后的是关于访问及其来源的更详细信息。注意,真实的内核地址仅在使用内核命令行选项 no_hash_pointers 时才会显示。

释放后使用(Use-after-free)访问报告为

==================================================================
BUG: KFENCE: use-after-free read in test_use_after_free_read+0xb3/0x143

Use-after-free read at 0xffff8c3f2e2a0000 (in kfence-#79):
 test_use_after_free_read+0xb3/0x143
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

kfence-#79: 0xffff8c3f2e2a0000-0xffff8c3f2e2a001f, size=32, cache=kmalloc-32

allocated by task 488 on cpu 2 at 33.871326s:
 test_alloc+0xfe/0x738
 test_use_after_free_read+0x76/0x143
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

freed by task 488 on cpu 2 at 33.871358s:
 test_use_after_free_read+0xa8/0x143
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

CPU: 2 PID: 488 Comm: kunit_try_catch Tainted: G    B             5.13.0-rc3+ #7
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
==================================================================

KFENCE 还会报告无效释放,例如重复释放(double-frees)

==================================================================
BUG: KFENCE: invalid free in test_double_free+0xdc/0x171

Invalid free of 0xffff8c3f2e2a4000 (in kfence-#81):
 test_double_free+0xdc/0x171
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

kfence-#81: 0xffff8c3f2e2a4000-0xffff8c3f2e2a401f, size=32, cache=kmalloc-32

allocated by task 490 on cpu 1 at 34.175321s:
 test_alloc+0xfe/0x738
 test_double_free+0x76/0x171
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

freed by task 490 on cpu 1 at 34.175348s:
 test_double_free+0xa8/0x171
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

CPU: 1 PID: 490 Comm: kunit_try_catch Tainted: G    B             5.13.0-rc3+ #7
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
==================================================================

KFENCE 还会在对象保护页的另一侧使用基于模式的红区(redzones),以检测对象未受保护一侧的越界写操作。这些会在释放时报告

==================================================================
BUG: KFENCE: memory corruption in test_kmalloc_aligned_oob_write+0xef/0x184

Corrupted memory at 0xffff8c3f2e33aff9 [ 0xac . . . . . . ] (in kfence-#156):
 test_kmalloc_aligned_oob_write+0xef/0x184
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

kfence-#156: 0xffff8c3f2e33afb0-0xffff8c3f2e33aff8, size=73, cache=kmalloc-96

allocated by task 502 on cpu 7 at 42.159302s:
 test_alloc+0xfe/0x738
 test_kmalloc_aligned_oob_write+0x57/0x184
 kunit_try_run_case+0x61/0xa0
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x176/0x1b0
 ret_from_fork+0x22/0x30

CPU: 7 PID: 502 Comm: kunit_try_catch Tainted: G    B             5.13.0-rc3+ #7
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
==================================================================

对于此类错误,会显示发生破坏的地址以及无效写入的字节(相对于该地址的偏移量);在这种表示法中,“.”表示未被触及的字节。在上面的例子中,0xac 是写入偏移量 0 处的无效地址的值,其余的“.”表示后续字节未被触及。注意,真实值仅在内核以 no_hash_pointers 启动时才会显示;否则为避免信息泄露,会改用“!”来表示无效写入的字节。

最后,KFENCE 还可能报告对任何受保护页的无效访问,在这些访问中无法确定关联的对象(例如,如果相邻的对象页尚未分配)

==================================================================
BUG: KFENCE: invalid read in test_invalid_access+0x26/0xe0

Invalid read at 0xffffffffb670b00a:
 test_invalid_access+0x26/0xe0
 kunit_try_run_case+0x51/0x85
 kunit_generic_run_threadfn_adapter+0x16/0x30
 kthread+0x137/0x160
 ret_from_fork+0x22/0x30

CPU: 4 PID: 124 Comm: kunit_try_catch Tainted: G        W         5.8.0-rc6+ #7
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.13.0-1 04/01/2014
==================================================================

DebugFS 接口

一些调试信息通过 debugfs 暴露

  • 文件 /sys/kernel/debug/kfence/stats 提供运行时统计信息。

  • 文件 /sys/kernel/debug/kfence/objects 提供通过 KFENCE 分配的对象列表,包括那些已经释放但仍受保护的对象。

实现细节

受保护的分配是根据采样间隔建立的。在采样间隔到期后,通过主分配器(SLAB 或 SLUB)进行的下一次分配将从 KFENCE 对象池中返回一个受保护的分配(支持最大为 PAGE_SIZE 的分配大小)。此时,定时器被重置,下一次分配将在间隔到期后建立。

当使用 CONFIG_KFENCE_STATIC_KEYS=y 时,KFENCE 分配通过依赖静态键(static keys)基础设施的静态分支,在主分配器的快速路径(fast-path)中被“控流(gated)”。静态分支被切换以将分配重定向到 KFENCE。根据采样间隔、目标工作负载和系统架构,这可能比简单的动态分支表现更好。建议进行仔细的基准测试。

每个 KFENCE 对象都驻留在专门的页面上,位于随机选择的左侧或右侧页面边界处。对象页左右两侧的页面是“保护页(guard pages)”,其属性被更改为受保护状态,并在任何尝试访问时引发页错误(page fault)。然后,这些页错误会被 KFENCE 拦截,KFENCE 通过报告越界访问来优雅地处理该错误,并将该页面标记为可访问,以便出错的代码可以(错误地)继续执行(可设置 panic_on_warn 转为触发 panic)。

为了检测对对象页内存本身的越界写操作,KFENCE 还使用基于模式的红区。对于每个对象页,会为所有非对象内存设置红区。对于典型的对齐方式,红区仅在对象未受保护的一侧是必需的。由于 KFENCE 必须遵守缓存请求的对齐方式,特殊的对齐可能会导致对象两侧出现未受保护的间隙,这些间隙全都会被加入红区保护。

下图展示了页面布局

---+-----------+-----------+-----------+-----------+-----------+---
   | xxxxxxxxx | O :       | xxxxxxxxx |       : O | xxxxxxxxx |
   | xxxxxxxxx | B :       | xxxxxxxxx |       : B | xxxxxxxxx |
   | x GUARD x | J : RED-  | x GUARD x | RED-  : J | x GUARD x |
   | xxxxxxxxx | E :  ZONE | xxxxxxxxx |  ZONE : E | xxxxxxxxx |
   | xxxxxxxxx | C :       | xxxxxxxxx |       : C | xxxxxxxxx |
   | xxxxxxxxx | T :       | xxxxxxxxx |       : T | xxxxxxxxx |
---+-----------+-----------+-----------+-----------+-----------+---

在释放 KFENCE 对象时,该对象的页面会再次受到保护,并且该对象被标记为已释放。对该对象的任何进一步访问都会引发错误,KFENCE 会报告释放后使用(use-after-free)访问。已释放的对象会被插入到 KFENCE 空闲链表(freelist)的尾部,以便最久未释放的对象首先被重用,从而增加检测最近释放的对象的释放后使用错误的几率。

如果内存池利用率达到 75%(默认值)或更高,为了降低内存池最终被已分配对象完全占用的风险,同时确保分配的多样化覆盖,KFENCE 会限制同一来源的当前已覆盖分配继续填满内存池。分配的“来源”基于其部分分配栈回溯。一个副作用是,这也限制了同一来源的频繁长寿命分配(例如 pagecache)永久填满内存池,这是内存池变满并导致采样分配率降至零的最常见风险。开始限制当前已覆盖分配的阈值可以通过启动参数 kfence.skip_covered_thresh(内存池使用率百分比)进行配置。

接口

以下描述了分配器以及页面处理代码用于设置和处理 KFENCE 分配的函数。

bool is_kfence_address(const void *addr)

检查某个地址是否属于 KFENCE 内存池

参数

const void *addr

要检查的地址

返回

根据地址是否在 KFENCE 对象范围内返回 true 或 false。

描述

KFENCE 对象位于单独的页面范围内,不得与常规堆对象混杂(例如,绝不能将 KFENCE 对象添加到分配器的空闲链表中)。如果不这样做可能会也必将导致堆损坏,因此必须使用 is_kfence_address() 来检查对象是否需要特殊处理。

注意

此函数可在快速路径中使用,且对性能至关重要。未来的更改应考虑到这一点;例如,我们希望避免引入另一次加载(load),因此需要将 KFENCE_POOL_SIZE 保持为常量(直到内核中添加了即时修补支持)。

void kfence_shutdown_cache(struct kmem_cache *s)

处理 KFENCE 对象的 shutdown_cache()

参数

struct kmem_cache *s

正在被关闭的缓存

描述

在关闭缓存之前,必须确保没有从其中分配的剩余对象。由于 KFENCE 对象不是直接从缓存中引用的,我们需要在这里检查它们。

请注意,shutdown_cache() 是 SL*B 内部的,并且如果仍有已分配的对象存在,kmem_cache_destroy() 不会返回:它会打印一条错误消息并直接中止缓存的销毁,从而导致内存泄漏。

如果唯一的此类对象是 KFENCE 对象,我们不会泄漏整个缓存,而是尝试通过将已分配的对象变成“僵尸分配(zombie allocations)”来提供更有用的调试信息。此后对象可能仍被使用或释放(这会被优雅地处理),但使用会导致显示 KFENCE 错误报告,其中包含对象的用户的栈回溯、原始分配地点以及 shutdown_cache() 的调用者。

void *kfence_alloc(struct kmem_cache *s, size_t size, gfp_t flags)

以低概率分配一个 KFENCE 对象

参数

struct kmem_cache *s

带有对象要求的 struct kmem_cache

size_t size

要分配的对象的精确大小(例如对于 kmalloc 缓存,可以小于 s->size

gfp_t flags

GFP 标志

返回

  • NULL - 必须像往常一样继续分配,

  • 非 NULL - 指向 KFENCE 对象的指针。

描述

应该将 kfence_alloc() 插入到堆分配的快速路径中,允许它使用静态分支以低概率透明地返回 KFENCE 分配的对象(概率由 kfence.sample_interval 启动参数控制)。

size_t kfence_ksize(const void *addr)

获取为 KFENCE 对象实际分配的内存量

参数

const void *addr

指向堆对象的指针

返回

  • 0 - 不是 KFENCE 对象,必须改为调用 __ksize()

  • 非 0 - 可以访问这么多字节而不会引起内存错误。

描述

kfence_ksize() 返回在分配时为 KFENCE 对象请求的字节数。这个数字可能小于相应 struct kmem_cache 的对象大小。

void *kfence_object_start(const void *addr)

寻找 KFENCE 对象的起点

参数

const void *addr

KFENCE 分配的对象内的地址

返回

对象起点的地址。

描述

SL[AU]B 分配的对象在一个页面内一个接一个地布局,因此给定其中的指针和对象大小,很容易计算出对象的起点。对于 KFENCE 则不然,它将单个对象放置在页面的任一端。此辅助函数用于查找 KFENCE 分配的对象的起点。

void __kfence_free(void *addr)

将一个 KFENCE 堆对象释放回 KFENCE 内存池

参数

void *addr

要释放的对象

描述

要求:is_kfence_address(addr)

释放一个 KFENCE 对象并将其标记为已释放。

bool kfence_free(void *addr)

尝试将任意堆对象释放回 KFENCE 内存池

参数

void *addr

要释放的对象

返回

  • false - 对象不属于 KFENCE 内存池,已被忽略,

  • true - 对象已被释放到 KFENCE 内存池。

描述

释放一个 KFENCE 对象并将其标记为已释放。可以对任何对象(甚至是非 KFENCE 对象)调用此函数,以简化将钩子集成到分配器的释放代码路径中。分配器必须检查返回值以确定它是否为 KFENCE 对象。

bool kfence_handle_page_fault(unsigned long addr, bool is_write, struct pt_regs *regs)

为 KFENCE 页面执行页错误处理

参数

unsigned long addr

出错地址

bool is_write

访问是否为写操作

struct pt_regs *regs

当前的 struct pt_regs(可以为 NULL,但会显示完整的栈回溯)

返回

  • false - 地址在 KFENCE 内存池之外,

  • true - 页错误已由 KFENCE 处理,无需额外处理。

描述

KFENCE 内存池内的页错误表示内存错误,例如越界访问、释放后使用或无效的内存访问。在这些情况下,KFENCE 会打印错误消息并将出错的页面标记为存在(present),以便内核可以继续运行。