이번 글에서는 최근에 공부했던 FSOP 를 포함한 여러 익스 테크닉들을 빠르게 다뤄보겠습니다.
메모 느낌이라 틀린 부분이 있을수도 있으니 가볍게 봐주시면 감사하겠습니다.
_IO_FILE
glibc의 파일 입출력은 _IO_FILE 구조체 하나로 구현되어 있습니다.
stderr, stdout, stdin 같은 기본 스트림들도 전부 이 구조체 기반이고, scanf()가 어디에 쓸지, 버퍼를 어디서 읽을지 전부 이 구조체 안의 포인터들에 의해 결정됩니다.
libc 내부에서는 스트림들이 _IO_FILE로 구현되어 싱글 링크드 리스트로 연결되어 있습니다.
_IO_list_all ->
_IO_2_1_stderr_.file._chain ->
_IO_2_1_stdout_.file._chain ->
_IO_2_1_stdin_.file._chain
_IO_buf_base
_IO_2_1_stdin_.file._IO_buf_base를 덮어쓸 수 있어야 함- 이후
fgets(),scanf()등stdin을 사용하는 함수가 호출되어야 함
scanf()나 fgets() 같은 함수가 입력을 받을 때, _IO_buf_base 부터 _IO_buf_end까지의 영역을 버퍼로 사용합니다. 이 두 포인터를 원하는 주소로 조작하면, 다음에 scanf()가 호출될 때 해당 메모리 영역에 임의 쓰기가 가능합니다.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/libio/libio.h#L241
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
};
libc 주소를 이미 leak 했다면 __malloc_hook 같은 후킹 변수를 덮어씌울수도 있습니다.
_IO_FILE vtable Overwrite
_IO_FILE_plus.vtable포인터를 덮어쓸 수 있어야 함.- 어떤 파일 함수가 호출될지에 따라 오프셋도 달라짐.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/libio/libioP.h#L343
struct _IO_FILE_plus
{
_IO_FILE file;
const struct _IO_jump_t *vtable;
};
_IO_FILE_plus에는 파일 구조체 뒤에 vtable 포인터가 붙습니다.
fclose(), fwrite() 같은 파일 관련 함수가 호출되면 glibc는 직접 구현 함수를 부르는 게 아니라 이 vtable에서 함수 포인터를 통해서 간접 호출하게 됩니다.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/libio/fileops.c#L1545
const struct _IO_jump_t _IO_file_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_file_finish),
JUMP_INIT(overflow, _IO_file_overflow),
JUMP_INIT(underflow, _IO_file_underflow),
JUMP_INIT(uflow, _IO_default_uflow),
JUMP_INIT(pbackfail, _IO_default_pbackfail),
JUMP_INIT(xsputn, _IO_file_xsputn),
JUMP_INIT(xsgetn, _IO_file_xsgetn),
JUMP_INIT(seekoff, _IO_new_file_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_new_file_setbuf),
JUMP_INIT(sync, _IO_new_file_sync),
JUMP_INIT(doallocate, _IO_file_doallocate),
JUMP_INIT(read, _IO_file_read),
JUMP_INIT(write, _IO_new_file_write),
JUMP_INIT(seek, _IO_file_seek),
JUMP_INIT(close, _IO_file_close),
JUMP_INIT(stat, _IO_file_stat),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};
vtable 포인터를 fake vtable 주소로 덮으면 원하는 함수 호출이 가능한데, glibc 2.24(Ubuntu 16.04) 이후엔 이런 공격을 막기 위해 _IO_vtable_check 라는 검증 로직이 추가되었습니다.
_IO_vtable_check 우회 (glibc >= 2.24)
glibc 2.24부터 도입된 vtable 검증 로직은 이렇습니다.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/libio/libioP.h#L932
/* Perform vtable pointer validation. If validation fails, terminate
the process. */
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;
}
vtable 포인터가 __libc_IO_vtables 섹션 범위 안에 있는지만 확인하고 범위 밖이면 _IO_vtable_check()가 프로세스를 죽이게 됩니다.
범위 안에 있는지만 보는 단순한 검증이라 우회가 가능합니다.
// https://elixir.bootlin.com/glibc/glibc-2.27/source/libio/strops.c#L359
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_finish() 로 우회가 가능한데 여기서는 _IO_str_overflow() 만 설명하겠습니다.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/libio/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;
// #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;
if (new_size < old_blen)
return EOF;
new_buf
= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
// ...
}
_IO_str_overflow()의 중간 부분에서는 _s._allocate_buffer 함수 포인터를 호출합니다. 이 포인터를 system()으로 덮어써야 하는데 이 부분이 실행되려면 여러 조건을 통과해야합니다.
조건에서 사용되는 값인 _IO_blen 은 _IO_FILE 의 필드로서 _IO_buf_end 와 _IO_buf_base 에 의해 계산되는 값 입니다.
이를 이용해 _IO_buf_base가 0을, _IO_buf_end가 ("/bin/sh" 주소 - 100) / 2 를 가르키도록 조작하면, 내부 계산 과정에서 new_size가 "/bin/sh" 주소로 셋팅되도록 만들 수 있습니다.
결과적으로 system("/bin/sh")을 호출하게 됩니다.
glibc 2.28부터는 이 unchecked function pointer들이 제거되었습니다.
tcbhead_t.stack_guard leak 으로 SSP 우회
- SSP(Stack Smashing Protector)가 활성화되어 있어야 함
tcbhead_t.stack_guard를 읽을 수 있는 방법이 있어야 함.
SSP가 켜진 바이너리는 스택에 카나리 값을 삽입해서 스택 오버플로를 감지합니다.
카나리는 프로세스가 시작될 때 랜덤하게 생성되어 TLS의 stack_guard 필드에 저장됩니다.
스레드가 생성될 때 동일한 값으로 초기화되므로 같은 프로세스 내 모든 스레드는
결과적으로 같은 카나리를 가집니다.
따라서 TLS를 한 번이라도 읽을 수 있다면 SSP를 우회할 수 있습니다.
리눅스에서 TLS 구현체는 TCB이고, 이걸 표현하는 구조체가 tcbhead_t 입니다.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/sysdeps/i386/nptl/tls.h#L53
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
uintptr_t sysinfo;
uintptr_t stack_guard;
uintptr_t pointer_guard;
int gscope_flag;
#ifndef __ASSUME_PRIVATE_FUTEX
int private_futex;
#else
int __glibc_reserved1;
#endif
/* Reservation of some values for the TM ABI. */
void *__private_tm[4];
/* GCC split stack support. */
void *__private_ss;
} tcbhead_t;
stack_guard 필드에 카나리 값이 들어가고 이 값은 security_init() 에서 초기화합니다.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/elf/rtld.c#L704
static void
security_init (void)
{
/* Set up the stack checker's canary. */
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random);
#ifdef THREAD_SET_STACK_GUARD
THREAD_SET_STACK_GUARD (stack_chk_guard);
#else
__stack_chk_guard = stack_chk_guard;
#endif
/* Set up the pointer guard as well, if necessary. */
uintptr_t pointer_chk_guard
= _dl_setup_pointer_guard (_dl_random, stack_chk_guard);
#ifdef THREAD_SET_POINTER_GUARD
THREAD_SET_POINTER_GUARD (pointer_chk_guard);
#endif
__pointer_chk_guard_local = pointer_chk_guard;
/* We do not need the _dl_random value anymore. The less
information we leave behind, the better, so clear the
variable. */
_dl_random = NULL;
}
THREAD_SET_STACK_GUARD 매크로가 tcbhead_t.stack_guard에 값을 쓰게 됩니다.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/sysdeps/i386/nptl/tls.h#L414
#define THREAD_SET_STACK_GUARD(value) \
THREAD_SETMEM (THREAD_SELF, header.stack_guard, value)
_rtld_global Overwrite
_rtld_global._dl_rtld_lock_recursive와_rtld_global._dl_load_lock두 필드를 덮을 수 있어야 함- 덮어쓴 뒤 프로세스가
exit()를 호출해야 함.
exit() 는 _run_exit_handlers() 를 호출하는데, 여기서 _dl_fini()가 호출됩니다.
// https://elixir.bootlin.com/glibc/glibc-2.24/source/elf/dl-fini.c#L129
void
internal_function
_dl_fini (void)
{
/* Lots of fun ahead. We have to call the destructors for all still
loaded objects, in all namespaces. The problem is that the ELF
specification now demands that dependencies between the modules
are taken into account. I.e., the destructor for a module is
called before the ones for any of its dependencies.
To make things more complicated, we cannot simply use the reverse
order of the constructors. Since the user might have loaded objects
using `dlopen' there are possibly several other modules with its
dependencies to be taken into account. Therefore we have to start
determining the order of the modules once again from the beginning. */
/* We run the destructors of the main namespaces last. As for the
other namespaces, we pick run the destructors in them in reverse
order of the namespace ID. */
#ifdef SHARED
int do_audit = 0;
again:
#endif
for (Lmid_t ns = GL(dl_nns) - 1; ns >= 0; --ns)
{
/* Protect against concurrent loads and unloads. */
__rtld_lock_lock_recursive (GL(dl_load_lock));
__rtld_lock_lock_recursive는 매크로 상으로 _dl_rtld_lock_recursive 를 가르키고, 이는 _rtld_global 안에 존재하는 함수 포인터 입니다.
vmmap 으로 보면 _rtld_global 구조체가 있는 메모리 영역에는 쓰기 권한이 있기 때문에 덮는 것이 가능합니다.
exit() 호출 전 _dl_rtld_lock_recursive 를 system() 으로 덮고 _dl_load_lock 를 "/bin/sh" 로 덮어주면 쉘을 딸수있습니다.
.fini_array Overwrite
- No RELRO(Full 또는 Partial RELRO 에서는
.fini_array가 읽기 전용이 됩니다.)
.fini_array는 프로그램 종료 시 _dl_fini()에 의해 순차적으로 호출되는 함수 포인터 배열입니다. 그래서 해당 배열을 덮으면 종료 과정에서 원하는 함수를 호출할 수 있습니다.
0x7ffff7de7dc9 <_dl_fini+777>: mov r12,QWORD PTR [rax+0x8]
...
0x7ffff7de7de5 <_dl_fini+805>: je 0x7ffff7de7e00 <_dl_fini+832>
0x7ffff7de7de7 <_dl_fini+807>: nop WORD PTR [rax+rax*1+0x0]
0x7ffff7de7df0 <_dl_fini+816>: mov edx,r13d
=> 0x7ffff7de7df3 <_dl_fini+819>: call QWORD PTR [r12+rdx*8]
읽어주셔서 감사합니다.