FSOP

Study
2025. 7. 28.

Introduction

FSOP(File Stream Oriented Programming) 는 FILE 구조를 이용해서 익스를 하는 프로그래밍 기법이다. 이것은 libc 2.23까지는 보안이 약했으나, 2.24부터 보안 장치가 들어오더니 2.29부터는 상당히 보안이 강화되었다. 그럼에도 여전히 익스가 가능한 취약점이 존재하고, 다른 방식으로 RCE가 불가능할 때 주로 사용한다.

해당 내용을 다루기 위해서 필자는 glibc 2,23 부터 2.35 버전을 살펴보았으며, 일반적인 개론 이후 각 버전에 어떠한 보안 장치가 있는지, 그리고 어떻게 우회할지 하나씩 다룰 것이다.

Overview in FILE structure

C에서 쓰이는 FILE 구조체는 glibc 내부적으로 _IO_FILE로 typedef 되어 있으며, 이 구조체는 파일 스트림을 관리하는데 사용된다. 이 구조체는 다양한 필드를 포함하고 있으며, 파일의 상태, 버퍼링 정보, 읽기/쓰기 위치 등을 저장한다.

Note: FSOP에서 주로 다루는 함수들은 libioP.h에 정의되어 있다.

// glibc 2.23
struct _IO_FILE {
  int _flags;		/* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

  /* The following pointers correspond to the C++ streambuf protocol. */
  /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
  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;  // 다른 FILE 스트림과 연결된 체인

  int _fileno;
#if 0
  int _blksize;
#else
  int _flags2;
#endif
  _IO_off_t _old_offset; /* This used to be _offset but it's too small.  */

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

  /*  char* _save_gptr;  char* _save_egptr; */

  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE  // compatibility를 위해 존재한다.
// };                       // 일반적으로 compil되지 않으므로 무시한다.
//
// struct _IO_FILE_complete
// {
//   struct _IO_FILE _file;
#endif
#if defined _G_IO_IO_FILE_VERSION && _G_IO_IO_FILE_VERSION == 0x20001
  _IO_off64_t _offset;
# if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
  /* Wide character stream stuff.  */
  struct _IO_codecvt *_codecvt;
  struct _IO_wide_data *_wide_data;  // 익스에 활용 가능
  struct _IO_FILE *_freeres_list;
  void *_freeres_buf;
# else
  void *__pad1;
  void *__pad2;
  void *__pad3;
  void *__pad4;
# endif
  size_t __pad5;
  int _mode;
  /* Make sure we don't get into trouble again.  */
  char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
#endif
};

상당히 길기도 하고, 어차피 자주 쓰이는 부분만 살펴보면 된다. 아래 표의 형태로 정리했다. (설명은 verbose하게 썼는데, 핵심만 읽어도 된다.)

