KernelSnitch[논문 리뷰]
1. Intro
이 글은 NDSS 2025에서 발표된 KernelSnitch 논문을 소개이다. 이 연구는 커널의 평범한 데이터 구조체들이 가진 본질적인 특성이 어떻게 심각한 보안 취약점이 되는지를 보여준다.
핵심은 이러하다: "데이터 구조체의 크기에 따른 접근 시간 차이를 이용해 커널의 비밀 정보를 유출할 수 있다"
여기서는 커널 힙 포인터 유출에 집중해서 설명한다. 이 공격이 성공하면 KASLR을 우회하고 더 심각한 커널 익스플로잇의 발판이 될 수 있다.
2. 공격 대상 자료구조와 취약성 분석
Hash Table - Futex를 중심으로 먼저 가장 중요한 공격 대상인 futex 해시 테이블을 살펴보자.
2. 1 Futex(fast userspace mutex)란?
유저 공간의 빠른 동기화 매커니즘
대부분의 경우 커널 호출 없이 사용자 공간에서 처리.
아래와 같이 커널에 구현되어있다.
// kernel/futex/futex.h
struct futex_hash_bucket {
atomic_t waiters;
spinlock_t lock;
struct plist_head chain;
} ____cacheline_aligned_in_smp;
// kernel/futex/core.c
static struct {
struct futex_hash_bucket *queues;
unsigned long hashmask;
} __futex_data __read_mostly __aligned(2*sizeof(long));
#define futex_queues (__futex_data.queues)
#define futex_hashmask (__futex_data.hashmask)
2.2 왜 취약한가?
// kernel/futex/core.c
// line 443~452
struct futex_q *futex_top_waiter(struct futex_hash_bucket *hb, union futex_key *key)
{
struct futex_q *this;
// 리스트의 모든 요소를 순회하는 매크로
plist_for_each_entry(this, &hb->chain, list) {
if (futex_match(&this->key, key))
return this;
}
return NULL;
}
futex의 futex_top_waiter함수를 보면 리스트의 요소 개수에 따라 실행 명령어의 수가 달라질 수 있다는 것을 알 수 있다.
Case1: Empty Bucket
1. plist_for_each_entry 시작
2. 리스트가 비어있음 확인 (head->next == head)
3. 즉시 루프 종료
4. NULL 반환
실행 명령어: ~10개
Case2: 5 elements
1. plist_for_each_entry 시작
2. 첫 번째 요소 접근
- this 포인터 로드
- futex_match 호출 (key 비교)
- 일치하지 않음 → 계속
3. 두 번째 요소 접근
- 동일한 과정 반복
4. ... 5번째까지 반복
5. NULL 반환
실행 명령어: 10 + (8 × 5) = 50개
와 같다. 결과적으로 요소의 개수에 따라 실행 시간에 차이가 발생할 것임을 알 수 있다.
이 실행 시간 차이가 공격의 시작점이 된다.
2.3 사이클 측정하기
futex_wake의 코드를 살펴보면 다음과 같다.
// kernel/futex/waitwake.c
int futex_wake(u32 __user *uaddr, unsigned int flags, int nr_wake, u32 bitset) {
...
plist_for_each_entry_safe(this, next, &hb->chain, list) {
if (futex_match (&this->key, &key)) {
if (this->pi_state || this->rt_waiter) {
ret = -EINVAL;
break;
}
/* Check if one of the bits is set in both bitsets */
if (!(this->bitset & bitset))
continue;
this->wake(&wake_q, this);
if (++ret >= nr_wake)
break;
}
}
futex_wake는 전체 리스트를 순회하고, 특별한 권한 없이도 호출 가능하므로 좋은 실행 시간 측정 도구가 될 수 있다.
2.4 공격하기(커널 주소 유출)
공격의 목표는 mm_struct의 커널 주소이다. mm_struct는 프로세스의 메모리 관리 정보를 담고 있고, 이 주소를 알면 KASLR을 우회할 수 있다.
공격은 두 단계로 이루어지는데,
해시 충돌 감지 (같은 버킷에 들어가는 주소들 수집)
역해싱으로 커널 주소 추정 (수집한 정보로 mm_struct 주소 계산) 의 과정으로 진행된다.
2.4.1 Futex 해시 함수의 실제 위치와 동작
실제 커널 코드에서 해시 함수는 다음과 같다.
// kernel/futex/core.c
struct futex_hash_bucket *futex_hash(union futex_key *key)
{
// key 전체를 해시값으로 변환하고
u32 hash = jhash2((u32 *)key, offsetof(typeof(*key), both.offset) / 4,
key->both.offset);
// 해시값을 버킷 번호로 변환한다.
return &futex_queues[hash & futex_hashmask];
}
여기서 공격의 첫 단계로 해시 충돌을 일으킨다.
2.4.2 비둘기집 원리로 증명되는 해시 충돌
// kernel/futex/core.c
// line 55. 56
#define futex_queues (__futex_data.queues)
#define futex_hashmask (__futex_data.hashmask)
가능한 futex_queues는 CPU 개수에 종속적으로 결정된다.
따라서 비둘기집의 원리를 적용하면
가능한 사용자 주소: $2^{48}$ 개
버킷 수: 256 ~ 4096개(시스템에 따라)
가능한 사용자 주소 수 > 버킷 수에 따라 같은 버킷을 사용하는 서로 다른 사용자 주소의 존재성이 증명된다.
futex 해시 테이블은 uaddr(유저 주소)와 mm_struct을 이용하여 해시값을 생성하므로, 서로 다른 uaddr에 대해 같은 mm_struct를 사용했을 때 해시 충돌이 발생하는 조합을 찾는다.
여기서 해시 충돌의 발생 조합을 찾는 과정에서 논문이 제시하는 KernelSnitch가 사용된다. 이를 통해 직접 커널 내부를 보지 않고도 해시 충돌 조합을 구할 수 있다.
3. KernelSnitch의 원리
KernelSnitch는 캐시 사이드 채널을 이용해 커널 데이터 구조의 메모리 접근 여부를 사용자 공간에서 추론하는 도구다. 즉 커널이 특정 주소를 읽거나 썼는지를 CPU 캐시 상태를 통해 간접적으로 관찰할 수 있게 해준다.
기본 아이디어: Flush+Reload로,
공격자는 다음과 같은 과정을 통해 커널의 접근 여부를 관찰할 수 있다.
특정 구조체 필드가 위치한 주소를
clflush명령어로 캐시에서 제거한다.해당 구조체를 접근하도록 커널을 유도한다. (예: futex 호출)
다시 사용자 공간에서 해당 주소를 읽고 접근 시간을 측정한다.
캐시 접근이면 빠르게 로드됨 → 커널이 접근한 것
메모리 접근이면 느리게 로드됨 → 커널이 접근하지 않은 것
이와 같은 방법으로 커널 내부 구조체에 대한 접근 여부를 사용자 공간에서 추론할 수 있다.
구체적으로는 다음과 같다.
① 해시 충돌 탐지 시 Occupancy 측정 (Section: B. Kernel Heap Pointer Leak)
해당 공격에서 시간 측정은 다음과 같은 "간접적인 occupancy(버킷의 리스트 길이)" 측정 도구로 사용된다.
인용:
A low occupancy level, such as for the user identifier uaddrY, indicates a different hash bucket.
Conversely, a higher occupancy level, e.g., uaddrZ, means that the values futex_hash(uaddrA, mmA)
and futex_hash(uaddrZ, mmA) match.
의미:
공격자가
uaddrA로 채운 버킷과uaddrZ를 조합하여 충돌 여부를 확인한다.이때, 커널이 리스트를 몇 개 순회하는지를
futex_wake()같은 인터페이스로 호출하고,
호출 시간이 오래 걸리는지로 충돌 여부를 추론한다.즉, "같은 버킷이라면 요소가 많아지고 시간이 길어진다" 는 점을 이용하는 것이다.
요약:
더 오래 걸리면 → 같은 해시 버킷 → 충돌 발생
짧게 끝나면 → 다른 버킷 → 충돌 아님
② Flush+Reload로 구조체 필드 접근 여부 측정 (Section: KernelSnitch architecture)
공격자는 구조체의 특정 필드를 대상으로 다음과 같은 흐름을 수행한다:
인용:
We
clflusha field of a kernel structure, invoke the syscall that may access it,
and then reload the same memory to measure access time.
의미:
clflush→ 커널 구조체 필드를 CPU 캐시에서 제거한다.시스템콜 유도 (
futex_wake()등) → 커널이 해당 필드를 접근하면 다시 캐시에 올라간다.사용자 공간에서 해당 주소를 다시 읽고 접근 시간 측정:
캐시에 올라가 있으면 → 빠르게 읽힘 → 커널이 접근했다는 뜻
메모리 접근이면 → 느리게 읽힘 → 접근하지 않은 것
요약:
접근 시간 짧음 → 캐시에 있음 → 커널이 접근함
접근 시간 김 → 메모리 접근 → 커널이 접근하지 않음
4. 역계산
다시 본론으로 돌아와서 해시 충돌에 대한 충분한 정보를 얻었으므로 역계산을 통해 mm_struct를 알아낼 차례다.
해시 함수는 단방향 함수이므로 역계산을 하는 것은 어려워보이나, 위 과정으로부터 해시 함수의 알고리즘(jhash2)를 알고 있고 많은 사용자주소-버킷번호의 매핑을 수집했고, 해시 함수의 입력이 사용자 주소와 mm_struct 주소의 조합이라는 것도 알고 있다.
먼저 수집된 충돌 데이터를 정리한다. 예를 들어 주소 0x1000이 버킷 42로, 주소 0x3000도 버킷 42로 매핑되었다면, 이 두 주소는 같은 mm_struct와 조합될 때 같은 해시값을 만든다는 의미이다. 이런 식으로 10만 개의 충돌 데이터를 버킷별로 그룹화하면, 각 버킷에는 수백 개의 주소가 모인다.
다음으로 mm_struct가 위치할 수 있는 주소 공간을 이해해야 한다. x86_64 리눅스에서 커널 힙은 Direct Physical Mapping(DPM) 영역을 통해 접근되는데, 이 영역은 0xffff888000000000부터 0xffffc87fffffffff까지이다.
하지만 mm_struct는 아무 주소에나 할당되지 않는다. 슬랩 할당자의 규칙에 따라 8페이지(32KB) 단위로 정렬된 슬랩에 할당되며, 각 슬랩에는 23개의 mm_struct 객체가 들어간다.
이러한 제약 조건들을 활용하면 검색 공간을 2^46에서 2^35.5로 대폭 줄일 수 있다.
이제 실제 역계산을 수행한다. 가능한 모든 슬랩 베이스 주소를 순회하면서, 각 슬랩 내의 23개 위치에 대해 mm_struct 후보 주소를 생성한다. 각 후보 주소에 대해, 우리가 수집한 모든 충돌 패턴이 설명되는지 검증한다. 예를 들어 버킷 42에 속한 모든 주소들이 이 mm_struct 후보와 조합될 때 정말로 버킷 42로 해싱되는지 확인하는 것이다.
검증 과정은 매우 단순하다. 후보 mm_struct 주소와 사용자 주소로 futex_key를 재현하고, jhash2 함수로 해시값을 계산한 뒤, futex_hashmask와 AND 연산하여 버킷 번호를 구한다. 이 버킷 번호가 우리가 관찰한 버킷 번호와 일치하는지 확인한다. 모든 충돌 패턴이 일치하는 mm_struct 주소를 찾으면, 그것이 바로 우리가 찾던 커널의 정보다.
Cross-Cache Reuse로 공격 확장
위 과정을 통해 mm_struct의 주소를 얻었다. 그럼 이제 Cross-Cache Reuse를 통해 더 위험한 공격을 수행할 수 있다. 이는 커널의 메모리 관리 메커니즘을 악용하는 기법이다.
리눅스 커널은 효율적인 메모리 관리를 위해 슬랩 할당자(SLUB)을 사용하는데 슬랩 할당자는 같은 크기와 타입의 객체들을 미리 할당된 메모리 슬랩에서 관리한다. 예를 들면 mm_struct 전용 캐시같은 것이다. Cross-Cache Reuse의 핵심은 한 캐시에서 해제된 메모리를 다른 캐시에서 재할당받는 것이다.
이를 수행하려면, 먼저 유출된 mm_struct에서 슬랩 정보를 추출한다. mm_struct를 32KB(8 페이지)로 정렬하면 슬랩 베이스 주소를 얻을 수 있다. 이 슬랩에는 23개의 mm_struct 객체가 들어있는데, 우리의 목표는 이 슬랩 전체를 해제하는 것이다.
슬랩을 해제하는 방법은 여러 가지가 있는데 가장 직접적인 방법으로 해당 슬랩의 모든 mm_struct를 사용하는 프로세스들을 종료시키는 것이 있다.
슬랩이 해제되면 이제 같은 크기의 객체로 재할당받아야 한다. 그런데 여기서 중요한 것은 mm_struct와 같은 할당자 캐시를 사용하는 객체를 선택하는 것이다. msg_msg가 같은 할당자 캐시를 사용하기 때문에, 이를 할당한다.
이제 msg_msg의 주소도 알아냈으며, 이를 제어할 수 있다. msg_msg를 통해 use-after-free를 트리거하거나 임의 메모리 읽기/쓰기를 수행하거나 궁극적으로는 권한 상승을 달성할 수 있다.

