커널 exploit에 대한 완화책으로 Linux 커널에는 보안 기법이 몇 가지 존재합니다. 유저랜드에서 등장한 NX처럼 하드웨어 레벨에서의 보안 기법도 존재하기 때문에, 일부 지식은 Windows 커널 exploit에도 그대로 적용할 수 있습니다.

여기서 다루는 것은 커널 특유의 보호 기법으로, Stack Canary와 같은 보안 기법은 디바이스 드라이버에도 존재하지만, 그 부분에 대해서는 특기할 만한 점은 없으므로 여기서는 설명하지 않습니다.

커널 기동 시의 파라미터에 대해서는 공식 문서가 알기 쉽습니다.

SMEP (Supervisor Mode Execution Prevention)

커널 보안 기법의 대표적인 것으로 SMEP와 SMAP가 있습니다.
SMEP는 커널 공간의 코드를 실행 중에, 갑자기 유저 공간의 코드를 실행하는 것을 금지하는 보안 기법입니다. 이미지상으로는 NX와 비슷합니다.

SMEP는 완화 기법으로, 그것만으로 강력한 방어책인 것은 아닙니다. 예를 들어 커널 공간의 취약점을 이용해 공격자에게 RIP를 뺏겨버렸다고 합시다. 만약 SMEP가 무효라면, 다음과 같이 유저 공간에 준비한 쉘 코드를 실행해 버리고 맙니다.

1
2
3
4
5
char *shellcode = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXECUTE,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
memcpy(shellcode, SHELLCODE, sizeof(SHELLCODE));

control_rip(shellcode); // RIP = shellcode

하지만 SMEP가 유효한 경우, 위와 같이 유저 공간에 준비한 쉘 코드를 실행하려고 하면 커널 패닉을 일으킵니다. 이를 통해 공격자는 RIP를 빼앗아도 권한 상승으로 이어지지 못할 가능성이 올라갑니다.

늑대군

커널 공간의 쉘 코드에서는 뭘 실행하면 될까?
권한 상승 방법은 또 다른 장에서 공부할 거야.

SMEP는 qemu 실행 시 인자로 활성화할 수 있습니다. 다음과 같이 -cpu 옵션에 +smep가 붙어 있으면 SMEP가 활성화됩니다.

1
-cpu kvm64,+smep

머신 내부에서는 /proc/cpuinfo를 보는 것으로도 확인할 수 있습니다.

1
$ cat /proc/cpuinfo | grep smep

SMEP는 하드웨어 보안 기법입니다. CR4 레지스터의 21번째 비트를 세우면 SMEP가 활성화됩니다.

SMAP (Supervisor Mode Access Prevention)

유저 공간에서 커널 공간의 메모리를 읽고 쓸 수 없는 것은 보안상 당연하지만, 실은 커널 공간에서 유저 공간의 메모리를 읽고 쓸 수 없게 하는 SMAP(Supervisor Mode Access Prevention)라는 보안 기법이 존재합니다. 커널 공간에서 유저 공간의 데이터를 읽고 쓰려면 copy_from_user, copy_to_user라는 함수를 사용해야 합니다.
하지만 왜 높은 권한의 커널 공간에서 낮은 권한의 유저 공간의 데이터를 읽고 쓸 수 없게 하는 것일까요?

역사적인 경위에 대해서는 모르지만, SMAP에 의한 혜택은 주로 2가지가 있다고 생각됩니다.

먼저 첫 번째는 Stack Pivot 방지입니다.
SMEP에서 든 예시에서는 RIP를 제어할 수 있어도 쉘 코드는 실행할 수 없게 되었습니다. 하지만 Linux 커널은 매우 방대한 양의 기계어를 가지고 있기 때문에, 다음과 같은 ROP gadget이 반드시 존재합니다.

1
mov esp, 0x12345678; ret;

ESP에 들어가는 값이 무엇이든 간에, 이 ROP gadget이 호출되면 RSP는 그 값으로 변경됩니다[1]. 반면, 이런 낮은 주소는 유저랜드에서 mmap으로 확보 가능하므로, SMEP가 유효해도 공격자는 RIP를 탈취하는 것만으로 다음과 같이 ROP chain을 실행할 수 있습니다.

1
2
3
4
5
6
7
8
void *p = mmap(0x12340000, 0x10000, ...);
unsigned long *chain = (unsigned long*)(p + 0x5678);
*chain++ = rop_pop_rdi;
*chain++ = 0;
*chain++ = ...;
...

control_rip(rop_mov_esp_12345678h);

만약 SMAP가 유효하다면, 유저 공간에서 mmap한 데이터(ROP chain)은 커널 공간에서 볼 수 없으므로, stack pivot의 ret 명령에서 커널 패닉을 일으킵니다.
이처럼 SMEP와 더불어 SMAP가 유효해짐으로써, ROP를 이용한 공격을 완화할 수 있습니다.