필드명오프셋핵심설명
_flags0x00파일 권한 설정파일 스트림의 상태를 나타내는 플래 그. 파일이 열려 있는지, 읽기/쓰기 모드인지 등을 나타낸다.
_IO_read_ptr0x08fread 시 포인터현재 읽기 위치를 가리키는 포인터. 파일에서 읽을 다음 바이트의 위치를 나타낸다.
_IO_read_end0x10fread 시 읽는 파일의 끝읽기 영역의 끝을 가리키는 포인터. 읽기 작업이 끝나는 위치를 나타낸다.
_IO_read_base0x18fread 시 읽는 파일의 시작읽기 영역의 시작을 가리키는 포인터. 파일에서 읽기 작업을 시작하는 위치를 나타낸다.
_IO_write_base0x20fwrite 시 쓰는 파일의 시작쓰기 영역의 시작을 가리키는 포인터. 파일에 쓰기 작업을 시작하는 위치를 나타낸다
_IO_write_ptr0x28fwrite 시 포인터현재 쓰기 위치를 가리키는 포인터. 파일에 쓸 다음 바이트의 위치를 나타낸다.
_IO_write_end0x30fwrite 시 쓰는 파일의 끝쓰기 영역의 끝을 가리키는 포인터. 쓰기 작업이 끝나는 위치를 나타낸다.
_IO_buf_base0x38fread에서 버퍼를 사용시 버퍼의 시작버퍼 영역의 시작을 가리키는 포인터. 파일 스트림의 버퍼링 정보를 나타낸다.
_IO_buf_end0x40fread에서 버퍼를 사용시 버퍼의 끝버퍼 영역의 끝을 가리키는 포인터. 버퍼의 끝을 나타낸다.
_IO_save_base0x48fread에서 버퍼를 사용시 버퍼 포인터비활성화된 읽기 영역의 시작을 가리키는 포인터. 이전 읽기 작업의 상태를 저장한다.
note-buffer에 대한 추가 설명_IO_read_end - _IO_read_ptr < 1일시 내부적으로 read 호출 후 버퍼가 사용된다. 이를 이용해 익스 가능.
_IO_backup_base0x50-백업 영역의 시작을 가리키는 포인터. 읽기 작업을 되돌릴 때 사용된다.
_IO_save_end0x58-비활성화된 읽기 영역의 끝을 가리키는 포인터. 이전 읽기 작업의 끝을 저장한다.
_markers0x60-파일 스트림의 마커를 가리키는 포인터. 파일 스트림에서 특정 위치를 표시하는 데 사용된다.
_chain0x68-파일 스트림의 체인을 가리키는 포인터. 여러 파일 스트림을 연결할 때 사용된다.
_fileno0x70-파일 디스크립터를 나타내는 정수. 파일 스트림이 연결된 파일의 디스크립터를 나타낸다.
_flags20x74-추가적인 파일 스트림 플래그를 나타내는 정수. 파일 스트림의 추가적인 상태 정보를 나타낸다.
_old_offset0x78-이전 오프셋을 나타내는 정수. 파일 스트림의 이전 위치를 저장한다.
_cur_column0x80-현재 열(column) 번호를 나타내는 정수. 파일 스트림의 현재 열 위치를 나타낸다.
_vtable_offset0x82-가상 함수 테이블의 오프셋을 나타내는 정수. C++ 스트림에서 가상 함수 호출을 지원한다.
_shortbuf0x84-짧은 버퍼를 나타내는 문자 배열. 파일 스트림의 짧은 버퍼를 저장한다.
_lock0x88_lock가 풀려 있어야 파일관련 함수 실행 가능.파일 스트림의 잠금을 나타내는 포인터. 멀티스레딩 환경에서 파일 스트림의 동기화를 지원한다.
libc + libc_e.bss() + 0x1000 처럼 write 권한이 있는 영역을 가리키도록 하면 된다.
_offset0x90-파일 스트림의 오프셋을 나타내는 정수. 파일 스트림의 현재 위치를 나타낸다.
_codecvt0x98-와이드 문자 인코딩 변환을 위한 구조체 포인터. 와이드 문자 스트림에서 인코딩 변환을 지원한다.
_wide_data0xa0-와이드 문자 스트림 데이터를 나타내는 구조체 포인터. 와이드 문자 스트림의 데이터를 저장한다.
_freeres_list0xa8-파일 스트림의 자유 자원 목록을 나타내는 포인터. 파일 스트림이 해제될 때 사용되는 자원 목록을 저장한다.

_lock 에 대한 추가설명을 하자면 _lock가 가리키는 값은 쓰기 영역이고 해당 값이 0인 주소여야 한다.

평소에 C를 사용할 때는 보이지 않지만, 이러한 구조체를 이용해서 파일 스트림을 관리한다. 그렇치만, 아직 끝난 것이 아니다. _IO_FILE은 실제로 _IO_FILE_plus라는 구조체로 확장된다!

// glibc 2.23
struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

이렇게 말이다. 따라서 _IO_FILE을 넘어 offset 0xD8에 접근하게 되면 _IO_jump_t 구조체(정확히는 그 포인터)에 접근하게 된다.

Note: _IO_FILE의 크기인 0xD8는 운영체제 및 glibc 버전에 다를 수 있다. 다만 일반적으로는 0xD8의 offset을 가진다.

_IO_jump_t에는 재미있는 것이 있다.

// glibc 2.23
#define JUMP_FIELD(TYPE, NAME) TYPE NAME
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_pbackfail_t, __pbackfail);
    JUMP_FIELD(_IO_xsputn_t, __xsputn);
    JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
    JUMP_FIELD(_IO_seekoff_t, __seekoff);
    JUMP_FIELD(_IO_seekpos_t, __seekpos);
    JUMP_FIELD(_IO_setbuf_t, __setbuf);
    JUMP_FIELD(_IO_sync_t, __sync);
    JUMP_FIELD(_IO_doallocate_t, __doallocate);
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
    JUMP_FIELD(_IO_stat_t, __stat);
    JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
    JUMP_FIELD(_IO_imbue_t, __imbue);
