LK01(Holstein) 장에서는 Kernel Exploit의 기초적인 공격 기법에 대해 배웁니다. 도입 장에서 LK01을 다운로드하지 않은 분은, 먼저 연습문제 LK01 파일을 다운로드해 주세요.
qemu/rootfs.cpio가 파일 시스템이 됩니다. 여기서는 mount 디렉터리를 만들고, 그곳에 cpio를 전개해 둡니다. (root 권한으로 생성해 주세요.)
초기화 처리 확인
먼저 /init이라는 파일이 있는데, 이것은 커널 로드 후 가장 먼저 사용자 공간에서 실행되는 처리 과정이 됩니다. CTF 등에서는 여기에 커널 모듈 로드 등의 처리가 적혀 있는 경우도 있으므로, 반드시 확인합시다.
이번에는 /init이 buildroot 표준이며, 모듈 로드 등의 처리는 /etc/init.d/S99pawnyable에 기재되어 있습니다.
1 |
|
여기서 중요해지는 행이 몇 가지 있습니다. 먼저
1 | echo 2 > /proc/sys/kernel/kptr_restrict |
인데, 이것은 이미 배운 대로 KADR을 제어하는 명령으로, KADR이 활성화되어 있음을 알 수 있습니다. 이것은 디버깅에 방해가 되므로 비활성화해 둡시다.
다음으로 주석 처리되어 있는
1 | #echo 1 > /proc/sys/kernel/dmesg_restrict |
인데, 이것은 CTF 문제에서는 많은 경우 활성화되어 있습니다. 의미는 일반 사용자에게 dmesg를 허용할지 여부입니다. 이번에는 연습이므로 dmesg를 허용하고 있습니다.
다음으로
1 | insmod /root/vuln.ko |
에서 커널 모듈을 로드하고 있습니다.
insmod 명령으로 /root/vuln.ko라는 모듈을 로드하고, 그 후 mknod로 /dev/holstein이라는 캐릭터 디바이스 파일에 holstein이라는 이름의 모듈을 연결하고 있습니다.
마지막으로
1 | setsid cttyhack setuidgid 1337 sh |
인데, 이것은 사용자 ID를 1337로 하여 sh를 실행하고 있습니다. 로그인 프롬프트 없이 쉘이 시작되는 것은 이 명령 덕분입니다.
디버깅할 때는 이 사용자 ID를 0으로 해두면 root 쉘을 얻을 수 있으므로, 아직 예제를 마치지 않은 분은 변경해 두세요.
또한, /etc/init.d에는 그 외에도 S01syslogd나 S41dhcpcd 등의 초기화 스크립트가 있습니다. 이것들은 네트워크 설정 등을 하지만, 이번 exploit에서는 디버깅 시 필요 없으므로 다른 디렉터리로 이동하는 등 호출되지 않도록 하는 것을 권장합니다. 이렇게 하면 부팅 시간이 몇 초 빨라집니다.
디렉터리에는 rcK, rcS, S99pawnyable이 남는 상태가 되면 OK입니다.
Holstein 모듈 분석
이 장에서는 Holstein이라고 명명된 취약한 커널 모듈을 소재로 Kernel Exploit을 배웁니다. src/vuln.c에 커널 모듈의 소스 코드가 있으므로, 먼저 이것을 읽어 봅시다.
초기화와 종료
커널 모듈을 작성할 때는 반드시 초기화와 종료 처리를 작성합니다.
108행에서
1 | module_init(module_initialize); |
라고 기술되어 있는데, 여기서 각각 초기화, 종료 처리 함수를 지정하고 있습니다. 먼저 초기화인 module_initialize를 읽어 봅시다.
1 | static int __init module_initialize(void) |
사용자 공간에서 커널 모듈을 조작할 수 있도록 하기 위해서는 인터페이스를 생성해야 합니다. 인터페이스는 /dev나 /proc에 만들어지는 경우가 많으며, 이번에는 cdev_add를 사용하고 있으므로 캐릭터 디바이스 /dev를 통해 조작하는 타입의 모듈이 됩니다. 그렇다고 해도 이 시점에서 /dev 아래에 파일이 만들어지는 것은 아닙니다. 아까 S99pawnyable에서 본 것처럼, /dev/holstein은 mknod 명령으로 만들어졌습니다.
그럼, cdev_init라는 함수의 두 번째 인수에 module_fops라는 변수의 포인터를 전달하고 있습니다. 이 변수는 함수 테이블로, /dev/holstein에 대해 open이나 write 등의 조작이 있었을 때, 대응하는 함수가 호출되도록 되어 있습니다.
1 | static struct file_operations module_fops = |
이 모듈에서는 open, read, write, close 4가지에 대한 처리만 정의하고 있으며, 그 외에는 미구현(호출해도 아무 일도 일어나지 않음) 상태입니다.
마지막으로, 모듈의 해제 처리는 단순히 캐릭터 디바이스를 삭제하는 것뿐입니다.
1 | static void __exit module_cleanup(void) |
open
module_open을 살펴봅시다.
1 | static int module_open(struct inode *inode, struct file *file) |
printk라는 낯선 함수가 있는데, 이것은 문자열을 커널 로그 버퍼에 출력합니다. KERN_INFO라는 것은 로그 레벨로, 그 외에도 KERN_WARN 등이 있습니다. 출력은 dmesg 명령으로 확인할 수 있습니다.
다음으로 kmalloc이라는 함수를 호출하고 있습니다.
이것은 커널 공간에서의 malloc으로, 힙에서 지정한 크기의 영역을 확보할 수 있습니다. 이번에는 char*형 전역 변수 g_buf에 BUFFER_SIZE(=0x400) 바이트의 영역을 확보하고 있습니다.
이 모듈을 open하면 0x400 바이트의 영역을 g_buf에 확보한다는 것을 알았습니다.
close
다음으로 module_close를 봅시다.
1 | static int module_close(struct inode *inode, struct file *file) |
kfree는 kmalloc과 대응하며, kmalloc으로 확보한 힙 영역을 해제합니다.
한번 사용자에 의해 open된 모듈은 최종적으로는 반드시 close되므로, 처음에 확보한 g_buf를 해제한다는 것은 자연스러운 처리입니다. (사용자 공간 프로그램이 명시적으로 close를 호출하지 않아도, 그 프로그램이 종료될 때 커널이 자동으로 close를 호출합니다.)
사실 이 단계에서 이미 LPE로 이어지는 취약점이 있지만, 그것은 나중 장에서 다룹니다.
read
module_read는 사용자가 read 시스템 콜 등을 호출했을 때 불리는 처리입니다.
1 | static ssize_t module_read(struct file *file, |
g_buf에서 BUFFER_SIZE만큼 kbuf라는 스택 변수에 memcpy로 복사하고 있습니다.
다음으로, _copy_to_user라는 함수를 호출하고 있습니다. SMAP 절에서 이미 설명했지만, 이것은 사용자 공간에 안전하게 데이터를 복사하는 함수입니다. copy_to_user가 아니라 _copy_to_user로 되어 있는데, 이것은 스택 오버플로우를 감지하지 않는 버전의 copy_to_user가 됩니다. 보통은 사용되지 않지만, 이번에는 취약점을 넣기 위해 사용하고 있습니다.

copy_to_user나 copy_from_user는 인라인 함수로 정의되어 있어서, 가능한 경우 크기 검사를 하도록 되어 있어.
정리하면, read 함수는 g_buf에서 일단 스택으로 데이터를 복사하고, 그 데이터를 요청한 크기만큼 읽어들이는 처리가 됩니다.
write
마지막으로 module_write를 읽어봅시다.
1 | static ssize_t module_write(struct file *file, |
먼저 _copy_from_user로 사용자 공간에서 데이터를 kbuf라는 스택 변수로 복사하고 있습니다. (이것도 스택 오버플로우를 감지하지 않는 버전의 copy_from_user입니다.) 마지막으로 memcpy로 g_buf에 최대 BUFFER_SIZE만큼 kbuf에서 데이터를 복사하고 있습니다.
스택 오버플로우 취약점
자, 커널 모듈을 대강 다 읽었는데, 몇 개의 취약점을 발견했나요?
Kernel Exploit에 도전하는 분이라면 적어도 하나는 취약점을 찾았을 것입니다. 이 절에서는 다음 위치에 있는 스택 오버플로우 취약점을 다룹니다.
1 | static ssize_t module_write(struct file *file, |
9행에서 복사할 크기 count는 사용자로부터 전달받는 반면, kbuf는 0x400 바이트이므로 명백한 스택 버퍼 오버플로우가 있습니다. 커널 공간에서도 함수 호출의 구조는 사용자 공간과 같으므로, 리턴 주소를 덮어쓰거나 ROP chain을 실행하거나 할 수 있습니다.
취약점 발현
취약점을 악용하기 전에, 이 커널 모듈을 평범하게 사용하는 프로그램을 작성하여 동작하는지 확인해 봅시다. 이번에는 다음과 같은 프로그램을 작성해 보았습니다.
1 |
|
write로 "Hello, World!"라고 쓰고, 그것을 read로 읽기만 하는 프로그램입니다.
이것을 커널 상에서 실행해 봅시다.
기대대로 작동하는 것을 알 수 있습니다. 또한, 커널 모듈이 출력한 로그를 확인해도 특별히 오류는 발생하지 않았습니다.
다음으로 스택 오버플로우를 발생시켜 봅니다. 이런 느낌이면 되겠지요.
1 |
|
실행합니다.
뭔가 불길한 메시지가 출력되었습니다.
이처럼 커널 모듈이 비정상적인 처리를 일으키면 보통 커널째로 뻗어 버립니다. 그 때 크래시된 원인과, 크래시 시의 레지스터 상태나 스택 트레이스가 출력됩니다. 이 정보는 Kernel Exploit 디버깅에서 매우 중요합니다.
이번 크래시의 원인은
1 | BUG: stack guard page was hit at (____ptrval____) (stack is (____ptrval____)..(____ptrval____)) |
라고 되어 있습니다. ptrval이라는 것은 포인터이지만, KADR에 의해 숨겨져 있습니다.
레지스터 상태에서 신경 쓰이는 것은 RIP인데, 아쉽게도 0x414141414141414141로는 되어 있지 않습니다.
1 | RIP: 0010:memset_orig+0x33/0xb0 |
크래시의 원인에도 적혀 있는 것처럼, copy_from_user로 쓸 때 스택의 끝(guard page)에 도달해 버린 것 같습니다. 너무 많이 쓴 것이 원인이므로, 쓰는 양을 줄여 봅시다.
1 | write(fd, buf, 0x420); |
그러면 크래시 메시지가 바뀝니다.
이번에는 general protection fault가 되고, RIP를 탈취했습니다!
1 | RIP: 0010:0x4141414141414141 |
이처럼, 커널 공간에서도 사용자 공간과 마찬가지로 스택 오버플로우로 RIP를 탈취할 수 있습니다. 다음 절에서는 여기서부터 권한을 상승시키는 방법에 대해 배웁니다.