FSOP

FSOPと関数ポインタの書き換え

Challenge

[Distribution File]

1
nc sc.skb.pw 49403

既存の構造体の利用・関数ポインタの書き換え

exploitの基本的なプリミティブであるREAD/WRITEプリミティブには、いくつかのレベルがあります。 たとえば、どれだけ好きな値をWRITEできるかが考えられます。 アプリケーション上の制約によってはASCII文字列しか入力できないとか、あるいは30文字しか入力できないなどの文字数制限がある場合があります。 また、FSBの章にも書いたとおりOverflowなのかOoBなのかによってターゲット以外の余計なところも書き換えてしまうか否かが決まります。 それからREADできる値にも種類があり、libcbaseをleakできるもの・.textbaseをleakできるもの・heap/stackをleakできるもの等があります。

exploitをする際には、得られるプリミティブとそれらの種類を考慮しながら最終的な目標(userlandならシェルを取る・kernelならrootを取る・もしくは他の方法でflagだけを読み出す)を達成する道筋を立てていきます。 これがしばしばpwnがパソコンを使ったパズルみたいだと言われる理由です。

本章では、RIPを取るための手法として FSOP: File Structure Oriented Programming を扱います。 これはglibcが標準的に利用するstdinstdoutなどのFILE構造体を書き換える、とりわけその内部にある関数ポインタを書き換えることで、任意の関数を呼び出す手法です。 このような、既存の構造体を書き換えたり関数ポインタに細工をするという手法はuserlandだけではなくkernelのexploitにおいても頻繁に利用する手法です。 ぜひその感覚と考え方を手を動かしつつ体験してみてください。

struct FILE / struct _IO_FILE_plus

struct FILEは、stdinstdout・その他ユーザが開いたファイルの入出力処理やバッファリング・ストリーミング等を行うための構造体です(/libio/bits/types/struct_FILE.h):

 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
struct _IO_FILE
{
  int _flags;		/* High-order word is _IO_MAGIC; rest is flags. */
  /* The following pointers correspond to the C++ streambuf protocol. */
  char *_IO_read_ptr;	/* Current read pointer */
  char *_IO_read_end;	/* End of get area. */
  char *_IO_read_base;	/* Start of putback+get area. */
  char *_IO_write_base;	/* Start of put area. */
  char *_IO_write_ptr;	/* Current put pointer. */
  char *_IO_write_end;	/* End of put area. */
  char *_IO_buf_base;	/* Start of reserve area. */
  char *_IO_buf_end;	/* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */
  struct _IO_marker *_markers;
  struct _IO_FILE *_chain;
  int _fileno;
  int _flags2;
  __off_t _old_offset; /* This used to be _offset but it's too small.  */
  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];
  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

stdin/stdoutなどは実際はstruct FILEをラップするstruct _IO_FILE_plusという構造体です(/libio/libioP.h):

1
2
3
4
5
struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

_IO_FILEの直後にstruct _IO_jump_t型のポインタを持っていることが分かります。 これは、ファイルの入出力に利用する関数ポインタのリストです(/libio/libioP.h):

 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
struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);
    JUMP_FIELD(size_t, __dummy2);
    JUMP_FIELD(_IO_finish_t, __finish);
    JUMP_FIELD(_IO_overflow_t, __overflow);
    JUMP_FIELD(_IO_underflow_t, __underflow);
    JUMP_FIELD(_IO_underflow_t, __uflow);
    JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
    /* showmany */
    JUMP_FIELD(_IO_xsputn_t, __xsputn);
    JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
    JUMP_FIELD(_IO_seekoff_t, __seekoff);
    JUMP_FIELD(_IO_seekpos_t, __seekpos);
    JUMP_FIELD(_IO_setbuf_t, __setbuf);
    JUMP_FIELD(_IO_sync_t, __sync);
    JUMP_FIELD(_IO_doallocate_t, __doallocate);
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
    JUMP_FIELD(_IO_stat_t, __stat);
    JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
    JUMP_FIELD(_IO_imbue_t, __imbue);
};