#if 0
    get_column;
    set_column;
#endif
};

바로 FILE 스트림의 함수 포인터들을 가지고 있다. 만약 이 값들을 오버라이트 할 수만 있다면 원하는 함수를 호출할 수 있다. 아쉽게도 vtable은 읽기 전용 영역이라 오버라이트는 못하지만, vtable 포인터 자체를 오버라이트 할 수 있다. 바로 아래에서 살펴볼 vtable overwrite이다. libc 2.23이하에서 가능하다.

아래는 대략적으로 어떤 포인터가 어떤 함수인지 정리한 것이다.

필드명offset핵심 함수설명
__finish0x10TODO파일 스트림을 닫을 때 호출되는 함수.
__overflow0x18TODO버퍼 오버플로우 시 호출되는 함수.
__underflow0x20TODO버퍼 언더플로우 시 호출되는 함수.
__uflow0x28TODO언더플로우 시 호출되는 함수.
__pbackfail0x30TODO백업 실패 시 호출되는 함수.
__xsputn0x38fprintf문자열을 출력할 때 호출되는 함수.
__xsgetn0x40TODO문자열을 입력할 때 호출되는 함수.
__seekoff0x48TODO파일 오프셋을 이동할 때 호출되는 함수.
__seekpos0x50TODO파일 위치를 이동할 때 호출되는 함수.
__setbuf0x58TODO버퍼를 설정할 때 호출되는 함수.
__sync0x60TODO파일 스트림을 동기화할 때 호출되는 함수.
__doallocate0x68TODO파일 스트림을 할당할 때 호출되는 함수.
__read0x70TODO파일 스트림에서 읽을 때 호출되는 함수.
__write0x78TODO파일 스트림에 쓸 때 호출되는 함수.
__seek0x80TODO파일 스트림의 위치를 이동할 때 호출되는 함수.
__close0x88TODO파일 스트림을 닫을 때 호출되는 함수.
__stat0x90TODO파일 스트림의 상태를 가져올 때 호출되는 함수.
__showmanyc0x98TODO파일 스트림에서 많은 문자를 읽을 때 호출되는 함수.
__imbue0x100TODO파일 스트림의 로케일을 설정할 때 호출되는 함수.

마지막으로 stdin, stdout, stderr는 각각 _IO_2_1_stdin_, _IO_2_1_stdout_, _IO_2_1_stderr_로 정의되어 있다.

// stdio.h
/* Standard streams.  */
extern struct _IO_FILE *stdin;		/* Standard input stream.  */
extern struct _IO_FILE *stdout;		/* Standard output stream.  */
extern struct _IO_FILE *stderr;		/* Standard error output stream.  */
/* C89/C99 say they're macros.  Make them happy.  */
#define stdin stdin
#define stdout stdout
#define stderr stderr

// stdio.c
_IO_FILE *stdin = (FILE *) &_IO_2_1_stdin_;
_IO_FILE *stdout = (FILE *) &_IO_2_1_stdout_;
_IO_FILE *stderr = (FILE *) &_IO_2_1_stderr_;

flags도 익스에 필요하기에 같이 첨부한다.

#define _IO_MAGIC         0xFBAD0000 /* Magic number */
#define _IO_MAGIC_MASK    0xFFFF0000
#define _IO_USER_BUF          0x0001 /* Don't deallocate buffer on close. */
#define _IO_UNBUFFERED        0x0002
#define _IO_NO_READS          0x0004 /* Reading not allowed.  */
#define _IO_NO_WRITES         0x0008 /* Writing not allowed.  */
#define _IO_EOF_SEEN          0x0010
#define _IO_ERR_SEEN          0x0020
#define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close.  */
#define _IO_LINKED            0x0080 /* In the list of all open files.  */
#define _IO_IN_BACKUP         0x0100
#define _IO_LINE_BUF          0x0200
#define _IO_TIED_PUT_GET      0x0400 /* Put and get pointer move in unison.  */
#define _IO_CURRENTLY_PUTTING 0x0800
#define _IO_IS_APPENDING      0x1000
#define _IO_IS_FILEBUF        0x2000
                           /* 0x4000  No longer used, reserved for compat.  */
