가상 메모리상 커널 영역의 text 영역, vmalloc 영역 등의 위치를 무작위로 변경하여, 공격자가 커널 영역의 주소를 예측하고 활용하는 것을 어렵게 만든다.
ASLR을 적용한 바이너리의 코드, 데이터, 힙 등의 베이스 주소가 실행 중에 변경되지 않는 것 처럼 KASLR도 재부팅되기 전에는 베이스 주소가 변하지 않는다. 따라서 커널 패닉이 발생하지 않는다면 브루트포스 공격으로 커널 베이스를 구할 수 있다.
또한 16비트 이상의 엔트로피를 가지는 ASLR와 달리 KASLR은 64비트에서 최대 9비트의 엔트로피만 가질 수 있기 때문에 355번 시도하면 약 50%의 확률로 추측에 성공할 수 있다.
이러한 엔트로피를 설정하는 리눅스 커널 옵션은 CONFIG_PHYSICAL_ALIGN이다. 이 옵션은 커널이 물리적 메모리에 로드될 때 사용할 물리적 메모리 주스의 정렬 기준값을 의미하고, 2의 거듭제곱 형태로 설정된다.
만약 CONFIG_PHYSICAL_ALIGN 값이 0x200000으로 설정된 커널에선 9비트 엔트로피를 가지게 된다.

0x1000000으로 설정하게 되면 6비트 엔트로피를 가진다.

