what is _IO_buf_base & vulnerability

우리가 흔히 FILE 구조체가

C
struct _IO_FILE {
  int           _flags;           // 0x00
  char*         _IO_read_ptr;     // 0x08
  char*         _IO_read_end;     // 0x10
  char*         _IO_read_base;    // 0x18
  char*         _IO_write_base;   // 0x20
  char*         _IO_write_ptr;    // 0x28
  char*         _IO_write_end;    // 0x30
  char*         _IO_buf_base;     // 0x38
  char*         _IO_buf_end;      // 0x40
  /* skip some save/backups pointers ~0x60 */
  struct _IO_marker *_markers;    // ~0x60
  struct _IO_FILE*  _chain;       // ~0x68
  int               _fileno;      // ~0x70
  int               _flags2;      // ~0x74
  __off_t           _old_offset;  // ~0x78
  unsigned short    _cur_column;  // ~0x80
  signed char       _vtable_offset;// ~0x88
  char              _shortbuf[1]; // ~0x89
  _IO_lock_t       *_lock;        // ~0x90 (approx)
  ...
};

struct _IO_FILE_plus {
  struct _IO_FILE file;
  const struct _IO_jump_t *vtable; // at offset ≈0xd8
};

다음과 같이 생겨 먹은것은 이제 흔히 알것이다. 우리는 오늘 그중

char* _IO_read_ptr; // 0x08 char* _IO_read_end; // 0x10 char* _IO_read_base; // 0x18 char* _IO_write_base; // 0x20 char* _IO_write_ptr; // 0x28 char* _IO_write_end; // 0x30 char* _IO_buf_base; // 0x38 char* _IO_buf_end; // 0x40

얘내들에 대해 알아볼면서 한다. 특히 _IO_buf_base 얘를 중점으로 알아볼것이다.

_IO_buf_base 얘는 한 프로세스에서 전체적인 하나의 내부 버퍼의 시작을 의미한다. 반대로

_IO_buf_end 얘는 한 프로세스에서 전체적인 하나의 내부 버퍼의 끝을 의미한다.

우리가 stdin이나 stdout , stderr 혹은 그 외의 파일 구조체를 사용하는 파일들은 모두 고유한

내부 버퍼를 가지고 있다. ( 각각 다른 위치의 ) 그리고 file function ( fread , fgets , fwrite , printf ) 들은 이런 내부버퍼를 사용해서 buf에 쓰거나 file 구조체에 쓴다. file function도 read() 함수과와 write() 함수과가 존재한다.

read() 함수과 → stdin 같은 파일 입력을 통해서 프로세스 메모리 내의 buf에 저장하기 ( ex. fread,fgets )

write() 함수과 → 프로세스 메모리 내의 buf 에서 읽어서 stdout와 같은 파일 출력을 통해( ex. fwrite ,fputs , printf , fprintf )

_IO_SYSWRITE , _IO_SYSREAD , _IO_overflow , _IO_underflow , _IO_flush diff 이글을 읽고 오면 편하다.

read 함수과

fgets 함수로 예시를 들겠다.

https://elixir.bootlin.com/glibc/glibc-2.23/source/libio/iofgets.c#L70

fgets 함수가 실행되면 실질적으로

C
count = _IO_getline (fp, buf, n - 1, '\n', 1);

_IO_getline 함수가 실행된다.

여기서 ‘\n’ 이 궁금할수도 있어서 다루어보았다.

_IO_getline 에서는 무한 While 문을 돌면서