SMAP에 의한 두 번째 혜택은 커널 프로그래밍에서 발생하기 쉬운 버그 방지입니다.
이것에는 디바이스 드라이버 등의 프로그래머가 저지르는 커널 특유의 버그가 관계됩니다. 드라이버가 다음과 같은 코드를 작성했다고 합시다. (지금은 함수 정의의 의미는 몰라도 상관없습니다.)

1
2
3
4
5
6
7
8
9
10
char buffer[0x10];

static long mydevice_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
if (cmd == 0xdead) {
memcpy(buffer, arg, 0x10);
} else if (cmd == 0xcafe) {
memcpy(arg, buffer, 0x10);
}
return
}

memcpybuffer라는 전역 변수에 데이터를 읽고 쓰고 있다는 것이 상상될 것입니다.

이 모듈은 유저 공간에서 다음과 같이 이용하면, 0x10 바이트의 데이터를 기억해 줍니다.

1
2
3
4
5
6
7
8
9
int fd = open("/dev/mydevice", O_RDWR);

char src[0x10] = "Hello, World!";
char dst[0x10];

ioctl(fd, 0xdead, src);
ioctl(fd, 0xcafe, dst);

printf("%s\n", dst); // --> Hello, World!

유저 공간 프로그래밍에 익숙해져 있다면 별것 아닙니다. memcpy 사이즈도 고정이라 딱히 문제는 없어 보입니다.

하지만 만약 SMAP가 무효라면, 다음과 같은 호출도 허용되어 버립니다.

1
ioctl(fd, 0xdead, 0xffffffffdeadbeef);

0xffffffffdeadbeef라는 것은 유저 공간으로서는 무효한 주소지만, 가령 이것이 Linux 커널 속의 비밀 데이터가 들어있는 주소였다고 합시다. 그러면 디바이스 드라이버는

1
memcpy(buffer, 0xffffffffdeadbeef, 0x10);

를 실행해 버려, 비밀 데이터를 읽어 버립니다. 이번 예처럼 아무런 체크도 없이 유저 공간에서 받은 주소로 memcpy를 사용해 버리면, 유저 공간에서 커널 공간의 임의의 주소를 읽고 쓸 수 있게 됩니다.
커널 프로그래밍에 익숙하지 않은 분에게는 매우 알아차리기 어려운 취약점이지만, AAR/AAW가 가능하기 때문에 영향은 중대합니다. 이러한 실수를 막기 위해서라도 SMAP는 도움이 되고 있습니다.

SMAP는 qemu 실행 시 인자로 활성화할 수 있습니다. 다음과 같이 -cpu 옵션에 +smap이 붙어 있으면 SMAP가 활성화됩니다.

1
-cpu kvm64,+smap

머신 내부에서는 /proc/cpuinfo를 보는 것으로도 확인할 수 있습니다.

1
$ cat /proc/cpuinfo | grep smap

SMAP도 SMEP와 마찬가지로 하드웨어 보안 기법입니다. CR4 레지스터의 22번째 비트를 세우면 SMAP가 활성화됩니다.

늑대군

Intel CPU에서는 EFLAGS.AC (Alignment Check)라는 플래그를 각각 1, 0으로 변경하는 STACCLAC이라는 명령이 있어서, AC가 세팅되어 있는 동안은 SMAP의 효력이 무효가 돼.

KASLR / FGKASLR

유저 공간에서는 주소를 랜덤화하는 ASLR(Address Space Layout Randomization)이 존재했습니다. 이와 마찬가지로 Linux 커널이나 디바이스 드라이버의 코드·데이터 영역의 주소를 랜덤화하는 KASLR(Kernel ASLR)이라는 완화 기법도 존재합니다.
커널은 한 번 로드되면 이동하지 않으므로, KASLR은 기동 시에 1번만 작동합니다. 뭔가 하나라도 Linux 커널 내의 함수나 데이터의 주소를 유출할 수 있다면, 베이스 주소를 구할 수 있습니다.

2020년에 접어들어 FGKASLR(Function Granular KASLR)이라 불리는 더욱 강력한 KASLR이 등장했습니다. 2022년 현재는 디폴트로 무효인 듯하지만, 이것은 Linux 커널의 함수마다 주소를 랜덤화한다는 기술입니다. 설령 Linux 커널 내 함수의 주소를 유출할 수 있어도, 베이스 주소는 구할 수 없습니다.
하지만 FGKASLR은 데이터 섹션 등은 랜덤화하지 않으므로, 데이터의 주소를 유출할 수 있다면 베이스 주소를 구할 수 있습니다. 물론 베이스 주소로부터 특정 함수의 주소를 구하는 것도 불가능하지만, 나중에 등장할 특수한 공격 벡터에서는 이용 가능합니다.

주소는 커널 공간에서 공통이라는 점에 주의해 주세요. 설령 어떤 디바이스 드라이버가 KASLR 덕분에 exploit 불가능하더라도, 다른 드라이버가 커널 주소를 유출해 버리면, 주소는 공통이므로 exploit 가능해집니다.

KASLR은 커널의 기동 시 인자로 무효화할 수 있습니다. qemu의 -append 옵션에 nokaslr가 붙어 있으면 KASLR은 무효화되어 있습니다.