#define _IO_USER_LOCK         0x8000

// 보통 0xFBAD2080으로 설정한 후 상황에 맟추어 바꾼다.

Libc < 2.23

vtable overwrite

libc 2.23에서는 위에서 살펴본 _IO_jump_t 구조체의 함수 포인터들을 오버라이트 할 수 있다.

왜 가능할까?

확인하기 위해서 fprintf의 구현을 살펴보자.

/* The 'finish' function does any final cleaning up of an _IO_FILE object.
   It does not delete (free) it, but does everything else to finalize it.
   It matches the streambuf::~streambuf virtual destructor.  */
typedef void (*_IO_finish_t) (_IO_FILE *, int); /* typedef for finish function */
#define _IO_FINISH(FP) JUMP1 (__finish, FP, 0)
#define _IO_WFINISH(FP) WJUMP1 (__finish, FP, 0)

예를 들어 fclose를 호출하면 내부적으로 호출되는 _IO_new_fclose같은 함수에서 __IO_FINISH 매크로를 통해 __finish 함수 포인터를 호출한다. 그런데 이 과정에서 별도의 체크가 없기에 vtable을 오버라이드할 수 있다.

실습

Dreamhack의 IO_FILE vtable 문제를 같이 풀면서 공부했다. 별도의 글에 작성했다.

libc < 2.29

Bypass IO_validate_vtables

libc2.24부터는 vtable 호출시 추가적인 검사가 추가되었다. 확인을 위해 libc2.27을 살펴보자.

# define _IO_JUMPS_FUNC(THIS) \
  (IO_validate_vtable                                                   \
   (*(struct _IO_jump_t **) ((void *) &_IO_JUMPS_FILE_plus (THIS)	\
			     + (THIS)->_vtable_offset)))
...
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;
}

위 함수는 JUMP 매크로를 실행할 때 함께 호출된다. validation을 어떻게 하는지 분석하자.

  1. section_length = stop_libc_IO_vtables - start_libc_IO_vtables; -> __libc_IO_vtables 섹션의 길이를 계산한다.
  2. ptr = (const char *) vtable; -> vtable 포인터를 char 포인터로 변환한다.
  3. offset = ptr - start_libc_IO_vtables; -> vtable 포인터가 __libc_IO_vtables 섹션의 시작 주소에서 얼마나 떨어져 있는지 계산한다.
  4. if (__glibc_unlikely (offset >= section_length)) -> 만약 offset이 섹션 길이보다 크거나 같다면,
  5. _IO_vtable_check (); -> _IO_vtable_check() 함수를 호출한다. 이 함수는 vtable이 올바른 섹션에 있는지 확인하고, 그렇지 않으면 프로세스를 종료시킨다.

따라서 위 validation은 vtable을 가리키는 포인터가 변조되지 않도록 하는 역할을 한다.

추가적으로 _IO_vtable_check()를 살펴보자.

void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
  /* Honor the compatibility flag.  */
  void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (flag);
#endif
  if (flag == &_IO_vtable_check)
    return;

  /* In case this libc copy is in a non-default namespace, we always
     need to accept foreign vtables because there is always a
     possibility that FILE * objects are passed across the linking
     boundary.  */
  {
    Dl_info di;
    struct link_map *l;
    if (!rtld_active ()
        || (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
            && l->l_ns != LM_ID_BASE))
      return;
  }

#else /* !SHARED */
  /* We cannot perform vtable validation in the static dlopen case
     because FILE * handles might be passed back and forth across the
     boundary.  Therefore, we disable checking in this case.  */
  if (__dlopen != NULL)
    return;
#endif

  __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}

IO_accept_foreign_vtables는 외부에 정의된 vtables를 허용할지 여부를 결정하는 플래그이다. 만약 iofile_vtable 문제처럼 vtable에 정의된 영역이 아닌 곳으로 vtable을 사용하면 IO_accept_foreign_vtables의 허가 없이는 vtable을 정상적으로 사용할 수 없다.

