KCFI(CONFIG_CFI_CLANG)는 간접 호출마다 함수 타입 해시를 대조해 제어 흐름 하이재킹을 막는 Clang 전용 보호 기능이다. 함수마다 타입 ID가 붙고 호출 지점마다 검사 코드가 들어가니 커널이 커지는 것은 분명한데, 그러면 런타임 메모리, 특히 커널이 상시 붙잡고 있는 슬랩(slab)도 같이 늘어날까. 커널 6.12를 Clang 18로 빌드한 것과 거기에 CONFIG_CFI_CLANG만 켠 것 두 개를 만들어 동일 조건에서 6회씩 부팅하며 비교했다. 결과는 예상과 조금 달랐다.
빌드 구성
비교 대상이 KCFI 하나여야 하므로, GCC 빌드와 Clang 빌드 비교에서 쓴 Clang 빌드의 .config를 복사해 CFI_CLANG만 켜고 나머지는 olddefconfig에 맡겼다.
# 이전 글에서 만든 Clang 빌드의 .config 를 그대로 복사해 CFI_CLANG 만 켠다
cd ~/kbuild
cp config.clang build-kcfi/.config
linux-6.12/scripts/config --file build-kcfi/.config -e CFI_CLANG
cd linux-6.12
make O=../build-kcfi LLVM=1 olddefconfig
make O=../build-kcfi LLVM=1 -j6 bzImage스위치 하나를 켰지만 딸려 오는 것이 몇 개 더 있다.
$ diff config.clang config.kcfi
> CONFIG_CFI_AUTO_DEFAULT=y
< CONFIG_FUNCTION_PADDING_BYTES=16
---
> CONFIG_FUNCTION_PADDING_BYTES=11
> CONFIG_FINEIBT=y
< CONFIG_PREFIX_SYMBOLS=y
< # CONFIG_CFI_CLANG is not set
---
> CONFIG_ARCH_USES_CFI_TRAPS=y
> CONFIG_CFI_CLANG=y
> # CONFIG_CFI_ICALL_NORMALIZE_INTEGERS is not set
> # CONFIG_CFI_PERMISSIVE is not set
| 옵션 | 의미 |
|---|---|
CFI_AUTO_DEFAULT | 빌드 전체에 CFI를 기본 적용 |
FINEIBT | CPU가 IBT를 지원하면 부팅 시 FineIBT 방식으로 전환 |
ARCH_USES_CFI_TRAPS | 검사 실패를 ud2 트랩으로 처리하고 그 위치를 .cfi_sites에 기록 |
FUNCTION_PADDING_BYTES 16 → 11 | 함수 앞 패딩 16바이트 중 5바이트를 타입 ID mov 명령이 차지 |
PREFIX_SYMBOLS 해제 | 패딩 표시용 __pfx_ 심볼이 __cfi_ 심볼로 대체됨 |
바이너리에서 확인하는 KCFI
함수 앞 16바이트가 어떻게 바뀌는지 같은 함수에서 비교하면 바로 보인다.
$ objdump -d build-clang/vmlinux ...
ffffffff81321d40 <__pfx_ksys_sync>:
ffffffff81321d40: 90 nop
ffffffff81321d41: 90 nop
... (16바이트 전부 nop)
$ objdump -d build-kcfi/vmlinux ...
ffffffff813297b0 <__cfi_ksys_sync>:
ffffffff813297b0: b8 0c 67 40 a5 mov $0xa540670c,%eax
ffffffff813297b5: 90 nop
... (nop 11개)
ffffffff813297c0 <ksys_sync>:
ffffffff813297c0: f3 0f 1e fa endbr64
0xa540670c가 ksys_sync의 함수 타입 해시다. 간접 호출 시 대상 주소 앞 16바이트에서 이 값을 읽어 호출 측이 기대하는 타입과 비교하고, 다르면 트랩을 낸다. 심볼 수로 보면 __pfx_ 60150개가 __cfi_ 50994개로 대체되면서 전체 심볼이 9135개 줄었다.
부팅 로그에서 실제 적용 방식도 확인된다.
$ grep -i cfi out-kcfi-1.log
[ 0.009799] SMP alternatives: Using kCFI
FINEIBT=y로 빌드했지만 QEMU TCG의 가상 CPU가 IBT를 지원하지 않아 kCFI 모드로 동작했다. FineIBT는 IBT 지원 CPU에서만 선택된다.
슬랩은 변하지 않는다
먼저 305개 캐시의 object_size/slab_size/objs_per_slab을 전수 비교했다. 차이는 없었고, 캐시 목록 자체도 동일했다. KCFI는 코드와 호출 규약에만 손대고 자료구조 정의는 건드리지 않으니 당연한 결과다.
사용량도 마찬가지다. 빌드당 6회 부팅에서 active_objs 범위가 서로 겹치지 않는 캐시는 하나도 없었다.
| 지표 | Clang | Clang + KCFI | 차이 |
|---|---|---|---|
Slab 총량 | 8074 kB (7900~8204) | 8087 kB (7960~8192) | +13 kB |
SUnreclaim | 6989 kB | 7011 kB | +21 kB |
KernelStack | 1089 kB | 1089 kB | 0 |
kernfs_node_cache | 1543.4 KB | 1546.3 KB | +2.9 KB (변동폭 16~28 KB) |
task_struct | 455.3 KB | 440.8 KB | -14.5 KB (변동폭 29 KB) |
ftrace_event_field | 359.3 KB | 359.3 KB | 0 (변동폭 0) |
총량 차이 13 kB는 같은 커널을 다시 부팅했을 때의 변동폭(약 300 kB)에 한참 못 미친다. 슬랩 관점에서 두 커널은 구분되지 않는다.
오히려 늘어난 가용 메모리
반면 MemTotal은 6회 부팅 모두 정확히 같은 값으로 갈렸다. 그런데 방향이 반대다. KCFI 쪽이 301 kB 더 많다.
clang MemTotal: 2021652~2021656 kB
kcfi MemTotal: 2021956 kB (6회 전부 동일) → +301 kB
이미지가 더 커졌는데 쓸 수 있는 메모리가 늘어난 이유는 커널 이미지의 배치 방식에 있다.
$ readelf -SW build-clang/vmlinux | grep -E '\.(cfi_sites|bss) '
[45] .bss NOBITS ffffffff8369b000 2a9b000 165000 00 WA 0 0 4096
$ readelf -SW build-kcfi/vmlinux | grep -E '\.(cfi_sites|bss) '
[35] .cfi_sites PROGBITS ffffffff8367ae40 2a7ae40 031cc0 04 A 0 0 1
[47] .bss NOBITS ffffffff836cf000 2acf000 131000 00 WA 0 0 4096
$ grep -E ' (__bss_stop|_end)$' build-clang/System.map build-kcfi/System.map
build-clang/System.map:ffffffff83800000 B __bss_stop
build-clang/System.map:ffffffff83830000 B _end
build-kcfi/System.map:ffffffff83800000 B __bss_stop
build-kcfi/System.map:ffffffff83830000 B _end
KCFI가 추가한 .cfi_sites는 199 KB(0x31cc0)다. 그런데 __bss_stop과 _end가 두 빌드에서 완전히 같다. 커널 이미지 끝이 2MB 경계에 정렬되어 있어, 늘어난 199 KB만큼 .bss가 줄어들며 전체 크기는 그대로 유지된 것이다. 즉 부팅 시 예약되는 이미지 크기는 두 빌드가 바이트 단위로 동일하다.
여기에 .cfi_sites가 init 영역에 놓인다는 점이 더해진다. FineIBT/kCFI 패치는 부팅 초기에 한 번만 하면 되므로 이 테이블은 부팅 후 해제된다.
clang: Freeing unused kernel image (initmem) memory: 2744K
Freeing unused kernel image (rodata/data gap) memory: 1260K 합계 4004K
kcfi : Freeing unused kernel image (initmem) memory: 2944K
Freeing unused kernel image (rodata/data gap) memory: 1360K 합계 4304K
KCFI 쪽이 300 kB를 더 반납한다. 해제된 init 메모리는 totalram_pages에 더해지므로 MemTotal 차이 +301 kB와 정확히 맞아떨어진다. 결국 이 구성에서 KCFI의 메모리 비용은 상시 점유가 아니라 부팅 중 잠깐 쓰는 199 KB 테이블이 전부였다.
주의사항
_end가 같았던 것은 이 config에서 증가분이 2MB 정렬 여유 안에 들어갔기 때문이다. 이미지가 경계를 넘는 config라면 한 번에 2MB가 더 잡힐 수 있으니, 다른 config에서는_stext~_end를 직접 확인해야 한다.size vmlinux의 text/data/bss 합계로 비교하면 KCFI 쪽이 오히려 105 KB 작게 나온다. 섹션 크기 합은 정렬 패딩과.bss조정을 반영하지 않으므로, 실제 상주량은System.map의_stext/_end로 봐야 한다.- KCFI를 켜면 실행 성능에는 비용이 있다. 이 글은 메모리만 측정했고, QEMU TCG 환경이라 실행 시간 비교는 의미가 없어 다루지 않았다.
FINEIBT는 IBT를 지원하는 CPU에서만 선택된다. 실제 하드웨어에서는 부팅 로그가Using FineIBT로 바뀌고 패치되는 코드도 달라지므로, 메모리 수치를 그대로 옮기기 전에 로그부터 확인할 것.- 모듈을 쓰는 시스템이라면 이야기가 달라질 수 있다. 이번 측정은 defconfig를 모듈 없이 initramfs만으로 부팅한 상태 기준이다.
마무리
KCFI는 커널 텍스트에 함수당 16바이트짜리 타입 ID를 심고 199 KB짜리 .cfi_sites 테이블을 추가하지만, 슬랩 캐시의 오브젝트 크기도 사용량도 바꾸지 않는다. 자료구조를 건드리지 않는 보호 기능이니 당연한 결과인데, 실제로 재보면 부팅 후 가용 메모리가 오히려 301 kB 늘어나는 것까지 확인된다. 보안 옵션의 메모리 비용을 추정할 때 이미지 크기 증가분을 그대로 런타임 비용으로 계산하면 안 된다는 사례다.