例として、puts()がglibcでどのように実装されているか見てみましょう(/libio/ioputs.c):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int
_IO_puts (const char *str)
{
  int result = EOF;
  size_t len = strlen (str);
  _IO_acquire_lock (stdout);

  if ((_IO_vtable_offset (stdout) != 0
       || _IO_fwide (stdout, -1) == -1)
      && _IO_sputn (stdout, str, len) == len
      && _IO_putc_unlocked ('\n', stdout) != EOF)
    result = MIN (INT_MAX, len + 1);

  _IO_release_lock (stdout);
  return result;
}

最初にロックを撮ったり諸々して、ifの中ほどで_IO_sputn()を呼んでいます:

1
2
3
4
5
6
#define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)
#define _IO_XSPUTN(FP, DATA, N) JUMP2 (__xsputn, FP, DATA, N)
#define JUMP2(FUNC, THIS, X1, X2) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1, X2)
# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))
#define _IO_JUMPS_FILE_plus(THIS) \
  _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE_plus, vtable)

マクロはこのあとも続いていきますが、要はstruct _IO_FILE_plusvtableの中から該当するメンバ__xsputnを呼び出していることが分かります。この関数ポインタは、実際には以下の値が入っています:

gef> p _IO_2_1_stdout_->vtable->__xsputn
$18 = (_IO_xsputn_t) 0x7f6ab248b680 <_IO_new_file_xsputn>

最終的には_IO_new_file_xsputn()(/libio/fileops.c)が呼び出されています。このようにして、FILEは対応する関数を関数ポインタから呼び出すことでファイルの種類に応じて適切に振る舞いを変えながら入出力を行います。

FSOP

関数ポインタと言われると、関数ポインタを書き換えてRIPを取りたくなると思います。 しかし、関数テーブル _IO_file_jumps がどこにマップされているかを見てみると以下のようになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
gef> p/x &_IO_file_jumps
$20 = 0x7f6ab2616600

gef> vmmap
[ Legend:  Code | Heap | Stack | Writable | RWX]
Start              End                Size               Offset             Perm Path
...
0x00007f6ab2400000 0x00007f6ab2428000 0x0000000000028000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007f6ab2428000 0x00007f6ab25bd000 0x0000000000195000 0x0000000000028000 r-x /usr/lib/x86_64-linux-gnu/libc.so.6  <-  $rcx, $rip
0x00007f6ab25bd000 0x00007f6ab2615000 0x0000000000058000 0x00000000001bd000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007f6ab2615000 0x00007f6ab2619000 0x0000000000004000 0x0000000000214000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007f6ab2619000 0x00007f6ab261b000 0x0000000000002000 0x0000000000218000 rw- /usr/lib/x86_64-linux-gnu/libc.so.6
...
0xffffffffff600000 0xffffffffff601000 0x0000000000001000 0x0000000000000000 --x [vsyscall]

_IO_file_jumpsのアドレスは0x00007f6ab2615000 - 0x00007f6ab2615000にマップされており、 この領域はr--でマップされていることが分かります。 それもそのはずで、このテーブルはconstで定義されています(/libio/fileops.c)。 つまり、 _IO_file_jumps内の関数ポインタを書き換えることはできません

となると次に考えるのは、自前のフェイクvtableを用意したあと、_IO_FILE_plus.vtableを偽のvtableを指すように書き換えてしまうことです。 これだと確かにRWXプロットの制限には引っかかりません。 しかし、先程の_IO_sputn()の呼び出しマクロを辿ってみると以下の箇所に突き当たることが分かります:

1
# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))

IO_validate_vtableは以下のように定義されています(/libio/libioP.h):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  /* Fast path: The vtable pointer is within the __libc_IO_vtables
     section.  */
  uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
  uintptr_t ptr = (uintptr_t) vtable;
  uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
  if (__glibc_unlikely (offset >= section_length))
    /* The vtable pointer is not in the expected section.  Use the
       slow path, which will terminate the process if necessary.  */
    _IO_vtable_check ();
  return vtable;
}