즉 CONFIG_PHYSICAL_ALIGN 값을 작게 설정할수록 KASLR의 무작위화 간격이 더 촘촘해져 엔트로피가 증가한다.
커널 모드(ring 0)일 때 유저 모드(ring 3)에서 할당된 페이지의 코드가 실행되는 것을 막는 보호 기법이다. 유저 영역의 보호 기법인 No-eXecute(NX)와 유사한 효과를 가지고 있다.
만약 커널 모드에서 유저 모드 주소 공간의 코드를 실행하려고 하면 CPU에서 Page Fault가 발생하고, 해당 동작을 중지시킨다.
이 보호 기법은 v3.0부터 도입되었으며 다음과 같이 CPU의 cr4 레지스터에 SMEP 관련 비트를 설정하는 방식으로 활성화한다.
void dummy(void)
{
return;
}
int main()
{
int fd;
fd = open("/dev/exec", O_RDONLY);
ioctl(fd, 0, dummy);
return 0;
}
이 코드는 ioctl()을 호출해서 exec 모듈에 유저 영역의 dummy() 주소를 전달하여 커널 영역에서 유저 영역의 코드를 실행하게 해주는 코드이다.
static long exec_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
void (*exec)(void) = (void (*)(void))arg;
exec();
return 0;
}
SMEP가 활성화된 커널에서 코드를 실행하면 커널 패닉이 발생하며 unable to execute userspace code (SMEP?) 이라는 메세지를 남긴다.
정리하면 유저 영역에서 할당된 페이지의 코드는 커널 영역에서 실행할 수 없다.
커널 모드(ring 0)일 때 유저 모드(ring 3)에서 할당된 메모리에 대한 읽기와 쓰기를 막는 보호 기법이다. 실행을 막는 SMEP의 한계점을 보완한다.
이 보호 기법은 v3.7부터 도입되었으며 SMEP와 비슷하게 CPU의 cr4 레지스터에 SMAP 관련 비트를 설정하는 방식으로 SMAP을 활성화할 수 있다.
static __cpuinit void setup_smap(struct cpuinfo_x86 *c)
{
if (cpu_has(c, X86_FEATURE_SMAP)) {
if (unlikely(disable_smap)) {
setup_clear_cpu_cap(X86_FEATURE_SMAP);
clear_in_cr4(X86_CR4_SMAP);
} else {
set_in_cr4(X86_CR4_SMAP); // cr4 레지스터 비트 설정
/*
* Don't use clac() here since alternatives
* haven't run yet...
*/
asm volatile(__stringify(__ASM_CLAC) ::: "memory");
}
}
}
int main()
{
char buf;
int fd;
fd = open("/dev/rw", O_RDONLY);
buf = 0;
ioctl(fd, 0, &buf);
printf("buf: 0x%x\n", buf);
return 0;
}
이 코드는 rw 모듈에 유저 영역 주소인 지역 변수 buf의 주소를 전달한다. 이로 인해 커널 영역에서 유저 영역의 페이지에 데이터를 읽고 쓰게 된다.
static long rw_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
void __user *uaddr = (void __user *)arg;
if (*(char *)uaddr == 0)
*(char *)uaddr = 0x41;
return 0;
}
SMAP이 활성화된 커널에서 코드를 실행하면 커널 패닉이 발생하며 #PF: supervisor read access in kernel mode 라는 메세지를 남긴다.
정리하면 유저 영역인 buf에 대한 읽기 작업인 *(char *)uaddr == 0이 실행되는 도중 SMAP에 의해 페이지 폴트가 발생한 것이다.
이렇게 SMEP와 SMAP이 모두 활성화 되어 있을 경우 유저 영역에서 할당된 페이지에 대한 실행, 읽기, 쓰기가 모두 차단된다.
스택 버퍼 오버플로우 공격을 방지하기 위해 사용되는 보호 기법으로, 스택에 랜덤한 canary 값을 배치하여 이 값이 오염되는지의 여부로 오버플로우 발생을 탐지한다.
유저 영역의 카나리와 동일한 원리로 작동하며 SSP가 트리거되면 유저 영역에선 stack smashing detected 메세지가 출력되고 커널 영역에선 커널 패닉이 발생한다.
Meltdown 취약점에 대응하기 위해 도입된 보호 기법으로, 커널 영역과 유저 영역의 페이지 테이블을 분리하는 방식으로 작동한다.
Meltdown 취약점
2018년 대다수의 상용 CPU에 존재했던 Meltdown 취약점은 사용자 모드 프로그램이 커널 주소공간의 메모리를 읽을 수 있는 취약점이다. 운영 체제의 사용자 권한을 가진 공격자는 Meltdown 취약점을 이용하여 페이지 테이블에 지정된 커널/사용자 접근 권한 비트를 무시하고 시스템의 민감한 정보를 탈취할 수 있게 된다.
Meltdown 취약점은 CPU의 투기적 실행 구현이 잘못되어 발생한다. 접근 권한이 없는 메모리를 읽는 명령이 발생할 수 있는 여러 가능한 경로를 미리 추측하고, 그 중 일부를 사전 실행할 때 CPU 캐시가 변경된다. 공격자는 이를 바탕으로 접근할 수 없는 메모리의 내용을 추론할 수 있다.
KPTI는 사용자 모드에서 사용하는 페이지 테이블과 커널 모드에서 사용하는 페이지 테이블을 분리하는 방식으로 작동한다.
KPTI가 활성화된 커널에서 커널 익스플로잇을 수행하게 되면 사용자 모드로 복귀할 때 페이지 테이블 또한 사용자 주소공간으로 전환해야 한다.
커널 메모리와 유저 공간 메모리를 별도의 페이지 테이블에 매핑하는 KAISER 보호 기법은 이전에 발견되었던 CPU 취약점을 통해 KASLR을 우회하는 공격을 막기 위해 설계되었지만 취약점의 파급력에 비해 성능 저하가 커서 쓰이진 않았다. 그러나 Meltdown 취약점이 발표되면서 이론적인 모델이었던 KAISER를 리눅스 커널에 구현하여 KPTI 방어 기법으로 발전되었고 성능 저하에도 불구하고 리눅스 커널에 기본적으로 포함되었다.
커널 모드 페이지 테이블은 모든 주소공간을 포괄하는 반면에 사용자 모드 페이지 테이블은 사용자 주소공간 및 커널 모드 전환에 필요한 최소한의 영역만을 포함한다.
KPTI가 활성화된 커널에서 사용자 모드로 진입하게 되면 사용 중인 페이지 테이블 역시 사용자 모드 페이지 테이블로 전환된다. 이렇게 되면 트램폴린을 제외한 커널 주소공간이 가상 메모리에 존재하지 않게 되고 Meltdown 공격을 수행해도 민감한 커널 메모리에 접근할 수 없다.
사용자 모드와 커널 모드의 페이지 테이블을 분리하게 되면 커널은 각 페이지 테이블에 접근 권한을 따로 지정할 수 있다. 리눅스 커널은 이를 이용하여 커널 모드 페이지 테이블에서 사용자 주소공간 전체를 NX 영역으로 지정한다.
위의 모든 보호 기법들을 우회한 후 Root 권한을 획득해야 한다. Root 권한을 획득하는 방법을 알기 위해선 prepare_kernel_cred()와 commit_creds()에 대해 알아야 한다.
prepare_kernel_cred()는 원하는 자격 증명 정보를 담은 cred 구조체를 생성한 뒤 반환하는 함수이다. 이 함수의 원형은 다음과 같다.
struct cred *prepare_kernel_cred(struct task_struct *daemon)
먼저 이 함수는 다음과 같이 daemon의 cred 구조체를 old에 저장한다.
old = get_task_cred(daemon);
만약 daemon으로 init_task를 전달하면 old에 Root 권한을 나타내는 init_cred가 저장된다.
struct task_struct init_task __aligned(L1_CACHE_BYTES) = {
...
.group_leader = &init_task,
RCU_POINTER_INITIALIZER(real_cred, &init_cred),
RCU_POINTER_INITIALIZER(cred, &init_cred),
...
cred를 가져온 후에 new를 새로 할당하여 old를 복사하고 구조체 멤버를 초기화한 뒤 반환한다.
new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
old = get_task_cred(daemon);
*new = *old;
new->non_rcu = 0;
atomic_long_set(&new->usage, 1);
get_uid(new->user);
get_user_ns(new->user_ns);
get_group_info(new->group_info);
return new;
따라서 이 함수에 daemon 값으로 &init_task를 전달할 수 있으면, Root 권한을 갖는 cred 구조체를 반환받을 수 있다.
commit_creds()는 현재 태스크의 자격 증명을 다른 자격 증명으로 변경하는 커널 함수이다. 함수의 원형은 다음과 같다.
int commit_creds(struct cred *new)
먼저 이 함수는 현재 태스크를 가리키는 task 포인터를 선언한다.
struct task_struct *task = current;
그리고 인자로 받은 new로 현재 태스크의 자격 증명을 교체한다
rcu_assign_pointer(task->real_cred, new);
rcu_assign_pointer(task->cred, new);
최종적으로 prepare_kernel_cred의 인자로 &init_task를 전달하여 Root 권한의 cred를 반환시키고 이를 다시 commit_creds(0의 인자로 전달하면 현재 태스크의 권한을 Root 권한으로 상승시킬 수 있다.
commit_creds(prepare_kernel_cred(&init_task));
따라서 커널 권한에서 임의의 코드를 실행시킬 수 있는 상황에서 위 두 함수의 주소 및 init_task의 주소를 알고 있다면 이를 이용하여 권한 상승을 시도할 수 있다.
따라서 아까 설명했던 커널 보호 기법들은 commit_creds(prepare_kernel_cred(&init_task));를 호출하지 못하게 막기 위한 목적으로 커널에 추가되었다.
😩