이전 Linux File System _IO_FILE에 이어서 _IO_FILE_Plus와 _IO_FILE vtable Overwrite에 대한 정리글로
드림핵 자료위주로 정리하였습니다.
https://skysquirrel.tistory.com/268
[Linux] Linux File System _IO_FILE
드림핵 로드맵을 진행하면서 이해하는데 어려운부분이 있어 공부하며 정리하는 글로 아래 자료를 참고하여 정리하였습니다. (공부중인 내용이므로 다르거나 틀린 내용이 있을 시 댓글로 알려
skysquirrel.tistory.com
(공부중인 내용이므로 틀리거나 잘못된 부분이 있다면 댓글로 알려주세요.)
_IO_FILE_plus 구조체
먼저 이전에 봤던 _IO_FILE_plus의 구조를 다시보면 _IO_jump_t로 *vtable이 존재합니다.
/* We always allocate an extra word following an _IO_FILE.
This contains a pointer to the function jump table used.
This is for compatibility with C++ streambuf; the word can
be used to smash to a pointer to a virtual function table. */
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
여기서 _IO_jump_t에 대한 구조체를 보면 다음과 같습니다.
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);
/* 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);
};
여기서 말하는 *vtable은 Virtual function Table의 약자로 가상 함수를 사용할때 할당되는 테이블을 말합니다.
이는 메모리에 가상함수를 담을 영역을 할당하고 함수의 주소를 기록하는데 가상 함수를 사용하면
해당 테이블을 기준으로 상대 주소로 호출하게 되는 구조입니다.
문제는 vtable을 공격자가 덮어쓸 수있다면 원하는 가상 함수를 호출할 수 있기에 매우 좋은 먹잇감이 됩니다.
해서 이에 해당하는 공격 기법을 함께 정리할 것이고 실제 익스플로잇 하는 것은 워게임문제를 통해 다루도록 하겠습니다.
_IO_FILE vtable
일단 해당 vtable에 대해 검증하는 라이브러리 코드가 존재하는 2.27을 기준으로 설명하도록 하겠습니다.
위에서 애기한것 처럼 해당 vtable는 가상 함수 테이블로 힙영역에 할당되게 됩니다.
먼저 할당되면서 초기화가 되는데 이는 _fopen_internal 코드에서 확인이 가능합니다.
FILE *__fopen_internal (const char *filename, const char *mode, int is32)
{
struct locked_FILE
{
struct _IO_FILE_plus fp;
#ifdef _IO_MTSAFE_IO
_IO_lock_t lock;
#endif
struct _IO_wide_data wd;
} *new_f = (struct locked_FILE *) malloc (sizeof (struct locked_FILE));
if (new_f == NULL)
return NULL;
#ifdef _IO_MTSAFE_IO
new_f->fp.file._lock = &new_f->lock;
#endif
_IO_no_init (&new_f->fp.file, 0, 0, &new_f->wd, &_IO_wfile_jumps);
_IO_JUMPS (&new_f->fp) = &_IO_file_jumps;
_IO_new_file_init_internal (&new_f->fp);
if (_IO_file_fopen ((FILE *) new_f, filename, mode, is32) != NULL)
return __fopen_maybe_mmap (&new_f->fp.file);
_IO_un_link (&new_f->fp);
free (new_f);
return NULL;
}
위 코드에서 아래에서 7번째줄에 해당하는 부분에서 다음과 같이 할당하고 있습니다.
_IO_JUMPS (&new_f->fp) = &_IO_file_jumps;
extern const struct _IO_jump_t _IO_file_jumps;
할당되는 값은 라이브러리의 전역변수를 말하는데 이는 위에서 미리 봤던
호출할 함수들을 담고있는 _IO_jump_t 구조체를 명시하고 있습니다.
이처럼 _fopen_internal 에서 초기화를 수행하여 가상 함수 테이블을 정의하고 있습니다.
다음으로는 _IO_FILE vtable Overwrite 공격기법을 알기위해서는 위 테이블에 있는 함수가
어떤 과정을 거쳐서 호출되는지를 알아야 합니다.
_IO_FILE_Plus.vtable 호출과정
호출과정을 보기 위해 Linux File System _IO_FILE 글에서 예시로 들었던 코드로 확인해보면
#include <stdio.h>
int main()
{
char file_data[256];
int ret;
FILE *fp;
strcpy(file_data, "AAAA");
fp = fopen("testfile","r");
fread(file_data, 1, 256, fp);
printf("%s",file_data);
fclose(fp);
}
fread 함수에서 파일을 읽어들일때 내부적으로 __GI_IO_fread함수를 호출합니다.
내부적으로 호출되는 해당 함수 _IO_fread 코드를 보면 다음과 같습니다.
( 앞에붙은 __GI는 라이브러리를 의미입니다.)
size_t _IO_fread (void *buf, size_t size, size_t count, FILE *fp)
{
size_t bytes_requested = size * count;
size_t bytes_read;
CHECK_FILE (fp, 0);
if (bytes_requested == 0)
return 0;
_IO_acquire_lock (fp);
bytes_read = _IO_sgetn (fp, (char *) buf, bytes_requested);
_IO_release_lock (fp);
return bytes_requested == bytes_read ? count : bytes_read / size;
}
해당 함수를 보면 밑에서 4번째 줄에서 _IO_sgetn함수롤 다시 호출합니다.
_IO_sgetn (_IO_FILE *fp, void *data, _IO_size_t n)
{
/* FIXME handle putback buffer here! */
return _IO_XSGETN (fp, data, n);
}
해당 함수는 _IO_XSGETN의 매크로로 _IO_XSGETN을 리턴한다고 하는데 이를 define을 통해보면 다음과 같습니다.



