KASAN은 힙 오버플로우나 use-after-free를 잡아내는 데 강력하지만, 모든 할당에 리다이렉션 존(redzone)과 섀도 메모리 검사를 붙이는 방식이라 오버헤드가 크고 프로덕션에 상시 켜두기는 부담스럽다. KFENCE(Kernel Electric-Fence)는 이 문제를 확률적 샘플링으로 우회한다 — 모든 할당이 아니라 일정 주기마다 딱 하나씩만 감시 대상으로 삼기 때문에 오버헤드가 거의 0에 가깝고, 그래서 실제로 프로덕션 커널에 상시 켜두는 용도로 설계됐다. 이 글에서는 KFENCE의 동작 원리를 정리하고, 실제로 활성화해서 out-of-bounds 읽기를 잡아내는 것까지 직접 확인한다.
KFENCE 동작 원리
KFENCE는 부팅 시 전용 메모리 풀을 확보하고, 그 안에 가드 페이지(unmapped guard page)와 객체 페이지를 번갈아 배치한다. 객체 하나가 페이지 하나를 통째로 쓰고, 그 앞뒤로 매핑되지 않은 가드 페이지가 붙어 있는 구조다. 객체는 페이지 안에서 왼쪽 끝(뒤쪽 오버플로우 탐지용) 또는 오른쪽 끝(앞쪽 언더플로우 탐지용)에 무작위로 배치된다. 이 풀에 들어간 객체를 벗어나 인접 가드 페이지를 건드리면 즉시 페이지 폴트가 나고, KFENCE의 폴트 핸들러가 이를 가로채 리포트를 남긴 뒤 — 커널을 죽이지 않고 — 실행을 그대로 이어간다.
모든 kmalloc()이 이 풀로 가는 게 아니라, sample_interval(밀리초)마다 타이머가 한 번씩만 “다음 할당을 KFENCE 풀로 보내라”고 표시해둔다. 나머지 할당은 평소처럼 일반 slab 캐시를 쓴다. 이 확률적 샘플링 덕분에 커버리지는 KASAN보다 훨씬 낮지만, 오버헤드도 거의 없어서 상시 켜두고 버그가 우연히 걸리기를 기다리는 방식이 성립한다.
활성화 확인
먼저 커널이 KFENCE로 빌드됐는지 확인한다.
$ grep CONFIG_KFENCE /boot/config-$(uname -r)
CONFIG_KFENCE=y
CONFIG_KFENCE_SAMPLE_INTERVAL=0
CONFIG_KFENCE_NUM_OBJECTS=255
CONFIG_KFENCE_SAMPLE_INTERVAL=0은 빌드 시 기본값이 “비활성”이라는 뜻이다. 재부팅해서 커널 파라미터에 kfence.sample_interval=100을 추가하는 방법이 흔히 소개되지만, 실제로는 재부팅 없이 런타임에도 켤 수 있다. sample_interval 파라미터는 module_param_cb()로 등록된 콜백이라, 부팅이 끝난 뒤 값을 새로 쓰면 kfence_enable_late()가 호출되어 그 자리에서 바로 활성화된다.
$ echo 100 | sudo tee /sys/module/kfence/parameters/sample_interval
$ sudo cat /sys/kernel/debug/kfence/stats
enabled: 1
currently allocated: 0
total allocations: 1
total frees: 1
zombie allocations: 0
total bugs: 0
out-of-bounds 접근 실제로 재현하기
KFENCE가 켜져 있어도 kmalloc() 한 번이 곧바로 KFENCE 풀에 걸린다는 보장은 없다 — 샘플링 타이머가 돌아올 때까지 기다려야 한다. 커널 자체의 KUnit 테스트(mm/kfence/kfence_test.c)도 같은 이유로 is_kfence_address()로 확인하며 재시도하는 루프를 쓰는데, 같은 패턴으로 데모 모듈을 짰다.
// SPDX-License-Identifier: GPL-2.0
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/kfence.h>
#include <linux/sched.h>
#include <linux/jiffies.h>
#define TEST_SIZE 32
static int __init kfence_demo_init(void)
{
unsigned long timeout = jiffies + msecs_to_jiffies(10000);
char *buf = NULL;
char *guard;
volatile char oob;
while (time_before(jiffies, timeout)) {
buf = kmalloc(TEST_SIZE, GFP_KERNEL);
if (is_kfence_address(buf))
break;
kfree(buf);
buf = NULL;
schedule_timeout_uninterruptible(1);
}
if (!buf) {
pr_err("kfence_demo: 10초 안에 KFENCE 할당을 받지 못했습니다\n");
return 0;
}
/* buf가 속한 페이지 바로 다음 페이지 = KFENCE 풀 구조상 항상 가드 페이지 */
guard = (char *)(((unsigned long)buf | (PAGE_SIZE - 1)) + 1);
pr_info("kfence_demo: KFENCE 버퍼 확보 (buf=%p), 가드 페이지(%p)를 의도적으로 읽습니다\n",
buf, guard);
oob = *guard;
pr_info("kfence_demo: 여기까지 왔다면 KFENCE가 못 잡은 것입니다 (값=%d)\n", oob);
kfree(buf);
return 0;
}
static void __exit kfence_demo_exit(void)
{
}
module_init(kfence_demo_init);
module_exit(kfence_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("KFENCE out-of-bounds read demo");객체가 페이지 왼쪽에 배치될지 오른쪽에 배치될지는 무작위라, buf 바로 다음 1바이트가 항상 가드 페이지라는 보장이 없다. 대신 KFENCE 풀은 가드 페이지와 객체 페이지가 번갈아 배치되는 구조라서, buf가 속한 페이지의 바로 다음 페이지는 객체가 어느 쪽에 있든 항상 가드 페이지다. ((unsigned long)buf | (PAGE_SIZE - 1)) + 1로 그 주소를 계산해 읽었다.
실제로 빌드하고(make, 배포 커널과 동일한 gcc-13 사용) insmod한 결과다.
$ sudo insmod kfence_demo.ko
$ sudo dmesg | tail -25
kfence_demo: KFENCE 버퍼 확보 (buf=0000000083105675), 가드 페이지(000000004c8d9ba0)를 의도적으로 읽습니다
==================================================================
BUG: KFENCE: out-of-bounds read in kfence_demo_init+0x10d/0xff0 [kfence_demo]
Out-of-bounds read at 0x000000004c8d9ba0 (4096B right of kfence-#64):
kfence_demo_init+0x10d/0xff0 [kfence_demo]
do_one_initcall+0x5d/0x340
do_init_module+0x97/0x2c0
load_module+0x99c/0xac0
kfence-#64: 0x0000000083105675-0x000000001be6cc7d, size=32, cache=kmalloc-rnd-10-32
allocated by task 44174 on cpu 3 at 85352.861967s (0.000055s ago):
kfence_demo_init+0x86/0xff0 [kfence_demo]
do_one_initcall+0x5d/0x340
do_init_module+0x97/0x2c0
CPU: 3 UID: 0 PID: 44174 Comm: insmod Tainted: G OE
Tainted: [O]=OOT_MODULE, [E]=UNSIGNED_MODULE
==================================================================
kfence_demo: 여기까지 왔다면 KFENCE가 못 잡은 것입니다 (값=0)
“4096B right of kfence-#64″가 계산한 가드 페이지 주소가 정확히 맞아떨어졌다는 뜻이다. 눈여겨볼 부분은 리포트가 찍힌 다음 줄에 pr_info가 그대로 이어져 출력됐다는 점이다 — KFENCE는 폴트를 리포트한 뒤 정상적인 값(0)으로 접근을 무마하고 실행을 계속 이어가게 해준다. rmmod도 그대로 성공했고 시스템도 멀쩡했다. sudo cat /sys/kernel/debug/kfence/stats의 total bugs 값도 1 증가해 있었다.
주의사항
- 이 데모가 안전한 건
panic_on_warn=0(기본값)일 때다.panic_on_warn=1로 설정된 시스템에서는 KFENCE 리포트가 그대로 커널 패닉으로 이어질 수 있으니, 재현하기 전에 반드시cat /proc/sys/kernel/panic_on_warn으로 확인해야 한다. - KFENCE 풀 크기는
CONFIG_KFENCE_NUM_OBJECTS(이 커널은 255개)로 제한된다.sample_interval을 너무 낮게 잡으면 풀이 꽉 차skipped allocations (capacity)로 건너뛰는 할당이 늘어난다. - out-of-tree 모듈을 올리면 커널이 taint된다. 리포트에도
Tainted: [O]=OOT_MODULE, [E]=UNSIGNED_MODULE이 그대로 찍혔는데, 실제 문제 있는 인하우스 드라이버를 조사할 때 KFENCE 리포트와 함께 taint 플래그도 같이 확인하는 습관을 들이면 원인 추적이 빨라진다. - 테스트가 끝나면
sample_interval을 원래 값(0 또는 배포 기본값)으로 되돌려 시스템을 원상복구하는 게 좋다. 공유 서버라면 특히 그렇다.
마무리
KFENCE의 핵심은 “모든 할당을 감시하지 않는 대신, 감시하는 동안은 확실하게 잡는다”는 절충이다. 확률적 샘플링이라 재현에 최대 100 * sample_interval 정도의 시간이 걸릴 수 있지만, 실제로 걸리기만 하면 KASAN 못지않게 정확한 리포트(정확한 오프셋, 할당 스택트레이스)를 남기면서도 시스템을 죽이지 않는다. 이 낮은 오버헤드 덕분에 KASAN처럼 “이번 빌드에서만 켜서 재현 확인” 용도가 아니라, 프로덕션 플릿에 상시 켜두고 우연히 걸리는 버그를 잡아내는 용도로 실제로 쓰인다.