leak을 출력해줄 수 있는 적절한 코드가 없는 경우에 이를 출력하게 만드는 기법에 대해 알아본다.
_IO_2_1_stdout_
의 offset 0부터 partial overwrite가 가능하다는 것을 전제로 한다.
file stream에서의 기본적인 동작방식을 알고 있다고 가정한다.
만약 잘 모르겠다면, 아래의 글을 한 번 읽고 오는 것을 추천한다.
https://velog.io/@dandb3/fwrite%ED%95%A8%EC%88%98
분석은 glibc-2.39 버전으로 진행하였다.
_IO_2_1_stdout_
stdout
은 아래와 같이 구성된다.
특히 setvbuf(stdout, 0, _IONBF, 0);
가 호출된 경우 입출력 버퍼를 사용하지 않기 때문에 위 그림과 같이 버퍼와 관련된 field들이 모두 같은 값을 가지는 것을 확인할 수 있다.
앞으로 stdout
은 위와 같이 구성되었다고 가정하고 분석을 진행할 것이다.
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()
를 호출하게 된다.
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
를 전달한다는 점이다.
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()
을 호출한다.
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
로 설정되어 있기 때문에 이후의 동작에 아무런 영향을 끼치지 않는다.
_flags
= 0xfbad3887 ( 0xfbad2887 | _IO_IS_APPENDING
)_IO_write_base
의 하위 1바이트 = 0사실 plaidCTF2025에서 못 푼 문제가 있어서 writeup을 보다가 알게 된 내용이다.
그 문제도 시간이 나면 정리해야지..