この関数は、指定されたvtableが有効な領域内を指しているかどうかをチェックし、そうでない場合にはプロセスを終了させます。 この有効な領域内というのは以下に示すような範囲であり、先程のvmmapのこともわかるとおり全てRead Onlyでマップされています:

gef> p __start___libc_IO_vtables
$21 = 0x7f6ab2615a00 <_IO_helper_jumps> ""
gef> p __stop___libc_IO_vtables
$22 = 0x7f6ab2616768 ""

すなわち、この場合はvtableの指し示す先を書き換えて偽のvtableを用意することもできません。

詰んだ感じがしますが、2022年10月にkylebot(kCTFやpwn2ownで荒稼ぎしてる人)がブログにおいてangrを使ってジャンプテーブルのvalidationが存在しないパスを発見しています。

このパスでは_IO_file_jumpsテーブルではなく、_IO_wfile_jumpsテーブルを利用します(/libio/wfileops.c):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const struct _IO_jump_t _IO_wfile_jumps libio_vtable =
{
  JUMP_INIT_DUMMY,
  JUMP_INIT(finish, _IO_new_file_finish),
  JUMP_INIT(overflow, (_IO_overflow_t) _IO_wfile_overflow),
  JUMP_INIT(underflow, (_IO_underflow_t) _IO_wfile_underflow),
  JUMP_INIT(uflow, (_IO_underflow_t) _IO_wdefault_uflow),
  JUMP_INIT(pbackfail, (_IO_pbackfail_t) _IO_wdefault_pbackfail),
  JUMP_INIT(xsputn, _IO_wfile_xsputn),
  JUMP_INIT(xsgetn, _IO_file_xsgetn),
  JUMP_INIT(seekoff, _IO_wfile_seekoff),
  JUMP_INIT(seekpos, _IO_default_seekpos),
  JUMP_INIT(setbuf, _IO_new_file_setbuf),
  JUMP_INIT(sync, (_IO_sync_t) _IO_wfile_sync),
  JUMP_INIT(doallocate, _IO_wfile_doallocate),
  JUMP_INIT(read, _IO_file_read),
  JUMP_INIT(write, _IO_new_file_write),
  JUMP_INIT(seek, _IO_file_seek),
  JUMP_INIT(close, _IO_file_close),
  JUMP_INIT(stat, _IO_file_stat),
  JUMP_INIT(showmanyc, _IO_default_showmanyc),
  JUMP_INIT(imbue, _IO_default_imbue)
};

注目すべきはこの中のoverflowフィールドに入ってる _IO_wfile_overflow() です。 これは内部で_IO_wdoallocbuf()を呼びます:

 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
wint_t
_IO_wfile_overflow (FILE *f, wint_t wch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      ...
      return WEOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0)
    {
      /* Allocate a buffer if needed. */
      if (f->_wide_data->_IO_write_base == 0)
	{
	  _IO_wdoallocbuf (f);
    ...
	}
      else
    }
  ...
}

void
_IO_wdoallocbuf (FILE *fp)
{
  if (fp->_wide_data->_IO_buf_base)
    return;
  if (!(fp->_flags & _IO_UNBUFFERED))
    if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)
      return;
  _IO_wsetb (fp, fp->_wide_data->_shortbuf,
		     fp->_wide_data->_shortbuf + 1, 0);
}

#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

_IO_wdoallocbuf()_IO_WDOALLOCATEマクロを実行してジャンプテーブルにアクセスします。 このマクロを辿っていくと、 _IO_file_jumpsの場合と違ってvtableの存在する範囲チェックが存在しないことが分かります

つまり、FILE._wide_data._wide_vtableテーブルが指し示す先は自由に書き換えてしまえます。 以上を踏まえて、FSOPの手順をまとめると以下のようになります:

  1. FILE._wide_data._wide_vtableを任意に書き込めるアドレスを指すように書き換える
  2. 書き換えた先に偽のvtableを用意し、doallocateに当たる部分を実行したい命令のアドレスに書き換える
  3. FILE._vtable_IO_file_jumpsから_IO_wfile_jumpsに書き換える (この書き換え自体は、_IO_wfile_jumpsが有効なアドレスにあるためOK)
  4. FILE._vtable.__overflow(==_IO_wfile_overflow)を呼び出す
  5. その内部で、上に見たようにdoallocate(任意の命令アドレス)にRIPが移る

