[DH] validator-revenge

Writeup
2025. 8. 5.

Introduction

validator-revenge에 대한 writeup이다. 구체적인 코드는 생략하였고, 문제를 해결하기 위한 접근과 stack 구조만 작성하였다.

Analysis

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x400000)

문제에서 있는 input validation은 별 것 없고 "DREAMHACK!\x00zxy..." 이런식으로 어렵지 않게 조건에 맞는 입력을 찾을 수 있다. 이후 canary도 없는 상태에서 평화롭게 ROP를 진행하면 되는 문제이다. ROP gadget도 충분한 상황인데 문제가 어려워진 건 어떻게 libc를 leak할 것인가였다.

GOT 영역을 보아도 출력 함수는 없었고, 그나마 IO 관련 함수는 readfflush 함수가 있었다. 그래서 최근에 집중했던 FSOP로 방향을 잡고 leak을 진행하기로 했다.

Approach

Stack pivoting

stack pivoting이란 stack pointer를 원하는 위치로 옮겨서 그 위치에서 ROP를 진행하는 것을 의미한다. 내가 원하는 위치에서 ROP를 진행할 수 있기에 훨씬 자유도가 높다. 특히 PIE가 적용되지 않은 상황에서 stack pivoting을 통해서 bss 영역 내부에서 ROP를 진행하면 주소를 모두 알고 지정할 수 있기에 편하다. pop rsp가 없었다면 곤란했겠지만, pop_rsp_r13_r14_r15가 있어서 쉽게 해결할 수 있었다.

스택 구조는 그림으로 대체한다.

fflush 함수 분석

해당 분석은 문제 버전에 맞는 glibc 2.27 버전을 기준으로 작성되었습니다.

fflush 함수는 아래처럼 버퍼를 비우기 위한 함수이다.

printf("Hello, world");
fflush(stdout);            // it gets printed immediately

그리고 FSOP를 통해 파일 구조체를 잘 조작하면 원하는 영역의 데이터를 출력할 수 있다. 확인하기 위해 분석을 진행했다.


일단 fflush 함수의 구현을 살펴보면 아래와 같다. (실제로는 _IO_fflush 함수가 호출된다.)

int
_IO_fflush (_IO_FILE *fp)
{
  if (fp == NULL)
    return _IO_flush_all ();
  else
    {
      int result;
      CHECK_FILE (fp, EOF);
      _IO_acquire_lock (fp);
      result = _IO_SYNC (fp) ? EOF : 0;
      _IO_release_lock (fp);
      return result;
    }
}

CHECK_FILE_IO_acquire_lock FILE 구조체에 대한 검사를 하는 것을 추측된다. GDB를 돌려보았을 때 두 함수 모두 통과되어 바로 _IO_SYNC 함수에 대한 분석으로 넘어갔다.

아래는 _IO_SYNC 함수의 JUMP 테이블에 등록된 함수이다.

int
_IO_new_file_sync (_IO_FILE *fp)
{
  _IO_ssize_t delta;
  int retval = 0;

  /*    char* ptr = cur_ptr(); */
  if (fp->_IO_write_ptr > fp->_IO_write_base)
    if (_IO_do_flush(fp)) return EOF;
  delta = fp->_IO_read_ptr - fp->_IO_read_end;
  if (delta != 0)
    {
#ifdef TODO
      if (_IO_in_backup (fp))
	delta -= eGptr () - Gbase ();
#endif
      _IO_off64_t new_pos = _IO_SYSSEEK (fp, delta, 1);
      if (new_pos != (_IO_off64_t) EOF)
	fp->_IO_read_end = fp->_IO_read_ptr;
      else if (errno == ESPIPE)
	; /* Ignore error from unseekable devices. */
      else
	retval = EOF;
    }
  if (retval != EOF)
    fp->_offset = _IO_pos_BAD;
  /* FIXME: Cleanup - can this be shared? */
  /*    setg(base(), ptr, ptr); */
  return retval;
}
libc_hidden_ver (_IO_new_file_sync, _IO_file_sync)

fp->_IO_write_ptr > fp->_IO_write_base라면 _IO_do_flush(fp) 함수를 호출한다. 그리고 _IO_do_flush(fp)는 아래 매크로로 정의된다.

_IO_do_write(_f, (_f)->_IO_write_base,				      \
		  (_f)->_IO_write_ptr-(_f)->_IO_write_base)       \
          ...

이를 통해서 vtable의 _IO_do_write 함수가 호출되는 것을 확인할 수 있다.

그리고 _IO_do_write 함수는 new_do_write 함수를 호출한다.

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;
}

그리고 우리가 원하는 것을 찾았다. count = _IO_SYSWRITE (fp, data, to_do);

_IO_SYSWRITE 점프 테이블을 통해서 __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_nocancel (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;
}

fp->_IO_write_ptr > fp->_IO_write_base, _IO_IS_APPENDING = 1로 설정하면 (_f)->_IO_write_base부터 (_f)->_IO_write_ptr-(_f)->_IO_write_base)만큼의 데이터를 작성하게 된다.

적용

fs = FileStructure()
fs.flags = 0xFBAD0000 | 0x1000  # _IO_IS_APPENDING = 1
fs._IO_write_base = stdout
fs._IO_write_ptr = stdout + 0x8
fs._IO_write_end = stdout + 0x100
fs._lock = 0x601600  # writeable addr
fs.fileno = 1  # **stdout fileno**

p.send(bytes(fs)[:-8])  # remove vtable pointer

Troubleshooting

  • stack overflow stack pivoting 과정에서 fflush를 호출하면서(물론 main을 결국 썼지만) 스택 오버플로우가 발생했다. 0x601000 주소아래 영역은 권한이 없는데 콜 스택이 아래로 자라면서 문제가 발생했다. 0x601500 주소를 사용해서 스택을 위로 올려서 해결했다.

  • fflush in main vs plt 처음에는 plt를 통해 바로 fflush를 호출했지만, mov rax, qword [obj.stdout]과 같은 포인터 연산 과정이 없어서 plt로 호출하면 stdout이 FILE*이라고 해석해버린다. 그래서 main 함수를 통해 fflush를 호출해야 한다.

[DH] validator-revenge