Leakless Heap Exploit

dandb3·2025년 4월 9일
0

pwnable

목록 보기
26/26
post-thumbnail

leak을 출력해줄 수 있는 적절한 코드가 없는 경우에 이를 출력하게 만드는 기법에 대해 알아본다.
_IO_2_1_stdout_의 offset 0부터 partial overwrite가 가능하다는 것을 전제로 한다.

file stream에서의 기본적인 동작방식을 알고 있다고 가정한다.

만약 잘 모르겠다면, 아래의 글을 한 번 읽고 오는 것을 추천한다.
https://velog.io/@dandb3/fwrite%ED%95%A8%EC%88%98

Analysis

분석은 glibc-2.39 버전으로 진행하였다.

_IO_2_1_stdout_

stdout은 아래와 같이 구성된다.

특히 setvbuf(stdout, 0, _IONBF, 0);가 호출된 경우 입출력 버퍼를 사용하지 않기 때문에 위 그림과 같이 버퍼와 관련된 field들이 모두 같은 값을 가지는 것을 확인할 수 있다.
앞으로 stdout은 위와 같이 구성되었다고 가정하고 분석을 진행할 것이다.

_IO_new_file_xsputn()

size_t
_IO_new_file_xsputn (FILE *f, const void *data, size_t n)
{
  const char *s = (const char *) data;
  size_t to_do = n;
  int must_flush = 0;
  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))
    {
    	...
    }
  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 (to_do + must_flush > 0)
    {
      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)

현재 _flags에는 _IO_LINE_BUF 값이 set 되어있지 않고, _IO_write_end == _IO_write_ptr 이기 때문에 count값은 0인 상태로 바뀌지 않는다. 그렇기 때문에 바로 if (to_do + must_flush > 0)에 걸리게 되고, _IO_OVERFLOW()를 호출하게 된다.

_IO_new_file_overflow()

int
_IO_new_file_overflow (FILE *f, int ch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
    {
    	...
    }
  if (ch == EOF)
    return _IO_do_write (f, f->_IO_write_base,
			 f->_IO_write_ptr - f->_IO_write_base);
  ...
}
libc_hidden_ver (_IO_new_file_overflow, _IO_file_overflow)

_IO_NO_WRITES 비트는 unset 상태이고, _IO_write_base != NULL 이므로 바로 _IO_do_write() 함수를 호출하게 된다.
여기서 주목할 점은 인자로 _IO_write_base, _IO_write_ptr - _IO_write_base를 전달한다는 점이다.

_IO_do_write()

int
_IO_new_do_write (FILE *fp, const char *data, size_t to_do)
{
  return (to_do == 0
	  || (size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}
libc_hidden_ver (_IO_new_do_write, _IO_do_write)

만약 to_do == 0 이라면 바로 return한다.
그렇지 않다면 인자들을 그대로 유지한 채로 new_do_write()을 호출한다.

new_do_write()

static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
  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)
    {
      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() syscall이 호출된다.

만약 _IO_write_base의 하위 1바이트를 0으로 overwrite하게 된다면, 이 경우 to_do > 0이 되어 new_do_write()가 호출되고, 0x7f452b2cc600 ~ 0x7f452b2cc643 까지의 byte가 실제로 출력되게 된다.


실제로 메모리 영역을 살펴보면, 버퍼관련 변수들이 가리키는 영역은 _IO_2_1_stdout_ 내부를 가리키고 있고, 0x7f452b2cc600 ~ 0x7f452b2cc643 영역 내부에는 libc address가 존재한다.

즉, 이 방법을 통해서 leak이 가능한 함수가 없더라도 libc address를 출력할 수 있는 것이다.

심지어 출력이 성공한 이후에도 fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base; 코드를 통해 정상적으로 _IO_write_base값이 복구되기 때문에 이후 stdout을 통한 출력 시에도 정상적으로 잘 동작한다.

  • _IO_SYSWRITE() 앞에 존재하는 if / else if 문?

stdout의 경우, 애초에 write만을 위한 file stream이므로 else if 에 걸리게 된다면 프로세스가 hang 상태가 된다.
그렇기에 _flags값에 _IO_IS_APPENDING을 추가하게 되면 if문을 통해 이를 우회할 수 있다.
특히 _offset의 경우 이미 _IO_pos_BAD로 설정되어 있기 때문에 이후의 동작에 아무런 영향을 끼치지 않는다.

Summary

  • _flags = 0xfbad3887 ( 0xfbad2887 | _IO_IS_APPENDING)
  • _IO_write_base 의 하위 1바이트 = 0

사실 plaidCTF2025에서 못 푼 문제가 있어서 writeup을 보다가 알게 된 내용이다.
그 문제도 시간이 나면 정리해야지..

Reference

https://corgi.rip/posts/leakless_heap_1/

profile
공부 내용 저장소

0개의 댓글