1
-append "... nokaslr ..."

KPTI (Kernel Page-Table Isolation)

2018년에 Intel 등의 CPU에서 Meltdown이라고 불리는 사이드 채널 공격이 발견되었습니다. 이 취약점에 대해서는 설명하지 않겠지만, 커널 공간의 메모리를 유저 권한으로 읽을 수 있다는 중대한 취약점으로, KASLR 우회 등이 가능했습니다. 근래의 Linux 커널에서는 Meltdown의 대책으로 KPTI(Kernel Page-Table Isolation), 혹은 예전 명칭으로 KAISER라고 불리는 기법이 유효화되어 있습니다.

가상 주소를 물리 주소로 변환할 때 페이지 테이블이 이용된다는 것은 아시는 바와 같지만, 이 페이지 테이블을 유저 모드와 커널 모드로 분리하는[2] 것이 이 보안 기법입니다. KPTI는 어디까지나 Meltdown을 막기 위한 보안 기법이므로 일반적인 커널 exploit에 있어서는 문제가 되지 않습니다. 하지만 커널 공간에서 ROP를 하는 경우 등에 KPTI가 유효라면, 마지막에 유저 공간으로 돌아갈 때 문제가 발생합니다. 구체적인 해결 방법은 Kernel ROP 장에서 다시 설명합니다.

KPTI는 커널 기동 시 인자로 활성화할 수 있습니다. qemu의 -append 옵션에 pti=on이 붙어 있으면 KPTI는 활성화되고, pti=offnopti가 붙어 있으면 무효화됩니다.

1
-append "... pti=on ..."

KPTI는 /sys/devices/system/cpu/vulnerabilities/meltdown에서도 확인할 수 있습니다. 다음과 같이 "Mitigation: PTI"라고 적혀 있으면 KPTI가 활성화된 것입니다.

1
2
# cat /sys/devices/system/cpu/vulnerabilities/meltdown
Mitigation: PTI

무효인 경우는 "Vulnerable"이 됩니다.

KPTI는 페이지 테이블의 전환이므로, CR3 레지스터의 조작으로 유저·커널 공간을 전환할 수 있습니다. Linux에 있어서는 CR3에 0x1000을 OR하는(즉 PDBR을 변경하는) 것으로 커널 공간에서 유저 공간으로 전환됩니다. 이 조작은 swapgs_restore_regs_and_return_to_usermode에 정의되어 있지만, 자세한 내용은 실제로 exploit을 작성하는 장에서 설명합니다.

KADR (Kernel Address Display Restriction)

Linux 커널에서는 함수의 이름과 주소 정보를 /proc/kallsyms에서 읽을 수 있습니다. 또한 디바이스 드라이버에 따라서는 printk 함수 등을 사용해 다양한 디버그 정보를 로그에 출력하는 것도 있어서, 이 로그는 dmesg 명령어 등으로 유저가 볼 수 있습니다.
이처럼 커널 공간의 함수나 데이터, 힙 등의 주소 정보 유출을 막기 위한 기법이 Linux에는 존재합니다. 정식 명칭은 없다고 생각하지만, 참고 문헌에서는 KADR(Kernel Address Display Restriction)이라고 부르고 있는 듯해서, 이 사이트에서도 그 명칭을 채용합니다.

이 기능은 /proc/sys/kernel/kptr_restrict 값에 따라 변경할 수 있습니다. kptr_restrict가 0인 경우, 주소 표시에 제한은 걸리지 않습니다. kptr_restrict가 1인 경우, CAP_SYSLOG 권한을 가진 유저에게는 주소가 표시됩니다. kptr_restrict가 2인 경우, 유저가 특권 레벨이라도 커널 주소는 숨겨집니다.
KADR이 무효인 경우는 주소 릭의 필요가 없어지므로, 처음에 확인하면 exploit이 간단해지는 경우가 있습니다.


연습문제 LK01의 커널에 대해 다음 조작을 수행합시다. (이전 예제에서 이미 root 권한 쉘을 가지고 있는 상태에서 시작해 주세요.)
(1) run.sh를 읽고, KASLR, KPTI, SMAP, SMEP가 유효한지 확인해 주세요.
(2) SMAP, SMEP 양쪽을 유효하게 하는 옵션을 붙여서 기동하고, /proc/cpuinfo를 보고 SMAP, SMEP가 유효하게 되어 있는지 확인해 주세요. (확인 후에 SMAP, SMEP는 다시 무효화해 주세요.)
(3) "head /proc/kallsyms"로 처음 나타나는 주소는 커널 베이스 주소입니다. KASLR이 무효인 경우, 베이스 주소가 몇이 되는지 확인해 주세요. (힌트: KADR에 주의)

  1. x64에서는 32-bit 레지스터에 대해 연산한 결과가 64-bit로 확장됩니다. ↩︎

  2. 시스템 콜 호출만은 커널·유저 공간에서 공유됩니다. ↩︎