물론 IO_accept_foreign_vtablestrue로 설정하면 외부 vtable을 허용할 수 있지만, 손이 많이 간다는 문제가 있다. PTR_MANGLE 과정도 있기에 fs레지스터가 가리키는 tcbhead_t를 이용해서 pointer guard를 유출하거나 오버라이트 해야한다.

Note: 그렇다고 해서 불가능 한 것을 아니다...!

따라서 일반적으로 libc<2.29에서는 vtable 내부의 함수를 활용한다.

_IO_str_overflow

// glibc 2.29
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);  // HERE!
    JUMP_FIELD(_IO_underflow_t, __underflow);
    JUMP_FIELD(_IO_underflow_t, __uflow);
    JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
    /* showmany */
    JUMP_FIELD(_IO_xsputn_t, __xsputn);
    JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
    JUMP_FIELD(_IO_seekoff_t, __seekoff);
    JUMP_FIELD(_IO_seekpos_t, __seekpos);
    JUMP_FIELD(_IO_setbuf_t, __setbuf);
    JUMP_FIELD(_IO_sync_t, __sync);
    JUMP_FIELD(_IO_doallocate_t, __doallocate);
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
    JUMP_FIELD(_IO_stat_t, __stat);
    JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
    JUMP_FIELD(_IO_imbue_t, __imbue);
#if 0
    get_column;
    set_column;
#endif
};

__overflow는 일반적으로 _IO_file_overflow로 매핑된다. 하지만, vtable을 오버라이트해서 vtable 내에 있는 다른 취약한 함수를 사용한다면 어떻게 될까?

아래 _IO_str_overflow를 살펴보자.

// glibc 2.29, strops.c
int _IO_str_overflow (_IO_FILE *fp, int c)
{
  int flush_only = c == EOF;
  _IO_size_t pos;
  if (fp->_flags & _IO_NO_WRITES)
      return flush_only ? 0 : EOF;
  if ((fp->_flags & _IO_TIED_PUT_GET) && !(fp->_flags & _IO_CURRENTLY_PUTTING))
    {
      fp->_flags |= _IO_CURRENTLY_PUTTING;
      fp->_IO_write_ptr = fp->_IO_read_ptr;
      fp->_IO_read_ptr = fp->_IO_read_end;
    }
  pos = fp->_IO_write_ptr - fp->_IO_write_base;
  if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
    {
      if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */
	return EOF;
      else
	{
	  char *new_buf;
	  char *old_buf = fp->_IO_buf_base;
	  size_t old_blen = _IO_blen (fp);
	  _IO_size_t new_size = 2 * old_blen + 100;
	  if (new_size < old_blen)
	    return EOF;
	  new_buf
	    = (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
	  if (new_buf == NULL)
	    {
	      /*	  __ferror(fp) = 1; */
	      return EOF;
	    }
	  if (old_buf)
	    {
	      memcpy (new_buf, old_buf, old_blen);
	      (*((_IO_strfile *) fp)->_s._free_buffer) (old_buf);
	      /* Make sure _IO_setb won't try to delete _IO_buf_base. */
	      fp->_IO_buf_base = NULL;
	    }
	  memset (new_buf + old_blen, '\0', new_size - old_blen);

	  _IO_setb (fp, new_buf, new_buf + new_size, 1);
	  fp->_IO_read_base = new_buf + (fp->_IO_read_base - old_buf);
	  fp->_IO_read_ptr = new_buf + (fp->_IO_read_ptr - old_buf);
	  fp->_IO_read_end = new_buf + (fp->_IO_read_end - old_buf);
	  fp->_IO_write_ptr = new_buf + (fp->_IO_write_ptr - old_buf);

	  fp->_IO_write_base = new_buf;
	  fp->_IO_write_end = fp->_IO_buf_end;
	}
    }

  if (!flush_only)
    *fp->_IO_write_ptr++ = (unsigned char) c;
  if (fp->_IO_write_ptr > fp->_IO_read_end)
    fp->_IO_read_end = fp->_IO_write_ptr;
  return c;
}

이중 new_buf= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size); 부분이 공격에 활용된다. 만약 _allocate_buffernew_size를 조작할 수 있다면...system("/bin/sh");처럼 실행시킬 수 있을것이다.

가능한지 확인하자. 그러기 위해 _allocate_buffer가 어디서 나왔는지 알아야 한다.