이는 glibc 2.27부터 존재하는 검증코드로
_IO_XSGETN -> JUMP2 -> _IO_JUMPS_FUNC -> IO_validate_vtable의 검증코드를 거치게됩니다.
검증이 완료된다면 _IO_FILE_XSGETN이 실행되는데

보기 쉽게 _IO_FILE_XSGETN의 코드부분만 발췌하면 다음과 같습니다.
_IO_file_xsgetn (_IO_FILE *fp, void *data, _IO_size_t n)
{
_IO_size_t want, have;
_IO_ssize_t count;
char *s = data;
want = n;
if (fp->_IO_buf_base == NULL)
{
/* Maybe we already have a push back pointer. */
if (fp->_IO_save_base != NULL)
{
free (fp->_IO_save_base);
fp->_flags &= ~_IO_IN_BACKUP;
}
_IO_doallocbuf (fp);
}
while (want > 0)
{
have = fp->_IO_read_end - fp->_IO_read_ptr;
if (want <= have)
{
memcpy (s, fp->_IO_read_ptr, want);
fp->_IO_read_ptr += want;
want = 0;
}
else
{
if (have > 0)
{
s = __mempcpy (s, fp->_IO_read_ptr, have);
want -= have;
fp->_IO_read_ptr += have;
}
/* Check for backup and repeat */
if (_IO_in_backup (fp))
{
_IO_switch_to_main_get_area (fp);
continue;
}
/* If we now want less than a buffer, underflow and repeat
the copy. Otherwise, _IO_SYSREAD directly to
the user buffer. */
if (fp->_IO_buf_base
&& want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base))
{
if (__underflow (fp) == EOF)
break;
continue;
}
/* These must be set before the sysread as we might longjmp out
waiting for input. */
_IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
_IO_setp (fp, fp->_IO_buf_base, fp->_IO_buf_base);
/* Try to maintain alignment: read a whole number of blocks. */
count = want;
if (fp->_IO_buf_base)
{
_IO_size_t block_size = fp->_IO_buf_end - fp->_IO_buf_base;
if (block_size >= 128)
count -= want % block_size;
}
count = _IO_SYSREAD (fp, s, count);
if (count <= 0)
{
if (count == 0)
fp->_flags |= _IO_EOF_SEEN;
else
fp->_flags |= _IO_ERR_SEEN;
break;
}
s += count;
want -= count;
if (fp->_offset != _IO_pos_BAD)
_IO_pos_adjust (fp->_offset, count);
}
}
return n - want;
}
이렇게 2.27버전 부터는 검증을 거치게 되고 함수를 통한 검증이 완료되면
_IO_FILE_XSGETN 함수에서_IO_buf_base 같은 _flags 비트값들을 이용해 동작하는 구조입니다.
최종목표는 _IO_FILE vtable Overwrite 이니 검증코드를 분석해 보도록하겠습니다.
validate_vtable 검증코드
위에서 확인한 검증코드 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;
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;
}
코드의 주석을 보면 vtable의 주소는 _libc_IO_vtables 섹션 안에 있어야한다고 합니다.
그리고 해당 섹션의 길이는 section_length 부분의 코드로 알아내고
const char *ptr = (const char *) vtable;
uintptr_t offset = ptr - __start___libc_IO_vtables;
if (__glibc_unlikely (offset >= section_length))
위 코드로 검증을 거칩니다.
검증부분을 보면 vtable의 주소에서 섹션의 시작부분의 주소를 빼주고 있는데
이를 통해 vtable의 주소가 섹션보다 작은 주소에 위치해 있으면 unsigned 자료형에 의해 section_length 보다 크게되고,
섹션의 뒤에 있어도 커지기에 주석에서 해당 위치는 섹션내부에 있어야 한다고 명시하는 것입니다.
마지막으로 if조건문에 맞게 되는 경우 인데 이는 vtable가 존재하지 않는 경우로
_IO_vtable_check() 코드를 보면 vtable가 존재하지 않을때 해당 코드를 통해 함수 포인터를 확인하는 것을 알수 있습니다.
_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_FILE vtable Overwrite
위에서도 언급했듯이 최종목표는 공격자 입장에서 검증코드를 가상 함수를 덮어씌워
원하는 함수를 호출하는 공격기법으로 연계하는 것입니다.
그러면 공격에 사용할 함수인 _IO_str_overflow()와 해당 함수가 어디서 사용되는지부터 보도록하겠습니다.
해당 함수의 위치는 이전 fopen_internal()코드에서 _IO_file_jumps 구조체를 보았을때
그 다음 구조체에 해당하는 구조체로 _IO_str_jumps라는 구조체 내에 있습니다.
이 구조체 안에 공격에 사용될 _IO_str_overflow() 함수가 존재합니다.
const struct _IO_jump_t _IO_str_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_str_finish),
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),
JUMP_INIT(xsgetn, _IO_default_xsgetn),
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)
};
_IO_str_overflow() 함수의 코드를 분석해보면 다음과 같습니다.
_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);
해당 코드는 new_buf의 값을 결정하는 코드인데 _s._allocate_buffer는 함수 포인터이므로 이를 덮어쓰고
전달이 가능하다면 new_size역시 조작이 가능해 원하는 함수를 호출하는 것이 가능해지게 됩니다.
하지만 해당 코드를 실행시키기 위해서는 인자 조작과 함께 조건문을 넘어야 합니다.
조건문 우회 & 인자 조작
먼저 _IO_str_overflow() 함수에서 new_buf를 조작하기 위해 필요한 부분만 보면 다음과 같습니다.
#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
_IO_str_overflow (_IO_FILE *fp, int c)
{
int flush_only = c == EOF;
...
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
{
...
//인자값 조작이 필요한 부분
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);
위에서 부터 조건문과 그에 필요한 변수이며 주석 다음부터는 인자값 조작이 필요한 부분입니다.
먼저 위의 조건문과 필요한 변수는 각각 다음과 같은 변수가 들어가게 됩니다.
_IO_FILE.IO_buf_end
_IO_FILE.IO_buf_base
_IO_FILE.IO_write_ptr
_IO_FILE.IO_write_base
fluse_only = 0
(fluse_only는 초기값으로 0을 가지게 됩니다.)
이중에서 최종 목표인 원하는 함수 overwrite를 위해서
조작이 필요한 변수는 2개로 write_ptr과 write_base입니다.
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
먼저 if문 통과를 위한 조건을 보면 _IO_blen(fp)+ flush_only 인데 flush_only값은 0이니 정리하면
다음과 같이 정리할수 있습니다.
if (pos >= (_IO_blen (fp))
여기서 _IO_blen(fp)는 _IO_str_overflow()의 인자 조작에서 조작하게 되니 패스하고 보면
pos값이 남게됩니다.
pos 값이 정해지는 코드를 보면 wrtie_ptr - write_base임을 알 수 있습니다.
pos = fp->_IO_write_ptr - fp->_IO_write_base;
여기서 base를 0으로 조작한뒤 write_ptr을 원하는 값으로 설정하면
_IO_blen(fp)와 상관없이 조건을 넘길수 있게 됩니다.
그러면 이제 필요한 인자값을 조작해야 합니다.
//인자값 조작이 필요한 부분
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);
먼저 old_blen = _IO_blen(fp) 부분은 define으로 지정되어 있습니다.
#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
여기서 buf_base값을 0으로만들고 end를 원하는 값으로 지정하면 _old_blen값을 원하는대로 설정하는 것이 가능합니다.
old_blen 값이 정해지면 다음 식에 의해 new_size가 결정됩니다.
여기서 결정된 new_size가 다시if조건문을 통과하고 new_buf에 사용되므로
원하는 buf_end와 buf_base를 만들기 위해서는 식에 사용되는 *2 와 +100을 역으로 치환해주면
_IO_buf_end = ( 원하는값 - 100 )/2
_IO_buf_base = 0
위와 같은 식으로 인자값을 조작을 해야하는 것을 알 수 있게 됩니다.
new_buf = (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
이렇게 조작된 값들이 최종식인 new_buf를 정의하는 식을 조작하면서
조작된 값을 _IO_strfile의 포인터로 해석하고 해당 포인터인 _s.allocate_buffer 함수 포인터에
공격자가 실행하려는 함수를 호출하게 되는 구조입니다.
_s.allocate_buffer
그렇다면 _s.allocate_buffer는 무엇이고 _IO_strfile로 해석해야 하는가?
이건 먼저 *fp가 해석될때 _IO_strfile로 해석되어 _s.allocate_buffer를 가르키고 있기에
_IO_strfile부터 봐야 합니다.
라이브러리의 해당 구조체를 보면 다음과 같습니다.



IO_strfile 의 구조체에는 streambuf 와 str_fields가 존재하는데 해당 필드에 allocate_buffer이 존재하고
_IO_strfile._s.allocate_buffer으로 해석하기 위해 위와 같은 식이 정의 된것입니다.
여기서 _IO_alloc_type을 보면

typedef *(*_IO_alloc_type)구조로 원하는 함수로 해당 값을 원하는 함수로
overwrite 하면 호출할 수 있게 되어 _IO_FILE vtable Overwrite 공격이 성공하게 됩니다.
주의점은 공격 흐름을 보면 _IO_FILE 구조체를 _IO_strfile 구조체로 재해석해 멤버에 접근하고
_IO_FILE_Plus 멤버를 변형시키는 구조입니다.
즉, 해당 공격을 성공시키기 위해서는 힙 영역에 할당되어 있는 _IO_FILE_Plus 오염이 필수 조건입니다.
위 내용을 활용한 실제 공격 실습은 워게임 문제를 통해 분석해가면서 정리하도록 하겠습니다.
(틀린 내용이 있거나 잘못된 부분이 있다면 지적해주세요)
Reference
1. https://learn.dreamhack.io/11#p198
2. https://aidencom.tistory.com/497
4. https://keyme2003.tistory.com/entry/dreamhack-Bypass-IOvalidatevtable
5. https://elixir.bootlin.com/glibc/glibc-2.27/source/libio/strfile.h#L34
'Reference > Pwnable_Study' 카테고리의 다른 글
[Pwn] _IO_FILE Arbitrary Write (0) | 2023.07.24 |
---|---|
[Pwn] _IO_FILE Arbitrary Read (0) | 2023.07.03 |
[Pwn] SROP(Sig Return Oriented Programming) (0) | 2023.05.02 |
[Pwn] gdb offset Tip (0) | 2023.04.01 |
[Pwn] Overwrite _rtld_global (0) | 2023.04.01 |