4の__overflowの呼び出しについてですが、glibcではexit時に呼ばれる関数 __libc_atexit として _IO_cleanup が登録されています。 この関数は、内部で _IO_flush_all_lockp() を呼び出します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

_IO_flush_all_lockp (int do_lock)
{
  int result = 0;
  FILE *fp;
...
  for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
    {
      ...
      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)
        result = EOF;
 
    ...
    }
...
}

よって、__overflowの呼び出し時には単純にexitするパスを通ればよいということが分かります。

FSOPの制約

ここまでで大まかなFSOPの手順が分かったと思います。 しかし、 このような既存の構造体を利用するexploitには構造体が満たすべき制約がついてまわります

例えば先程の_IO_flush_all_lockp()関数が_IO_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が呼び出されたあとも、いくつか満たすべき制約が存在します(Exercise 1)。 とりわけOverflowのような線形WRITEでは、書き換えたいところだけを部分的に書き換えることは難しく、 必然的にFILE構造体を最初から最後まで書き換えてやる必要があります。 よってその際にはこれらの制約を全て満たし、目的のパスまで到達させてあげる必要があります。

本Challengeの概要

脆弱性

FSOP自体の説明は以上で終わりです。最後に少しだけ本challengeの概要を見てみます。 もう解けそうな人はExerciseに進んで良いと思います。

本challengeのプログラムはbase64のデコード・エンコードをしてくれます。実際のデコード・エンコードは適当に見つけたMITライセンスで配布されてるライブラリを使っています:

 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
struct Data {
  char *buf;
  size_t size;
};
struct Data *datas[MAX_DATA] = {0};

void encode(int index) {
  struct Data *data = datas[index];
  int size = get_size(index);
  if (data == NULL) {
    data = malloc(sizeof(struct Data));
    data->buf = malloc(size);
    data->buf[0] = ENCODE_TOKEN_CHAR;
    data->size = size;
    datas[index] = data;
  }

  print("Content > ");
  readn(data->buf, size);
  char *tmp = b64_encode(data->buf, strlen(data->buf));
  memcpy(data->buf + 1, tmp, strlen(tmp));

  put(data->buf + 1);
  free(tmp);
}

サイズを指定させて入力を受け付けた後、b64_encode()でエンコードしてバッファに格納しています。

脆弱性は、memcpy(data->buf + 1, tmp, strlen(tmp))でbase64エンコードした文字列をbufに格納する部分です。 base64では、エンコードした後のバイト数が30%程度増加します。 それにも関わらずエンコード前のサイズ分しか確保していないバッファにmemcpyするのでオーバーフローが発生します。

ここで、2つほど適当にencodeしたあとのheapを見てみましょう:

gef> x/60gx 0x564cfc064290
#### Data A ######################################################
0x564cfc064290: 0x0000000000000000      0x0000000000000021
0x564cfc0642a0: 0x0000564cfc0642c0      0x0000000000000040
#### Buf  A ######################################################
0x564cfc0642b0: 0x0000000000000000      0x0000000000000051
0x564cfc0642c0: 0x0000003d3d515141      0x0000000000000000
0x564cfc0642d0: 0x0000000000000000      0x0000000000000000
0x564cfc0642e0: 0x0000000000000000      0x0000000000000000
0x564cfc0642f0: 0x0000000000000000      0x0000000000000000
#### Data B ######################################################
0x564cfc064300: 0x0000000000000000      0x0000000000000021
0x564cfc064310: 0x0000564cfc064330      0x0000000000000040
#### Buf  B ######################################################
0x564cfc064320: 0x0000000000000000      0x0000000000000051
0x564cfc064330: 0x0000003d3d515141      0x0000000000000000
0x564cfc064340: 0x0000000000000000      0x0000000000000000
0x564cfc064350: 0x0000000000000000      0x0000000000000000
0x564cfc064360: 0x0000000000000000      0x0000000000000000
#### TOP #########################################################
0x564cfc064370: 0x0000000000000000      0x0000000000020c91