// glibc 2.29
typedef struct _IO_strfile_
{
  struct _IO_streambuf _sbf;
  struct _IO_str_fields _s;
} _IO_strfile;

struct _IO_streambuf
{
  struct _IO_FILE _f;
  const struct _IO_jump_t *vtable;
};

struct _IO_str_fields
{
  _IO_alloc_type _allocate_buffer;
  _IO_free_type _free_buffer;
};

즉, _IO_strfile_의 메모리상 구조는

_IO_strfile_
{
  struct _IO_FILE _f;
  const struct _IO_jump_t *vtable;
  _IO_alloc_type _allocate_buffer;
  _IO_free_type _free_buffer;
}

위와 같다. 갑자기 _IO_strfile_가 등장해서 당황스러울 것이다. 위 구조체들이 등장하는 이유는 "정상적인" 함수 호출이라면 위 구조체를 통해서 _IO_str_overflow가 호출되기 때문이고, 우리는 이에 맞추어 해당 위치에 system 함수를 오버라이드하고 싶은 상황이다.

즉, vtable포인터를 넘어 다음 것이 _allocate_buffer이므로 이곳을 system으로 오버라이트 해야 한다는 것이다.

한편 new_size2*old_blen+100로 계산되는데, old_blen_IO_blen(fp)라는 매크로에서 온 값이다. 그리고 _IO_blen(fp)fp->_IO_buf_end - fp->_IO_buf_base로 계산된다.

이를 종합하자. "/bin/sh"의 메모리 주소를 m이라 하면 m=2*(_IO_buf_end-_IO_buf_base)+100.

_IO_buf_base = 0
_IO_buf_end = (m - 100)/2

위처럼 주소를 설정하면 된다.

마지막으로 이제 실행 조건만 맞추어 _IO_str_overflow를 호출하면 된다.

이 내용은 securitykss.log님의 블로그를 참고해서 작성하였다.

_IO_str_overflow는 메모리를 통해 발생한 스트림에서 쓰이는 함수이기에 일반적으로 호출되지 않는다. 그렇기에 vtable의 주소를 변경 해서 _IO_str_overflow가 호출되도록 해야 한다. 이렇게 되면 여전히 vtable 내부의 함수를 사용하는 것이므로 앞서 살펴본 검사를 통과할 수 있다.

다음으로는 _IO_str_overflow의 if문을 통과해야 한다.

pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))

이 조건을 통과하기 위해서 fp->_IO_write_base=0, fp->_IO_write_ptr=(m-100)/2으로 설정한다.

이렇게 vtable을 조작하고 _IO_str_overflow를 호출하면 _allocate_buffersystem으로 오버라이트되고, /bin/sh가 실행된다.

Note: 복잡하지만, 이 방법 또한 최신 libc에서는 막혔다. new_buf에서 함수를 실행해주는 부분이 삭제되었다.

_IO_str_finish

이 함수 또한 비슷한 방식으로 익스가 가능하다.

실습

Dreamhack의 Bypass IO_validate_vtable, iofile_vtable_check을 통해 연습이 가능하다.

libc > 2.29

_wdoallocate를 이용한다. 자세한 내용은 관련 문제 풀이에서 확인하자.

struct _IO_wide_data
{
  wchar_t *_IO_read_ptr;	/* Current read pointer */
  wchar_t *_IO_read_end;	/* End of get area. */
  wchar_t *_IO_read_base;	/* Start of putback+get area. */
  wchar_t *_IO_write_base;	/* Start of put area. */
  wchar_t *_IO_write_ptr;	/* Current put pointer. */
  wchar_t *_IO_write_end;	/* End of put area. */
  wchar_t *_IO_buf_base;	/* Start of reserve area. */
  wchar_t *_IO_buf_end;		/* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  wchar_t *_IO_save_base;	/* Pointer to start of non-current get area. */
  wchar_t *_IO_backup_base;	/* Pointer to first valid character of
				   backup area */
  wchar_t *_IO_save_end;	/* Pointer to end of non-current get area. */

  __mbstate_t _IO_state;
  __mbstate_t _IO_last_state;
  struct _IO_codecvt _codecvt;

  wchar_t _shortbuf[1];

  const struct _IO_jump_t *_wide_vtable;
};