C
  while (n != 0)
    {
      _IO_ssize_t len = fp->_IO_read_end - fp->_IO_read_ptr;
      if (len <= 0)
				{
				  int c = __uflow (fp);
				  if (c == EOF)
				    {
				      if (eof)
					*eof = c;
					
					...

다음과 같은 코드가 실행되는데

fp->_IO_read_end - fp->_IO_read_ptr;

이 말은 다 현재 버퍼에서 읽은 것을 다 읽었으면~ 이라는 의미이다. 혹은, 맨처음에는

_IO_read_end == _IO_read_ptr 이므로 자연스럽게 if (len <= 0) 에 충족되어

__uflow 함수 즉, 언더플로우 함수가 실행된다. 해당 함수는 vtable에 있는 함수이다.

glibc with vtable 해당글을 참고하면 이해가 쉽다.

__uflow 함수는 glibc 상 _IO_new_file_underflow 함수이고

해당 함수에서는,

C
  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);

기본적인 _IO_read_base _IO_read_ptr _IO_read_end 값을 내부버퍼 시작 (_IO_buf_base) 값으로 초기화 시키고 _IO_SYSREAD 를 통해 내부적으로

read ( fp→fileno , fp->_IO_buf_base , fp->_IO_buf_end - fp->_IO_buf_base )를 실행시켜서

표준 입력으로부터 _IO_buf_base에 입력을 받는다.

C
 fp->_IO_read_end += count;

그리고 받은 바이트 수 많큼 _IO_read_end 값을 변경해준다.

그리고 이제 다시 _IO_getline 함수로 돌아와서

C
  char *t;
	  if ((_IO_size_t) len >= n)
	    len = n;
	  t = (char *) memchr ((void *) fp->_IO_read_ptr, delim, len);

memchr 함수로 delim 즉, “\n” 까지 자른뒤

C
  memcpy ((void *) ptr, (void *) fp->_IO_read_ptr, len);
	  fp->_IO_read_ptr += len;
	  ptr += len

buf에 fp->_IO_read_ptr 부터 len 만큼의 바이트를 memcpy 하면서 붙여넣게 된다.

write 함수과

fwrite 함수를 예시로 들겠다.

https://elixir.bootlin.com/glibc/glibc-2.23/source/libio/iofwrite.c#L54

C
_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;
}

_IO_sputn 함수로 buf 인자를 가지고 들어간다.

_IO_new_file_xsputn 함수에서는 buf를

C
const char *s = (const char *) data;

s로 넣은뒤

C
memcpy (f->_IO_write_ptr, s, count);

다음과 같이 buf에서 _IO_write_ptr 위치에 복사를 한고

_IO_new_file_overflow 함수에서는

C
	f->_IO_read_end = f->_IO_read_ptr = f->_IO_buf_base;
      f->_IO_write_ptr = f->_IO_read_ptr;
      f->_IO_write_base = f->_IO_write_ptr;
      f->_IO_write_end = f->_IO_buf_end;
      f->_IO_read_base = f->_IO_read_ptr = f->_IO_read_end;

다음과 같이 기본적인 설정을 한뒤에

C
_IO_do_write (f, f->_IO_write_base,
			 f->_IO_write_ptr - f->_IO_write_base);

다음과 같은 함수를 실행시킨다.

C
# define _IO_new_do_write _IO_do_write
C
_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;
}

new_do_write 함수를 실행하고

C
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_SYSWRITE 을 통해 통해 write( fp→fileno , data , to_do ) 로 실질적으로 표준 출력인 터미널에 write를 진행한다.

따라서 다시 돌아와서_IO_new_file_overflow 함수에서 언제 flush가 되어서 출력이 되는지 확인해보면

C
if (ch == EOF)
    return _IO_do_write(...);

char이 eof 일때 버퍼에 남은 데이터를 write 하고

C
if (f->_IO_write_ptr == f->_IO_buf_end)
    if (_IO_do_flush(f) == EOF)
        return EOF;

_IO_write_ptr가 계속 커지다. _IO_buf_end 까지 다다르면 내부버퍼가 꽉 찬거 이므로 _IO_do_flush 를 진행한다.

C
if ((_IO_LINE_BUF && ch == '\n') || _IO_UNBUFFERED)
    _IO_do_write(...);

_IO_LINE_BUF → 줄다위 버퍼링인데 char이 “\n” 일때도 flush 를 진행한다.

_IO_buf_base vulnerability

이제 취약점을 알아볼것인데 예를 들어 stdin 파일 구조체의 _IO_buf_base 부분을 덮어쓸 수 있으면,

위에 이 부분에서

C
  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);

_IO_SYSREAD 의 fp->_IO_buf_base 이 부분을 컨트롤 할 수 있다. 이는

read(0, _IO_buf_base , size) 를 의미하므로 _IO_buf_base 위치에 Arbitrary writable이 가능하게 된다.