struct Dataとエンコードした後の文字列を格納するバッファがそれぞれ2つずつ生成されていますね。 とりわけDataには対応するバッファのアドレスとサイズが格納されていることが分かります。

ここで、Buf Aのエンコードされた文字列をオーバーフローさせるとData Bを書き換えられそうだということが分かります。 Data Bbufアドレスを書き換えると、次にData Bのバッファに対してエンコード・デコードを行う際に書き換えたアドレスに対して書き込みを行えそうですね

例えば入力された文字列をエンコードした後の文字列が長さ0x50であり、かつ最後がP(0x70)で終わっていたとすると、 heapの状態は以下になります(なお、エンコードされた文字列はbuf + 1からコピーされるようになっています):

#### Data A ######################################################
0x564cfc064290: 0x0000000000000000      0x0000000000000021
0x564cfc0642a0: 0x0000564cfc0642c0      0x0000000000000040
#### Buf  A ######################################################
0x564cfc0642b0: 0x0000000000000000      0x0000000000000051
0x564cfc0642c0: 0x4141414141414145      0x4141414141414141
0x564cfc0642d0: 0x4141414141414141      0x4141414141414141
0x564cfc0642e0: 0x4141414141414141      0x4141414141414141
0x564cfc0642f0: 0x4141414141414141      0x4141414141414141
#### Data B ######################################################
0x564cfc064300: 0x4141414141414141      0x4141414141414141
0x564cfc064310: 0x0000564cfc064370      0x0000000000000040         <== overflowで書き換えられる
#### Buf  B ######################################################
0x564cfc064320: 0x0000000000000000      0x0000000000000051
0x564cfc064330: 0x0000003d3d515141      0x0000000000000000
0x564cfc064340: 0x0000000000000000      0x0000000000000000
0x564cfc064350: 0x0000000000000000      0x0000000000000000
0x564cfc064360: 0x0000000000000000      0x0000000000000000
#### TOP #########################################################
0x564cfc064370: 0x0000000000000000      0x0000000000020c91          <== `Data B`のバッファが新たに指す場所

Data Bのバッファがheap内の関係ないところを指すようになりました。 これでData Bから次にデータを読み書きする際に、heap内の関係ないところにアクセスできるようになりました。

このプリミティブを使うことで、AAR/AAWを実現することができます。 AAR/AAWを得るのはExercise 2とします。

libcbase leak

FSOPをするためにはlibcbaseをleakする必要があります。 今回は確保するバッファサイズをユーザが任意に指定し、かつfree()も任意のタイミングで行えるため 自由にunsorted chunkを生成することができます

heapの章でもやったとおり、unsortedbinはdouble-linked listでchunkを管理しており、 fd/bkにはそれぞれ前後のchunkのアドレスが格納されています。 また、リストの最初と最後のchunkではfd/bkに対してmain_arena内のアドレスが格納されています。 よって、生成したunsorted chunkのfd/bkを読み出すことでmain_arenaのアドレス及びlibcbaseをleakすることができます。

unsorted chunkの生成にはサイズが重要となります。 heapの章のテーブルを参照すると、tcache/fastbinに入り切らないのは0x420以上のサイズであることが分かります。 よって、0x420以上のサイズのchunkを作った後Deleteを選択すると、unsortedに繋がります。


AAW/AARの実現とlibcbaseのleakができたらあとはFSOPをするだけです。 ぜひ実際に自分でexploitを書いてみてください。


Exercise

1. 今回のFSOPでFILEが満たすべき条件

_IO_flush_all_lockp()の呼び出しから、_IO_WDOALLOCATEが呼び出されるまでに必要なFILEが満たすべき条件を、 ソースコードを追うことで列挙してみてください。

2. challengeでのAAW/AAR

challengeにおいてAAW/AARを実現してください。 challengeはExercise 3と同じです。

3. b64fsop

[Distribution File]

1
nc sc.skb.pw 49403
Last modified November 15, 2023: add warning about challenge server (55ad5da)